Skip to main content

OdontoX Mobile — Plan A: Foundation (Security + Notifications)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Make the app production-secure and wired for notifications — every resume from background requires biometric/mPIN, push tokens register on login, in-app toasts show for foreground notifications, and a Notifications screen exists for all four roles. Architecture: Auth store gains suspend/resume/reauth methods. AppState listener in root layout drives the security gate. expo-notifications listeners handle foreground/background notification delivery. react-native-toast-message is the in-app banner system. All four role layouts get a Notifications tab. Tech Stack: Expo SDK 55, react-native-toast-message ^2.2, expo-haptics ~55.x, @gorhom/bottom-sheet ^5.1 (installed here, used in Plan B), react-native-webview ~14.x (installed here, used in Plan B), Zustand, expo-notifications, expo-local-authentication, React Native AppState.

File Map

FileActionResponsibility
odontox-app/package.jsonModifyAdd all new dependencies
odontox-app/app.jsonModifyAdd react-native-webview plugin
odontox-app/store/auth.tsModifyAdd needsReauth, suspendSession, resumeSession, requireReauth
odontox-app/app/_layout.tsxModifyGestureHandlerRootView wrap, AppState listener, Toast provider, notification listeners
odontox-app/app/auth/pin.tsxModifyHandle needsReauth redirect, push token registration after auth
odontox-app/lib/notifications.tsCreatePush token registration helper
odontox-app/components/ui/PinPad.tsxModifyAdd expo-haptics on press/success/error
odontox-app/components/notifications/NotificationsList.tsxCreateShared notification list UI for all roles
odontox-app/app/(patient)/notifications.tsxCreatePatient notifications screen
odontox-app/app/(doctor)/notifications.tsxCreateDoctor notifications screen
odontox-app/app/(admin)/notifications.tsxCreateAdmin notifications screen
odontox-app/app/(receptionist)/notifications.tsxCreateReceptionist notifications screen
odontox-app/app/(patient)/_layout.tsxModifyAdd Notifications tab with unread badge
odontox-app/app/(doctor)/_layout.tsxModifyAdd Notifications tab with unread badge
odontox-app/app/(admin)/_layout.tsxModifyAdd Notifications tab with unread badge
odontox-app/app/(receptionist)/_layout.tsxModifyAdd Notifications tab with unread badge
odontox-app/app/(patient)/settings/index.tsxModifyReal biometric enrollment toggle

Task 1: Install Dependencies

Files:
  • Modify: odontox-app/package.json
  • Modify: odontox-app/app.json
  • Step 1: Install all new packages
cd odontox-app
npx expo install @gorhom/bottom-sheet react-native-toast-message expo-haptics react-native-webview react-hook-form zod @hookform/resolvers react-native-image-viewing
Expected: packages added to package.json, no peer dep errors. @gorhom/bottom-sheet already has react-native-reanimated and react-native-gesture-handler satisfied.
  • Step 2: Add plugins to app.json
Open odontox-app/app.json. The plugins array currently ends with "expo-sharing". Add "react-native-webview" after it:
"plugins": [
  "expo-router",
  "expo-secure-store",
  [
    "expo-local-authentication",
    { "faceIDPermission": "Allow OdontoX to use Face ID for secure access" }
  ],
  [
    "expo-notifications",
    { "color": "#5048E5" }
  ],
  "expo-sharing",
  "react-native-webview"
]
  • Step 3: Verify TypeScript is happy
cd odontox-app && npx tsc --noEmit 2>&1 | head -20
Expected: only pre-existing errors (if any) — no new errors from the added packages.
  • Step 4: Commit
cd odontox-app && git add package.json app.json
git commit -m "feat(mobile): install bottom-sheet, toast, haptics, webview, rhf, zod, image-viewing"

Task 2: Auth Store — Session Suspension

Files:
  • Modify: odontox-app/store/auth.ts
  • Step 1: Add needsReauth to the state interface and initial state
