Refactor Wear OS UI to use native surfaces

This commit is contained in:
2026-04-23 22:00:12 +02:00
parent 66d13e741a
commit cea8ed8c06
+74 -39
View File
@@ -147,54 +147,81 @@ Kein Netzwerk → Aufnahmen bleiben lokal, Sync bei Wiederherstellung
--- ---
**UI — Ein Screen pro Eintrag, zwei Zustände:** **UI — drei native Wear-OS-Surfaces:**
Jeder Listeneintrag füllt das gesamte Watch-Display. Navigation via Swipe hoch/runter. "Neu" immer ganz oben, darunter heutige Fälle chronologisch absteigend (neuester zuerst). Nur ein Button pro Eintrag. Die Watch-App exponiert ihre Funktion über die drei kanonischen Wear-OS-Surfaces — jede in ihrer eigentlichen Rolle. Ein früherer Plan („ein Screen pro Eintrag, Swipe hoch/runter zwischen Fällen auf einem Tile") ist **verworfen**: Tiles sind per Design nicht scrollbar (ProtoLayout rendert einen statischen Snapshot), und Long-Press / vertikaler Swipe sind System-Gesten (Tile-Edit-Karussell bzw. Quick-Panel), die Apps nicht abfangen können. Die einzige App-seitige Interaktion auf einem Tile sind **Tap-Regionen** (`Clickable`-Modifier auf ProtoLayout-Elementen) — mehrere pro Tile sind HIG-konform.
| Surface | Rolle | Entry-Point |
|---|---|---|
| Activity | Fallliste + Recording-Screen | App-Launcher, Complication-Tap, Tile-Tap |
| Tile | Glance: aktueller Fall | Swipe links vom Watchface |
| Complication | Fast-Launch zur Fallliste | Tap auf Watchface (optional, vom Arzt platziert) |
---
**Activity (Hauptscreen) — funktional-analog zum Desktop-Client:**
``` ```
"Neu" (ganz oben): Recording (gleicher Screen): ┌─────────────────────┐
┌─────────────────┐ ┌─────────────────┐ │ ☁↑ 2 🟢 │ ← Header: Sync-Indikator + Online-Status
⏱ 10:35 │ │ ⏱ 10:35 ───────────────
│ │ ● REC 00:42 10:43
[● Neu] │ │ [■ Stop] │ Kniegelenk re., │ ← Listeneintrag (Tap → Fortsetzen)
│ │ V.a. Meniskus
☁↑ 2 │ │ ☁↑ 2 │ │
└─────────────────┘ └─────────────────┘ │ 10:32 │
│ ⏳ │ ← Fall ohne Oneliner (Polling aktiv)
↓ Swipe runter nach Stop: │ │
│ [ ● Neu ] │ ← EdgeButton (Material-3 Expressive, primäre Aktion)
─────────────────┐ ┌─────────────────┐ ─────────────────────┘
│ 10:32 │ │ 10:32 │
│ Kniegelenk... │ │ ⏳ │ ← Oneliner noch nicht da
│ [▶ Fortsetzen] │ │ [▶ Fortsetzen] │
└─────────────────┘ └─────────────────┘
↓ Swipe runter
┌─────────────────┐
│ 09:15 │
│ Hypertonie... │
│ [▶ Fortsetzen] │
└─────────────────┘
``` ```
**Screen-Verhalten nach Stop:** - Fallliste als `ScalingLazyColumn`, gespeist aus dem Singleton-`CaseStore`-Snapshot (Kotlin-Pendant zum Rust-`CaseStore` in `doctate-client-core`; strukturell 1:1, kein UniFFI-Binding bis zum Handy-App-Start — siehe Abweichungen → Client-Architektur)
Nach dem Stoppen einer Aufnahme bleibt der aktuelle Fall auf dem Display — der Screen wechselt vom Recording-Zustand direkt zu "Fortsetzen". Der Arzt kann sofort weiter diktieren, ohne zu scrollen. Für einen neuen Fall swiped er bewusst nach oben zu "Neu". - EdgeButton „● Neu" → neue `case_id` + Marker + direkter Recording-Screen
- Tap auf Listeneintrag → „Fortsetzen" (neue Aufnahme mit bestehender `case_id`)
- Nach Stop: Screen bleibt auf dem aktiven Fall, Foreground Service startet aggressives Oneliner-Polling (Post-Stop-Burst, siehe unten)
- Einträge ohne Oneliner zeigen ⏳; sobald das Polling den Oneliner eingetragen hat, wechselt die Anzeige automatisch (Snapshot-Flow)
**Oneliner-Polling:** **Tile (Glance: aktueller Fall):**
Solange der aktuell angezeigte Fall noch keinen Oneliner hat (⏳), fragt die Watch alle paar Sekunden den Server ab (`GET /api/oneliner/{case_id}`). Sobald ein Oneliner empfangen wird, wird er in die Marker-Datei geschrieben und im Display angezeigt — das Polling für diesen Fall stoppt. Beim Weiterswischen zu einem anderen Fall ohne Oneliner beginnt das Polling für den neuen sichtbaren Fall.
```
┌──────────────────────┐
│ 10:43 ☰ Fälle │ ← Zeit + kleine zweite Tap-Region (→ Activity auf Liste)
│ │
│ Kniegelenk re., │ ← große mittige Tap-Region (→ Fortsetzen)
│ V.a. Meniskus │
│ │
│ [ ● Neu ] │ ← EdgeButton (→ Activity mit new=true)
└──────────────────────┘
```
- „Aktueller Fall" = laufende Aufnahme, sonst Fall mit maximalem `last_activity_at` im Snapshot
- Drei Tap-Regionen — nicht drei Buttons. In ProtoLayout ist `Clickable` ein Modifier auf beliebige Elemente (Box/Row/Text), mehrere pro Tile sind von System-Tiles (Fitness, Wetter) etabliert
- Datenquelle: derselbe Singleton-`CaseStore`-Snapshot wie die Activity — Tile und Activity divergieren nie
- Tile-Refresh wird explizit getriggert nach jedem `CaseStore`-Merge via `TileService.getUpdater(ctx).requestUpdate(DoctateTileService::class.java)`. Tiles pollen nicht selbst; wer Daten ändert, ruft `requestUpdate()`
**Complication (optional, vom Arzt auf sein Watchface platziert):**
- Slot-Typ: `SHORT_TEXT` oder `SMALL_IMAGE` — minimal, damit jedes Watchface sie unterstützt. Kein OneLiner-Text (`LONG_TEXT` wird von weniger Watchfaces akzeptiert)
- Tap → `PendingIntent` auf `MainActivity` mit Extra `open=list` → Fallliste
- Rolle: Fast-Launch vom Watchface, spart den Zwischenschritt über App-Launcher oder Tile-Karussell
- Ist rein *optional* — Arzt platziert sie selbst in den Watchface-Einstellungen; die App funktioniert ohne Complication vollständig
---
**Post-Stop-Burst (aggressives Polling):**
Nach `Stop` einer Aufnahme pollt der Foreground Service den frisch erzeugten Oneliner **aggressiv mit Timeout** (z.B. 2 s Intervall, 60 s Budget), damit der OneLiner möglichst schon sichtbar ist, wenn der Arzt das nächste Mal auf die Watch blickt. Nach Ablauf oder Treffer fällt das Polling auf das reguläre (lazy, sichtbarkeits-getriebene) Intervall zurück. Begründung: der gefühlte „Time-to-Oneliner" nach einer Aufnahme bestimmt, ob der Arzt der Automatik vertraut — hier lohnt sich eine kurze Netzwerk-Burst gegenüber dem Standard-Snapshot-Poll.
**Weitere UI-Indikatoren:**
- 🎧 Headset-Indikator wenn Bluetooth-Headset aktiv - 🎧 Headset-Indikator wenn Bluetooth-Headset aktiv
- ☁↑ zeigt Anzahl ungesyncter Aufnahmen (dezent, verschwindet bei 0) - ☁↑ zeigt Anzahl ungesyncter Aufnahmen (dezent, verschwindet bei 0)
- Default beim Öffnen: immer ganz oben ("Neu") - Kein Verwerfen-Button — Korrekturen per Diktat („Korrektur: …")
- Stop → Aufnahme geht direkt in Sync-Queue, kurzes ✓-Feedback, Screen bleibt auf aktuellem Fall
- Kein Verwerfen-Button — Korrekturen per Diktat ("Korrektur: ...")
Oneliner-Zustände bei Fällen: **Oneliner-Zustände (Activity + Tile gleich):**
- Verfügbar → Uhrzeit + Oneliner (stabil, Polling beendet) - Verfügbar → Uhrzeit + Oneliner (stabil, Polling beendet)
- Lädt → Uhrzeit + ⏳ (Polling aktiv) - Lädt → Uhrzeit + ⏳ (Polling aktiv)
- Kein Server → nur Uhrzeit (Polling mit Backoff) - Kein Server → nur Uhrzeit (Polling mit Backoff)
- Keine Fälle heute → nur "Neu" sichtbar - Keine Fälle heute → Activity zeigt Leer-Hinweis, Tile zeigt nur EdgeButton „● Neu"
--- ---
@@ -283,7 +310,9 @@ GET /api/oneliner/{case_id}
- Oneliner wird beim ersten Transkript einmalig generiert und danach nur noch gelesen - Oneliner wird beim ersten Transkript einmalig generiert und danach nur noch gelesen
- Der Arzt kann den Oneliner beeinflussen, indem er im Diktat eine Bezeichnung nennt (z.B. „Bezeichnung: Kniegelenk") - Der Arzt kann den Oneliner beeinflussen, indem er im Diktat eine Bezeichnung nennt (z.B. „Bezeichnung: Kniegelenk")
- Timeout: ~2 Sekunden - Timeout: ~2 Sekunden
- **Polling:** Solange der aktuell angezeigte Fall keinen Oneliner in seiner Marker-Datei hat, fragt die Watch alle paar Sekunden ab. Polling läuft nur für den sichtbaren Fall. - **Polling (zwei Modi):**
- *Post-Stop-Burst:* direkt nach `Stop` einer Aufnahme pollt der Foreground Service aggressiv (2 s Intervall, 60 s Budget) für den gerade gesendeten Fall — Ziel: OneLiner ist da, bevor der Arzt zurück auf Activity/Tile blickt.
- *Lazy-Regulär:* danach bzw. bei bestehenden Fällen ohne OneLiner fragt die Watch „alle paar Sekunden" nur für den aktuell sichtbaren Fall ab (Activity im Vordergrund, oder Fall = „aktueller Fall" auf dem Tile).
- Bei 200: Oneliner in Marker-Datei `/recordings/cases/{case_id}.json` schreiben → Polling für diesen Fall beenden - Bei 200: Oneliner in Marker-Datei `/recordings/cases/{case_id}.json` schreiben → Polling für diesen Fall beenden
- Bei 404/503/Timeout → weiter pollen (Watch funktioniert normal, Marker-Datei bleibt ohne Oneliner, UI zeigt ⏳) - Bei 404/503/Timeout → weiter pollen (Watch funktioniert normal, Marker-Datei bleibt ohne Oneliner, UI zeigt ⏳)
- Keine Blockierung des Aufnahme-Workflows — Polling läuft unabhängig vom Recording - Keine Blockierung des Aufnahme-Workflows — Polling läuft unabhängig vom Recording
@@ -1191,9 +1220,13 @@ wiremock = "0.6"
#### 5b — Pixel Watch App (primäres Entwicklungsziel) #### 5b — Pixel Watch App (primäres Entwicklungsziel)
- [x] Wear OS Projekt in Android Studio (`:app-wear`) — `watch/wearos/` Gradle-Projekt (AGP 9.2, Kotlin 2.2.10, Compose BOM 2024.09, minSdk 30, targetSdk 36). **Single `:app` statt `:app-wear` + `:core-*`.** - [x] Wear OS Projekt in Android Studio (`:app-wear`) — `watch/wearos/` Gradle-Projekt (AGP 9.2, Kotlin 2.2.10, Compose BOM 2024.09, minSdk 30, targetSdk 36). **Single `:app` statt `:app-wear` + `:core-*`.**
- [x] Jetpack Compose UI (ein Screen pro Eintrag, fullscreen, Swipe-Navigation) — PoC-UI (`RecordingScreen`) rendert den Record-Flow. Swipe-Navigation und Pro-Eintrag-Screens sind für die Fallliste geplant, aber noch nicht gebaut. - [x] Jetpack Compose UI — PoC-UI (`RecordingScreen`) rendert den Record-Flow. Fallliste + Tile + Complication sind geplant, aber noch nicht gebaut.
- [ ] Vertikale Fallliste (Neu ganz oben, heutige Fälle darunter) - [ ] Activity-Hauptscreen: Fallliste als `ScalingLazyColumn` + EdgeButton „● Neu" (funktional-analog zum Desktop-Client). Tap auf Eintrag → Fortsetzen; Tap auf EdgeButton → neuer Fall.
- [ ] Nach Stop: Screen bleibt auf aktuellem Fall (→ "Fortsetzen" direkt sichtbar) - [ ] Singleton-`CaseStore` in Kotlin (Pendant zum Rust-`CaseStore` in `doctate-client-core`): Snapshot-Flow, `create_local` / `mark_activity` / `merge_server_snapshot` / `reconcile_with_server_snapshot`. Gemeinsame Datenquelle für Activity + TileService.
- [ ] Tile-Service (`DoctateTileService` via androidx.wear.tiles): ProtoLayout mit drei Tap-Regionen — Mitte (OneLiner → Fortsetzen), oben-rechts (☰ Fälle → Activity/Liste), EdgeButton (● Neu → Activity/Recording). Refresh via `getUpdater().requestUpdate(...)` nach jedem `CaseStore`-Merge.
- [ ] Complication-Service (`ComplicationDataSourceService`): `SHORT_TEXT` oder `SMALL_IMAGE`, Tap → MainActivity mit `open=list`. Kein OneLiner-Text im Slot.
- [ ] Nach Stop: Screen bleibt auf aktuellem Fall (→ „Fortsetzen" direkt sichtbar)
- [ ] Post-Stop-Burst-Polling: 2 s Intervall, 60 s Budget, dann Abstieg ins reguläre Intervall; triggert Tile-Refresh bei Oneliner-Treffer
- [x] MediaRecorder-Integration via `:core-audio` — in `audio/AudioRecorder.kt` (MPEG-4/AAC-LC, 16 kHz mono, 64 kbps, `context.cacheDir`). Landet bei künftigem Multi-Modul-Split in `:core-audio`. - [x] MediaRecorder-Integration via `:core-audio` — in `audio/AudioRecorder.kt` (MPEG-4/AAC-LC, 16 kHz mono, 64 kbps, `context.cacheDir`). Landet bei künftigem Multi-Modul-Split in `:core-audio`.
- [ ] `AudioSource.MIC``VOICE_RECOGNITION` umstellen — Wear-OS-DSP (Noise-Suppression, AGC) liefert saubereres Signal bei niedrigerem Rohpegel. Geht nur zusammen mit serverseitigem Replay-Gain (siehe Phase 4), weil VR allein ~9 dB leiser ist als MIC. PoC am 2026-04-23 bestätigt: VR + Replay-Gain klingt deutlich besser als MIC + Replay-Gain (letzteres verstärkt auch Rauschen mit). - [ ] `AudioSource.MIC``VOICE_RECOGNITION` umstellen — Wear-OS-DSP (Noise-Suppression, AGC) liefert saubereres Signal bei niedrigerem Rohpegel. Geht nur zusammen mit serverseitigem Replay-Gain (siehe Phase 4), weil VR allein ~9 dB leiser ist als MIC. PoC am 2026-04-23 bestätigt: VR + Replay-Gain klingt deutlich besser als MIC + Replay-Gain (letzteres verstärkt auch Rauschen mit).
- [ ] Bluetooth-Headset Erkennung + Indikator - [ ] Bluetooth-Headset Erkennung + Indikator
@@ -1400,3 +1433,5 @@ Alle Einträge beziehen sich auf den Ist-Stand im Repository. Die ursprüngliche
| Watch-Case-ID-Strategie im PoC | Plan: "Neu" → neue case_id + Marker, "Fortsetzen" → bestehende | **PoC**: jede Aufnahme = neue `UUID.randomUUID()`; keine Marker-Datei, keine Fortsetzen-Logik | Solange keine Fallliste-UI existiert, gibt es keinen Mechanismus zum Auswählen eines bestehenden Cases. Neu-pro-Aufnahme matcht das Server-Verhalten (unbekannte UUID → neuer Case-Ordner) und hält den PoC-Scope klein. Umstellung auf Neu/Fortsetzen berührt nur das ViewModel (Case-Store hält die "aktuelle" ID), nicht `UploadClient` oder `AudioRecorder`. | | Watch-Case-ID-Strategie im PoC | Plan: "Neu" → neue case_id + Marker, "Fortsetzen" → bestehende | **PoC**: jede Aufnahme = neue `UUID.randomUUID()`; keine Marker-Datei, keine Fortsetzen-Logik | Solange keine Fallliste-UI existiert, gibt es keinen Mechanismus zum Auswählen eines bestehenden Cases. Neu-pro-Aufnahme matcht das Server-Verhalten (unbekannte UUID → neuer Case-Ordner) und hält den PoC-Scope klein. Umstellung auf Neu/Fortsetzen berührt nur das ViewModel (Case-Store hält die "aktuelle" ID), nicht `UploadClient` oder `AudioRecorder`. |
| Watch-Datei-Lifecycle im PoC | Plan: Persistentes `unsynced/`-Verzeichnis, Sync-Service löscht nach ACK | **PoC**: Aufnahme in `cacheDir`, im `finally`-Block des Upload-Flows gelöscht — egal ob Success, Transient oder Terminal | Für den vertikalen Beweis unnötig, Crash-Recovery zu implementieren. Volle Pending-Queue mit Marker + Retry + Backoff kommt in der Arbeitsphase nach dem PoC (entspricht den noch offenen Phase-5b-Tasks). | | Watch-Datei-Lifecycle im PoC | Plan: Persistentes `unsynced/`-Verzeichnis, Sync-Service löscht nach ACK | **PoC**: Aufnahme in `cacheDir`, im `finally`-Block des Upload-Flows gelöscht — egal ob Success, Transient oder Terminal | Für den vertikalen Beweis unnötig, Crash-Recovery zu implementieren. Volle Pending-Queue mit Marker + Retry + Backoff kommt in der Arbeitsphase nach dem PoC (entspricht den noch offenen Phase-5b-Tasks). |
| Watch-Instrumented-Test-Hang | Nicht im Plan | `UploadClientTest` (MockWebServer-basiert) **hängt in `@Before setUp()`** auf Wear-OS-34-AVD. JVM-Unit-Tests laufen. Real-Server-E2E validiert denselben Vertrag strenger. | Vermutlich Wear-SELinux-Policy gegen Loopback-Socket-Bind aus der Instrumentation-APK-Prozess. Priorität niedrig, weil der echte Server-Upload funktioniert — Mock-Test ist nur Regressions-Absicherung fürs Refactoring. `SKIP_INSTRUMENTED=1 ./run.sh test` überspringt den on-device-Tier. | | Watch-Instrumented-Test-Hang | Nicht im Plan | `UploadClientTest` (MockWebServer-basiert) **hängt in `@Before setUp()`** auf Wear-OS-34-AVD. JVM-Unit-Tests laufen. Real-Server-E2E validiert denselben Vertrag strenger. | Vermutlich Wear-SELinux-Policy gegen Loopback-Socket-Bind aus der Instrumentation-APK-Prozess. Priorität niedrig, weil der echte Server-Upload funktioniert — Mock-Test ist nur Regressions-Absicherung fürs Refactoring. `SKIP_INSTRUMENTED=1 ./run.sh test` überspringt den on-device-Tier. |
| Watch-UI-Surface-Aufteilung | Plan (alt): „ein Screen pro Eintrag, Swipe hoch/runter zwischen Fällen", konzeptionell als Tile-Paginierung gedacht | **Drei native Wear-OS-Surfaces** in getrennten Rollen: Activity (`ScalingLazyColumn` + EdgeButton „● Neu") = Fallliste + Recording; Tile (ProtoLayout mit drei Tap-Regionen: OneLiner-Mitte / ☰ Fälle / EdgeButton „● Neu") = Glance auf den aktuellen Fall; Complication (`SHORT_TEXT`/`SMALL_IMAGE`, Tap → Liste) = optionaler Fast-Launch vom Watchface | Tile-Paginierung ist technisch unmöglich: ProtoLayout ist nicht scrollbar, Long-Press und vertikaler Swipe sind System-Gesten (Tile-Edit-Karussell / Quick-Panel) und von Apps nicht abfangbar. Die einzige App-seitige Interaktion auf einem Tile ist Tap auf `Clickable`-Regionen — mehrere pro Tile sind HIG-konform und von System-Tiles etabliert. Activity + Tile + Complication teilen sich einen Singleton-`CaseStore`-Snapshot (Kotlin-Pendant zum Rust-`CaseStore`), damit sie nie divergieren; Tile-Refresh via `getUpdater().requestUpdate()` nach jedem Merge. |
| Watch-Core in Kotlin (nicht Rust) | „Es war geplant, die Business-Logik aller Clients in Rust zu entwickeln" (Diskussionsstand) — technisch machbar via UniFFI-Bindings auf `doctate-client-core` | **Watch-`CaseStore` in Kotlin nachgebaut**, strukturell 1:1 zum Rust-Pendant (Snapshot-Flow, Merge-/Reconcile-Asymmetrie, Sync-Flag-Semantik) | Für MVP (PoC → Watch-App-Abschluss) wiegt der zusätzliche Build-Stack (NDK, Cross-Compile für `aarch64-linux-android` + `armv7-linux-androideabi`, APK-Größe) schwerer als der Code-Sharing-Nutzen — es gibt genau *einen* Kotlin-Consumer. Natürlicher Einzug-Moment ist der Start der **Handy-App** (zweiter Kotlin-Consumer → erster echter Duplikations-Druck); bis dahin wird die Kotlin-Portierung diszipliniert strukturgleich zum Rust-Original geführt, damit ein späterer UniFFI-Swap kein Refactoring der Call-Sites erzwingt. |