feat(app): mobile UX consistency pass: shared primitives, contract and audit fixes

Merged
GI
23d ago
+111448-60783

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), OmiFeedback toasts (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 contract app/docs/ux-contract.md.

Lanes

LaneScopeState
1aconversations list, recaps, recordings, offline syncmerged
1bconversation detail, transcript, capture/processing pages, media viewermerged
2memories, tasks, goals, peoplemerged
3chat, apps, payments, announcements, referral, AI app generatormerged
4settings, one device page, phone calls, plans & usage, account flowsmerged
5onboarding, home shell, pairing, firmware, prompts, notifications, startupmerged

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.dart getDialog,
    widgets/fade_in_words_text.dart and SpeechProfileProvider, 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 Action with closed enums action (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.json with the consumer mobile-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.dart table, so
    the list card and the live page say the same thing (Listening / Paused / Offline buffering / Processing).
  • routeToPage now builds omiPageRoute (platform from defaultTargetPlatform); the remaining bare
    MaterialPageRoute pushes in AppShell deep links, the name-speaker sheet and the firmware prompt use it.
  • SettingsDrawer.show returns 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.showFirmwareUpdateDialog still calls showDialog directly (its prompt coordinator owns
    dismissal); moving it into PromptQueue is 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 / itemsDeletedResult append {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 under TZ=Pacific/Kiritimati and TZ=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.dart changes 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