Open odontox-app/store/auth.ts. In the AuthState interface, after initialized: boolean;, add:
needsReauth: boolean;
suspendSession: () => void;
requireReauth: () => void;
resumeSession: () => Promise<void>;
In the create<AuthState>((set, get) => ({ initial state block, after initialized: false,, add:
needsReauth: false,
  • Step 2: Add the three new methods to the store implementation
After the clearPin method, add:
suspendSession: () => {
  // Clears user from memory only. JWT + pinHash stay in SecureStore.
  // On resume, user is restored from SecureStore after PIN/biometric re-auth.
  set({ user: null, needsReauth: true });
},

requireReauth: () => {
  set({ needsReauth: true, user: null });
},

resumeSession: async () => {
  const userJson = await getUserJson();
  const user = userJson ? (JSON.parse(userJson) as AppUser) : null;
  set({ user, needsReauth: false });
},
  • Step 3: Verify TypeScript compiles
cd odontox-app && npx tsc --noEmit 2>&1 | head -20
Expected: no new errors.
  • Step 4: Commit
git add odontox-app/store/auth.ts
git commit -m "feat(mobile/auth): add suspendSession/requireReauth/resumeSession to auth store"

Task 3: Root Layout — GestureHandlerRootView + AppState + Toast

Files:
  • Modify: odontox-app/app/_layout.tsx
  • Step 1: Replace the full content of app/_layout.tsx
import { useEffect, useRef } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import { Slot, useRouter, useSegments } from 'expo-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import Toast from 'react-native-toast-message';
import * as Notifications from 'expo-notifications';
import { useAuthStore } from '@/store/auth';
import '../global.css';

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
});

// Foreground notification handler — suppress OS banner, show in-app toast instead
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: false,
    shouldPlaySound: false,
    shouldSetBadge: true,
  }),
});

function AuthGate() {
  const { jwt, pinHash, user, initialized, needsReauth } = useAuthStore();
  const router = useRouter();
  const segments = useSegments() as string[];

  useEffect(() => {
    if (!initialized) return;
    const inAuth = segments[0] === 'auth';

    if (!jwt) {
      if (!inAuth || segments[1] !== 'login') router.replace('/auth/login');
      return;
    }
    if (!pinHash) {
      if (!inAuth || segments[1] !== 'set-pin') router.replace('/auth/set-pin');
      return;
    }
    // needsReauth: user suspended on background, must re-verify PIN/biometric
    if (needsReauth || !user) {
      if (!inAuth || segments[1] !== 'pin') router.replace('/auth/pin');
      return;
    }
    if (inAuth) {
      const roleRoute: Record<string, string> = {
        patient: '/(patient)/',
        doctor: '/(doctor)/',
        admin: '/(admin)/',
        receptionist: '/(receptionist)/',
      };
      router.replace((roleRoute[user.role] ?? '/auth/login') as never);
    }
  }, [initialized, jwt, pinHash, user, needsReauth, segments]);

  return <Slot />;
}

function AppStateWatcher() {
  const appState = useRef<AppStateStatus>(AppState.currentState);

  useEffect(() => {
    const sub = AppState.addEventListener('change', (nextState) => {
      if (
        appState.current === 'active' &&
        (nextState === 'background' || nextState === 'inactive')
      ) {
        // Suspend: clear user from memory, keep JWT in SecureStore
        useAuthStore.getState().suspendSession();
      }
      appState.current = nextState;
    });
    return () => sub.remove();
  }, []);

  return null;
}

function NotificationWatcher() {
  const router = useRouter();

  useEffect(() => {
    // Foreground: show in-app toast
    const foregroundSub = Notifications.addNotificationReceivedListener((notification) => {
      Toast.show({
        type: 'info',
        text1: notification.request.content.title ?? 'OdontoX',
        text2: notification.request.content.body ?? '',
        visibilityTime: 4000,
        position: 'top',
      });
    });

    // Background/killed tap: deep link to relevant screen
    const responseSub = Notifications.addNotificationResponseReceivedListener((response) => {
      const url = response.notification.request.content.data?.url as string | undefined;
      if (url) {
        router.push(url as never);
      }
    });

    return () => {
      foregroundSub.remove();
      responseSub.remove();
    };
  }, []);

  return null;
}

export default function RootLayout() {
  const { initialize, initialized } = useAuthStore();

  useEffect(() => {
    if (!initialized) initialize();
  }, []);

  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <QueryClientProvider client={queryClient}>
        <StatusBar style="auto" />
        <AppStateWatcher />
        <NotificationWatcher />
        <AuthGate />
        <Toast />
      </QueryClientProvider>
    </GestureHandlerRootView>
  );
}
  • Step 2: Verify TypeScript compiles
cd odontox-app && npx tsc --noEmit 2>&1 | grep -v "node_modules" | head -20
Expected: no new errors.
  • Step 3: Start app and verify it launches
