The Flutter app's interaction rules, and the one primitive that implements each. Read this before
adding a page, a sheet, a control, a dialog, a toast, a date, a speaker name or a prompt. If the
primitive you need does not exist, extend the shared one under lib/ui/ and update this file in
the same PR; do not draw a local variant.
Primitives live in lib/ui/ (omi_tokens.dart, components/, feedback/, format/,
prompts/). This is the mobile twin of the macOS contract (desktop/macos/.../ux-contract.md,
INV-UI-2): same rules where the platforms agree, platform-correct where they do not.
Frame cost (UI-isolate work, rebuild cascades, blur, image decode) has its own rules and guard:
performance.md, INV-PERF-1.
Guard: INV-UI-3 (product/invariants/mobile-ux-contract.md)
runs .github/scripts/check_mobile_ux_contract.py, a per-file no-increase ratchet over the
hand-rolled patterns named below (rule ids in code). A deliberate exception ends its line with
// omi-ux-allow: <rule> -- <reason>. Run it on your files before you push:
python3 .github/scripts/check_mobile_ux_contract.py --report app/lib/pages/foo.dartThere are exactly two ways out, and they mean different things.
| The surface… | Control | Where | Primitive |
|---|---|---|---|
| replaced the page you were on (pushed page, detail, settings page) | back | leading edge of its app bar | OmiBackButton() (OmiBackButton.circled() when the header floats over content) |
| floats over the page (sheet, full-screen modal, viewer, tutorial) | close (X) | trailing edge of its header | OmiCloseButton(), plus swipe-down where the platform offers it |
- One glyph family:
OmiBackButtondraws the platform's back glyph (iOS chevron, Android arrow). NeverIcons.arrow_back*,FontAwesomeIcons.arrowLeft/chevronLeftor a leadingIcons.chevron_left(raw-back-glyph). - Never an X on a pushed page, never a back chevron on something that floats.
- System back and the iOS edge swipe always do what the on-screen control does. A multi-step
flow makes each step a real route (or a nested
Navigator) so the swipe steps back one step;PopScope(canPop: false)that disables the swipe is a bug unless it guards unsaved input (§4) or holds a sheet nothing may dismiss: a required update or a progress sheet that cannot be cancelled (§2). - Push with
routeToPage(context, page), oromiPageRoute(builder)when you need aRoute(pushReplacement). NeverPageRouteBuilderfor a push: it has no iOS back swipe (page-route-builder). - Chat is one sheet everywhere (D1, revised 2026-09-29):
openChatSheet(context, ChatPage(...))(lib/pages/chat/chat_route.dart) rises over a blurred, dimmed page, carries anOmiCloseButton, and closes on the X, a swipe down on its header, or system back — from every entry point (home chat bar, mic, deep link, app detail, quick action, "Ask Omi"). - Custom non-opaque routes (chat sheet, global search) get the iOS back gesture through
OmiEdgeSwipeRoute(lib/ui/navigation/omi_edge_swipe.dart): a 32 pt strip on the left edge tracks the finger horizontally and pops on platform-style distance/velocity thresholds; it never reacts to centre or right-edge drags, honoursPopScope, and does nothing on Android. - Conversation detail has no body-wide horizontal swipe to another conversation (D2).
Transcript / Summary tabs are swipeable where that does not fight a row's
Dismissible. No prev/next controls replace it. - Android system back on a non-Home tab returns to the Home tab before it exits the app (D6).
- Deep links and notification taps open inside the existing Home: pop to the first route, then push
the destination (parent before child) —
HomeNavigation.openRoute(route)(lib/pages/home/home_navigation.dart); a flow's final Done returns withHomeNavigation.returnHome(context). Never push orpushReplacementa secondHomePageWrapper. A missing target says so (OmiFeedback.info).
- Every bottom sheet is
showOmiSheet(...), orOmiSheetScaffoldaround content that is already a widget. It owns the top radius (OmiRadius.xl), the 36×4 drag handle, the optional title row with a trailingOmiCloseButton, safe-area and keyboard insets, andisScrollControlled. Never a rawshowModalBottomSheet(raw-bottom-sheet) and never a hand-drawn handle. - A sheet nothing may dismiss (a required update) passes
isDismissible: false, enableDrag: falseand blocks back withPopScope, and has no handle that promises a swipe:showOmiSheetdrops it itself;showOmiSurfaceSheettakesshowDragHandle: false. - A sheet that edits something is
showOmiEditSheet(...)/OmiEditSheet(isDirty:, ...), with explicit Save and Cancel. It owns the swipe-down itself, because the framework's sheet drag pops without consultingPopScope. Swipe-down, tap-outside, the close X and system back on a dirty sheet ask first (confirmDiscardChanges):showOmiConfirm(title: l10n.discardChangesTitle, message: l10n.discardChangesMessage, confirmLabel: l10n.discard, cancelLabel: l10n.keepEditing, destructive: true). A clean sheet just closes. - A progress sheet that cannot be cancelled blocks back with
PopScopeand pops with its own context, never the page's.
| Need | Use | Not |
|---|---|---|
| Text action | OmiButton — .primary (white fill and black label in dark mode; inverse in light mode), .secondary, .destructive, .tertiary; size regular (48) or compact (36 visual, 44 target); isLoading |
ElevatedButton.styleFrom(...) with a local colour, height or radius |
| Icon-only action | OmiIconButton(icon, label: …) — label is required and is the tooltip and the screen-reader name |
a bare GestureDetector/InkWell around an Icon, an IconButton with no tooltip |
| Header circle button | OmiIconButton filled-circle style (HeaderCircleButton is an alias) |
a 36 pt circle with a 36 pt target |
| On/off setting | OmiSwitch in an OmiSettingsRow |
a checkbox, a purple/green/indigo switch |
| Settings list | OmiSettingsGroup of OmiSettingsRows under an OmiSectionHeader |
a hand-built row per page |
| Search | OmiSearchField(placeholder: l10n.searchConversations) |
a styled TextField per page |
| Filters over a list | a row of OmiFilterChip(label:, selected:, onSelected:, count:) — one selected, accent-filled; 44 pt target; an icon-only "+" chip is icon: with an empty label and a semanticsLabel |
a local chip with its own colours per page |
| A choice among people or answers (tag a speaker, a likely-speaker Yes / Not) | OmiFilterChip as above, with an optional leading icon: ("+ Add Person") |
a Material ChoiceChip on a grey slab that reads as disabled |
| A removable date filter | OmiDateFilterChip(start:, end:, onClear:) — calendar glyph, localized date or start – end, ×; remove semantics removeFilter + the date |
a raw InputChip, a filter with no way off |
| A level (how sure Omi is, how close a voice is) | OmiLevelMeter(level: 0–3, semanticsLabel:) — three neutral steps; a newly filled step animates in (OmiMotion.standard) |
a percentage, a coloured or traffic-light bar |
| Loading indicator | OmiSpinner (small / regular / large) |
CircularProgressIndicator( with a local colour and stroke (raw-spinner) |
| Something happening live (Omi listening, a device syncing quietly) | OmiLiveDot(live:) — a small success-green dot that pulses while live and holds still otherwise (reduced motion: still); OmiActivityRing(active:, child:) — a thin spinning ring around a glyph, no text, hidden when idle |
a local AnimationController pulse per page, a "Syncing…" label in a header |
| Locked card preview | OmiLockedPreview(child:, label:, onPressed:) — clips a child-only blur and a translucent surface beneath an OmiButton.tertiary; excludes the obscured content from touch and semantics |
a dark strip with readable content overlapping its upgrade label |
- Every tappable control is at least 44×44 pt (48 dp on Android is fine), including the label
next to a checkbox (
OmiCheckboxRow). - A button label is a verb in Title Case ("Save", "Delete Task", "Try Again"). A button that is busy keeps its size and shows a spinner in place of or beside its label.
- A disabled control looks disabled. A Send that cannot send is not white.
- The accent is neutral: white in dark mode, black in light mode (INV-UI-1, no purple). Colour is for state (danger, success), not decoration.
One policy, and never neither:
| The delete… | Pattern |
|---|---|
| can be deferred and restored — a memory, a task, a goal | delete at once, OmiFeedback.undo(...) for 5 s; no confirmation dialog |
| is a conversation | confirm (showOmiConfirmWithOptOut, or showOmiConfirmMenu(offerOptOut: true) from a swiped row's delete button; "Don't ask again" allowed) and always an Undo toast backed by the provider's pending-delete window, which is at least OmiFeedbackTiming.undo (D5) |
| cannot be undone — a local recording file, forget/unpair device, clear chat, sign out, account deletion, bulk delete | showOmiConfirm(..., destructive: true) every time; never "Don't ask again" |
- The confirm button is a verb naming the action — "Delete", "Forget Device", "Clear Chat", "Sign Out" — never "OK", "Confirm" or "Yes". The message names the consequence.
destructive: truemakes the button red (Material) orisDestructiveAction(iOS), and makes Cancel the default choice.- An undo toast has no close button. Nothing in it means "destroy this sooner"; a replaced or swiped-away undo commits.
- "Don't ask again" only where an Undo backs the action.
- A bulk delete (several conversations) confirms every time and, when the provider can defer it, also offers Undo.
- A row's long-press opens
showOmiRowMenu(context, title:, actions: [OmiMenuAction(...)])— the same menu shape on conversations, memories and tasks (Open first, Delete last and destructive); multi-select is a "Select" entry in that menu, not the long-press itself. - Swipe-to-delete follows the same table: the confirm shows for things that cannot be undone;
restorable things dismiss and show Undo. A swipe means the same thing on every row of a list. A
conversation row swipes open to a round delete button and asks with
showOmiConfirmMenufrom that button; a long swipe asks straight away.
lib/ui/feedback/omi_dialogs.dart is the only dialog system (raw-dialog counts
AlertDialog( / CupertinoAlertDialog():
| Need | Call |
|---|---|
| a question with two answers | await showOmiConfirm(context, title:, message:, confirmLabel:, destructive:) → bool |
| the same with "Don't ask again" (only when Undo backs it, §4) | await showOmiConfirmWithOptOut(...) → OmiConfirmResult(confirmed, dontAskAgain) |
| a destructive confirm from the button that asked (a row's delete button) | await showOmiConfirmMenu(context, anchor:, title:, message:, confirmLabel:, offerOptOut:) → OmiConfirmResult |
| information with one button | await showOmiAlert(context, title:, message:, okLabel:) |
a widget for showDialog(builder:) |
OmiAlertDialog(title:, message:, content:, actions: [OmiDialogAction(...)]) |
| a dialog that holds a control ("Don't ask again") | OmiDialogCard(title:, message:, content:, actions:) |
- Adaptive:
CupertinoAlertDialogwithCupertinoDialogActions on iOS,AlertDialogelsewhere. Cancel is always present (localized) and always closes. Titles are Title Case questions ("Delete Conversation?"). - A dialog with a control is
OmiDialogCardon every platform (showOmiConfirmWithOptOutuses it): the system alert is a fixed 270 pt and has no room for one. Its width follows the screen (32 pt side margins, at most 400 pt) and its text is centred like the system alerts. Its buttons are plain text in a hairline-split bar, destructive in red, the default bold, and they stack when a label does not fit. - Legacy entry points (
ConfirmationDialog,OmiConfirmDialog,AppDialog) are thin adapters over the same widget; they acceptdestructive. New code calls the functions above.
OmiFeedback (lib/ui/feedback/omi_feedback.dart) is the only toast (raw-snackbar counts
SnackBar(). One at a time; a new one replaces the current.
| Kind | Call | Stays | For |
|---|---|---|---|
| confirm | OmiFeedback.confirm(context, msg) |
1.5 s | the reader just did it ("Saved", "Copied") |
| info | OmiFeedback.info(context, msg) |
4 s | something the reader did not directly cause |
| error | OmiFeedback.error(context, msg, actionLabel: l10n.tryAgain, onAction:) |
8 s, with close | a failure; offer Try Again when retrying can help |
| undo | final undone = await OmiFeedback.undo(context, msg, onUndo:, icon:) |
5 s, no close | deferred deletes (§4) and other deferred commits (a voice-card answer, with its own icon); commit when it resolves false |
| progress | OmiFeedback.progress(context, msg) |
until replaced (≤ 1 min) | ongoing work, replaced by its result |
- Neutral surface with a small coloured status icon; never a red or green slab (white on red fails
contrast). Floating, above Home's chat bar. A pushed page with a pinned bottom action (conversation
detail's Ask Omi bar) wraps its body in
OmiFeedbackClearance(bottom:)so toasts float above it. - Code without a
BuildContextusesAppSnackbar(same toasts on the global navigator). - An informational toast has no "OK" action.
await OmiClipboard.copy(context, text, what: l10n.transcript) (raw-clipboard counts
Clipboard.setData(). Empty text is not copied and nothing is shown. It confirms with "Copied" /
"Transcript copied" — except on Android 13+, where the system chip already confirms. A copy made by
something else (a selection toolbar) calls OmiClipboard.confirmCopied(context).
OmiDateFormat.of(context) (locale and the device's 24-hour setting) and OmiDuration. Never a
pattern string: DateFormat('h:mm a'), dateTimeFormat('MMM d', …) (date-pattern) force English
order and a 12-hour clock on everyone.
| Style | en_US example | Use |
|---|---|---|
time |
10:43 AM (10:43 on a 24-hour clock or locale) | a row inside a day group |
dayHeader |
Today · Yesterday · Wed, Sep 23 · Wed, Sep 23, 2025 | group headers (today gets a header too) |
date |
Sep 23, 2026 | a date standing alone |
dateTime |
Sep 23, 2026 10:43 AM | an absolute moment (details, exports) |
timestamp |
10:43 AM · Yesterday at 10:43 AM · Sep 21 10:43 AM | a moment in a feed or chat |
timeRange |
10:17 – 11:19 AM | a span; across days both ends carry the date |
OmiDuration.offset |
0:05 · 3:38 · 1:02:05 | a position in a recording, time remaining |
OmiDuration.compact |
8s · 4m 10s · 42m · 1h 5m | a length — the list row and the detail page use the same one |
OmiDuration.live |
8s · 10m 1s · 1h 5m 1s | an elapsed live length that keeps ticking every second |
OmiDuration.long |
12 mins 34 secs | screen-reader labels and sentences |
A row inside a day group shows only the time. Group rows by the same date the row displays.
SpeakerNames.forSegments(segments, people:, l10n:) (lib/ui/format/speaker_names.dart) is the
only way to name a transcript speaker — bubbles, the edit-segment and tag-speaker sheets,
participant lists, the speaker filter and every copied, shared or exported transcript
(TranscriptSegment.segmentsAsString uses it).
- Server-projected Review excerpts use
SpeakerNames.excerptLabelfor stored names and localized owner/unnamed labels. - The owner is You in the app; exports use the owner's given name, else "You".
- A speaker assigned to a person shows the person's name (live people list first).
- Omi's own speaker is Omi, never "Speaker 100".
- Everyone else is Speaker N, N dense per conversation (1, 2, 3… in order of first
appearance, skipping the owner and Omi). Never a raw id, never a gap. Naming a person does not
renumber the others.
TranscriptSegment.getDisplaySpeakerIdreturns the same N. - When the server could not resolve speakers across the conversation (
speaker_resolution.statusisunavailable), raw ids from different recordings are not comparable, so an unnamed voice is the plain Speaker (l10n.unnamedSpeakerLabel), never "Speaker ?" and never a number, and the transcript heading omits its speaker count. A saved transcript names each speaker once per turn (consecutive lines from the same voice), so a change of voice is still visible. - On-device overflow transcripts (
stt_provider=on_device:<engine>) have one unlabeled voice: omit speaker names and the speaker count. Local engines never imply diarization or identity. - The speaker filter is "Filter by speaker" (
l10n.filterBySpeaker), never the loudspeaker stringphoneSpeaker.
lib/ui/omi_tokens.dart has light and dark palettes; System follows OS brightness. Where you touch code, replace literals with tokens
(color-literal, font-size-literal, radius-literal); new code has none.
- Colour
OmiColors:surface0(black in dark mode, grouped #F2F2F7 in light mode),surface1/2/3(card / elevated / pressed),border,textPrimary/textSecondary/textTertiary(tertiary no darker than ~#8E8E93, ≥ 4.5:1 on surface1),accent(white in dark mode, black in light mode — INV-UI-1) /onAccent,success,warning,danger,dangerSurface.AppStylesandResponsiveHelperpalettes are legacy. - Type
OmiType: an iOS-like ramp (11 / 13 / 15 / 17 / 20 / 24 / 28 / 34) asTextStyles. - Radius
OmiRadius: sm 8 · md 12 · lg 16 · xl 24 · pill. SpacingOmiSpacing: 4 · 8 · 12 · 16 · 20 · 24 · 32. - Motion
OmiMotion: quick 150 ms (a control answering a press), standard 250 ms (content changing in place), emphasized 400 ms (a surface arriving or leaving). Read durations throughOmiMotion.of(context), which is zero under Reduce Motion. - Haptics
OmiHaptics:selection()for tabs, segments and navigation,light()for toggles,medium()for record start/stop and completions,success()/error()for outcomes. - The theme (
buildOmiTheme()) sets the app bar (black, centred title, white icons), the spinner, snackbar, dialog and switch colours, so an unstyled widget already looks right.
All user-facing text is context.l10n.<key>, added with scripts/l10n.py with real translations
for every locale (hardcoded-text counts Text('…') with letters in it).
| Say | Not |
|---|---|
| Omi | omi, OMI |
| Task(s) (D3) | Action Item(s), To-Do(s) |
| Memories | Facts |
| Try Again | Retry, Try again |
| Not Now (postpone) | Later, Maybe Later, No |
| Sign Out, Forget Device, Clear Chat, Delete (the verb for the action) | OK, Confirm, Yes |
- Title Case for buttons, menu items, tabs, navigation titles, dialog titles ("Delete Task?", "Copy Transcript"). Sentence case for descriptions, messages, placeholders, tooltips and toasts ("Task deleted").
- "…" (one character) for ongoing states ("Saving…") and an action that asks for more input; never
"..." (
three-dot-ellipsis). - A search placeholder says what it searches, without an ellipsis: "Search conversations".
- A count says what it counts ("5 conversations · 3 tasks") or has a semantic label; never a bare number next to an icon.
- Emoji are not punctuation in titles or buttons.
- Everyday settings live in top-level Settings (D4): data & privacy, export/import, transcription, conversation display and timeout, payment methods, phone calls. Developer Settings keeps only developer tools (webhooks, MCP, API keys, firmware channels, experiments).
- A setting applies when it changes. A page with a Save button is an editor (§2): it guards unsaved edits.
- Language is chosen in one place.
| State | Primitive | Carries |
|---|---|---|
| first load | OmiLoadingState (or a skeleton that previews the layout, with a timeout) |
one OmiSpinner |
| load failed | OmiErrorState(message:, onRetry:) |
the cause in words and Try Again |
| partially loaded / truncated | OmiPartialNotice(onRetry:) |
a quiet notice above the kept rows and Try Again to reload |
| nothing here / nothing matches | OmiEmptyState(icon:, title:, message:, action:) (glyph: FaIcon(…) instead of icon: where the screen's glyphs are FontAwesome) |
Title Case title, one action when it is how the page gets its first row |
- Pull-to-refresh refreshes what the page shows. A failed load always offers Try Again.
- Home and Tasks use
OmiEmptyState(titleLayoutReference:, messageLayoutReference:)to reserve enough room for either tab’s title and guidance, keeping their empty-state icons and titles aligned when the text wraps differently. The reference is measured at the available width and text scale; only the current title and message are displayed or announced. Empty Home displays only its icon and title; its reserved guidance space is not displayed or announced. Empty Tasks points to conversation capture and has no creation button.
PromptQueue.instance.enqueue(id, PromptPriority.x, show: (context) => …)
(lib/ui/prompts/prompt_queue.dart) presents every startup and background prompt: upgrade alert,
announcements, changelog, device tutorial, firmware notice, plan sheet, review request, Bluetooth
guidance.
- One modal prompt at a time, by priority (critical › high › normal › low), deduplicated by id.
- Never while recording, on a call or during a firmware update. The home shell sets
PromptQueue.instance.blockedand callspump()when that changes. - A prompt is marked seen only on an explicit answer (a button or the close X), never on a stray barrier tap. Postpone is Not Now, and it postpones; it does not silence forever.
- Ask with a pre-prompt that says why —
OmiPermissionRow(lib/ui/components/omi_permission_row.dart): title, one-sentence reason, and one action for its state (Allow → the system prompt; Allowed; Open Settings) — then the system prompt. A screen's Continue asks, in row order, for every permission still askable (requestMissingOnboardingPermissions), then moves on whatever the reader chose. - Permanently denied → the row says so and offers Open Settings (
l10n.openSettings). - Ask when a feature needs the permission ("Always" location only when a feature requires it).
- Every icon-only control has a label (
OmiIconButtonrequires one); decorative icons are excluded from semantics. - Targets ≥ 44 pt (§3).
- Text scales: containers of text use min-height, not fixed height; test at 200 %.
- Contrast: body text ≥ 4.5:1 on its surface; no grey darker than
textTertiaryfor text. - Motion honours Reduce Motion (
OmiMotion.of); live status changes (toasts, recording state) are announced (OmiFeedbackmarks its text as a live region).
Changing a rule here is a product decision: update the primitive, migrate its callers in the same PR,
and add or tighten a rule in check_mobile_ux_contract.py (with a failing case in
test_check_mobile_ux_contract.py) so the old pattern cannot come back.