feat(app): one row per recorded event and a compact conversation page on mobile

Merged
GI
23d ago
+4823-503

Depends on BasedHardware/omi#17281 (base branch feat/cross-surface-capture-groups; retarget to main after it merges).

What changed

Mobile parity with the desktop capture-group work in #17281, plus a polish pass on the conversation page.

List: one row per recorded event

  • ServerConversation decodes the additive capture_group field (already in the regenerated Dart wire) and round-trips it through the cache.
  • ConversationProvider collapses the loaded rows after its client-side filters, using the desktop rule: the loaded member whose id is primary_id represents the event, otherwise the first loaded member in list order; a member with no other loaded member is shown as itself. Paging offsets still count server rows (collapse only affects grouping), and every member stays loaded and reachable (loadedConversationById).
  • A row that becomes hidden behind its event's row leaves the merge selection, so merge never acts on a row the user cannot see. Mobile has no "select all".
  • Rows for an event recorded by more than one kind of device show small device icons (desktop / pendant / phone / watch) after the duration, with a "Recorded by …" accessibility label.

Conversation page

  • One compact header now serves Summary, Transcript and Action items, so the title and facts stay visible on every tab. Before, they appeared only at the top of Summary. The header has:
    1. emoji and title (22pt instead of 32pt; tap to rename, as before)
    2. one line of facts: Today · 1:57 PM – 2:59 PM · 1h 1m · Business. It adds the device when only one device recorded the conversation. A linked calendar event puts its logo first and opens the event on tap.
    3. a row of chips: the event's recordings (a stack of device icons plus "3 recordings"), who spoke (You, Dana +2, with calendar attendees as the fallback), folder, and visibility.
  • The recordings sheet lists each recording with its device and time range, and a check marks the open one. Tapping a row opens that recording, using the loaded row or fetching it by id when the list has not loaded it. "Separate…" asks for confirmation, then calls POST /v1/conversations/{id}/capture-group/separate. On ok or unchanged it reloads the detail and the list (the search results too, when a search is active) and closes the sheet. On failure it shows an inline error and changes nothing.
  • The overflow menu gains Rename and Move to folder to match desktop. The inline title edit and the folder chip still work.
  • All other features are unchanged: Ask Omi, star, share, search, copy transcript/summary/ID, share audio, link event, test prompt, reprocess, delete, the visibility sheet, the folder sheet, calendar details, the swipe to the next or previous conversation, and the bottom tab/audio bar.
  • GetSummaryWidgets is removed from widgets.dart. Its folder chip and visibility sheet moved into widgets/conversation_detail_header.dart without changing their behavior.

Shared rule

  • New parity fixture contracts/parity/capture_group_collapse.json, with a Flutter adapter (test/parity/parity_contracts_test.dart) and a backend structural check (backend/tests/unit/test_parity_contracts.py). The macOS adapter is listed as pending in the README; CaptureGroupPresentationTests.swift covers the same cases inline.

Strings: 12 new keys, added to all 49 locales with scripts/l10n.py.

Screenshots

These are iPhone 17 Pro simulator screenshots of the real production widgets, using fixture data served by an in-process loopback server (no account, no backend). The app ran from a gitignored preview entry point. Local paths:

Before:

  • /private/tmp/claude-501/-Users-dazheng-workspace-omi/d53b4269-6dbf-4422-8cc7-bca8dc0f20fc/scratchpad/mobile/before-list.png: the one event appears as three rows (desktop plus two pendant pieces).
  • …/mobile/before-detail-grouped.png: 32pt title, date and duration chips, folder and visibility chips; header only on Summary.
  • …/mobile/before-detail-transcript.png: the Transcript tab has no title or context.

After:

  • …/mobile/after-list.png: one row with desktop and pendant icons.
  • …/mobile/after-detail-grouped.png: compact header with "3 recordings" and "You, Speaker 2 +2".
  • …/mobile/after-recordings-sheet.png: the recordings sheet, with Desktop checked.
  • …/mobile/after-detail-member.png: a pendant recording, hidden in the list, opened from the sheet.
  • …/mobile/after-separate-confirm.png, after-separated.png (now "2 recordings"), after-list-post-separate.png: the separated piece is its own row again.
  • …/mobile/after-detail-single.png, after-detail-single-transcript.png: a single-device conversation; the header stays on the Transcript tab.

Android was not captured: no Android SDK or emulator on this machine.

Tests

  • flutter test test/unit/capture_groups_test.dart test/parity/parity_contracts_test.dart: covers wire decode and cache round trip, collapse and the representative fallback, the rule that a lone member is never hidden, recordings order, current-recording fallback, resolving a loaded member versus fetching it, the +N people summary, participant order, the separation controller (success reload, unchanged reload, failure without reload, one separation at a time), provider collapse with every row kept loaded, and dropping hidden rows from the selection. Passes under both TZ=UTC and TZ=Pacific/Kiritimati.
  • bash app/test.sh (full hermetic suite, after merging the base tip 55c23aa0cf): 2726 passed, 5 skipped, 0 failed; spine tests passed.
  • app/scripts/analyze_ratchet.sh: passed.
  • backend/tests/unit/test_parity_contracts.py: 15 passed.
  • scripts/pr-preflight --base origin/feat/cross-surface-capture-groups --pr-body-file …: 33 checks passed.
  • python3 app/scripts/l10n.py check reports keys missing from zh that were already missing on the base (audioUploadFailedKeptLocal, …). This PR's keys are complete in every locale.

Line counts

Line-Count-Exception: app/lib/pages/conversation_detail/page.dart | 2235 -> 2309 | the page owns the recordings sheet's open and separate handlers, which need its navigator, tab index and detail provider; the header, sheet, controller and rules live in new files, and GetSummaryWidgets moved out of widgets.dart (-443 lines)
Line-Count-Exception: app/lib/providers/conversation_provider.dart | 1962 -> 1989 | collapse must run where the provider builds its visible groups so paging, search and the merge selection agree; the rule itself lives in lib/utils/conversations/capture_groups.dart

Failure class (fixes)

Failure-Class: FC-same-speech-captured-twice-surfaces-twice

(The fix subjects in this branch's history are #17281's commits, which landed on main as squash 8d8984bac8 and are
not ancestors of main; this PR's own changes are features. #17281 declared this class.)

🤖 Generated with Claude Code