cd odontox-app && npx expo start --ios 2>&1 | head -30
Expected: app launches, login screen appears, no crash.
  • Step 4: Commit
git add odontox-app/app/_layout.tsx
git commit -m "feat(mobile): add GestureHandlerRootView, AppState suspend, Toast, notification listeners"

Task 4: PIN Screen — Handle needsReauth + Push Token Registration

Files:
  • Create: odontox-app/lib/notifications.ts
  • Modify: odontox-app/app/auth/pin.tsx
  • Step 1: Create lib/notifications.ts
// odontox-app/lib/notifications.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import * as Application from 'expo-application';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
import { papi } from './api';

export async function registerPushToken(): Promise<void> {
  if (!Device.isDevice) return; // Simulators cannot receive push notifications

  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }

  if (finalStatus !== 'granted') return;

  // Android requires a notification channel
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'Default',
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
    });
  }

  const projectId = Constants.expoConfig?.extra?.eas?.projectId as string | undefined;
  if (!projectId) return;

  const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });

  await papi.post('/user-devices', {
    pushToken: tokenData.data,
    deviceModel: Device.modelName ?? undefined,
    osVersion: Device.osVersion ?? undefined,
    appVersion: Application.nativeApplicationVersion ?? undefined,
    bundleId: Application.applicationId ?? undefined,
  }).catch(() => {
    // Non-fatal: device token registration failure does not break auth
  });
}
  • Step 2: Update app/auth/pin.tsx to call registerPushToken after successful auth and handle needsReauth resume
Open odontox-app/app/auth/pin.tsx. At the top, add these imports after the existing ones:
import { registerPushToken } from '@/lib/notifications';
import { fetchAndCachePermissions } from '@/lib/permissions';
Find the function handleSuccess (or wherever router.replace(...) is called after verifyPin/biometric succeeds). Replace the entire success handler with:
async function handleSuccess() {
  const { resumeSession, user: currentUser } = useAuthStore.getState();

  // If returning from background suspension, restore user from SecureStore
  await resumeSession();

  // Register push token (non-blocking, fire-and-forget)
  registerPushToken();

  // Re-cache permissions in case they changed
  fetchAndCachePermissions().catch(() => {});

  const { user } = useAuthStore.getState();
  if (!user) {
    router.replace('/auth/login');
    return;
  }

  const roleRoute: Record<string, string> = {
    patient: '/(patient)/',
    doctor: '/(doctor)/',
    admin: '/(admin)/',
    receptionist: '/(receptionist)/',
  };
  router.replace((roleRoute[user.role] ?? '/auth/login') as never);
}
Find every place router.replace(...) or router.push(...) is called after a success path in the pin screen and replace it with a call to handleSuccess(). There should be two places: biometric success and PIN verification success. The call to verifyPin block should look like:
const ok = await verifyPin(pin);
if (ok) {
  await handleSuccess();
} else {
  setAttempts(a => {
    const next = a + 1;
    if (next >= 5) {
      logout().then(() => router.replace('/auth/login'));
    }
    return next;
  });
  setPin('');
  setError(`Incorrect PIN. ${5 - attempts - 1} attempts remaining.`);
}
The biometric success path:
const result = await LocalAuthentication.authenticateAsync({
  promptMessage: 'Authenticate to open OdontoX',
  fallbackLabel: 'Use PIN',
});
if (result.success) {
  await handleSuccess();
}
  • Step 3: Verify TypeScript compiles
cd odontox-app && npx tsc --noEmit 2>&1 | grep -v "node_modules" | head -20
Expected: no errors.
  • Step 4: Commit
git add odontox-app/lib/notifications.ts odontox-app/app/auth/pin.tsx
git commit -m "feat(mobile): register push token after PIN/biometric auth, handle needsReauth resume"

Task 5: Haptics on PinPad

Files:
  • Modify: odontox-app/components/ui/PinPad.tsx
  • Step 1: Read the current PinPad file
Read odontox-app/components/ui/PinPad.tsx fully to understand its props and structure before editing.
  • Step 2: Add haptics import and wire it up
At the top of PinPad.tsx, add:
import * as Haptics from 'expo-haptics';
Find the onPress or digit press handler (the function called when a digit button is tapped). After the existing logic that appends the digit, add:
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
Find the delete/backspace handler and add:
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
The PinPad component should accept optional onSuccess and onError props (or the parent passes them). Wherever the parent calls success/error after PIN verification, add these calls in the parent (pin.tsx) rather than in PinPad itself: In app/auth/pin.tsx, after the successful handleSuccess() call:
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await handleSuccess();
After a failed PIN verification:
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
  • Step 3: Verify TypeScript compiles
