feat(watch): push manual oneliner edits to server + 👤 indicator

Tap-to-edit on the case detail now does both halves:
1. Optimistic local update (caseStore.setOnelinerLocal) — UI flips to
   Manual immediately, no waiting for the network round-trip.
2. PUT /api/cases/{case_id}/oneliner with X-API-Key — fire-and-forget
   so an offline edit doesn't block the UI. On HTTP failure we log;
   the local Manual sticks and the next manual edit (presumably online)
   gets pushed up. Edits before the case has been uploaded server-side
   will 404 here — accepted divergence.

OnelinerOverrideClient mirrors the server contract from
server/src/routes/oneliner_override.rs:108-124 exactly: PUT body is
{text: string}, response is OnelinerState (Manual variant). 5 new
MockWebServer tests cover happy path, 400/404/network classification,
and the on-the-wire request shape.

UI: 👤 prefix on doctor-authored OneLiners across all three surfaces
(CaseListScreen row, CaseDetailScreen large text, Tile center). Auto-
generated (Ready) and unset (Empty/Error/null) states render without
the prefix, so the doctor can tell at a glance whether they wrote
this themselves. Verified live on emulator-5556 against existing
server Manual cases ('Kastanienbaum', 'eigener Bezeichner').

78 tests green total.
This commit is contained in:
2026-04-27 00:16:10 +02:00
parent 1016788514
commit 0205301018
6 changed files with 177 additions and 3 deletions
@@ -8,6 +8,7 @@ import com.doctate.watch.domain.CaseStore
import com.doctate.watch.domain.DiskCaseStore import com.doctate.watch.domain.DiskCaseStore
import com.doctate.watch.domain.StartupCleanup import com.doctate.watch.domain.StartupCleanup
import com.doctate.watch.net.HttpClientProvider import com.doctate.watch.net.HttpClientProvider
import com.doctate.watch.net.OnelinerOverrideClient
import com.doctate.watch.net.OnelinersClient import com.doctate.watch.net.OnelinersClient
import com.doctate.watch.net.SnapshotCache import com.doctate.watch.net.SnapshotCache
import com.doctate.watch.net.UploadClient import com.doctate.watch.net.UploadClient
@@ -39,6 +40,9 @@ class DoctateApp : Application() {
val httpClient: OkHttpClient by lazy { HttpClientProvider.create() } val httpClient: OkHttpClient by lazy { HttpClientProvider.create() }
val uploadClient: UploadClient by lazy { UploadClient(httpClient, settings) } val uploadClient: UploadClient by lazy { UploadClient(httpClient, settings) }
val onelinersClient: OnelinersClient by lazy { OnelinersClient(httpClient, settings) } val onelinersClient: OnelinersClient by lazy { OnelinersClient(httpClient, settings) }
val onelinerOverrideClient: OnelinerOverrideClient by lazy {
OnelinerOverrideClient(httpClient, settings)
}
val snapshotCache: SnapshotCache by lazy { val snapshotCache: SnapshotCache by lazy {
SnapshotCache(File(filesDir, "recordings/snapshot_cache.json")) SnapshotCache(File(filesDir, "recordings/snapshot_cache.json"))
} }
@@ -0,0 +1,68 @@
package com.doctate.watch.net
import android.util.Log
import com.doctate.watch.domain.OnelinerState
import com.doctate.watch.domain.onelinerFromJson
import com.doctate.watch.settings.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
/**
* `PUT /api/cases/{case_id}/oneliner` — push a doctor's manual override
* to the server. Mirrors `OnelinerOverrideRequest` from
* `doctate-common/src/oneliners.rs` (just `{text: string}`); the server
* timestamps it server-side as `OnelinerState.Manual` and persists
* `oneliner.json` next to the audio.
*
* The watch fires this fire-and-forget right after a tap-to-edit save:
* UI updates optimistically via `caseStore.setOnelinerLocal`, then we
* push to the server. On failure we log — the next successful poll
* usually pulls in the server-side state and reconciles. Pre-upload
* edits (case_dir doesn't exist server-side) get a 404 here; the local
* Manual sticks until the case is uploaded and the user re-edits.
*/
class OnelinerOverrideClient(
private val http: OkHttpClient,
private val settings: Settings,
) {
sealed interface Outcome {
data class Success(val state: OnelinerState) : Outcome
data class HttpError(val code: Int, val body: String) : Outcome
data class Network(val cause: Throwable) : Outcome
}
suspend fun put(caseId: String, text: String): Outcome = withContext(Dispatchers.IO) {
val payload = JSONObject().put("text", text).toString()
val request = Request.Builder()
.url(settings.serverUrl.trimEnd('/') + buildPath(caseId))
.header(API_KEY_HEADER, settings.apiKey)
.put(payload.toRequestBody(JSON.toMediaType()))
.build()
try {
http.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
if (response.code in 200..299) {
Outcome.Success(onelinerFromJson(JSONObject(body)))
} else {
Log.w(TAG, "PUT oneliner caseId=${caseId.take(8)} http=${response.code} body=$body")
Outcome.HttpError(response.code, body)
}
}
} catch (e: Exception) {
Log.w(TAG, "PUT oneliner caseId=${caseId.take(8)} network: ${e.message}")
Outcome.Network(e)
}
}
companion object {
private const val TAG = "OnelinerOverride"
private const val API_KEY_HEADER = "X-API-Key"
private const val JSON = "application/json"
private fun buildPath(caseId: String): String = "/api/cases/$caseId/oneliner"
}
}
@@ -36,6 +36,7 @@ import androidx.wear.input.RemoteInputIntentHelper
import com.doctate.watch.DoctateApp import com.doctate.watch.DoctateApp
import com.doctate.watch.domain.OnelinerState import com.doctate.watch.domain.OnelinerState
import com.doctate.watch.domain.displayText import com.doctate.watch.domain.displayText
import com.doctate.watch.domain.isManual
import java.time.Instant import java.time.Instant
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate import java.time.LocalDate
@@ -75,10 +76,17 @@ fun CaseDetailScreen(
.orEmpty() .orEmpty()
if (text.isNotEmpty()) { if (text.isNotEmpty()) {
coroutineScope.launch { coroutineScope.launch {
// Optimistic local update — UI flips to Manual immediately.
app.caseStore.setOnelinerLocal( app.caseStore.setOnelinerLocal(
caseId, caseId,
OnelinerState.Manual(text, setAt = Instant.now().toString()), OnelinerState.Manual(text, setAt = Instant.now().toString()),
) )
// Push the edit to the server. Fire-and-forget: the local
// state stays even if the network is down. Edits before
// the case has been uploaded will 404 here — that's
// logged inside the client and we accept the divergence
// until the next manual edit lands after upload.
app.onelinerOverrideClient.put(caseId, text)
} }
} }
} }
@@ -156,7 +164,9 @@ fun CaseDetailScreen(
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Text( Text(
text = case.oneliner.displayText() ?: "", text = case.oneliner.displayText()
?.let { if (case.oneliner.isManual()) "👤 $it" else it }
?: "",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 2, maxLines = 2,
@@ -29,6 +29,7 @@ import androidx.wear.compose.material3.Text
import com.doctate.watch.DoctateApp import com.doctate.watch.DoctateApp
import com.doctate.watch.domain.CaseEntry import com.doctate.watch.domain.CaseEntry
import com.doctate.watch.domain.displayText import com.doctate.watch.domain.displayText
import com.doctate.watch.domain.isManual
@Composable @Composable
fun CaseListScreen( fun CaseListScreen(
@@ -107,7 +108,7 @@ private fun CaseRow(case: CaseEntry, pendingUpload: Boolean, onTap: () -> Unit)
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) )
Text( Text(
text = case.oneliner.displayText() ?: "", text = formatOnelinerForRow(case),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -117,6 +118,12 @@ private fun CaseRow(case: CaseEntry, pendingUpload: Boolean, onTap: () -> Unit)
} }
} }
/** "👤 text" if doctor-authored, plain text if auto-generated, "…" placeholder otherwise. */
private fun formatOnelinerForRow(case: CaseEntry): String {
val text = case.oneliner.displayText() ?: return ""
return if (case.oneliner.isManual()) "👤 $text" else text
}
@Composable @Composable
private fun EmptyState(onNewCase: () -> Unit) { private fun EmptyState(onNewCase: () -> Unit) {
Column( Column(
@@ -25,6 +25,7 @@ import androidx.wear.protolayout.ModifiersBuilders.Padding
import androidx.wear.protolayout.TypeBuilders.StringProp import androidx.wear.protolayout.TypeBuilders.StringProp
import com.doctate.watch.domain.CaseEntry import com.doctate.watch.domain.CaseEntry
import com.doctate.watch.domain.displayText import com.doctate.watch.domain.displayText
import com.doctate.watch.domain.isManual
import com.doctate.watch.presentation.NavCommand import com.doctate.watch.presentation.NavCommand
private const val CLICK_ID_LIST = "list" private const val CLICK_ID_LIST = "list"
@@ -77,7 +78,12 @@ private fun topRow(packageName: String): LayoutElement {
} }
private fun middleContent(packageName: String, current: CaseEntry?): LayoutElement { private fun middleContent(packageName: String, current: CaseEntry?): LayoutElement {
val label = current?.oneliner.displayText() ?: current?.let { "" } ?: "Noch kein Fall heute" val label = when {
current == null -> "Noch kein Fall heute"
else -> current.oneliner.displayText()
?.let { if (current.oneliner.isManual()) "👤 $it" else it }
?: ""
}
val textBuilder = Text.Builder() val textBuilder = Text.Builder()
.setText(label) .setText(label)
.setMaxLines(3) .setMaxLines(3)
@@ -0,0 +1,79 @@
package com.doctate.watch.net
import com.doctate.watch.domain.OnelinerState
import com.doctate.watch.settings.Settings
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
class OnelinerOverrideClientTest {
private lateinit var server: MockWebServer
private lateinit var client: OnelinerOverrideClient
@Before fun setUp() {
server = MockWebServer()
server.start()
val settings = object : Settings {
override val serverUrl = server.url("/").toString().trimEnd('/')
override val apiKey = "test-key"
}
client = OnelinerOverrideClient(OkHttpClient(), settings)
}
@After fun tearDown() {
server.shutdown()
}
@Test fun success_parses_returned_manual_state() = runTest {
server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""{"kind":"manual","text":"Doctor wrote this","set_at":"2026-04-26T22:00:00Z"}""",
),
)
val outcome = client.put("case-aaa", "Doctor wrote this")
assertThat(outcome).isInstanceOf(OnelinerOverrideClient.Outcome.Success::class.java)
val state = (outcome as OnelinerOverrideClient.Outcome.Success).state
assertThat(state).isInstanceOf(OnelinerState.Manual::class.java)
assertThat((state as OnelinerState.Manual).text).isEqualTo("Doctor wrote this")
}
@Test fun sends_PUT_with_correct_path_headers_and_body() = runTest {
server.enqueue(MockResponse().setResponseCode(200).setBody(stubManual()))
client.put("550e8400-e29b-41d4-a716-446655440000", "neue Bezeichnung")
val request = server.takeRequest()
assertThat(request.method).isEqualTo("PUT")
assertThat(request.path).isEqualTo("/api/cases/550e8400-e29b-41d4-a716-446655440000/oneliner")
assertThat(request.getHeader("X-API-Key")).isEqualTo("test-key")
assertThat(request.getHeader("Content-Type")).contains("application/json")
assertThat(request.body.readUtf8()).contains("\"text\":\"neue Bezeichnung\"")
}
@Test fun http_404_classifies_as_HttpError_not_thrown() = runTest {
server.enqueue(MockResponse().setResponseCode(404).setBody("not found"))
val outcome = client.put("case-x", "anything")
assertThat(outcome).isInstanceOf(OnelinerOverrideClient.Outcome.HttpError::class.java)
assertThat((outcome as OnelinerOverrideClient.Outcome.HttpError).code).isEqualTo(404)
}
@Test fun http_400_validation_error_classifies_as_HttpError() = runTest {
server.enqueue(MockResponse().setResponseCode(400).setBody("oneliner text must not be empty"))
val outcome = client.put("case-x", "")
assertThat(outcome).isInstanceOf(OnelinerOverrideClient.Outcome.HttpError::class.java)
assertThat((outcome as OnelinerOverrideClient.Outcome.HttpError).code).isEqualTo(400)
}
@Test fun network_failure_yields_Network_outcome() = runTest {
server.shutdown()
val outcome = client.put("case-x", "text")
assertThat(outcome).isInstanceOf(OnelinerOverrideClient.Outcome.Network::class.java)
}
private fun stubManual() = """{"kind":"manual","text":"x","set_at":"2026-04-26T22:00:00Z"}"""
}