diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseEntry.kt b/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseEntry.kt index baf5b07..ee0b233 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseEntry.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseEntry.kt @@ -1,21 +1,23 @@ package com.doctate.watch.domain /** - * Minimal per-case record held client-side. Mirrors the essentials of - * `doctate-client-core::CaseEntry` (Rust) without the synced/local flags or - * disk persistence — this PoC stub lives in memory only. + * Per-case record held client-side. Mirrors the essentials of + * `doctate-client-core::CaseEntry` (Rust). * - * `oneliner == null` means the server has not produced one yet; UI should - * render a placeholder ("…") rather than hide the row. + * `oneliner == null` means no state has been observed yet (fresh case); + * the UI renders a placeholder ("…"). `OnelinerState.Manual` marks a + * doctor-authored override; auto-updates from the server merge must not + * overwrite it (the merge logic checks `oneliner.isManual()`). * - * `manualOneliner == true` marks the oneliner as doctor-authored. Automatic - * updates (fake LLM burst) must not overwrite it; a subsequent manual edit - * still wins. + * `syncedToServer` flips to true once the server has acknowledged at + * least one upload for this case. Used by `reconcileWithServerSnapshot` + * to safely drop markers that no longer appear in the server window — + * pending markers are never reconciled away. */ data class CaseEntry( val caseId: String, val createdAt: Long, val lastActivityAt: Long, - val oneliner: String?, - val manualOneliner: Boolean = false, + val oneliner: OnelinerState?, + val syncedToServer: Boolean = false, ) diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseStoreStub.kt b/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseStoreStub.kt index e118807..9caceb4 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseStoreStub.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseStoreStub.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.Log import androidx.wear.tiles.TileService import com.doctate.watch.tile.DoctateTileService +import java.time.Instant import java.time.LocalDate import java.time.ZoneId import kotlinx.coroutines.flow.MutableStateFlow @@ -66,21 +67,21 @@ object CaseStoreStub { } /** - * Update a case's oneliner. Auto-updates (simulated LLM burst) skip cases - * with a manually-authored oneliner. Manual edits always win and latch the - * `manualOneliner` flag to true. + * Update a case's oneliner. A previously-set Manual override sticks against + * any non-Manual update — the doctor's text wins until they overwrite it + * with another Manual. */ - fun setOneliner(caseId: String, text: String, manual: Boolean = false) { + fun setOneliner(caseId: String, state: OnelinerState) { _cases.update { current -> current.map { when { it.caseId != caseId -> it - !manual && it.manualOneliner -> it - else -> it.copy(oneliner = text, manualOneliner = manual || it.manualOneliner) + it.oneliner is OnelinerState.Manual && state !is OnelinerState.Manual -> it + else -> it.copy(oneliner = state) } } } - requestTileRefresh("setOneliner id=${caseId.take(8)} manual=$manual") + requestTileRefresh("setOneliner id=${caseId.take(8)} kind=${state::class.simpleName}") } fun seedDemoData(now: Long = System.currentTimeMillis()) { @@ -90,17 +91,18 @@ object CaseStoreStub { fun at(date: LocalDate, hour: Int, minute: Int): Long = date.atTime(hour, minute).atZone(zone).toInstant().toEpochMilli() fun minutesAgo(m: Long): Long = now - m * 60_000L + fun ready(text: String): OnelinerState = OnelinerState.Ready(text, Instant.now().toString()) // Covers all label variants: "Vor 1 Minute" / "Vor X Minuten" / HH:mm / // "Gestern - HH:mm" / "dd.MM.yy - HH:mm". `sortedByDescending` keeps the // store invariant (newest first) intact regardless of the current time of day. val entries = listOf( - CaseEntry(CaseId.new(), minutesAgo(1), minutesAgo(1), "Anamnese Herr Weber"), - CaseEntry(CaseId.new(), minutesAgo(32), minutesAgo(32), "Kniegelenksarthroskopie rechts"), - CaseEntry(CaseId.new(), at(today, 8, 15), at(today, 8, 15), "Befund Thorax unauffällig"), - CaseEntry(CaseId.new(), at(today.minusDays(1), 16, 40), at(today.minusDays(1), 16, 40), "Postoperative Visite Zimmer 12"), - CaseEntry(CaseId.new(), at(today.minusDays(3), 11, 20), at(today.minusDays(3), 11, 20), "MRT Schulter links"), - CaseEntry(CaseId.new(), at(today.minusDays(8), 9, 5), at(today.minusDays(8), 9, 5), "Laborkontrolle Diabetes"), + CaseEntry(CaseId.new(), minutesAgo(1), minutesAgo(1), ready("Anamnese Herr Weber")), + CaseEntry(CaseId.new(), minutesAgo(32), minutesAgo(32), ready("Kniegelenksarthroskopie rechts")), + CaseEntry(CaseId.new(), at(today, 8, 15), at(today, 8, 15), ready("Befund Thorax unauffällig")), + CaseEntry(CaseId.new(), at(today.minusDays(1), 16, 40), at(today.minusDays(1), 16, 40), ready("Postoperative Visite Zimmer 12")), + CaseEntry(CaseId.new(), at(today.minusDays(3), 11, 20), at(today.minusDays(3), 11, 20), ready("MRT Schulter links")), + CaseEntry(CaseId.new(), at(today.minusDays(8), 9, 5), at(today.minusDays(8), 9, 5), ready("Laborkontrolle Diabetes")), ) _cases.value = entries.sortedByDescending { it.lastActivityAt } requestTileRefresh("seedDemoData") diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/domain/OnelinerState.kt b/watch/wearos/app/src/main/java/com/doctate/watch/domain/OnelinerState.kt new file mode 100644 index 0000000..af8fc50 --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/domain/OnelinerState.kt @@ -0,0 +1,33 @@ +package com.doctate.watch.domain + +/** + * Outcome of the LLM oneliner generation for a single case. Mirrors + * `doctate-common/src/oneliners.rs::OnelinerState` (Rust). Internally-tagged + * JSON: `{"kind":"ready", ...}`. The variants are disjoint on purpose so the + * UI can distinguish "LLM said nothing medical" (Empty) from "LLM call + * failed" (Error) from "doctor overrode it" (Manual). + */ +sealed interface OnelinerState { + /** Diagnostic timestamp of when this state was produced (RFC3339 UTC). */ + val timestamp: String + + /** LLM produced a usable summary. */ + data class Ready(val text: String, val generatedAt: String) : OnelinerState { + override val timestamp: String get() = generatedAt + } + + /** LLM deliberately returned nothing medical — valid empty result, not failure. */ + data class Empty(val generatedAt: String) : OnelinerState { + override val timestamp: String get() = generatedAt + } + + /** LLM call errored — recovery may retry. */ + data class Error(val generatedAt: String) : OnelinerState { + override val timestamp: String get() = generatedAt + } + + /** Doctor-authored override. Sticky against auto-regeneration. */ + data class Manual(val text: String, val setAt: String) : OnelinerState { + override val timestamp: String get() = setAt + } +} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/domain/OnelinerStateExt.kt b/watch/wearos/app/src/main/java/com/doctate/watch/domain/OnelinerStateExt.kt new file mode 100644 index 0000000..c2c48a4 --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/domain/OnelinerStateExt.kt @@ -0,0 +1,17 @@ +package com.doctate.watch.domain + +/** + * Text the UI should render for a oneliner state, or null when nothing + * meaningful exists yet. Empty/Error collapse to null so the UI can render + * a placeholder ("…") in both cases. + */ +fun OnelinerState?.displayText(): String? = when (this) { + null -> null + is OnelinerState.Ready -> text + is OnelinerState.Manual -> text + is OnelinerState.Empty -> null + is OnelinerState.Error -> null +} + +/** True when the oneliner is a doctor-authored override. Drives auto-update suppression. */ +fun OnelinerState?.isManual(): Boolean = this is OnelinerState.Manual diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseDetailScreen.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseDetailScreen.kt index b46aab2..a678d81 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseDetailScreen.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseDetailScreen.kt @@ -32,6 +32,8 @@ import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text import androidx.wear.input.RemoteInputIntentHelper import com.doctate.watch.domain.CaseStoreStub +import com.doctate.watch.domain.OnelinerState +import com.doctate.watch.domain.displayText import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -67,7 +69,10 @@ fun CaseDetailScreen( ?.trim() .orEmpty() if (text.isNotEmpty()) { - CaseStoreStub.setOneliner(caseId, text, manual = true) + CaseStoreStub.setOneliner( + caseId, + OnelinerState.Manual(text, setAt = Instant.now().toString()), + ) } } @@ -144,7 +149,7 @@ fun CaseDetailScreen( verticalArrangement = Arrangement.Center, ) { Text( - text = case.oneliner ?: "…", + text = case.oneliner.displayText() ?: "…", style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, maxLines = 2, diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt index fe4a384..3294f9f 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt @@ -27,6 +27,7 @@ import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text import com.doctate.watch.domain.CaseEntry import com.doctate.watch.domain.CaseStoreStub +import com.doctate.watch.domain.displayText @Composable fun CaseListScreen( @@ -88,7 +89,7 @@ private fun CaseRow(case: CaseEntry, onTap: () -> Unit) { modifier = Modifier.align(Alignment.CenterHorizontally), ) Text( - text = case.oneliner ?: "…", + text = case.oneliner.displayText() ?: "…", style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt index c8a39d0..ebb2e15 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory import com.doctate.watch.DoctateApp import com.doctate.watch.audio.AudioRecorder import com.doctate.watch.domain.CaseStoreStub +import com.doctate.watch.domain.OnelinerState import com.doctate.watch.net.UploadClient import com.doctate.watch.net.UploadResult import kotlinx.coroutines.CoroutineScope @@ -154,7 +155,10 @@ class RecordingViewModel( applicationScope.launch { delay(2_000) val label = fakeLabelFormat.format(Date()) - CaseStoreStub.setOneliner(caseId, "[PoC] OneLiner $label") + CaseStoreStub.setOneliner( + caseId, + OnelinerState.Ready("[PoC] OneLiner $label", Instant.now().toString()), + ) } } diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/tile/TileLayouts.kt b/watch/wearos/app/src/main/java/com/doctate/watch/tile/TileLayouts.kt index 5ea71f4..7ceaa70 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/tile/TileLayouts.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/tile/TileLayouts.kt @@ -24,6 +24,7 @@ import androidx.wear.protolayout.ModifiersBuilders.Modifiers import androidx.wear.protolayout.ModifiersBuilders.Padding import androidx.wear.protolayout.TypeBuilders.StringProp import com.doctate.watch.domain.CaseEntry +import com.doctate.watch.domain.displayText import com.doctate.watch.presentation.NavCommand private const val CLICK_ID_LIST = "list" @@ -76,7 +77,7 @@ private fun topRow(packageName: String): LayoutElement { } private fun middleContent(packageName: String, current: CaseEntry?): LayoutElement { - val label = current?.oneliner ?: current?.let { "…" } ?: "Noch kein Fall heute" + val label = current?.oneliner.displayText() ?: current?.let { "…" } ?: "Noch kein Fall heute" val textBuilder = Text.Builder() .setText(label) .setMaxLines(3) diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/domain/CaseStoreStubTest.kt b/watch/wearos/app/src/test/java/com/doctate/watch/domain/CaseStoreStubTest.kt index e97beb7..a522d03 100644 --- a/watch/wearos/app/src/test/java/com/doctate/watch/domain/CaseStoreStubTest.kt +++ b/watch/wearos/app/src/test/java/com/doctate/watch/domain/CaseStoreStubTest.kt @@ -1,6 +1,7 @@ package com.doctate.watch.domain import com.google.common.truth.Truth.assertThat +import java.time.Instant import org.junit.Before import org.junit.Test @@ -15,6 +16,7 @@ class CaseStoreStubTest { assertThat(snapshot).hasSize(1) assertThat(snapshot[0].caseId).isEqualTo(id) assertThat(snapshot[0].oneliner).isNull() + assertThat(snapshot[0].syncedToServer).isFalse() } @Test fun createLocal_returns_new_case_at_head_of_list() { @@ -45,28 +47,29 @@ class CaseStoreStubTest { @Test fun setOneliner_mutates_target_case_only() { val a = CaseStoreStub.createLocal(now = 1_000L) val b = CaseStoreStub.createLocal(now = 2_000L) - CaseStoreStub.setOneliner(a, "Test-Oneliner") + CaseStoreStub.setOneliner(a, ready("Test-Oneliner")) val snapshot = CaseStoreStub.cases.value - assertThat(snapshot.first { it.caseId == a }.oneliner).isEqualTo("Test-Oneliner") + val ready = snapshot.first { it.caseId == a }.oneliner as OnelinerState.Ready + assertThat(ready.text).isEqualTo("Test-Oneliner") assertThat(snapshot.first { it.caseId == b }.oneliner).isNull() } - @Test fun setOneliner_manual_latches_flag_and_blocks_auto_update() { + @Test fun setOneliner_manual_blocks_subsequent_auto_update() { val id = CaseStoreStub.createLocal(now = 1_000L) - CaseStoreStub.setOneliner(id, "Doktor-Text", manual = true) - CaseStoreStub.setOneliner(id, "[PoC] Auto-Text") + CaseStoreStub.setOneliner(id, OnelinerState.Manual("Doktor-Text", setAt = nowRfc3339())) + CaseStoreStub.setOneliner(id, ready("[PoC] Auto-Text")) val entry = CaseStoreStub.cases.value.first { it.caseId == id } - assertThat(entry.oneliner).isEqualTo("Doktor-Text") - assertThat(entry.manualOneliner).isTrue() + val manual = entry.oneliner as OnelinerState.Manual + assertThat(manual.text).isEqualTo("Doktor-Text") } @Test fun setOneliner_manual_overwrites_previous_manual() { val id = CaseStoreStub.createLocal(now = 1_000L) - CaseStoreStub.setOneliner(id, "V1", manual = true) - CaseStoreStub.setOneliner(id, "V2", manual = true) + CaseStoreStub.setOneliner(id, OnelinerState.Manual("V1", setAt = nowRfc3339())) + CaseStoreStub.setOneliner(id, OnelinerState.Manual("V2", setAt = nowRfc3339())) val entry = CaseStoreStub.cases.value.first { it.caseId == id } - assertThat(entry.oneliner).isEqualTo("V2") - assertThat(entry.manualOneliner).isTrue() + val manual = entry.oneliner as OnelinerState.Manual + assertThat(manual.text).isEqualTo("V2") } @Test fun currentCase_returns_head_of_list() { @@ -76,4 +79,9 @@ class CaseStoreStubTest { val second = CaseStoreStub.createLocal(now = 2_000L) assertThat(CaseStoreStub.currentCase?.caseId).isEqualTo(second) } + + private fun ready(text: String): OnelinerState = + OnelinerState.Ready(text, generatedAt = nowRfc3339()) + + private fun nowRfc3339(): String = Instant.now().toString() } diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/domain/OnelinerStateExtTest.kt b/watch/wearos/app/src/test/java/com/doctate/watch/domain/OnelinerStateExtTest.kt new file mode 100644 index 0000000..615fd52 --- /dev/null +++ b/watch/wearos/app/src/test/java/com/doctate/watch/domain/OnelinerStateExtTest.kt @@ -0,0 +1,41 @@ +package com.doctate.watch.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class OnelinerStateExtTest { + private val ts = "2026-04-26T10:00:00Z" + + @Test fun displayText_returns_text_for_ready() { + val state: OnelinerState = OnelinerState.Ready("hello", ts) + assertThat(state.displayText()).isEqualTo("hello") + } + + @Test fun displayText_returns_text_for_manual() { + val state: OnelinerState = OnelinerState.Manual("doctor wrote this", ts) + assertThat(state.displayText()).isEqualTo("doctor wrote this") + } + + @Test fun displayText_returns_null_for_empty() { + val state: OnelinerState = OnelinerState.Empty(ts) + assertThat(state.displayText()).isNull() + } + + @Test fun displayText_returns_null_for_error() { + val state: OnelinerState = OnelinerState.Error(ts) + assertThat(state.displayText()).isNull() + } + + @Test fun displayText_returns_null_for_null_state() { + val state: OnelinerState? = null + assertThat(state.displayText()).isNull() + } + + @Test fun isManual_only_true_for_manual_variant() { + assertThat((OnelinerState.Manual("x", ts) as OnelinerState?).isManual()).isTrue() + assertThat((OnelinerState.Ready("x", ts) as OnelinerState?).isManual()).isFalse() + assertThat((OnelinerState.Empty(ts) as OnelinerState?).isManual()).isFalse() + assertThat((OnelinerState.Error(ts) as OnelinerState?).isManual()).isFalse() + assertThat((null as OnelinerState?).isManual()).isFalse() + } +}