cd odontox-app && npx tsc --noEmit 2>&1 | grep -v "node_modules" | head -10
Expected: no errors.
  • Step 4: Commit
git add odontox-app/components/ui/PinPad.tsx odontox-app/app/auth/pin.tsx
git commit -m "feat(mobile): add haptic feedback to PIN pad"

Task 6: Shared NotificationsList Component

Files:
  • Create: odontox-app/components/notifications/NotificationsList.tsx
  • Step 1: Create the component
// odontox-app/components/notifications/NotificationsList.tsx
import { useEffect, useState } from 'react';
import {
  View, Text, FlatList, RefreshControl, SafeAreaView,
  Pressable, StyleSheet, ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Colors, Typography, Spacing } from '@/constants/theme';
import { papi } from '@/lib/api';

interface AppNotification {
  id: string;
  type: string;
  title: string;
  message: string;
  priority: 'high' | 'medium' | 'low';
  isRead: boolean;
  actionUrl: string | null;
  createdAt: string;
  entityType: string | null;
  entityId: string | null;
}

function timeAgo(dateStr: string): string {
  const diff = Date.now() - new Date(dateStr).getTime();
  const mins = Math.floor(diff / 60000);
  if (mins < 1) return 'just now';
  if (mins < 60) return `${mins}m ago`;
  const hrs = Math.floor(mins / 60);
  if (hrs < 24) return `${hrs}h ago`;
  return `${Math.floor(hrs / 24)}d ago`;
}

const TYPE_ICON: Record<string, string> = {
  appointment: 'calendar-outline',
  payment: 'card-outline',
  patient: 'person-outline',
  system: 'information-circle-outline',
  reminder: 'alarm-outline',
  referral: 'git-branch-outline',
};

export function NotificationsList() {
  const [items, setItems] = useState<AppNotification[]>([]);
  const [loading, setLoading] = useState(true);
  const [refreshing, setRefreshing] = useState(false);

  async function load(isRefresh = false) {
    if (isRefresh) setRefreshing(true); else setLoading(true);
    try {
      const res = await papi.get<{ data: AppNotification[] }>('/notifications?limit=50');
      setItems(res.data ?? []);
    } catch {}
    finally { setLoading(false); setRefreshing(false); }
  }

  async function markRead(id: string) {
    try {
      await papi.patch(`/notifications/${id}/read`, {});
      setItems(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n));
    } catch {}
  }

  useEffect(() => { load(); }, []);

  if (loading) {
    return (
      <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
        <ActivityIndicator color={Colors.brand} />
      </View>
    );
  }

  return (
    <SafeAreaView style={{ flex: 1, backgroundColor: Colors.background }}>
      <View style={s.header}>
        <Text style={s.title}>Notifications</Text>
      </View>
      <FlatList
        data={items}
        keyExtractor={i => i.id}
        refreshControl={
          <RefreshControl refreshing={refreshing} onRefresh={() => load(true)} tintColor={Colors.brand} />
        }
        contentContainerStyle={{ padding: Spacing.lg, gap: Spacing.sm, paddingBottom: 40 }}
        ListEmptyComponent={
          <View style={{ alignItems: 'center', paddingVertical: Spacing['2xl'] }}>
            <Ionicons name="notifications-off-outline" size={40} color={Colors.textMuted} />
            <Text style={{ color: Colors.textMuted, marginTop: Spacing.md }}>No notifications yet</Text>
          </View>
        }
        renderItem={({ item }) => (
          <Pressable
            onPress={() => markRead(item.id)}
            style={({ pressed }) => [s.row, !item.isRead && s.rowUnread, pressed && { opacity: 0.7 }]}
          >
            <View style={[s.iconWrap, { backgroundColor: item.priority === 'high' ? '#FEE2E2' : Colors.brandSurface }]}>
              <Ionicons
                name={(TYPE_ICON[item.type] ?? 'notifications-outline') as any}
                size={18}
                color={item.priority === 'high' ? Colors.error : Colors.brand}
              />
            </View>
            <View style={{ flex: 1 }}>
              <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}>
                <Text style={[s.notifTitle, !item.isRead && s.notifTitleUnread]} numberOfLines={1}>
                  {item.title}
                </Text>
                <Text style={s.time}>{timeAgo(item.createdAt)}</Text>
              </View>
              <Text style={s.message} numberOfLines={2}>{item.message}</Text>
            </View>
            {!item.isRead && <View style={s.dot} />}
          </Pressable>
        )}
      />
    </SafeAreaView>
  );
}

