Skip to main content

OdontoX Mobile — Production-Ready Completion Spec

Date: 2026-05-08 Status: Approved for implementation References: 2026-05-06-odontox-mobile-app-design.md (architecture baseline) Target: Physical iOS device (TestFlight) + Google Play Internal Testing track

1. What This Spec Covers

The existing app has correct structure and all 19 server endpoints wired. This spec closes the gap between “screens exist and call APIs” and “production-ready app on a real device”. Six critical gaps + component library uplift + rule engine integration + notifications.

2. New Dependencies

Install all before writing any code. All are compatible with Expo SDK 55 managed workflow via EAS Build.
npx expo install @gorhom/bottom-sheet react-native-pdf react-native-image-viewing \
  react-native-toast-message react-hook-form zod expo-haptics react-native-webview
PackageVersionPurpose
@gorhom/bottom-sheet^5.1Booking modal, filter drawer, detail sheets. Uses existing reanimated + gesture-handler.
react-native-pdf^6.7True in-app PDF rendering (iOS + Android). EAS Build only — not Expo Go.
react-native-image-viewing^0.2X-ray / file image lightbox with pinch-zoom.
react-native-toast-message^2.2In-app notification toasts when app is foregrounded.
react-hook-form^7.54Booking form + patient create form validation.
zod^3.23Schema validation for booking form (same version as server).
expo-haptics~55.xTactile feedback on PIN pad digit press and biometric success.
react-native-webview~14.xFallback PDF renderer on Android if react-native-pdf unavailable.

app.json additions

"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-pdf",
  "react-native-webview"
]

3. Component → Library Map (no reinventing wheels)

FeatureLibraryAlready installed?
Date selection (booking, calendar view)react-native-calendars
Day/week schedule gridreact-native-calendars Agenda
Bar/line chartsreact-native-gifted-charts
Bottom sheet (booking, filters, detail)@gorhom/bottom-sheet v5🆕
mPIN padExisting components/ui/PinPad.tsx + expo-haptics✅ + 🆕
PDF viewer (in-app)react-native-pdf🆕
Image / X-ray lightboxreact-native-image-viewing🆕
In-app toast / notification bannerreact-native-toast-message🆕
Booking form validationreact-hook-form + zod🆕
Slot grid (available times)Custom grid → /available-slots APIbuilt here
Push notification tokenexpo-notifications (registered on login)✅ (unwired)
AppState (background/foreground)React Native AppState built-inbuilt here
Biometric on resumeexpo-local-authentication✅ (unwired)
Skeleton loadingreact-native-gifted-charts has built-in + manual shimmer

4. Gap 1 — AppState Session Security

Problem: App goes to background → comes back → no re-auth. v2 decision: session flushes on close. Fix: Add AppState listener in app/_layout.tsx.
// In RootLayout component, after useEffect({ initialize })
useEffect(() => {
  let backgroundedAt: number | null = null;
  const LOCK_AFTER_MS = 15 * 60 * 1000; // 15 min

  const sub = AppState.addEventListener('change', async (state) => {
    if (state === 'background') {
      backgroundedAt = Date.now();
      // Flush user from memory (keeps jwt+pin in SecureStore)
      useAuthStore.getState().suspendSession();
    }
    if (state === 'active' && backgroundedAt !== null) {
      const elapsed = Date.now() - backgroundedAt;
      if (elapsed > LOCK_AFTER_MS) {
        // Full re-login required
        await useAuthStore.getState().logout();
      } else {
        // Re-auth with biometric or PIN
        useAuthStore.getState().requireReauth();
      }
      backgroundedAt = null;
    }
  });
  return () => sub.remove();
}, []);
Auth store additions:
  • suspendSession() — clears user from memory but NOT from SecureStore (keeps jwt/pin)
  • requireReauth() — sets needsReauth: true flag, AuthGate redirects to /auth/pin
AuthGate addition: check needsReauth state → redirect to /auth/pin if true.

5. Gap 2 — Appointment Booking Flow

Problem: Patients can’t book. No booking UI anywhere. Architecture: Bottom sheet modal launched from patient appointments screen + home screen CTA.

5a. Slot grid component (components/ui/SlotGrid.tsx)

Mirrors the web SlotPicker. Calls /protected/appointments/available-slots?date=YYYY-MM-DD. Response shape (existing endpoint): { slots: string[], clinicClosed: bool, doctorOff: bool, clinicPhone: string|null }
// SlotGrid renders a 3-column grid of time slots
// Selected slot highlighted with brand indigo
// clinicClosed / doctorOff states show contextual empty state with clinic phone
// Loading: shows 9 shimmer placeholders

