DocuThinker-AI-App

DocuThinker Mobile - React Native + Expo

The DocuThinker mobile app is a production-grade React Native (Expo SDK 51) client that talks directly to the deployed DocuThinker backend. It mirrors the web frontend’s auth model and feature surface so users can sign in once and analyze, browse, and chat with their documents from iOS or Android.


Table of Contents


Overview

Β  Β 
Framework React Native 0.74 via Expo SDK 51
Language TypeScript
Router expo-router (file-based routing, typed routes enabled)
State React hooks + module-level event emitter (no Redux)
Persistence @react-native-async-storage/async-storage
Backend https://docuthinker-app-backend-api.vercel.app (shared with web)
Auth model Firebase custom token + userId, identical to web client
Runtime Expo Go (SDK 51) - npx expo start
Min targets iOS 14+, Android 7+

The app is not a shell or mock. Every screen reads from the same Vercel backend the web frontend uses. Sample data is limited to static UI copy (the four feature tiles on the Home screen).


Screenshots

Every screen, captured on real signed-in state (account newemail@example.com, ~37 docs, 590 days active). iOS shots are iPhone 16 Pro / iOS 18.5. Android shots are Pixel 6 / API 34. For deep dives and walkthroughs, see MOBILE_APPS.md.

Unauthenticated

Login Register Forgot password
iOS Login iOS Register iOS Forgot
Android Login Android Register Android Forgot

Authenticated tabs

Home Library Profile
iOS Home iOS Library iOS Profile
Android Home Android Library Android Profile

Document flow

Upload Summary Chat
iOS Upload iOS Summary iOS Chat
Android Upload Android Summary Android Chat

Summary now also has Generate key ideas and Generate discussion points buttons that hit POST /generate-key-ideas and POST /generate-discussion-points. Both responses render through MarkdownText so the lists, bold, and headings come through styled the same as the web client.

Settings

Account details Appearance Connections
iOS Account iOS Appearance iOS Connections
Android Account Android Appearance Android Connections
Privacy & security Help & support
iOS Privacy iOS Help
Android Privacy Android Help

Every settings row is fully implemented - no β€œcoming soon” stubs. Account writes via /update-email + /update-password, Appearance persists via lib/prefs.ts and pushes light/dark to /update-theme, Connections round-trip through /social-media + /update-social-media, and Privacy can purge all docs via DELETE /documents/:userId.

Loading states

iOS - Home loading Android - Home loading
iOS Home loading Android Home loading

Stat chips and recent-doc rows show animated Skeleton placeholders (see components/Skeleton.tsx) on first load so the screen never reads β€œ0 Documents Β· 0 Days active” before the real numbers arrive. The same pattern is used on Library, Profile, Account, Connections, and Privacy.


Architecture

graph TB
    subgraph Mobile["πŸ“± Mobile App (React Native / Expo SDK 51)"]
        direction TB
        Router["expo-router<br/>file-based routing"]
        subgraph Screens["Screens"]
            Login[login.tsx]
            Register[register.tsx]
            Home["(tabs)/index.tsx"]
            Docs["(tabs)/documents.tsx"]
            Profile["(tabs)/profile.tsx"]
            Upload[upload.tsx]
            Summary[summary.tsx]
            Chat[chat.tsx]
        end
        subgraph Lib["lib/"]
            Auth["auth.ts<br/>AsyncStorage + emitter"]
            API["api.ts<br/>fetch wrapper"]
        end
        subgraph UI["components/"]
            Screen["Screen + ScreenHeader"]
            UIKit["Avatar, Card, Pill,<br/>Button, TextField, IconCircle"]
        end
        Pickers["expo-document-picker<br/>expo-file-system"]
    end

    subgraph Backend["☁️ Backend (Vercel)"]
        REST["Express API<br/>docuthinker-app-backend-api.vercel.app"]
        FB[(Firebase Auth)]
        FS[(Firestore)]
        AI[Google AI / LangChain]
    end

    Router --> Screens
    Login --> Auth
    Register --> API
    Screens --> API
    Upload --> Pickers
    Pickers --> Auth
    Auth -.->|Bearer token<br/>opt-in| API
    API -->|HTTPS JSON| REST
    REST --> FB
    REST --> FS
    REST --> AI

The data plane is a single fetch wrapper (lib/api.ts) that all screens consume. The control plane is a tiny module-level emitter in lib/auth.ts that broadcasts login/logout - app/_layout.tsx subscribes to it and redirects between the auth stack (/login, /register) and the tabs group depending on whether a userId is present.