const s = StyleSheet.create({
  header: {
    paddingHorizontal: Spacing.lg,
    paddingTop: Spacing.md,
    paddingBottom: Spacing.sm,
  },
  title: { fontSize: Typography.xl, fontWeight: '700', color: Colors.text },
  row: {
    flexDirection: 'row',
    alignItems: 'flex-start',
    gap: Spacing.md,
    backgroundColor: Colors.surface,
    borderRadius: 12,
    padding: Spacing.md,
    borderWidth: 1,
    borderColor: Colors.border,
  },
  rowUnread: { borderColor: Colors.brand + '40', backgroundColor: Colors.brandSurface + '30' },
  iconWrap: {
    width: 36,
    height: 36,
    borderRadius: 10,
    alignItems: 'center',
    justifyContent: 'center',
    flexShrink: 0,
  },
  notifTitle: { fontSize: Typography.sm, fontWeight: '500', color: Colors.textSecondary, flex: 1, marginRight: 8 },
  notifTitleUnread: { fontWeight: '700', color: Colors.text },
  message: { fontSize: Typography.xs, color: Colors.textMuted, marginTop: 2, lineHeight: 16 },
  time: { fontSize: Typography.xs, color: Colors.textMuted, flexShrink: 0 },
  dot: {
    width: 8,
    height: 8,
    borderRadius: 4,
    backgroundColor: Colors.brand,
    alignSelf: 'center',
    flexShrink: 0,
  },
});
Note: papi.patch needs to exist in lib/api.ts. Verify it exists. If not, add:
patch: <T>(path: string, data: unknown) =>
  apiRequest<T>(`/protected${path}`, { method: 'PATCH', body: JSON.stringify(data) }),
  • Step 2: Commit
git add odontox-app/components/notifications/NotificationsList.tsx
git commit -m "feat(mobile): add shared NotificationsList component"

Task 7: Notifications Screens + Unread Count Hook

Files:
  • Create: odontox-app/hooks/useUnreadCount.ts
  • Create: odontox-app/app/(patient)/notifications.tsx
  • Create: odontox-app/app/(doctor)/notifications.tsx
  • Create: odontox-app/app/(admin)/notifications.tsx
  • Create: odontox-app/app/(receptionist)/notifications.tsx
  • Step 1: Create hooks/useUnreadCount.ts
// odontox-app/hooks/useUnreadCount.ts
import { useState, useEffect, useCallback } from 'react';
import { AppState } from 'react-native';
import { papi } from '@/lib/api';
import { useAuthStore } from '@/store/auth';

export function useUnreadCount() {
  const { user } = useAuthStore();
  const [count, setCount] = useState(0);

  const refresh = useCallback(async () => {
    if (!user) return;
    try {
      const res = await papi.get<{ count: number }>('/notifications/unread-count');
      setCount(res.count ?? 0);
    } catch {}
  }, [user]);

  useEffect(() => {
    refresh();
    // Refresh on app foreground
    const sub = AppState.addEventListener('change', state => {
      if (state === 'active') refresh();
    });
    return () => sub.remove();
  }, [refresh]);

  return count;
}
  • Step 2: Create app/(patient)/notifications.tsx
// odontox-app/app/(patient)/notifications.tsx
import { NotificationsList } from '@/components/notifications/NotificationsList';
export default function PatientNotifications() {
  return <NotificationsList />;
}
  • Step 3: Create app/(doctor)/notifications.tsx
// odontox-app/app/(doctor)/notifications.tsx
import { NotificationsList } from '@/components/notifications/NotificationsList';
export default function DoctorNotifications() {
  return <NotificationsList />;
}
  • Step 4: Create app/(admin)/notifications.tsx
// odontox-app/app/(admin)/notifications.tsx
import { NotificationsList } from '@/components/notifications/NotificationsList';
export default function AdminNotifications() {
  return <NotificationsList />;
}
  • Step 5: Create app/(receptionist)/notifications.tsx
