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) |
— | — |