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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user