// odontox-app/app/(receptionist)/notifications.tsx
import { NotificationsList } from '@/components/notifications/NotificationsList';
export default function ReceptionistNotifications() {
  return <NotificationsList />;
}
  • Step 6: Commit
git add odontox-app/hooks/useUnreadCount.ts \
  "odontox-app/app/(patient)/notifications.tsx" \
  "odontox-app/app/(doctor)/notifications.tsx" \
  "odontox-app/app/(admin)/notifications.tsx" \
  "odontox-app/app/(receptionist)/notifications.tsx"
git commit -m "feat(mobile): add notifications screens and unread count hook"

Task 8: Add Notifications Tab to All Role Layouts

Files:
  • Modify: odontox-app/app/(patient)/_layout.tsx
  • Modify: odontox-app/app/(doctor)/_layout.tsx
  • Modify: odontox-app/app/(admin)/_layout.tsx
  • Modify: odontox-app/app/(receptionist)/_layout.tsx
  • Step 1: Read each layout file first
Read all four layout files before editing to understand their current tab structure.
  • Step 2: Update app/(patient)/_layout.tsx
The patient layout currently has 6 tabs (Home, Appointments, Records, Bills, Chat, Settings). Add a Notifications tab between Chat and Settings. The updated layout should import useUnreadCount and show a badge:
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { View, Text, StyleSheet } from 'react-native';
import { Colors } from '@/constants/theme';
import { useUnreadCount } from '@/hooks/useUnreadCount';

function Badge({ count }: { count: number }) {
  if (count === 0) return null;
  return (
    <View style={bs.badge}>
      <Text style={bs.badgeText}>{count > 99 ? '99+' : count}</Text>
    </View>
  );
}

const bs = StyleSheet.create({
  badge: {
    position: 'absolute',
    top: -4,
    right: -8,
    backgroundColor: Colors.error,
    borderRadius: 8,
    minWidth: 16,
    height: 16,
    alignItems: 'center',
    justifyContent: 'center',
    paddingHorizontal: 3,
  },
  badgeText: { color: '#fff', fontSize: 10, fontWeight: '700' },
});

export default function PatientLayout() {
  const unread = useUnreadCount();

  return (
    <Tabs screenOptions={{ headerShown: false, tabBarActiveTintColor: Colors.brand }}>
      <Tabs.Screen name="index" options={{ title: 'Home', tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} /> }} />
      <Tabs.Screen name="appointments" options={{ title: 'Appointments', tabBarIcon: ({ color, size }) => <Ionicons name="calendar-outline" size={size} color={color} /> }} />
      <Tabs.Screen name="records" options={{ title: 'Records', tabBarIcon: ({ color, size }) => <Ionicons name="document-text-outline" size={size} color={color} /> }} />
      <Tabs.Screen name="bills" options={{ title: 'Bills', tabBarIcon: ({ color, size }) => <Ionicons name="card-outline" size={size} color={color} /> }} />
      <Tabs.Screen name="chat" options={{ title: 'Chat', tabBarIcon: ({ color, size }) => <Ionicons name="chatbubble-outline" size={size} color={color} /> }} />
      <Tabs.Screen
        name="notifications"
        options={{
          title: 'Alerts',
          tabBarIcon: ({ color, size, focused }) => (
            <View>
              <Ionicons name={focused ? 'notifications' : 'notifications-outline'} size={size} color={color} />
              <Badge count={unread} />
            </View>
          ),
        }}
      />
      <Tabs.Screen name="settings" options={{ title: 'Settings', tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} /> }} />
    </Tabs>
  );
}
  • Step 3: Update app/(doctor)/_layout.tsx, app/(admin)/_layout.tsx, app/(receptionist)/_layout.tsx
Apply the same pattern to each: import useUnreadCount, add the Badge component, add a notifications tab before settings. The tab icons and existing tabs differ per role — preserve existing tabs exactly, just insert notifications. For each layout, the notifications tab entry to add is:
<Tabs.Screen
  name="notifications"
  options={{
    title: 'Alerts',
    tabBarIcon: ({ color, size, focused }) => (
      <View>
        <Ionicons name={focused ? 'notifications' : 'notifications-outline'} size={size} color={color} />
        <Badge count={unread} />
      </View>
    ),
  }}
/>
  • Step 4: Verify TypeScript compiles
cd odontox-app && npx tsc --noEmit 2>&1 | grep -v "node_modules" | head -20
Expected: no errors.
  • Step 5: Commit