Screen Map

flowchart LR
    Boot(["App start"]) --> Hydrate["hydrateAuth"]
    Hydrate --> Authed{"userId in<br/>AsyncStorage?"}
    Authed -- "No" --> LoginRoute["login"]
    Authed -- "Yes" --> Tabs["tabs group"]

    LoginRoute -- "Sign In OK" --> Tabs
    LoginRoute -- "Create account" --> RegRoute["register"]
    RegRoute -- "Registered" --> LoginRoute

    subgraph TabBar["Tab Bar"]
        TabHome["Home"]
        TabLib["Library"]
        TabProf["Profile"]
    end

    Tabs --> TabHome
    Tabs --> TabLib
    Tabs --> TabProf

    TabHome -- "Analyze a Document" --> UploadRoute["upload"]
    TabHome -- "Recent doc tap" --> SummaryRoute["summary"]
    TabLib -- "Doc tap" --> SummaryRoute
    UploadRoute -- "upload OK" --> SummaryRoute
    SummaryRoute -- "Chat about document" --> ChatRoute["chat"]
    TabProf -- "Sign out" --> Boot

Every transition out of an authed route into /login flows through clearAuth(), which removes the AsyncStorage keys and broadcasts to the root layout - no hard refresh required.


Auth Flow

Mobile auth is intentionally symmetric with frontend/src/utils/auth.js. The same backend endpoints, same customToken + userId storage keys, same event-driven re-render strategy.

sequenceDiagram
    autonumber
    actor User
    participant Login as login.tsx
    participant API as lib/api.ts
    participant Backend as Express /login
    participant Auth as lib/auth.ts
    participant Storage as AsyncStorage
    participant Layout as app/_layout.tsx

    User->>Login: Enter email + password
    Login->>API: api.login(email, pwd)
    API->>Backend: POST /login {email, password}
    Backend-->>API: 200 {customToken, userId}
    API-->>Login: {customToken, userId}
    Login->>Auth: setAuth(customToken, userId)
    Auth->>Storage: multiSet([token, userId])
    Auth-->>Layout: emit() onAuthChange
    Layout->>Layout: setAuthed(true)
    Layout-->>User: router.replace("/")

Sign-out reverses the flow: clearAuth() β†’ AsyncStorage.multiRemove β†’ emit β†’ layout redirects to /login.

lib/auth.ts API:

Function Purpose
hydrateAuth() One-shot async read of stored credentials at boot; populates module cache
isAuthenticated() Sync check against cached userId
getToken() / getUserId() Sync accessors for api.ts and screens
setAuth(token, userId) Persist + emit
clearAuth() Wipe + emit
onAuthChange(handler) Subscribe; returns unsubscribe

API Client

lib/api.ts wraps fetch with three concerns: JSON content-type defaults, optional Authorization: Bearer <token> when callers pass auth: true, and error normalization (server error/message body β†’ thrown Error.message).

classDiagram
    class api {
        +login(email, password) LoginResponse
        +register(email, password) MessageResponse
        +verifyEmail(email) VerifyResponse
        +forgotPassword(email, newPassword) MessageResponse
        +getUserEmail(userId) EmailResponse
        +getDaysSinceJoined(userId) DaysResponse
        +getDocumentCount(userId) CountResponse
        +getUserJoinedDate(userId) JoinedResponse
        +getDocuments(userId) DocumentSummaryList
        +getDocumentDetails(userId, docId) DocumentDetails
        +deleteDocument(userId, docId) void
        +upload(userId, title, text) UploadResponse
        +chat(message, originalText, sessionId) ChatResponse
    }
    class request {
        -BASE_URL : string
        +authFlag : boolean
        +jsonContentType : string
        +errorNormalization : string
    }
    api --> request
    request --> fetch

Screen β†’ endpoint coverage

Screen Endpoints
login.tsx POST /login
register.tsx POST /register
(tabs)/index.tsx (Home) GET /users/:id, /document-count/:id, /days-since-joined/:id, /documents/:id
(tabs)/documents.tsx (Library) GET /documents/:id
(tabs)/profile.tsx GET /users/:id, /document-count/:id, /days-since-joined/:id, /user-joined-date/:id
upload.tsx POST /upload (plain-text body)
summary.tsx GET /document-details/:userId/:docId (when navigating from list)
chat.tsx POST /chat

