diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/DoctateApp.kt b/watch/wearos/app/src/main/java/com/doctate/watch/DoctateApp.kt index 50e554d..1dc3b92 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/DoctateApp.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/DoctateApp.kt @@ -8,6 +8,7 @@ import com.doctate.watch.domain.CaseStore import com.doctate.watch.domain.DiskCaseStore import com.doctate.watch.domain.StartupCleanup import com.doctate.watch.net.HttpClientProvider +import com.doctate.watch.net.OnelinerOverrideClient import com.doctate.watch.net.OnelinersClient import com.doctate.watch.net.SnapshotCache import com.doctate.watch.net.UploadClient @@ -39,6 +40,9 @@ class DoctateApp : Application() { val httpClient: OkHttpClient by lazy { HttpClientProvider.create() } val uploadClient: UploadClient by lazy { UploadClient(httpClient, settings) } val onelinersClient: OnelinersClient by lazy { OnelinersClient(httpClient, settings) } + val onelinerOverrideClient: OnelinerOverrideClient by lazy { + OnelinerOverrideClient(httpClient, settings) + } val snapshotCache: SnapshotCache by lazy { SnapshotCache(File(filesDir, "recordings/snapshot_cache.json")) } diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/net/OnelinerOverrideClient.kt b/watch/wearos/app/src/main/java/com/doctate/watch/net/OnelinerOverrideClient.kt new file mode 100644 index 0000000..4574b57 --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/net/OnelinerOverrideClient.kt @@ -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" + } +} 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 e88b1bb..9a7514a 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 @@ -36,6 +36,7 @@ import androidx.wear.input.RemoteInputIntentHelper import com.doctate.watch.DoctateApp import com.doctate.watch.domain.OnelinerState import com.doctate.watch.domain.displayText +import com.doctate.watch.domain.isManual import java.time.Instant import kotlinx.coroutines.launch import java.time.LocalDate @@ -75,10 +76,17 @@ fun CaseDetailScreen( .orEmpty() if (text.isNotEmpty()) { coroutineScope.launch { + // Optimistic local update — UI flips to Manual immediately. app.caseStore.setOnelinerLocal( caseId, 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, ) { Text( - text = case.oneliner.displayText() ?: "…", + text = case.oneliner.displayText() + ?.let { if (case.oneliner.isManual()) "👤 $it" else it } + ?: "…", style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, maxLines = 2, diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt index c390c6c..d18ee17 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt @@ -29,6 +29,7 @@ import androidx.wear.compose.material3.Text import com.doctate.watch.DoctateApp import com.doctate.watch.domain.CaseEntry import com.doctate.watch.domain.displayText +import com.doctate.watch.domain.isManual @Composable fun CaseListScreen( @@ -107,7 +108,7 @@ private fun CaseRow(case: CaseEntry, pendingUpload: Boolean, onTap: () -> Unit) modifier = Modifier.align(Alignment.CenterHorizontally), ) Text( - text = case.oneliner.displayText() ?: "…", + text = formatOnelinerForRow(case), style = MaterialTheme.typography.bodySmall, maxLines = 2, 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 private fun EmptyState(onNewCase: () -> Unit) { Column( diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/tile/TileLayouts.kt b/watch/wearos/app/src/main/java/com/doctate/watch/tile/TileLayouts.kt index 7ceaa70..7de99f6 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/tile/TileLayouts.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/tile/TileLayouts.kt @@ -25,6 +25,7 @@ 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.domain.isManual import com.doctate.watch.presentation.NavCommand 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 { - 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() .setText(label) .setMaxLines(3) diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/net/OnelinerOverrideClientTest.kt b/watch/wearos/app/src/test/java/com/doctate/watch/net/OnelinerOverrideClientTest.kt new file mode 100644 index 0000000..e9caa21 --- /dev/null +++ b/watch/wearos/app/src/test/java/com/doctate/watch/net/OnelinerOverrideClientTest.kt @@ -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"}""" +}