git add "odontox-app/app/(patient)/_layout.tsx" \
  "odontox-app/app/(doctor)/_layout.tsx" \
  "odontox-app/app/(admin)/_layout.tsx" \
  "odontox-app/app/(receptionist)/_layout.tsx"
git commit -m "feat(mobile): add Notifications tab with unread badge to all role layouts"

Task 9: Biometric Enrollment Toggle in Settings

Files:
  • Modify: odontox-app/app/(patient)/settings/index.tsx
  • Step 1: Read the current settings screen
Read odontox-app/app/(patient)/settings/index.tsx fully.
  • Step 2: Replace the biometric section with a real toggle
Find the section that currently shows biometric status as read-only. Replace it with:
import * as LocalAuthentication from 'expo-local-authentication';
import * as SecureStore from 'expo-secure-store';

// Add state inside the component:
const [biometricAvailable, setBiometricAvailable] = useState(false);
const [biometricEnabled, setBiometricEnabled] = useState(false);

useEffect(() => {
  async function checkBiometric() {
    const hasHardware = await LocalAuthentication.hasHardwareAsync();
    const isEnrolled = await LocalAuthentication.isEnrolledAsync();
    setBiometricAvailable(hasHardware && isEnrolled);
    const stored = await SecureStore.getItemAsync('odx_biometric_enabled');
    setBiometricEnabled(stored === 'true');
  }
  checkBiometric();
}, []);

async function toggleBiometric() {
  if (!biometricEnabled) {
    // Prove biometric works before enabling
    const result = await LocalAuthentication.authenticateAsync({
      promptMessage: 'Enable Face ID / Fingerprint for OdontoX',
    });
    if (result.success) {
      await SecureStore.setItemAsync('odx_biometric_enabled', 'true');
      setBiometricEnabled(true);
    }
  } else {
    await SecureStore.deleteItemAsync('odx_biometric_enabled');
    setBiometricEnabled(false);
  }
}
Replace the biometric display JSX with a Switch component:
{biometricAvailable && (
  <View style={s.row}>
    <View style={{ flex: 1 }}>
      <Text style={s.rowLabel}>Face ID / Fingerprint</Text>
      <Text style={s.rowSub}>Unlock OdontoX without your PIN</Text>
    </View>
    <Switch
      value={biometricEnabled}
      onValueChange={toggleBiometric}
      trackColor={{ false: Colors.border, true: Colors.brand }}
      thumbColor="#fff"
    />
  </View>
)}
Import Switch from react-native. Note: The pin.tsx screen’s biometric flow reads odx_biometric_enabled from SecureStore. Verify in app/auth/pin.tsx that the biometric auto-prompt only fires when this key is 'true'. If currently it uses LocalAuthentication.isEnrolledAsync() as the gate, update it to also check:
const biometricPref = await SecureStore.getItemAsync('odx_biometric_enabled');
const shouldUseBiometric = biometricAvailable && biometricPref === 'true';
if (shouldUseBiometric) { /* trigger biometric */ }
  • Step 3: Apply the same settings pattern to doctor/admin/receptionist settings screens
Each role has a settings/index.tsx. Apply the same biometric toggle to each. The code is identical — copy exactly.
  • Step 4: Verify TypeScript compiles
cd odontox-app && npx tsc --noEmit 2>&1 | grep -v "node_modules" | head -20
Expected: no errors.
  • Step 5: Commit
git add "odontox-app/app/(patient)/settings/index.tsx" \
  "odontox-app/app/(doctor)/settings/index.tsx" \
  "odontox-app/app/(admin)/settings/index.tsx" \
  "odontox-app/app/(receptionist)/settings/index.tsx"
git commit -m "feat(mobile): biometric enrollment toggle in settings for all roles"

Plan A — Done

At this point the app has:
  • ✅ Proper session suspension on background + biometric/PIN on every resume
  • ✅ Push tokens registered after auth
  • ✅ Foreground notifications shown as in-app toasts
  • ✅ Background notification taps deep-link to correct screens
  • ✅ Notifications screen for all four roles with unread badge
  • ✅ Biometric enrollment toggle in settings
  • ✅ Haptic feedback on PIN pad
Verify before moving to Plan B:
cd odontox-app
eas build --platform ios --profile preview --local 2>&1 | tail -10
If local build is not available, run: eas build --platform ios --profile preview Install on device via TestFlight or Apple Configurator 2. Run through auth flow, background app, return — verify PIN screen appears.