Skip to content

Onboarding: in-app notification permission bottom sheet on "Get Started" (design-system compliant) #248

Description

@nejos97

Summary

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.

Current behavior (master, v1.4.1)

src/app/onboarding.tsx:

const handleGetStarted = async () => {
  setIsContinuing(true);
  try {
    await requestNotificationPermissions();      // 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);
  }
};

src/lib/notifications/register-device.ts#requestNotificationPermissions:

  1. getPermissionsAsync()granted → returns true, no dialog
  2. canAskAgain === false (already denied) → returns false, no dialog, silently
  3. 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.

Why the prompt "does not show"

Cause Detail
#247 (iOS) 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":

  1. Tap "Get Started" → Notifications.getPermissionsAsync().
  2. If status === 'undetermined' (or canAskAgain === true and not granted) → present the NotificationPermissionBottomSheet.
    • "Enable notifications"requestNotificationPermissions() (OS dialog) → dismiss → navigate to /auth/create-or-import-seed.
    • "Not now" → dismiss → navigate.
    • Swiping the sheet down / tapping the backdrop behaves like "Not now" (navigation must still happen).
  3. If status === 'granted' → skip the sheet, navigate directly (Android ≤ 12 lands here).
  4. 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.
  5. 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()}.
  • React.forwardRef<BottomSheetModal, Props> + useImperativeHandle exposing present / dismiss (same shape as LogoutBottomSheet).
  • Props: onEnable: () => void, onSkip: () => void, loading?: boolean.
  • Use onDismiss from the Modal to route a swipe-down / backdrop tap to onSkip once (guard with a ref so "Enable" → dismiss does not also trigger onSkip).
// Skeleton — must stay visually consistent with LogoutBottomSheet
<Modal ref={ref} enableDynamicSizing maxDynamicContentSize={MAX_MODAL_HEIGHT} showCloseButton={false} bottomInset={bottomInset} onDismiss={handleDismiss}>
  <BottomSheetView>
    <View className="px-6 pb-4">
      {/* Icon badge — same rounded-full/primary pattern as PaymentMethodBottomSheet */}
      <View className="mb-4 items-center">
        <View className="items-center justify-center rounded-full bg-primary-600 p-4">
          <Ionicons name="notifications" size={28} color={colors.white} />
        </View>
      </View>

      <Text className={`mb-2 text-center text-2xl font-bold ${theme.textPrimary}`}>{t('onboarding.notifications.title')}</Text>
      <Text className={`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 */}
      <BenefitRow icon="flash" label={t('onboarding.notifications.benefitPayments')} />
      <BenefitRow icon="shield-checkmark" label={t('onboarding.notifications.benefitSecurity')} />

      <View className="mt-4">
        <Button testID="notification-permission-enable" label={t('onboarding.notifications.enable')} fullWidth size="lg" variant="secondary" textClassName="text-base text-white" loading={loading} onPress={onEnable} />
        <Button testID="notification-permission-skip" label={t('onboarding.notifications.skip')} fullWidth size="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.

Onboarding screen — src/app/onboarding.tsx

const sheetRef = useRef<BottomSheetModal>(null);
const [isRequesting, setIsRequesting] = useState(false);

const goNext = useCallback(() => router.push('/auth/create-or-import-seed'), [router]);

const handleGetStarted = useCallback(async () => {
  if (isContinuing) return;
  setIsContinuing(true);
  try {
    const { status, canAskAgain } = await Notifications.getPermissionsAsync();
    if (status === 'granted') return goNext();
    if (!canAskAgain) {
      showMessage({ message: t('onboarding.notifications.disabledHint'), type: 'info' });
      return goNext();
    }
    sheetRef.current?.present();
  } catch (error) {
    logPermissionRequestFailed(error instanceof Error ? error.message : String(error));
    goNext();
  } finally {
    setIsContinuing(false);
  }
}, [isContinuing, goNext, t]);

const handleEnable = useCallback(async () => {
  setIsRequesting(true);
  try {
    await requestNotificationPermissions();          // existing helper, unchanged
  } catch (error) {
    logPermissionRequestFailed(error instanceof Error ? error.message : String(error));
  } finally {
    setIsRequesting(false);
    sheetRef.current?.dismiss();
    goNext();
  }
}, [goNext]);

const handleSkip = useCallback(() => {
  sheetRef.current?.dismiss();
  goNext();
}, [goNext]);

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 sortedi18n-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.

Tests

  • src/components/modal/__tests__/notification-permission-bottom-sheet.test.tsx: renders title/description/buttons; "Enable" calls onEnable; "Not now" calls onSkip; loading disables the CTA.
  • Update src/app/__tests__/onboarding.test.tsx (mock expo-notifications.getPermissionsAsync + requestNotificationPermissions):
    1. undetermined → sheet presented, no navigation yet; tap Enable → requestNotificationPermissions called → navigates.
    2. undetermined → tap Not now → requestNotificationPermissions not called → navigates.
    3. granted → no sheet, navigates directly.
    4. denied + canAskAgain=false → no sheet, toast shown, navigates.
    5. getPermissionsAsync rejects → logger called, navigates.
  • Keep the existing register-device.test.ts untouched (helper unchanged).
  • Reminder from the project conventions: keep the mocked t stable across renders (per-render t mocks make screens with t in effect deps loop).

QA notes

  • iOS: to re-test the OS dialog on a device that already answered, use Settings → General → Transfer or Reset iPhone → Reset → Reset Location & Privacy, or wait for Wallet is automatically reconnected after uninstall/reinstall (seed persisted in iOS Keychain) #247 to be fixed so a reinstall really starts from the onboarding screen.
  • 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.

Related

Tasks

  • Create NotificationPermissionBottomSheet (src/components/modal/) following the Modal/useModal + theme + Button conventions above
  • Wire it in src/app/onboarding.tsx with the granted / ask / blocked branching; trigger stays on "Get Started"
  • Toast hint when canAskAgain === false; logger call on errors (no more console.warn)
  • Add onboarding.notifications.* keys to en.json and fr.json (sorted), run pnpm lint:translations
  • Unit tests for the sheet and the five onboarding branches
  • Manual QA on iOS (light + dark) and Android 12 / 13+; attach screenshots to the PR

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions