Add tap-to-edit oneliner on case detail screen
Tap opens the Wear OS system input picker (voice/keyboard/handwriting), pinned to de-DE for medical German vocabulary. Doctor-authored oneliners latch a manual flag that blocks the simulated LLM burst from overwriting them; a subsequent manual edit still wins.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user