State & Data Lifecycle

stateDiagram-v2
    [*] --> Booting
    Booting --> Hydrating: app/_layout mounts
    Hydrating --> Anonymous: no userId
    Hydrating --> Authed: userId present

    Anonymous --> Authed: setAuth() after /login OK
    Authed --> Loading: screen mount triggers fetch
    Loading --> Ready: 4 parallel GETs resolve
    Loading --> Stale: any GET fails β†’ previous state retained
    Ready --> Refreshing: pull-to-refresh
    Refreshing --> Ready

    Authed --> Anonymous: clearAuth() (Sign out)
    Authed --> Anonymous: 401 from API (future)

Each tab is responsible for its own data. There is no global store - by design, since each screen needs only its own slice, and React Query / Redux would be overkill for this surface. Pull-to-refresh re-runs the useCallback loader on Home, Library, and Profile.


Project Structure

mobile-app/
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ _layout.tsx              # Root stack + auth gate (hydrates, redirects)
β”‚   β”œβ”€β”€ login.tsx                # Email/password sign-in; persists via setAuth
β”‚   β”œβ”€β”€ register.tsx             # New account β†’ redirects to /login
β”‚   β”œβ”€β”€ upload.tsx               # Document picker + /upload + β†’ /summary
β”‚   β”œβ”€β”€ summary.tsx              # Renders summary from /upload OR /document-details
β”‚   β”œβ”€β”€ chat.tsx                 # /chat round-tripping with originalText + sessionId
β”‚   β”œβ”€β”€ +html.tsx
β”‚   β”œβ”€β”€ +not-found.tsx
β”‚   └── (tabs)/
β”‚       β”œβ”€β”€ _layout.tsx          # Bottom tab bar (Home, Library, Profile)
β”‚       β”œβ”€β”€ index.tsx            # Home: stats + recent docs + CTA
β”‚       β”œβ”€β”€ documents.tsx        # Library: searchable real /documents list
β”‚       └── profile.tsx          # Profile: real user data, settings rows, sign out
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ Screen.tsx               # Layout primitives + scrollProps RefreshControl
β”‚   └── ui.tsx                   # Avatar, Card, Pill (with `align`), Button, …
β”œβ”€β”€ constants/
β”‚   β”œβ”€β”€ sampleData.ts            # Only homeFeatures (static UI copy)
β”‚   β”œβ”€β”€ theme.ts                 # Brand tokens, spacing, radius, fontSize
β”‚   └── Colors.ts
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ auth.ts                  # AsyncStorage + module emitter (NEW)
β”‚   └── api.ts                   # fetch wrapper + endpoint map (EXPANDED)
β”œβ”€β”€ hooks/
β”‚   └── useColorScheme.{ts,web.ts}
β”œβ”€β”€ assets/
β”œβ”€β”€ app.json                     # Expo config (scheme: docuthinker)
β”œβ”€β”€ package.json
└── tsconfig.json

Getting Started

Prerequisites

Install

cd mobile-app
npm ci

Start the dev server

npx expo start

The server listens on http://localhost:8081. Use Metro’s i / a hotkeys to attach iOS / Android.


Running on Emulators

sequenceDiagram
    participant Dev as You
    participant CLI as npx expo start
    participant Metro as Metro :8081
    participant Sim as iOS Sim / Android AVD
    participant Go as Expo Go (SDK 51)

    Dev->>CLI: npx expo start
    CLI->>Metro: start bundler
    Dev->>CLI: press i (iOS)
    CLI->>Sim: xcrun simctl boot
    CLI->>Go: install Expo Go (SDK 51) if missing
    CLI->>Sim: openurl exp://127.0.0.1:8081
    Sim->>Metro: GET entry.bundle?platform=ios
    Metro-->>Sim: bundle
    Sim-->>Dev: app rendering
    Dev->>CLI: press a (Android)
    CLI->>Sim: adb install Expo Go (SDK 51)
    CLI->>Sim: am start exp://10.0.2.2:8081
    Sim-->>Dev: app rendering
# iOS Simulator (host = 127.0.0.1)
xcrun simctl openurl booted "exp://127.0.0.1:8081"

# Android AVD (host = 10.0.2.2 because AVD's localhost is the device itself)
adb shell am start -a android.intent.action.VIEW -d "exp://10.0.2.2:8081" host.exp.exponent

SDK 51 vs SDK 52 Expo Go