5b. Booking bottom sheet (components/booking/BookingSheet.tsx)

Three steps inside a single @gorhom/bottom-sheet:
Step 1: Date selection
  └─ react-native-calendars Calendar
  └─ Disable past dates (minDate = today)
  └─ On day select → fetch slots

Step 2: Slot selection
  └─ SlotGrid component
  └─ Selected slot persisted in react-hook-form

Step 3: Confirm
  └─ Appointment type (Checkup / Cleaning / Emergency / Consultation)
  └─ Notes (optional, TextInput)
  └─ Submit → POST /protected/appointments
     body: { appointmentDate, appointmentTime, appointmentType, notes, status: 'requested' }

5c. Zod schema for booking form

const bookingSchema = z.object({
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  time: z.string().regex(/^\d{2}:\d{2}$/),
  appointmentType: z.enum(['checkup', 'cleaning', 'emergency', 'consultation', 'other']),
  notes: z.string().max(500).optional(),
});

5d. Rule engine on mobile (client side)

The server enforces all rules. On mobile:
  • Past dates: minDate={today} on calendar — never shown
  • Available slots: only slots from /available-slots shown — booked/clinic-closed/doctor-off handled by API
  • Status: Patient submits status: 'requested' — clinic approves. Same as web patient portal.
  • Errors from server: Surface rule engine message field in a toast (react-native-toast-message)
Example: if server returns { code: 'PATIENT_DUPLICATE', message: '...' } → show error toast, keep sheet open.

5e. Cancellation (patient)

On appointment detail screen, add “Cancel appointment” button (only shown for upcoming, non-cancelled appointments).
→ Confirmation alert
→ PATCH /protected/appointments/:id/status { status: 'cancelled' }
→ Server applies canCancelAppointment rule (48h window)
→ If blocked: surface rule engine message in toast
→ If success: navigate back, invalidate appointments query

5f. Per-role booking access

RoleCan create appointmentHow
Patient✅ Request (status: requested)BookingSheet from appointments + home CTA
Doctor✅ Schedule directly (status: scheduled)New appointment from their patient detail
Receptionist✅ Schedule directly (status: scheduled)New appointment from appointments list
Admin✅ Schedule directly (status: scheduled)Same as receptionist
Doctor/receptionist/admin get a fuller form (patient search, doctor assignment, room) in their booking sheet.

6. Gap 3 — Past Appointments Display

Problem: past[] array fetched but never rendered. Fix: Add a “Past” section to patient appointments screen.
// After upcoming list, show past section:
{past.length > 0 && (
  <View style={s.sectionHeader}>
    <Text style={s.sectionTitle}>Past Appointments</Text>
  </View>
)}
// Past items rendered with muted styling (opacity 0.7, grey status badge)
// Tappable → appointment detail (read-only, no cancel button for past)

7. Gap 4 — Push Notification Token Registration

Problem: expo-notifications configured but token never registered with server. Fix: Register immediately after successful PIN verification / biometric auth in app/auth/pin.tsx.
// After successful verifyPin or biometric in pin.tsx:
async function registerPushToken() {
  const { status } = await Notifications.requestPermissionsAsync();
  if (status !== 'granted') return;
  const token = await Notifications.getExpoPushTokenAsync({
    projectId: Constants.expoConfig?.extra?.eas?.projectId,
  });
  // POST to existing endpoint
  await papi.post('/user-devices', {
    pushToken: token.data,
    deviceModel: Device.modelName,
    osVersion: Device.osVersion,
    appVersion: Application.nativeApplicationVersion,
    bundleId: Application.applicationId,
  });
}
Additional packages needed: expo-device, expo-application, expo-constants (all included in SDK 55 managed workflow).

Notification listeners in _layout.tsx

// In RootLayout:
useEffect(() => {
  // Foreground: suppress OS banner, show in-app toast
  const sub1 = Notifications.addNotificationReceivedListener(notification => {
    Toast.show({
      type: 'info',
      text1: notification.request.content.title ?? 'OdontoX',
      text2: notification.request.content.body ?? '',
    });
  });

  // Tap on notification (background/killed): deep link
  const sub2 = Notifications.addNotificationResponseReceivedListener(response => {
    const url = response.notification.request.content.data?.url as string;
    if (url) router.push(url as never);
  });

  return () => { sub1.remove(); sub2.remove(); };
}, []);

Notifications screen (all roles)

