diff --git a/watch/wearos/app/build.gradle.kts b/watch/wearos/app/build.gradle.kts index f6a01af..da740b6 100644 --- a/watch/wearos/app/build.gradle.kts +++ b/watch/wearos/app/build.gradle.kts @@ -84,6 +84,7 @@ dependencies { implementation(libs.wear.protolayout.expression) implementation(libs.wear.complications.data.source.ktx) implementation(libs.wear.compose.navigation) + implementation(libs.wear.input) implementation(libs.guava) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.ui.test.junit4) 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 601d49a..baf5b07 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 @@ -7,10 +7,15 @@ package com.doctate.watch.domain * * `oneliner == null` means the server has not produced one yet; UI should * render a placeholder ("…") rather than hide the row. + * + * `manualOneliner == true` marks the oneliner as doctor-authored. Automatic + * updates (fake LLM burst) must not overwrite it; a subsequent manual edit + * still wins. */ data class CaseEntry( val caseId: String, val createdAt: Long, val lastActivityAt: Long, val oneliner: String?, + val manualOneliner: 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 ceb24a6..e118807 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 @@ -65,11 +65,22 @@ object CaseStoreStub { requestTileRefresh("markActivity id=${caseId.take(8)}") } - fun setOneliner(caseId: String, text: String) { + /** + * 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. + */ + fun setOneliner(caseId: String, text: String, manual: Boolean = false) { _cases.update { current -> - current.map { if (it.caseId == caseId) it.copy(oneliner = text) else it } + current.map { + when { + it.caseId != caseId -> it + !manual && it.manualOneliner -> it + else -> it.copy(oneliner = text, manualOneliner = manual || it.manualOneliner) + } + } } - requestTileRefresh("setOneliner id=${caseId.take(8)}") + requestTileRefresh("setOneliner id=${caseId.take(8)} manual=$manual") } fun seedDemoData(now: Long = System.currentTimeMillis()) { 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 d3be326..b46aab2 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 @@ -1,6 +1,13 @@ package com.doctate.watch.presentation +import android.app.Activity +import android.app.RemoteInput +import android.os.Bundle +import android.speech.RecognizerIntent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,6 +30,7 @@ import androidx.wear.compose.material3.EdgeButton import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text +import androidx.wear.input.RemoteInputIntentHelper import com.doctate.watch.domain.CaseStoreStub import java.time.Instant import java.time.LocalDate @@ -30,6 +38,13 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale +private const val ONELINER_INPUT_KEY = "oneliner" + +// BCP-47 tag pinned to German so voice input stays in the app's language +// regardless of the watch's system locale. Medical vocabulary needs de-DE +// to produce usable transcripts. +private val VOICE_INPUT_LANGUAGE: String = Locale.GERMANY.toLanguageTag() + private val detailDateFormat = DateTimeFormatter.ofPattern("dd.MM.yy", Locale.getDefault()) private val detailTimeFormat = DateTimeFormatter.ofPattern("HH:mm:ss", Locale.getDefault()) @@ -41,6 +56,40 @@ fun CaseDetailScreen( val cases by CaseStoreStub.cases.collectAsStateWithLifecycle() val case = cases.firstOrNull { it.caseId == caseId } + val editLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult + val text = result.data + ?.let { RemoteInput.getResultsFromIntent(it) } + ?.getCharSequence(ONELINER_INPUT_KEY) + ?.toString() + ?.trim() + .orEmpty() + if (text.isNotEmpty()) { + CaseStoreStub.setOneliner(caseId, text, manual = true) + } + } + + val launchEdit: () -> Unit = { + val languageExtras = Bundle().apply { + putString(RecognizerIntent.EXTRA_LANGUAGE, VOICE_INPUT_LANGUAGE) + putString(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, VOICE_INPUT_LANGUAGE) + } + val remoteInput = RemoteInput.Builder(ONELINER_INPUT_KEY) + .setLabel("OneLiner") + .setAllowFreeFormInput(true) + .addExtras(languageExtras) + .build() + val intent = RemoteInputIntentHelper.createActionRemoteInputIntent().apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE, VOICE_INPUT_LANGUAGE) + putExtra(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, VOICE_INPUT_LANGUAGE) + } + RemoteInputIntentHelper.putRemoteInputsExtra(intent, listOf(remoteInput)) + RemoteInputIntentHelper.putTitleExtra(intent, "OneLiner") + editLauncher.launch(intent) + } + ScreenScaffold { contentPadding -> if (case == null) { Column( @@ -84,9 +133,13 @@ fun CaseDetailScreen( textAlign = TextAlign.Center, ) } - // Middle third: prominent one-liner. + // Middle third: prominent one-liner. Tap launches the Wear OS + // system input picker (voice / keyboard / handwriting). Column( - modifier = Modifier.weight(1f).fillMaxWidth(), + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clickable(onClick = launchEdit), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { 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 96a307a..e97beb7 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 @@ -51,6 +51,24 @@ class CaseStoreStubTest { assertThat(snapshot.first { it.caseId == b }.oneliner).isNull() } + @Test fun setOneliner_manual_latches_flag_and_blocks_auto_update() { + val id = CaseStoreStub.createLocal(now = 1_000L) + CaseStoreStub.setOneliner(id, "Doktor-Text", manual = true) + CaseStoreStub.setOneliner(id, "[PoC] Auto-Text") + val entry = CaseStoreStub.cases.value.first { it.caseId == id } + assertThat(entry.oneliner).isEqualTo("Doktor-Text") + assertThat(entry.manualOneliner).isTrue() + } + + @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) + val entry = CaseStoreStub.cases.value.first { it.caseId == id } + assertThat(entry.oneliner).isEqualTo("V2") + assertThat(entry.manualOneliner).isTrue() + } + @Test fun currentCase_returns_head_of_list() { assertThat(CaseStoreStub.currentCase).isNull() val first = CaseStoreStub.createLocal(now = 1_000L) diff --git a/watch/wearos/gradle/libs.versions.toml b/watch/wearos/gradle/libs.versions.toml index fda1a5c..60018a7 100644 --- a/watch/wearos/gradle/libs.versions.toml +++ b/watch/wearos/gradle/libs.versions.toml @@ -11,6 +11,7 @@ wearTiles = "1.4.1" wearProtolayout = "1.4.0" wearComplicationDataSource = "1.2.1" wearComposeNavigation = "1.5.6" +wearInput = "1.2.0-alpha02" guava = "33.3.1-android" activityCompose = "1.8.0" coreSplashscreen = "1.2.0" @@ -41,6 +42,7 @@ wear-protolayout-material3 = { group = "androidx.wear.protolayout", name = "prot wear-protolayout-expression = { group = "androidx.wear.protolayout", name = "protolayout-expression", version.ref = "wearProtolayout" } wear-complications-data-source-ktx = { group = "androidx.wear.watchface", name = "watchface-complications-data-source-ktx", version.ref = "wearComplicationDataSource" } wear-compose-navigation = { group = "androidx.wear.compose", name = "compose-navigation", version.ref = "wearComposeNavigation" } +wear-input = { group = "androidx.wear", name = "wear-input", version.ref = "wearInput" } guava = { group = "com.google.guava", name = "guava", version.ref = "guava" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" }