Brand and design · Specs

User flows

Nine flows as Mermaid and step tables: screen, trigger, API, success, failure, exit.

Screen ids refer to files in screens/ (<group>/<screen>--<state>[.en].html). Every API call is in openapi.yaml. Every request carries Authorization (after /auth/anonymous) and Accept-Language: <UI locale> (ADR-001 D13).

Contents: 1 First launch → first session · 2 Daily quest · 3 Free practice · 4 Resume after kill · 5 Permission denied · 6 Offline mid-upload · 7 Expired subscription · 8 Delete data · 9 Change languages


1. First launch → diagnostic → paywall → first real session

flowchart TD
  A([App launch]) --> B{Keychain install_id?}
  B -- no --> B1[create install_id] --> C
  B -- yes --> C[POST /auth/anonymous]
  C --> D[GET /config]
  D -->|app < min_app_version| FU[global/force-update]
  D --> E{me.onboarding.status}
  E -- not_started --> W[onboarding/welcome]
  W --> L[language: practice language<br/>default = device locale]
  L --> G[goal] --> S[scenario + topics] --> SL[self-level] --> CM[commitment + reminder time] --> NP[notification-priming]
  NP -->|POST /onboarding| DI[diagnostic/diag-intro]
  DI --> DR[diag-read] -->|POST reading-complete| DRC[diag-record idle]
  DRC -->|first tap| MIC[mic-purpose sheet] --> SYS{system mic prompt}
  SYS -- denied --> DEN[diag-record permission-denied]
  SYS -- granted --> REC[diag-record recording]
  REC -->|submit / 90 s auto-stop| UP[diag-processing]
  UP -->|assessed| RES[diag-result reveal]
  UP -->|unreliable| UNR[diag-processing unreliable] -->|re-record| DRC
  UP -->|failed retryable| FL[diag-processing failed] -->|POST /attempts/id/retry| UP
  RES --> PL[plan]
  PL --> PW[paywall default]
  PW -->|purchase / trial| TX[POST /subscriptions/apple/transactions]
  TX -->|entitlement trial/active| H[home first-time]
  PW -->|restore| RS[paywall restored] --> H
  PW -->|cancel / fail| PF[paywall purchase-failed] --> PW
  H -->|Start today's session| R1[read normal]
# Screen Trigger API call Success Failure Exit
1 (splash) Launch POST /auth/anonymous {install_id, timezone, locale, app_version, platform} tokens stored retry with backoff; offline → cached tokens or "connect to start" —
2 (splash) tokens ok GET /config limits, flags, supported_ui_locales, supported_practice_languages use bundled defaults force-update if version too low
3 onboarding/welcome onboarding.status = not_started — — — Get started
4 onboarding/language--selected Get started — practice language held locally (default device locale) — back to welcome
5–8 goal → scenario → self-level → commitment Continue (disabled until a choice) GET /topics (scenario) answers held locally topics hidden on error back to the previous step
9 onboarding/notification-priming Continue POST /onboarding (all answers incl. practice_language); if allowed: PUT /me/push-token {diagnostic_session, article} inline retry; answers kept "Not now" also submits
10 diagnostic/diag-intro — — — — Start the diagnostic
11 diagnostic/diag-read--normal — POST /sessions/{id}/reading-complete stage → round1 queued offline back = intro
12 diagnostic/mic-purpose--sheet first tap on mic — system prompt — Not now → idle
13 diagnostic/diag-record--* tap POST /sessions/{id}/attempts → PUT → POST /attempts/{id}/submit processing upload error → processing failed leave → leave-recording alert
14 diagnostic/diag-processing--* submitted poll GET /attempts/{id} every 2 s → 5 s assessed failed (retry), unreliable (re-record), >120 s (check later) —
15 diagnostic/diag-result--reveal assessed — count-up — See my plan
16 plan/plan--default — GET /plan 14 days 404 → spinner + retry (plan job not done) Continue
17 paywall/paywall--default — GET /paywall; StoreKit Product.products interactive loading-products until StoreKit returns no close (hard)
18 paywall Start trial Product.purchase() → POST /subscriptions/apple/transactions {signed_transaction} Entitlement.status = trial → home purchase-failed (not charged) —
19 home/home--first-time entitlement ok GET /home quest = plan day 1 home--error —

2. Daily quest

flowchart LR
  N[Reminder 08:30<br/>max 1/day] --> H[home normal]
  H -->|Start| S[POST /sessions kind=daily]
  S --> R[read] -->|reading-complete| R1[record round 1]
  R1 -->|submit| P1[processing]
  P1 -->|assessed| F[feedback]
  P1 -->|unreliable| R1
  F -->|Try again with this advice| R2[compress round 2]
  R2 -->|submit| P2[processing round 2]
  P2 -->|assessed| C[POST /sessions/id/complete]
  C --> CP[complete improved/same/shorter-lost]
  CP -->|level up| LU[complete level-up overlay]
  CP -->|achievement| AC[achievement sheet]
  CP --> G[growth ready] & H2[home completed]