New screen /(patient)/notifications (and equivalent per role). Calls GET /protected/notifications?limit=50 — endpoint exists. Renders list with:
  • Notification title + message
  • Relative time (e.g. “2 hours ago”)
  • Unread dot
  • Tap → deep link to actionUrl if present
  • Swipe left to mark read → PATCH /protected/notifications/:id/read
Add notification bell icon to each role’s settings/profile tab with unread count badge.

8. Gap 5 — Real Chart Data

Problem: Doctor + Admin weekly bar charts show hardcoded data. Fix: Both /stats/doctor and /stats/admin responses include weekly data. Check response shape and wire it. If the endpoint doesn’t return weekly breakdown, add a weeklyAppointments field:
// Client fallback: fetch appointments for current week
// GET /protected/appointments?startDate=MON&endDate=SUN&limit=200
// Group by appointmentDate, count per day → build WEEK_CHART from real data
Use react-native-gifted-charts BarChart as-is, just swap data source.

9. Gap 6 — Records Drill-Down + PDF/File Viewer

Problem: Records list tappable items go nowhere. PDF downloads fail or open share sheet only.

9a. PDF Viewer (components/viewer/PdfViewer.tsx)

Uses react-native-pdf:
import Pdf from 'react-native-pdf';

// PdfViewer screen receives { url: string, title: string }
// 1. Download signed URL via expo-file-system to cache
// 2. Render with react-native-pdf (native iOS/Android renderer)
// 3. Header with back button + share button (expo-sharing)
// 4. Loading progress bar during download
For Android fallback: if react-native-pdf render fails, open URL in react-native-webview using Google Docs viewer: https://docs.google.com/viewer?url=${encodeURIComponent(signedUrl)}.

9b. Image Viewer (components/viewer/ImageViewer.tsx)

Uses react-native-image-viewing:
import ImageViewing from 'react-native-image-viewing';
// Shows X-ray images, patient photos, uploaded files
// Pinch-zoom, swipe gallery for multiple images
// Share button via expo-sharing

9c. Records detail routing

From patient records list, tap routes based on item type:
  • prescription/(patient)/records/prescription/[id] — Prescription detail screen
  • file/(patient)/records/file/[id] — PdfViewer or ImageViewer based on MIME type
  • clinical_note/(patient)/records/note/[id] — Clinical note text display
From bills screen, tap “Download PDF” → PdfViewer (not share sheet).

9d. New server API needed

Prescriptions detail: GET /protected/prescriptions/:id — check if exists. Patient files: GET /protected/patient-files/:id — returns signed URL.

10. Per-Role Content Relevance

Doctor Home

  • “Today’s appointments” must filter by doctorId = currentUser.id
  • API already supports: GET /protected/appointments?date=today&doctorId={userId}
  • Doctor should ONLY see their own patients, not all clinic patients
  • Patient list: GET /protected/patients?doctorId={userId} (check if server supports filter — if not, filter client-side from response)

Receptionist Home

  • Show today’s appointment queue sorted by appointmentTime
  • Show “Check In” button on each appointment (PATCH status → in_progress)
  • Show walk-in count, no-show count for today

Admin Home

  • All clinic appointments (no doctor filter)
  • Full KPI strip: revenue today, appointments today, checked-in, no-shows
  • Weekly chart from real data (see Gap 5)

