feat(app): mobile UX consistency pass: shared primitives, contract and audit fixes
Mobile UX consistency pass (mobile twin of #17286)
David asked on 2026-09-23 for one comprehensive PR that fixes the mobile UI/UX audit (about 70 merged findings;
Tier 1 = data loss, dead ends and controls that lie) together with the foundational cleanup and shared
primitives. This PR is that work; its description is the working record: status, what is done, what is left.
Status (2026-09-24): all lanes merged (foundations A+B, 1a, 1b, 2, 3, 4, 5), #17297 merged in, David's
screenshot review applied (see Design rulings). Verification below.
Review round 1 (ZCode GLM-5.3, Multica SCA-539) covered foundations + 1a/1b/2/4 at aa328bce85: no P0/P1,
five P2s, all fixed in eb7b1d8a5b. Round 2 (lane 5, SCA-540): one P1 (firmware preparation throw left the page stuck
installing) and two P2s, fixed in 9ccea09c94. Round 3 (lane 3, SCA-541): no P0/P1, ten P2s, fixed in
bc9e93edbc (one was already prevented). Round 4 (#17297 merge + rulings,
SCA-542): one P1 (row Share made a private conversation public without asking) and P2s, fixed in 8072d4c747. Round 5
(David's post-screenshot changes, SCA-551): no P0/P1; P2s fixed in a8e3a4c4e9. iOS only was exercised on this Mac (no Android emulator); Android behaviour is covered by widget tests
only.
Product calls decided for this pass
- D1 Chat is a normal pushed page everywhere (no
fullscreenDialog), leading back button. - D2 In conversation detail a horizontal swipe no longer jumps to another conversation.
- D3 The noun is "Task(s)" in all UI (not Action Items / To-Do).
- D4 Everyday settings move out of Developer Settings into top-level Settings.
- D5 Conversation delete keeps its confirm (with "don't ask again") and always shows an Undo toast backed by the
provider's pending-delete window. Memory, task and goal deletes are Undo-only. Things that cannot be undone
(local recording file, forget device, clear chat, account deletion) always confirm, with no opt-out. - D6 Android system back on a non-Home tab returns to Home before exiting.
Foundations (merged)
- A — tokens, theme, controls:
OmiColors/OmiType/OmiRadius/OmiSpacing/OmiMotion/OmiHaptics; a real dark
ColorScheme(fixes black-on-black spinners),appBarTheme, sheet/dialog/snackbar/progress themes;
OmiBackButton,OmiCloseButton,OmiIconButton,OmiButton,OmiSpinner,OmiEmptyState,
OmiErrorState,showOmiSheet,OmiSwitch,OmiSettingsGroup/Row,OmiSearchField,omiPageRoute. - B — dialogs, feedback, formatting, prompts, guard:
showOmiConfirm/showOmiAlert(adaptive, Cancel always
shown, destructive verbs),OmiFeedbacktoasts (1.5 s / 4 s / 8 s / 5 s undo),OmiClipboard,
OmiDateFormat(locale + 24-hour aware),OmiDuration, shared speaker names with dense numbering,
PromptQueue, the INV-UI-3 no-increase ratchet (.github/scripts/check_mobile_ux_contract.py) and the mobile
UX contractapp/docs/ux-contract.md.
Lanes
| Lane | Scope | State |
|---|---|---|
| 1a | conversations list, recaps, recordings, offline sync | merged |
| 1b | conversation detail, transcript, capture/processing pages, media viewer | merged |
| 2 | memories, tasks, goals, people | merged |
| 3 | chat, apps, payments, announcements, referral, AI app generator | merged |
| 4 | settings, one device page, phone calls, plans & usage, account flows | merged |
| 5 | onboarding, home shell, pairing, firmware, prompts, notifications, startup | merged |
Design rulings applied (2026-09-24)
- Conversation page top bar (David, after reviewing the screenshots; replaces the earlier one-overflow call): Ask
Omi as the filled primary, Star and Share as matching 44pt icon buttons, then one "…" holding Rename, Move to
Folder, Recordings and the rest, Delete last and destructive. - Grouped conversation rows: the long-press menu adds Recordings and Separate… (same confirmation as the page).
- The capture source is "Pendant", never "Omi"/"omi" (
captureSourcePendant, 49 locales). - Speaker labels (David): the header's people chip names only named speakers ("You" for the owner) and counts
the rest ("David + 3 others",participantsSummary); with nobody named it hides. A linked calendar event's
attendees still stand in when the transcript names nobody. The transcript's per-line speaker labels are
unchanged (they still read "Speaker N" for unnamed speakers). - Voice profile (David): every entry point opens the guided introduction from #14514. The Settings/Profile Voice
Profile row sent people who already had a profile, and the capture card's "Teach Omi your voice" sent everyone,
to the old question page; both now open the guided flow, and the old page
(lib/pages/speech_profile/page.dart+ its two widgets,widgets/dialog.dartgetDialog,
widgets/fade_in_words_text.dartandSpeechProfileProvider, all only used by it) is deleted. The earlier screenshots showed that
old page because the harness captured it directly; the branch always contained #14514. Open PR #12531 edits
the deleted page and will need to drop those hunks. - Usage analytics (David): one registered event
Conversation Actionwith closed enumsaction(ask_omi, star,
unstar, share, rename, move_folder, recordings_open, separate, delete, copy_transcript, copy_summary,
share_audio, link_event, reprocess, search, visibility, open, select, copy_conversation_id, test_prompt) and
surface(top_bar, overflow, detail_body, row_long_press, row_swipe); no ids or content. Registered in
contracts/analytics/events.jsonwith the consumermobile-conversation-actions.
Integration notes (lead)
- #17297 (merged as 373126c05e) is merged in: its shared conversation header and recordings sheet are kept
and moved onto the primitives (title field, OmiDateFormat, tokens, showOmiSheet/showOmiConfirm). - The live and processing pages name their state from lane 1a's shared
capture_state_labels.darttable, so
the list card and the live page say the same thing (Listening / Paused / Offline buffering / Processing). routeToPagenow buildsomiPageRoute(platform fromdefaultTargetPlatform); the remaining bare
MaterialPageRoutepushes in AppShell deep links, the name-speaker sheet and the firmware prompt use it.SettingsDrawer.showreturns the sheet's future; Home awaits it before comparing language / voice profile /
model (onboarding-home #25).- Help Center opens in the app language when the help center has it (was always
/en/). - Sign Out keeps an explicit allowlist of device and display preferences (
sign_out.dart), with a unit test.
Known follow-ups (not in this PR)
- Submit App / Manage App forms still discard unsaved edits on back without asking (pre-existing).
DeviceProvider.showFirmwareUpdateDialogstill callsshowDialogdirectly (its prompt coordinator owns
dismissal); moving it intoPromptQueueis a follow-up.- Custom STT providers that follow the primary language are only re-synced from the two language pickers; a
login-time primary-language change updates them on the next Transcription save. - Pre-existing l10n plural-suffix bug:
deleteSelectedItemsMessage/itemsDeletedResultappend{s}to a
noun, which reads wrongly outside English (for example German "Aufgaben{s}" → "Aufgabens"). Needs ICU plurals,
which changes the key signatures. - Backend labels speakers 0-based ("Speaker 0") in LLM input, so summaries can disagree with the app's dense
1-based numbering. - Other open mobile PRs own these areas and are untouched here: #15958 (processing card), #15264 (recap backfill
settings), #12531 (speech-profile onboarding), #13499 (marketplace app actions), #17543 (audio-storage
settings). #17297 (one row per recorded event) merged first and is merged into this branch.
Verification (layer: hermetic tests and static checks on this Mac, Flutter 3.44.5, TZ=UTC)
bash app/test.sh: 2922 passed at a8e3a4c4e9 (the deleted speech-profile provider's tests went with it; 2953 before).
The suite also passed underTZ=Pacific/KiritimatiandTZ=Pacific/Pago_Pago(CI's timezone job) at 33613497df.make mobile-verify ARGS="fast --all": 23/23 hermetic journeys passed at bc9e93edbc.scripts/l10n.py check(49 locales),analyze_ratchet.sh, INV-UI-3, INV-UI-1, line-count, dead-code, agent-doc,
event-registry and mobile-changelog checks pass; pre-push preflight passes on every push.- Review: five rounds by ZCode GLM-5.3 (Multica SCA-539, SCA-540, SCA-541, SCA-542, SCA-551), every finding fixed or
answered in the linked commits. - Not verified: Android on a device or emulator (none on this Mac), and no installed-app or live-backend run.
- Screenshots: https://claude.ai/artifact/GTzdUJ5hfwXLcFZse6Eq7v (before/after of every changed screen, rendered from
the production widgets with real fonts; see the PR comments for harness limits).
Product invariants affected
- INV-DATA-1:
app/lib/main.dartchanges only the theme (buildOmiTheme), the Intl default-locale sync and the
startup-failure retry; the data-plane / environment selection authority is untouched and preserved. - INV-MEM-1:
app/lib/pages/memories/**changes presentation only (Undo-backed delete, read-only open, edit sheet
guard). No tier is added or renamed and the delete still goes through the existing memory API. - INV-UI-1: no purple added; touched files migrate purple to neutral tokens.
- INV-UI-3: introduced here (mobile UX contract ratchet).
Failure class (fixes)
Failure-Class: none
🤖 Generated with Claude Code