# Screen Trigger API call Success Failure Exit
1 home/home--normal open app / notification GET /home quest in a practice language cached + offline banner / error tabs
2 read/read--normal Start POST /sessions {article_id, kind: daily} (Idempotency-Key); 200 returns an existing in-progress session session 402 → paywall expired-return back keeps stage reading
3 record/record--* reading-complete attempts → PUT → submit processing see flows 5 & 6 leave alert
4 processing/processing--* submit poll GET /attempts/{id} assessed → haptic success failed / unreliable / long-wait "check later" → home resume
5 feedback/feedback--full assessed GET /attempts/{id}/exemplar (poll while pending), GET /attempts/{id}/audio on evidence tap, POST /assessments/{id}/feedback — exemplar failed → text + retry Try again → round 2
6 compress/compress--* CTA attempts {round 2} → PUT → submit processing round 2 409 stage precondition → refetch session leave alert
7 complete/complete--* round 2 assessed POST /sessions/{id}/complete (Idempotency-Key = session_id) Settlement 409 → show feedback for missing round; network → retry (idempotent) See progress / Back to Home
8 home/home--completed back GET /home (invalidate cache) streak +1, quest done — Free practice → library

3. Free practice from the library

# Screen Trigger API call Success Failure Exit
1 library/library--list Practice tab GET /topics, GET /articles?language=&topic=&status= list library--error —
2 library/library--language-en, --filtered filter chip GET /articles?language=en / ?topic= filtered list --filter-empty Clear filter
3 row tap (unlocked) — POST /sessions {article_id, kind: free} read locked row → toast with unlock_hint (CCR-3) —
4–8 same as daily quest steps 2–7 — — XP per D4 (50 % if repeated within 7 days; xp.repeat_discount) — —

4. Resume after the app was killed

flowchart TD
  K([Relaunch]) --> Q{Local unsent recording?}
  Q -- yes --> U[Upload queue resumes<br/>attempts → PUT → submit] --> H
  Q -- no --> H[GET /home]
  H --> RS{home.resume_session or today_quest.in_progress?}
  RS -- yes --> SH[global/resume-session sheet]
  SH -->|Continue| ROUTE{session.stage}
  ROUTE -- reading --> RD[read]
  ROUTE -- round1 / round2 --> REC[record / compress idle<br/>or interrupted sheet if partial file]
  ROUTE -- *_processing --> PR[processing]
  ROUTE -- completed --> CP[complete replayed]
  SH -->|Abandon| AB[POST /sessions/id/abandon] --> H2[home normal]
  RS -- no --> H2
# Screen Trigger API call Success Failure Exit
1 launch partial file on disk (local:recorder.fileURL) — interrupted sheet on the record screen file unreadable → "recording couldn't be recovered", re-record —
2 global/resume-session--sheet resume_session != null GET /sessions/{id} route by stage 404 → drop silently Continue / Abandon
3 complete/complete--replayed stage completed but settlement not seen POST /sessions/{id}/complete (same Idempotency-Key) replayed = true, no animations — —

5. Microphone permission denied

# Screen Trigger API call Success Failure Exit
1 diagnostic/mic-purpose--sheet first tap — system prompt — —
2 record/record--permission-denied denied (now or earlier) none — no attempt is created — — Open Settings (deep link) / Back to the article
3 return from Settings scenePhase = active + permission granted — record idle still denied → same screen —

6. Offline mid-upload

sequenceDiagram
  participant U as User
  participant A as App
  participant Q as Upload queue (disk)
  participant API
  U->>A: Submit recording
  A->>Q: persist file + attempt metadata
  A->>API: POST /sessions/{id}/attempts
  API-->>A: presigned PUT
  A--xAPI: PUT (network lost)
  A->>U: processing--offline (+ toast)
  Note over A,Q: NWPathMonitor satisfied
  A->>API: PUT (resume, same sha256)
  A->>API: POST /attempts/{id}/submit (idempotent)
  API-->>A: 202 → poll → assessed
  A->>U: feedback
# Screen Trigger API call Success Failure Exit
1 processing/processing--offline path unsatisfied during PUT — file persisted — leave allowed; home shows resume
2 global/offline--toast connectivity lost anywhere — non-blocking — —
3 auto path satisfied re-request presign if expires_at passed (POST attempts with same Idempotency-Key), PUT, submit processing → feedback 400 upload_checksum_mismatch → re-PUT once, then processing failed —

7. Expired subscription

# Screen Trigger API call Success Failure Exit
1 launch GET /me → entitlement.status = expired (or 402 on POST /sessions) GET /paywall, GET /me paywall/paywall--expired-return offline within grace_until → app usable from cache —
2 paywall expired-return Subscribe Product.purchase() (no intro offer) → POST /subscriptions/apple/transactions home normal (history intact) purchase-failed Restore

8. Delete training data / account

# Screen Trigger API call Success Failure Exit
1 profile/settings--default Delete all training data — settings--delete-data-confirm — Cancel
2 confirm Delete DELETE /me/training-data → 202 {job_id, purge_backups_by} toast "Training data deleted"; caches cleared; growth → empty; home first-time-like (streak 0) error alert, nothing deleted —
3 profile/settings--delete-account-confirm Delete account DELETE /me → 204, then POST /auth/anonymous with a new install_id back to onboarding welcome error alert "Manage subscription first" → App Store
4 session row (growth recent / history) swipe → delete DELETE /sessions/{id} → 204 row removed, stats refetched — —

9. Change UI language / practice languages

# Screen Trigger API call Success Failure Exit
1 profile/settings--language App language row PATCH /me {settings.ui_locale} (null = follow device) UI re-renders immediately; all caches invalidated; refetch with the new Accept-Language revert toggle + error toast back
2 same Practice language toggle PATCH /me {settings.practice_languages} (≥ 1, first = primary) GET /home refetched → quest/quick picks in the new set; library default filter updates revert back
3 in-progress session any language change — the session keeps its own session.language (content never switches mid-session) — —