Expo Go pins one SDK runtime per device. If Go is already installed at a different SDK, expo start prompts you to reinstall. Reverting is symmetric - opening another SDK 52 project later will prompt to reinstall SDK 52 Go.


Environment & Backend

lib/api.ts hard-codes:

export const BASE_URL = "https://docuthinker-app-backend-api.vercel.app";

There is no .env for mobile in this PR. To point at a local backend, edit BASE_URL (and remember Android emulators reach the host as 10.0.2.2, not localhost). The deployed Vercel backend uses the same Firebase project the web frontend authenticates against - accounts created via docuthinker.vercel.app sign in on mobile without any extra step.


Upload Limitation

flowchart TB
    subgraph Web["πŸ’» Web frontend"]
        WP["Pick PDF / DOCX / TXT"]
        WP --> WPdf{"File type"}
        WPdf -- "PDF" --> Pdfjs["pdfjs-dist<br/>client-side parse"]
        WPdf -- "DOCX" --> Mammoth["mammoth<br/>client-side parse"]
        WPdf -- "TXT" --> WTxt["FileReader"]
        Pdfjs --> WPost["POST /upload<br/>JSON title + text"]
        Mammoth --> WPost
        WTxt --> WPost
    end

    subgraph Mobile["πŸ“± Mobile (this PR)"]
        MP["expo-document-picker<br/>txt / md only"]
        MP --> MRead["expo-file-system<br/>readAsStringAsync"]
        MRead --> MPost["POST /upload<br/>JSON title + text"]
    end

    WPost --> Backend["Express /upload<br/>generateSummary"]
    MPost --> Backend

The backend /upload endpoint expects {userId, title, text} JSON - it does not parse binary files. The web frontend handles PDF/DOCX by parsing in the browser before sending text. The mobile app does not currently ship a comparable RN PDF/DOCX parser because:

  1. RN equivalents (react-native-pdf, mammoth + xmldom polyfill) require native modules and expo prebuild, which would drop the Expo Go workflow this app deliberately preserves.
  2. Routing binary uploads through Vercel is not reliable - Vercel serverless functions have a small request-body limit (~4.5 MB) and short hobby-tier timeouts, which makes streaming larger PDFs through /upload-file fragile.

Net effect: upload .txt/.md from mobile; upload PDF/DOCX from the web app. Both clients then see the same documents in /documents/:userId, so the round-trip surface is consistent.


Testing

There is no dedicated unit test suite in this PR - the screens are thin wrappers around lib/api.ts, and lib/auth.ts is exercised end-to-end on every dev cycle. To smoke-test:

# Type-check
npx tsc --noEmit

# Bundle (no devices required)
npx expo export --platform ios --output-dir /tmp/expo-export

# Lint
npx expo lint

End-to-end verification path:

  1. Boot Metro and attach both emulators.
  2. Sign in with an account created via the web app.
  3. Home tab β†’ confirm documentCount matches web.
  4. Library tab β†’ confirm documents listed.
  5. Pull-to-refresh on Profile.
  6. Upload a .txt, watch it land in Library.
  7. Open a document β†’ Summary β†’ Chat β†’ real Gemini round-trip.
  8. Sign out β†’ app should redirect to /login without restart.

Troubleshooting

Symptom Cause Fix
Project is incompatible with this version of Expo Go Device has SDK β‰  51 Go installed Let expo start install matching Go, or uninstall via adb uninstall host.exp.exponent / xcrun simctl uninstall booted host.exp.Exponent and re-run
Login screen flashes then loops Vercel backend cold-start returned 5xx Wait ~10 s and retry - the backend warms up after the first hit
Android emulator can’t reach Metro App used localhost:8081 from emulator Use 10.0.2.2:8081 (AVD’s loopback to the host)
β€œCould not connect to development server” on a stale URL Go cached a sub-server URL from a prior expo start --port xcrun simctl terminate booted host.exp.Exponent / adb shell am force-stop host.exp.exponent, then re-open with the current port
iOS Simulator has no devices available Fresh Xcode install with no AVD-equivalent xcrun simctl create "iPhone 16 Pro" com.apple.CoreSimulator.SimDeviceType.iPhone-16-Pro com.apple.CoreSimulator.SimRuntime.iOS-18-5 && xcrun simctl boot <udid>

Roadmap


License

CC-BY-NC 4.0 - see LICENSE.md.

Built by Son Nguyen.