feat(watch): lift CaseEntry to OnelinerState sealed type

Mirrors doctate-common/src/oneliners.rs::OnelinerState (Ready/Empty/Error/Manual)
in Kotlin. CaseEntry gains syncedToServer; manualOneliner boolean is gone — the
Manual variant carries that semantic. UI render sites use displayText() so the
'…' placeholder shows up consistently for null/Empty/Error states.

Phase 0 of the Wear-OS full-implementation plan: shape the data, no behaviour
change. All 30 JVM tests green.
This commit is contained in:
2026-04-26 23:27:35 +02:00
parent 9a00b592f0
commit 6d47b6b99b
10 changed files with 153 additions and 39 deletions
@@ -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,
)
@@ -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")
@@ -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
}
}
@@ -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
@@ -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,
@@ -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,
@@ -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()),
)
}
}
@@ -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)
@@ -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()
}
@@ -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()
}
}