You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When the user taps "Get Started" on the onboarding screen, the app currently fires the raw OS notification permission dialog (requestNotificationPermissions() in src/app/onboarding.tsx, added in #242) and then navigates to /auth/create-or-import-seed.
In practice the dialog is frequently not shown, and when it is shown the user has no context for it. This issue replaces the bare OS prompt with an in-app pre-permission bottom sheet that follows the app design system, keeps the trigger exactly on "Get Started", and makes the "no dialog" cases visible instead of silent.
consthandleGetStarted=async()=>{setIsContinuing(true);try{awaitrequestNotificationPermissions();// OS dialog, no explanation}catch(error){console.warn('[onboarding] Notification permission request failed',error);// swallowed}finally{router.push('/auth/create-or-import-seed');setIsContinuing(false);}};
otherwise → creates the Android channel and calls requestPermissionsAsync()
After the wallet is created, NotificationProvider (src/lib/context/notification-context.tsx) registers the device with the Notification Service only if the status is already granted; it never asks again. The only other place that asks is the toggle in src/app/settings/notifications.tsx.
After uninstall/reinstall the seed survives in the Keychain → the app opens straight into the wallet → the onboarding screen is never displayed → "Get Started" is never tapped → no prompt.
iOS – already answered
iOS shows the system dialog once per install. If the user already accepted/denied (including through the Settings toggle), steps 1–2 above exit silently.
Android ≤ 12
No runtime permission: status is granted by default, no dialog (expected).
Android 13+
POST_NOTIFICATIONS is injected by expo-notifications, but only in a regenerated native build; an old dev-client shows nothing.
Swallowed error
A native throw becomes a console.warn and navigation continues; the user sees nothing.
The implementation itself is correct and covered by tests (src/app/__tests__/onboarding.test.tsx), but the UX around the one-shot OS dialog is fragile: a single "Don't Allow" is irreversible without going through the OS Settings, and the user is asked with zero context.
Proposed behavior
Trigger stays on "Get Started":
Tap "Get Started" → Notifications.getPermissionsAsync().
If status === 'undetermined' (or canAskAgain === true and not granted) → present the NotificationPermissionBottomSheet.
Swiping the sheet down / tapping the backdrop behaves like "Not now" (navigation must still happen).
If status === 'granted' → skip the sheet, navigate directly (Android ≤ 12 lands here).
If canAskAgain === false → skip the sheet, navigate directly, and show a short showMessage toast (react-native-flash-message, already used) "Notifications are disabled. You can enable them later in Settings." so the user understands why nothing was asked.
Any error from the permission API → log through the notifications logger (new logPermissionRequestFailed(message) in src/lib/notifications/logger.ts) instead of a bare console.warn, then navigate.
No change to NotificationProvider, registerDeviceWithNotificationService or the Settings toggle.
Implementation details
New component — src/components/modal/notification-permission-bottom-sheet.tsx
Follow exactly the pattern of the existing sheets (src/components/logout-bottom-sheet.tsx, src/components/modal/payment-method-bottom-sheet.tsx):
Built on Modal + useModal from @/components/ui (wrapper around @gorhom/bottom-sheet), BottomSheetView, enableDynamicSizing, maxDynamicContentSize={Dimensions.get('window').height * 0.85}, showCloseButton={false}, bottomInset={useModalBottomInset()}.
Use onDismiss from the Modal to route a swipe-down / backdrop tap to onSkiponce (guard with a ref so "Enable" → dismiss does not also trigger onSkip).
// Skeleton — must stay visually consistent with LogoutBottomSheet<Modalref={ref}enableDynamicSizingmaxDynamicContentSize={MAX_MODAL_HEIGHT}showCloseButton={false}bottomInset={bottomInset}onDismiss={handleDismiss}><BottomSheetView><ViewclassName="px-6 pb-4">{/* Icon badge — same rounded-full/primary pattern as PaymentMethodBottomSheet */}<ViewclassName="mb-4 items-center"><ViewclassName="items-center justify-center rounded-full bg-primary-600 p-4"><Ioniconsname="notifications"size={28}color={colors.white}/></View></View><TextclassName={`mb-2 text-center text-2xl font-bold ${theme.textPrimary}`}>{t('onboarding.notifications.title')}</Text><TextclassName={`mb-4 text-center text-base leading-5 ${theme.textSecondary}`}>{t('onboarding.notifications.description')}</Text>{/* Benefit rows — same row/icon layout as PaymentMethodBottomSheet, non-pressable */}<BenefitRowicon="flash"label={t('onboarding.notifications.benefitPayments')}/><BenefitRowicon="shield-checkmark"label={t('onboarding.notifications.benefitSecurity')}/><ViewclassName="mt-4"><ButtontestID="notification-permission-enable"label={t('onboarding.notifications.enable')}fullWidthsize="lg"variant="secondary"textClassName="text-base text-white"loading={loading}onPress={onEnable}/><ButtontestID="notification-permission-skip"label={t('onboarding.notifications.skip')}fullWidthsize="lg"variant="outline"textClassName="text-base"onPress={onSkip}/></View></View></BottomSheetView></Modal>
Design-system requirements (must-have)
The sheet must not introduce any new colors, fonts, radii or spacing. Concretely:
Element
Rule
Container
Modal from @/components/ui only (no raw BottomSheetModal, no RN Modal). Same backdrop (renderBackdrop), same handle, same background as every other sheet.
Padding / spacing
px-6 pb-4 root, mb-2 / mb-4 between blocks, mt-4 before buttons — identical to LogoutBottomSheet.
Typography
Title: text-2xl font-bold ${theme.textPrimary}. Body: text-base leading-5 ${theme.textSecondary}. Benefit rows: text-base font-bold ${theme.textPrimary} / text-sm ${theme.textMuted}. Always use Text from @/components/ui (applies the app font).
Colors
Only tokens from src/components/ui/colors.js and src/lib/theme-classes.ts. Icon badge: bg-primary-600 with colors.white icon (same as the primary CTA). Never hard-code hex values.
Dark mode
Every text/background must use the theme.* classes or dark: variants (bg-white dark:bg-charcoal-950, text-gray-900 dark:text-charcoal-100, …). Check both schemes.
Buttons
Button from @/components/ui: primary CTA variant="secondary" (same as "Get Started"), secondary action variant="outline", both fullWidth size="lg" textClassName="text-base". Order: primary on top, outline below (same as LogoutBottomSheet).
Icons
Ionicons from @expo/vector-icons only (the app icon set), sizes 22–28 as in existing sheets.
Cards / rows
If benefit rows use a background, use theme.card (bg-gray-50 dark:bg-charcoal-850 border …) with rounded-2xl p-4 like PaymentMethodBottomSheet.
Safe area
bottomInset={useModalBottomInset()} so the outline button never sits under the home indicator.
Render <NotificationPermissionBottomSheet ref={sheetRef} onEnable={handleEnable} onSkip={handleSkip} loading={isRequesting} /> inside the screen (the root _layout.tsx already wraps the app in BottomSheetModalProvider).
Keep the permission-status read in the screen thin; optionally extract a getNotificationPermissionState(): Promise<'granted' | 'ask' | 'blocked'> helper in register-device.ts to make the branching unit-testable without the UI.
Translations — src/translations/en.json / fr.json
Add under onboarding (keys must stay alphabetically sorted — i18n-json/sorted-keys lint rule):
"notifications": {
"benefitPayments": "Know instantly when a payment arrives",
"benefitSecurity": "Security alerts for your wallet",
"description": "Grimm can notify you when you receive bitcoin and when something needs your attention. You can change this anytime in Settings.",
"disabledHint": "Notifications are disabled. You can enable them later in Settings.",
"enable": "Enable notifications",
"skip": "Not now",
"title": "Stay up to date"
}
French equivalents in fr.json (e.g. "Restez informé", "Activer les notifications", "Plus tard", "Les notifications sont désactivées. Vous pourrez les activer plus tard dans les Paramètres."). Run pnpm lint:translations.
Logger — src/lib/notifications/logger.ts
Add logPermissionRequestFailed(message: string) next to logPermissionRequest, using the existing LOG_PREFIX / warn helpers.
Android 13+: adb shell pm revoke <package> android.permission.POST_NOTIFICATIONS (or pm clear). Android ≤ 12 never shows a dialog and must skip the sheet (status granted).
Requires a regenerated native build (pnpm prebuild / new dev-client) — expo-notifications must be compiled in.
Verify light and dark mode screenshots of the sheet against LogoutBottomSheet for spacing, typography and button styles.
Summary
When the user taps "Get Started" on the onboarding screen, the app currently fires the raw OS notification permission dialog (
requestNotificationPermissions()insrc/app/onboarding.tsx, added in #242) and then navigates to/auth/create-or-import-seed.In practice the dialog is frequently not shown, and when it is shown the user has no context for it. This issue replaces the bare OS prompt with an in-app pre-permission bottom sheet that follows the app design system, keeps the trigger exactly on "Get Started", and makes the "no dialog" cases visible instead of silent.
Current behavior (
master, v1.4.1)src/app/onboarding.tsx:src/lib/notifications/register-device.ts#requestNotificationPermissions:getPermissionsAsync()→granted→ returnstrue, no dialogcanAskAgain === false(already denied) → returnsfalse, no dialog, silentlyrequestPermissionsAsync()After the wallet is created,
NotificationProvider(src/lib/context/notification-context.tsx) registers the device with the Notification Service only if the status is alreadygranted; it never asks again. The only other place that asks is the toggle insrc/app/settings/notifications.tsx.Why the prompt "does not show"
grantedby default, no dialog (expected).POST_NOTIFICATIONSis injected byexpo-notifications, but only in a regenerated native build; an old dev-client shows nothing.throwbecomes aconsole.warnand navigation continues; the user sees nothing.The implementation itself is correct and covered by tests (
src/app/__tests__/onboarding.test.tsx), but the UX around the one-shot OS dialog is fragile: a single "Don't Allow" is irreversible without going through the OS Settings, and the user is asked with zero context.Proposed behavior
Trigger stays on "Get Started":
Notifications.getPermissionsAsync().status === 'undetermined'(orcanAskAgain === trueand not granted) → present theNotificationPermissionBottomSheet.requestNotificationPermissions()(OS dialog) → dismiss → navigate to/auth/create-or-import-seed.status === 'granted'→ skip the sheet, navigate directly (Android ≤ 12 lands here).canAskAgain === false→ skip the sheet, navigate directly, and show a shortshowMessagetoast (react-native-flash-message, already used) "Notifications are disabled. You can enable them later in Settings." so the user understands why nothing was asked.logPermissionRequestFailed(message)insrc/lib/notifications/logger.ts) instead of a bareconsole.warn, then navigate.No change to
NotificationProvider,registerDeviceWithNotificationServiceor the Settings toggle.Implementation details
New component —
src/components/modal/notification-permission-bottom-sheet.tsxFollow exactly the pattern of the existing sheets (
src/components/logout-bottom-sheet.tsx,src/components/modal/payment-method-bottom-sheet.tsx):Modal+useModalfrom@/components/ui(wrapper around@gorhom/bottom-sheet),BottomSheetView,enableDynamicSizing,maxDynamicContentSize={Dimensions.get('window').height * 0.85},showCloseButton={false},bottomInset={useModalBottomInset()}.React.forwardRef<BottomSheetModal, Props>+useImperativeHandleexposingpresent/dismiss(same shape asLogoutBottomSheet).onEnable: () => void,onSkip: () => void,loading?: boolean.onDismissfrom theModalto route a swipe-down / backdrop tap toonSkiponce (guard with a ref so "Enable" → dismiss does not also triggeronSkip).Design-system requirements (must-have)
The sheet must not introduce any new colors, fonts, radii or spacing. Concretely:
Modalfrom@/components/uionly (no rawBottomSheetModal, no RNModal). Same backdrop (renderBackdrop), same handle, same background as every other sheet.px-6 pb-4root,mb-2/mb-4between blocks,mt-4before buttons — identical toLogoutBottomSheet.text-2xl font-bold ${theme.textPrimary}. Body:text-base leading-5 ${theme.textSecondary}. Benefit rows:text-base font-bold ${theme.textPrimary}/text-sm ${theme.textMuted}. Always useTextfrom@/components/ui(applies the app font).src/components/ui/colors.jsandsrc/lib/theme-classes.ts. Icon badge:bg-primary-600withcolors.whiteicon (same as the primary CTA). Never hard-code hex values.theme.*classes ordark:variants (bg-white dark:bg-charcoal-950,text-gray-900 dark:text-charcoal-100, …). Check both schemes.Buttonfrom@/components/ui: primary CTAvariant="secondary"(same as "Get Started"), secondary actionvariant="outline", bothfullWidth size="lg" textClassName="text-base". Order: primary on top, outline below (same asLogoutBottomSheet).Ioniconsfrom@expo/vector-iconsonly (the app icon set), sizes 22–28 as in existing sheets.theme.card(bg-gray-50 dark:bg-charcoal-850 border …) withrounded-2xl p-4likePaymentMethodBottomSheet.bottomInset={useModalBottomInset()}so the outline button never sits under the home indicator.Onboarding screen —
src/app/onboarding.tsxRender
<NotificationPermissionBottomSheet ref={sheetRef} onEnable={handleEnable} onSkip={handleSkip} loading={isRequesting} />inside the screen (the root_layout.tsxalready wraps the app inBottomSheetModalProvider).Keep the permission-status read in the screen thin; optionally extract a
getNotificationPermissionState(): Promise<'granted' | 'ask' | 'blocked'>helper inregister-device.tsto make the branching unit-testable without the UI.Translations —
src/translations/en.json/fr.jsonAdd under
onboarding(keys must stay alphabetically sorted —i18n-json/sorted-keyslint rule):French equivalents in
fr.json(e.g. "Restez informé", "Activer les notifications", "Plus tard", "Les notifications sont désactivées. Vous pourrez les activer plus tard dans les Paramètres."). Runpnpm lint:translations.Logger —
src/lib/notifications/logger.tsAdd
logPermissionRequestFailed(message: string)next tologPermissionRequest, using the existingLOG_PREFIX/warnhelpers.Tests
src/components/modal/__tests__/notification-permission-bottom-sheet.test.tsx: renders title/description/buttons; "Enable" callsonEnable; "Not now" callsonSkip;loadingdisables the CTA.src/app/__tests__/onboarding.test.tsx(mockexpo-notifications.getPermissionsAsync+requestNotificationPermissions):undetermined→ sheet presented, no navigation yet; tap Enable →requestNotificationPermissionscalled → navigates.undetermined→ tap Not now →requestNotificationPermissionsnot called → navigates.granted→ no sheet, navigates directly.denied+canAskAgain=false→ no sheet, toast shown, navigates.getPermissionsAsyncrejects → logger called, navigates.register-device.test.tsuntouched (helper unchanged).tstable across renders (per-rendertmocks make screens withtin effect deps loop).QA notes
adb shell pm revoke <package> android.permission.POST_NOTIFICATIONS(orpm clear). Android ≤ 12 never shows a dialog and must skip the sheet (statusgranted).pnpm prebuild/ new dev-client) —expo-notificationsmust be compiled in.LogoutBottomSheetfor spacing, typography and button styles.Related
Tasks
NotificationPermissionBottomSheet(src/components/modal/) following theModal/useModal+theme+Buttonconventions abovesrc/app/onboarding.tsxwith thegranted/ask/blockedbranching; trigger stays on "Get Started"canAskAgain === false; logger call on errors (no moreconsole.warn)onboarding.notifications.*keys toen.jsonandfr.json(sorted), runpnpm lint:translations