Patient Home

  • Only their own data — server enforces this for all /stats/patient/* endpoints
  • Next appointment CTA: if no upcoming → show “Book Appointment” button

11. Biometric Enrollment Toggle (Settings)

Problem: Settings shows biometric status read-only. Fix: Add toggle that actually enrolls:
// If biometricAvailable && !enrolled:
//   Show "Enable Face ID" button
//   On press: attempt authenticateAsync() → if success, store flag in SecureStore
//   Future PIN verifications → try biometric first automatically (already done in pin.tsx)

// If enrolled:
//   Show "Disable Face ID" toggle
//   On disable: remove flag from SecureStore
No extra library needed — expo-local-authentication already in place.

12. Haptics on PIN Pad

Fix in components/ui/PinPad.tsx:
import * as Haptics from 'expo-haptics';

// On each digit press:
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);

// On PIN success:
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);

// On PIN failure:
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);

13. Toast Setup

Add <ToastMessage /> to root layout (above <Slot />):
// app/_layout.tsx
import Toast from 'react-native-toast-message';

// At end of QueryClientProvider JSX:
<>
  <StatusBar style="auto" />
  <AuthGate />
  <Toast />
</>
Use from anywhere: Toast.show({ type: 'success' | 'error' | 'info', text1, text2 }).

14. EAS Build Config for react-native-pdf

react-native-pdf requires a custom dev client (not compatible with Expo Go). Update eas.json:
{
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal"
    },
    "preview": {
      "distribution": "internal",
      "ios": { "buildConfiguration": "Release" },
      "android": { "buildType": "apk" }
    },
    "production": {
      "autoIncrement": true,
      "android": { "buildType": "aab" }
    }
  }
}

15. Testing Without Publishing

iOS — physical device

# Build preview .ipa (real native, Release config, no App Store)
eas build --platform ios --profile preview

# Install options (pick one):
# A. TestFlight: eas submit --platform ios --profile preview
#    → App Store Connect → TestFlight → add your Apple ID as tester → install via TestFlight app
# B. Direct: download .ipa from EAS dashboard → Apple Configurator 2 → drag to connected device
# C. AltStore: install AltStore on device → sideload .ipa (free, no Apple account needed)

Android — physical device

# Build preview .apk (direct install, no Play Store)
eas build --platform android --profile preview

# Install:
# A. Direct: download .apk → email/AirDrop to Android → tap to install
#    (Settings → Security → Install unknown apps → allow)
# B. Google Play Internal Testing: upload .aab to Play Console → Internal Testing track
#    → share opt-in link with yourself → install via Play Store. No review. Instant.

E2E test checklist before submission

Auth:
[ ] Login with each role (patient/doctor/admin/receptionist)
[ ] PIN setup on first login
[ ] Biometric fallback
[ ] Background for 16+ min → re-login required
[ ] Background for < 15 min → PIN re-auth only

Patient:
[ ] Home shows next appointment + stats
[ ] Book appointment → date picker → slot grid → confirm → appointment appears in list
[ ] Cancel appointment (< 48h before → error shown, > 48h → success)
[ ] Past appointments visible and tappable
[ ] Records list → tap prescription → detail screen
[ ] Records list → tap file → PDF viewer renders
[ ] Bills → tap invoice → PDF downloads and renders in-app

Doctor:
[ ] Home shows TODAY's own appointments only
[ ] Appointment detail → mark in-progress → mark completed
[ ] Patient list → patient detail → clinical notes visible
[ ] Chat message send/receive

Receptionist:
[ ] Today queue on home, sorted by time
[ ] Check-in appointment (status → in_progress)
[ ] Finance → invoices + receipts, PDF viewer

Admin:
[ ] KPIs on home (real data)
[ ] Weekly chart shows real week data
[ ] Finance tab shows all clinic invoices

Notifications:
[ ] Push notification received while app is open → in-app toast shows
[ ] Push notification received while app is closed → tap opens correct screen
[ ] Notifications screen shows all notifications

16. Deployment Pipeline

code changes

eas build --profile preview (iOS + Android in parallel)

Install on physical devices and run checklist above

eas build --profile production

iOS: eas submit --platform ios → App Store Connect → Submit for Review (~24-48h)
Android: eas submit --platform android --track internal → Internal Testing (instant)
         → when ready: promote to Production track (~3-7h review)

OTA updates (post-launch)

For JS-only changes (no new native modules): eas update --channel production — reaches devices in ~5 min with no store review.

17. Desktop-Only Decisions (Critical Cuts)

FeatureDecisionRationale
Dental chart write modeDesktop onlySelecting individual teeth on 6” screen is unusable UX
Treatment plan authoringDesktop onlyMulti-service tables, cost estimation requires large viewport
DICOM workstationDesktop onlyWindowing, measurements, pixel-level zoom = desktop
Staff/user managementDesktop onlySecurity-sensitive configuration
Clinic settingsDesktop onlyOperating hours, service catalog, plan config
PayrollDesktop only
Report builderDashboard summary only on mobileFilter/export builder = desktop
Lab case creationDesktop only (view status on mobile)Complex attachments and workflows
What’s kept mobile that might seem complex:
  • Receptionist invoice creation (chair-side payment is real workflow)
  • Doctor quick clinical note (post-consult at chair-side)
  • Doctor new prescription (30-second Rx before next patient)

18. Server-Side Additions Required

AdditionEndpointStatus
Push token registrationPOST /protected/user-devices✅ exists
Notifications listGET /protected/notifications✅ exists
Available slotsGET /protected/appointments/available-slots✅ exists
Patient files signed URLGET /protected/patient-files/:idverify exists
Prescription detailGET /protected/prescriptions/:idverify exists
Mark notification readPATCH /protected/notifications/:id/readverify exists
Doctor-filtered patient listGET /protected/patients?doctorId=Xverify filter works
No new infrastructure. All endpoints on existing Cloudflare Workers / Hono.