diff --git a/watch/wearos/app/build.gradle.kts b/watch/wearos/app/build.gradle.kts
index 7cca982..f6a01af 100644
--- a/watch/wearos/app/build.gradle.kts
+++ b/watch/wearos/app/build.gradle.kts
@@ -78,6 +78,13 @@ dependencies {
implementation(libs.ui.graphics)
implementation(libs.ui.tooling.preview)
implementation(libs.wear.tooling.preview)
+ implementation(libs.wear.tiles)
+ implementation(libs.wear.protolayout)
+ implementation(libs.wear.protolayout.material3)
+ implementation(libs.wear.protolayout.expression)
+ implementation(libs.wear.complications.data.source.ktx)
+ implementation(libs.wear.compose.navigation)
+ implementation(libs.guava)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.ui.test.junit4)
debugImplementation(libs.ui.test.manifest)
diff --git a/watch/wearos/app/src/main/AndroidManifest.xml b/watch/wearos/app/src/main/AndroidManifest.xml
index bacb0db..c40b166 100644
--- a/watch/wearos/app/src/main/AndroidManifest.xml
+++ b/watch/wearos/app/src/main/AndroidManifest.xml
@@ -34,6 +34,7 @@
@@ -42,6 +43,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
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 1408b1a..c494147 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
@@ -1,10 +1,14 @@
package com.doctate.watch
import android.app.Application
+import com.doctate.watch.domain.CaseStoreStub
import com.doctate.watch.net.HttpClientProvider
import com.doctate.watch.net.UploadClient
import com.doctate.watch.settings.BuildConfigSettings
import com.doctate.watch.settings.Settings
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import okhttp3.OkHttpClient
/**
@@ -15,4 +19,16 @@ class DoctateApp : Application() {
val settings: Settings by lazy { BuildConfigSettings() }
val httpClient: OkHttpClient by lazy { HttpClientProvider.create() }
val uploadClient: UploadClient by lazy { UploadClient(httpClient, settings) }
+
+ /** Process-scoped coroutine scope for work that must outlive any single ViewModel
+ * (e.g. the post-stop OneLiner-burst fires after the user swipes back to the list). */
+ val applicationScope: CoroutineScope by lazy {
+ CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ CaseStoreStub.attach(this)
+ CaseStoreStub.seedDemoData()
+ }
}
diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/complication/DoctateComplicationService.kt b/watch/wearos/app/src/main/java/com/doctate/watch/complication/DoctateComplicationService.kt
new file mode 100644
index 0000000..a23414a
--- /dev/null
+++ b/watch/wearos/app/src/main/java/com/doctate/watch/complication/DoctateComplicationService.kt
@@ -0,0 +1,55 @@
+package com.doctate.watch.complication
+
+import android.app.PendingIntent
+import android.content.Intent
+import androidx.wear.watchface.complications.data.ComplicationData
+import androidx.wear.watchface.complications.data.ComplicationType
+import androidx.wear.watchface.complications.data.PlainComplicationText
+import androidx.wear.watchface.complications.data.ShortTextComplicationData
+import androidx.wear.watchface.complications.datasource.ComplicationRequest
+import androidx.wear.watchface.complications.datasource.SuspendingComplicationDataSourceService
+import com.doctate.watch.presentation.MainActivity
+import com.doctate.watch.presentation.NavCommand
+
+/**
+ * Fast-launch Complication for the Doctate app. Always reports the same label;
+ * tap opens the case list. Doctors place it on their watchface manually.
+ */
+class DoctateComplicationService : SuspendingComplicationDataSourceService() {
+
+ override fun getPreviewData(type: ComplicationType): ComplicationData? =
+ when (type) {
+ ComplicationType.SHORT_TEXT -> buildShortText(previewOnly = true)
+ else -> null
+ }
+
+ override suspend fun onComplicationRequest(request: ComplicationRequest): ComplicationData? =
+ when (request.complicationType) {
+ ComplicationType.SHORT_TEXT -> buildShortText(previewOnly = false)
+ else -> null
+ }
+
+ private fun buildShortText(previewOnly: Boolean): ShortTextComplicationData {
+ val text = PlainComplicationText.Builder("Doctate").build()
+ val builder = ShortTextComplicationData.Builder(
+ text = text,
+ contentDescription = PlainComplicationText.Builder("Open Doctate case list").build(),
+ )
+ if (!previewOnly) {
+ builder.setTapAction(buildTapIntent())
+ }
+ return builder.build()
+ }
+
+ private fun buildTapIntent(): PendingIntent {
+ val intent = Intent(this, MainActivity::class.java).apply {
+ putExtra(NavCommand.EXTRA_OPEN, NavCommand.OPEN_LIST)
+ }
+ return PendingIntent.getActivity(
+ this,
+ 0,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ }
+}
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
new file mode 100644
index 0000000..601d49a
--- /dev/null
+++ b/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseEntry.kt
@@ -0,0 +1,16 @@
+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.
+ *
+ * `oneliner == null` means the server has not produced one yet; UI should
+ * render a placeholder ("…") rather than hide the row.
+ */
+data class CaseEntry(
+ val caseId: String,
+ val createdAt: Long,
+ val lastActivityAt: Long,
+ val oneliner: String?,
+)
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
new file mode 100644
index 0000000..e170f2d
--- /dev/null
+++ b/watch/wearos/app/src/main/java/com/doctate/watch/domain/CaseStoreStub.kt
@@ -0,0 +1,95 @@
+package com.doctate.watch.domain
+
+import android.content.Context
+import android.util.Log
+import androidx.wear.tiles.TileService
+import com.doctate.watch.tile.DoctateTileService
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+/**
+ * In-memory stand-in for a future Kotlin port of the Rust `CaseStore`.
+ *
+ * All three Wear OS surfaces (Activity list, Tile, Complication) read from
+ * this singleton so they never diverge. The store is intentionally flat —
+ * no disk persistence, no server merge, no sync flags. When the process
+ * dies the state is gone; the PoC seeds a few fake cases on app start to
+ * keep the UI populated.
+ *
+ * Invariant: `_cases.value` is always sorted by `lastActivityAt` desc, so
+ * `value.firstOrNull()` is the "current case" for the Tile.
+ *
+ * The [appContext] must be set via [attach] from [com.doctate.watch.DoctateApp.onCreate]
+ * so every mutation can trigger a Tile refresh. When context is null (e.g. JVM unit
+ * tests), the refresh step is silently skipped.
+ */
+object CaseStoreStub {
+ private const val TAG = "CaseStoreStub"
+
+ private val _cases = MutableStateFlow>(emptyList())
+ val cases: StateFlow> = _cases.asStateFlow()
+
+ /** Snapshot accessor for non-suspending contexts (e.g. `TileService.onTileRequest`). */
+ val currentCase: CaseEntry? get() = _cases.value.firstOrNull()
+
+ private var appContext: Context? = null
+
+ fun attach(context: Context) {
+ appContext = context.applicationContext
+ }
+
+ fun createLocal(now: Long = System.currentTimeMillis()): String {
+ val id = CaseId.new()
+ val entry = CaseEntry(
+ caseId = id,
+ createdAt = now,
+ lastActivityAt = now,
+ oneliner = null,
+ )
+ _cases.update { listOf(entry) + it }
+ requestTileRefresh("createLocal id=${id.take(8)}")
+ return id
+ }
+
+ fun markActivity(caseId: String, now: Long = System.currentTimeMillis()) {
+ _cases.update { current ->
+ val idx = current.indexOfFirst { it.caseId == caseId }
+ if (idx < 0) return@update current
+ val updated = current[idx].copy(lastActivityAt = now)
+ (listOf(updated) + current.filterIndexed { i, _ -> i != idx })
+ }
+ requestTileRefresh("markActivity id=${caseId.take(8)}")
+ }
+
+ fun setOneliner(caseId: String, text: String) {
+ _cases.update { current ->
+ current.map { if (it.caseId == caseId) it.copy(oneliner = text) else it }
+ }
+ requestTileRefresh("setOneliner id=${caseId.take(8)}")
+ }
+
+ fun seedDemoData(now: Long = System.currentTimeMillis()) {
+ if (_cases.value.isNotEmpty()) return
+ val hour = 60L * 60L * 1_000L
+ _cases.value = listOf(
+ CaseEntry(CaseId.new(), now - 20 * 60_000L, now - 20 * 60_000L, "Kniegelenksarthroskopie rechts"),
+ CaseEntry(CaseId.new(), now - 2 * hour, now - 2 * hour, "Befund Thorax unauffällig"),
+ CaseEntry(CaseId.new(), now - 4 * hour, now - 4 * hour, "Postoperative Visite"),
+ )
+ requestTileRefresh("seedDemoData")
+ }
+
+ private fun requestTileRefresh(reason: String) {
+ val ctx = appContext ?: return
+ Log.i(TAG, "requestTileRefresh: $reason")
+ TileService.getUpdater(ctx).requestUpdate(DoctateTileService::class.java)
+ }
+
+ /** Test-only: wipe state between tests. `object` singletons leak across tests otherwise. */
+ internal fun resetForTest() {
+ _cases.value = emptyList()
+ appContext = null
+ }
+}
diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/AppNav.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/AppNav.kt
new file mode 100644
index 0000000..0ab04ae
--- /dev/null
+++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/AppNav.kt
@@ -0,0 +1,73 @@
+package com.doctate.watch.presentation
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavType
+import androidx.navigation.navArgument
+import androidx.wear.compose.material3.AppScaffold
+import androidx.wear.compose.navigation.SwipeDismissableNavHost
+import androidx.wear.compose.navigation.composable
+import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
+import com.doctate.watch.domain.CaseStoreStub
+import com.doctate.watch.presentation.theme.DoctateWatchTheme
+import kotlinx.coroutines.flow.StateFlow
+
+object Routes {
+ const val LIST = "list"
+ const val RECORDING = "recording/{caseId}"
+ fun recording(caseId: String) = "recording/$caseId"
+}
+
+@Composable
+fun AppNav(pendingCommand: StateFlow) {
+ val navController = rememberSwipeDismissableNavController()
+ val pending by pendingCommand.collectAsStateWithLifecycle()
+
+ // Key on seq so repeated identical commands still fire.
+ LaunchedEffect(pending.seq) {
+ when (val c = pending.cmd) {
+ NavCommand.ShowList -> {
+ // Initial value fires at app start; NavHost is already on LIST. No-op.
+ // Incoming Complication tap or Tile "Fälle" tap: pop to list if stacked.
+ navController.popBackStack(Routes.LIST, inclusive = false)
+ }
+ NavCommand.NewCase -> {
+ val id = CaseStoreStub.createLocal()
+ navController.navigate(Routes.recording(id))
+ }
+ is NavCommand.ContinueCase -> {
+ navController.navigate(Routes.recording(c.caseId))
+ }
+ }
+ }
+
+ DoctateWatchTheme {
+ AppScaffold {
+ SwipeDismissableNavHost(
+ navController = navController,
+ startDestination = Routes.LIST,
+ ) {
+ composable(Routes.LIST) {
+ CaseListScreen(
+ onCaseTap = { caseId ->
+ navController.navigate(Routes.recording(caseId))
+ },
+ onNewCase = {
+ val id = CaseStoreStub.createLocal()
+ navController.navigate(Routes.recording(id))
+ },
+ )
+ }
+ composable(
+ route = Routes.RECORDING,
+ arguments = listOf(navArgument("caseId") { type = NavType.StringType }),
+ ) { backStack ->
+ val caseId = backStack.arguments?.getString("caseId").orEmpty()
+ RecordingScreen(caseId = caseId, onDone = { navController.popBackStack() })
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000..0b1f09c
--- /dev/null
+++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt
@@ -0,0 +1,100 @@
+package com.doctate.watch.presentation
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
+import androidx.wear.compose.foundation.lazy.items
+import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
+import androidx.wear.compose.material3.Button
+import androidx.wear.compose.material3.Card
+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 com.doctate.watch.domain.CaseEntry
+import com.doctate.watch.domain.CaseStoreStub
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@Composable
+fun CaseListScreen(
+ onCaseTap: (caseId: String) -> Unit,
+ onNewCase: () -> Unit,
+) {
+ val cases by CaseStoreStub.cases.collectAsStateWithLifecycle()
+ val listState = rememberScalingLazyListState()
+
+ ScreenScaffold(
+ scrollState = listState,
+ edgeButton = {
+ EdgeButton(onClick = onNewCase) {
+ Text("● Neu")
+ }
+ },
+ ) { contentPadding ->
+ if (cases.isEmpty()) {
+ EmptyState(onNewCase)
+ } else {
+ ScalingLazyColumn(
+ state = listState,
+ contentPadding = contentPadding,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ items(cases, key = { it.caseId }) { case ->
+ CaseRow(case, onTap = { onCaseTap(case.caseId) })
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CaseRow(case: CaseEntry, onTap: () -> Unit) {
+ Card(onClick = onTap, modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = formatTime(case.createdAt),
+ style = MaterialTheme.typography.labelSmall,
+ )
+ Text(
+ text = case.oneliner ?: "…",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ }
+}
+
+@Composable
+private fun EmptyState(onNewCase: () -> Unit) {
+ Column(
+ modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ "Noch keine Fälle heute",
+ style = MaterialTheme.typography.titleMedium,
+ textAlign = TextAlign.Center,
+ )
+ Button(
+ onClick = onNewCase,
+ modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
+ ) {
+ Text("● Neu")
+ }
+ }
+}
+
+private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
+
+private fun formatTime(epochMs: Long): String = timeFormat.format(Date(epochMs))
diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/MainActivity.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/MainActivity.kt
index 641c06f..692b7dd 100644
--- a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/MainActivity.kt
+++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/MainActivity.kt
@@ -1,12 +1,34 @@
package com.doctate.watch.presentation
+import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
+import kotlinx.coroutines.flow.MutableStateFlow
+import java.util.concurrent.atomic.AtomicLong
class MainActivity : ComponentActivity() {
+ private val seq = AtomicLong(0)
+ private val pendingCommand = MutableStateFlow(
+ NavCommand.Pending(0L, NavCommand.ShowList),
+ )
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContent { RecordingScreen() }
+ dispatch(intent)
+ setContent { AppNav(pendingCommand = pendingCommand) }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ dispatch(intent)
+ }
+
+ private fun dispatch(intent: Intent?) {
+ pendingCommand.value = NavCommand.Pending(
+ seq = seq.incrementAndGet(),
+ cmd = NavCommand.fromIntent(intent),
+ )
}
}
diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/NavCommand.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/NavCommand.kt
new file mode 100644
index 0000000..11164ff
--- /dev/null
+++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/NavCommand.kt
@@ -0,0 +1,39 @@
+package com.doctate.watch.presentation
+
+import android.content.Intent
+
+/**
+ * Normalised entry-point commands carried via Intent extras from Tile, Complication,
+ * Launcher, or internal navigation. Keeps the string-key protocol confined to
+ * `fromIntent`; the rest of the code uses the sealed type.
+ */
+sealed interface NavCommand {
+ data object ShowList : NavCommand
+ data object NewCase : NavCommand
+ data class ContinueCase(val caseId: String) : NavCommand
+
+ /**
+ * Carries a monotonic `seq` so Compose sees every incoming tap as a distinct value
+ * — crucial when two identical commands (e.g. two "Neu" taps from the Tile) arrive
+ * back-to-back; `LaunchedEffect(key)` only fires on change.
+ */
+ data class Pending(val seq: Long, val cmd: NavCommand)
+
+ companion object {
+ const val EXTRA_OPEN = "open"
+ const val EXTRA_CASE_ID = "caseId"
+
+ const val OPEN_LIST = "list"
+ const val OPEN_NEW = "new"
+ const val OPEN_CONTINUE = "continue"
+
+ fun fromIntent(intent: Intent?): NavCommand = when (intent?.getStringExtra(EXTRA_OPEN)) {
+ OPEN_NEW -> NewCase
+ OPEN_CONTINUE -> intent.getStringExtra(EXTRA_CASE_ID)
+ ?.takeIf { it.isNotBlank() }
+ ?.let(::ContinueCase)
+ ?: ShowList
+ else -> ShowList
+ }
+ }
+}
diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingScreen.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingScreen.kt
index 32a8efd..7c10e01 100644
--- a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingScreen.kt
+++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingScreen.kt
@@ -17,17 +17,20 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
-import androidx.wear.compose.material3.AppScaffold
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.CircularProgressIndicator
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
-import com.doctate.watch.presentation.theme.DoctateWatchTheme
@Composable
fun RecordingScreen(
- viewModel: RecordingViewModel = viewModel(factory = RecordingViewModel.Factory),
+ caseId: String,
+ onDone: () -> Unit,
+ viewModel: RecordingViewModel = viewModel(
+ key = caseId,
+ factory = RecordingViewModel.factoryFor(caseId),
+ ),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -41,30 +44,44 @@ fun RecordingScreen(
}
}
- DoctateWatchTheme {
- AppScaffold {
- ScreenScaffold {
- Column(
- modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center,
- ) {
- when (val s = state) {
- UiState.Idle -> IdleContent(onRecord = viewModel::onRecordTap)
- UiState.RequestingPermission -> StatusText("Asking for mic…")
- is UiState.Recording -> StatusText("Recording: ${s.secondsLeft}s")
- UiState.Uploading -> UploadingContent()
- is UiState.Success -> SuccessContent(s.caseId, onReset = viewModel::onResetTap)
- is UiState.Error -> ErrorContent(s, onReset = viewModel::onResetTap)
- }
- }
+ ScreenScaffold {
+ Column(
+ modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ when (val s = state) {
+ UiState.Idle -> IdleContent(caseId = caseId, onRecord = viewModel::onRecordTap)
+ UiState.RequestingPermission -> StatusText("Asking for mic…")
+ is UiState.Recording -> StatusText("Recording: ${s.secondsLeft}s")
+ UiState.Uploading -> UploadingContent()
+ is UiState.Success -> SuccessContent(
+ s.caseId,
+ onDone = {
+ viewModel.onResetTap()
+ onDone()
+ },
+ )
+ is UiState.Error -> ErrorContent(
+ s,
+ onReset = {
+ viewModel.onResetTap()
+ onDone()
+ },
+ )
}
}
}
}
@Composable
-private fun IdleContent(onRecord: () -> Unit) {
+private fun IdleContent(caseId: String, onRecord: () -> Unit) {
+ Text(
+ caseId.take(8),
+ style = MaterialTheme.typography.labelSmall,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
Button(onClick = onRecord, modifier = Modifier.fillMaxWidth()) {
Text("Record")
}
@@ -90,7 +107,7 @@ private fun UploadingContent() {
}
@Composable
-private fun SuccessContent(caseId: String, onReset: () -> Unit) {
+private fun SuccessContent(caseId: String, onDone: () -> Unit) {
Text(
"OK",
style = MaterialTheme.typography.displaySmall,
@@ -101,7 +118,7 @@ private fun SuccessContent(caseId: String, onReset: () -> Unit) {
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
)
- Button(onClick = onReset, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
+ Button(onClick = onDone, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
Text("Done")
}
}
diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt
index a3cc977..4d78f9a 100644
--- a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt
+++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/RecordingViewModel.kt
@@ -7,9 +7,10 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.doctate.watch.DoctateApp
import com.doctate.watch.audio.AudioRecorder
-import com.doctate.watch.domain.CaseId
+import com.doctate.watch.domain.CaseStoreStub
import com.doctate.watch.net.UploadClient
import com.doctate.watch.net.UploadResult
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,11 +19,16 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import java.text.SimpleDateFormat
import java.time.Instant
+import java.util.Date
+import java.util.Locale
class RecordingViewModel(
+ private val caseId: String,
private val recorder: AudioRecorder,
private val uploadClient: UploadClient,
+ private val applicationScope: CoroutineScope,
) : ViewModel() {
private val _state = MutableStateFlow(UiState.Idle)
@@ -65,12 +71,14 @@ class RecordingViewModel(
}
dispatch(RecordingEvent.RecordingFinished)
- val caseId = CaseId.new()
val recordedAt = Instant.now().toString()
try {
when (val result = uploadClient.upload(caseId, recordedAt, finalFile)) {
- is UploadResult.Success ->
- dispatch(RecordingEvent.UploadSucceeded(result.caseId))
+ is UploadResult.Success -> {
+ CaseStoreStub.markActivity(caseId)
+ triggerFakeOnelinerBurst(caseId)
+ dispatch(RecordingEvent.UploadSucceeded(caseId))
+ }
is UploadResult.Transient ->
dispatch(RecordingEvent.UploadFailed(result.reason, transient = true))
is UploadResult.Terminal ->
@@ -82,17 +90,34 @@ class RecordingViewModel(
}
}
+ /**
+ * PoC stand-in for the real post-stop-burst polling. Real flow would poll
+ * `/api/oneliners` with a 2s/60s budget; here we just set a placeholder so
+ * the UI changes visibly once the "oneliner arrives".
+ */
+ private fun triggerFakeOnelinerBurst(caseId: String) {
+ applicationScope.launch {
+ delay(2_000)
+ val label = fakeLabelFormat.format(Date())
+ CaseStoreStub.setOneliner(caseId, "[PoC] OneLiner $label")
+ }
+ }
+
private fun dispatch(event: RecordingEvent) {
_state.update { current -> reduce(current, event) }
}
companion object {
- val Factory: ViewModelProvider.Factory = viewModelFactory {
+ private val fakeLabelFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
+
+ fun factoryFor(caseId: String): ViewModelProvider.Factory = viewModelFactory {
initializer {
val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as DoctateApp
RecordingViewModel(
+ caseId = caseId,
recorder = AudioRecorder(app),
uploadClient = app.uploadClient,
+ applicationScope = app.applicationScope,
)
}
}
diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/tile/DoctateTileService.kt b/watch/wearos/app/src/main/java/com/doctate/watch/tile/DoctateTileService.kt
new file mode 100644
index 0000000..aa74d5e
--- /dev/null
+++ b/watch/wearos/app/src/main/java/com/doctate/watch/tile/DoctateTileService.kt
@@ -0,0 +1,52 @@
+package com.doctate.watch.tile
+
+import androidx.wear.protolayout.LayoutElementBuilders
+import androidx.wear.protolayout.ResourceBuilders
+import androidx.wear.protolayout.TimelineBuilders
+import androidx.wear.tiles.RequestBuilders
+import androidx.wear.tiles.TileBuilders
+import androidx.wear.tiles.TileService
+import com.doctate.watch.domain.CaseStoreStub
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.ListenableFuture
+
+private const val RESOURCES_VERSION = "1"
+
+/**
+ * Serves the single Doctate Tile. Reads a non-blocking snapshot from [CaseStoreStub]
+ * and renders the "glance at current case" view with three tap regions. Refreshes
+ * are triggered externally via `TileService.getUpdater(ctx).requestUpdate(...)`.
+ */
+class DoctateTileService : TileService() {
+ override fun onTileRequest(
+ requestParams: RequestBuilders.TileRequest,
+ ): ListenableFuture {
+ val current = CaseStoreStub.currentCase
+ val layout = LayoutElementBuilders.Layout.Builder()
+ .setRoot(buildTileLayout(packageName, current))
+ .build()
+
+ val timeline = TimelineBuilders.Timeline.Builder()
+ .addTimelineEntry(
+ TimelineBuilders.TimelineEntry.Builder()
+ .setLayout(layout)
+ .build(),
+ )
+ .build()
+
+ val tile = TileBuilders.Tile.Builder()
+ .setResourcesVersion(RESOURCES_VERSION)
+ .setTileTimeline(timeline)
+ .build()
+ return Futures.immediateFuture(tile)
+ }
+
+ override fun onTileResourcesRequest(
+ requestParams: RequestBuilders.ResourcesRequest,
+ ): ListenableFuture {
+ val resources = ResourceBuilders.Resources.Builder()
+ .setVersion(RESOURCES_VERSION)
+ .build()
+ return Futures.immediateFuture(resources)
+ }
+}
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
new file mode 100644
index 0000000..5ea71f4
--- /dev/null
+++ b/watch/wearos/app/src/main/java/com/doctate/watch/tile/TileLayouts.kt
@@ -0,0 +1,159 @@
+package com.doctate.watch.tile
+
+import androidx.wear.protolayout.ActionBuilders
+import androidx.wear.protolayout.ColorBuilders.argb
+import androidx.wear.protolayout.DimensionBuilders.dp
+import androidx.wear.protolayout.DimensionBuilders.expand
+import androidx.wear.protolayout.LayoutElementBuilders
+import androidx.wear.protolayout.LayoutElementBuilders.Box
+import androidx.wear.protolayout.LayoutElementBuilders.Column
+import androidx.wear.protolayout.LayoutElementBuilders.FontStyle
+import androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_CENTER
+import androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_END
+import androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_START
+import androidx.wear.protolayout.LayoutElementBuilders.LayoutElement
+import androidx.wear.protolayout.LayoutElementBuilders.Row
+import androidx.wear.protolayout.LayoutElementBuilders.Spacer
+import androidx.wear.protolayout.LayoutElementBuilders.Text
+import androidx.wear.protolayout.LayoutElementBuilders.VERTICAL_ALIGN_BOTTOM
+import androidx.wear.protolayout.LayoutElementBuilders.VERTICAL_ALIGN_CENTER
+import androidx.wear.protolayout.ModifiersBuilders.Background
+import androidx.wear.protolayout.ModifiersBuilders.Clickable
+import androidx.wear.protolayout.ModifiersBuilders.Corner
+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.presentation.NavCommand
+
+private const val CLICK_ID_LIST = "list"
+private const val CLICK_ID_NEW = "new"
+private const val CLICK_ID_CONTINUE = "continue"
+
+/** Build the root layout element for the Tile. Returns the "current case" view or an
+ * empty-state view depending on whether any case exists in [CaseStoreStub]. */
+internal fun buildTileLayout(packageName: String, current: CaseEntry?): LayoutElement {
+ val column = Column.Builder()
+ .setWidth(expand())
+ .setHeight(expand())
+ .setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER)
+ .addContent(topRow(packageName))
+ .addContent(Spacer.Builder().setHeight(dp(8f)).build())
+ .addContent(middleContent(packageName, current))
+ .addContent(Spacer.Builder().setHeight(dp(8f)).build())
+ .addContent(bottomNewButton(packageName))
+ .build()
+
+ return Box.Builder()
+ .setWidth(expand())
+ .setHeight(expand())
+ .setVerticalAlignment(VERTICAL_ALIGN_CENTER)
+ .addContent(column)
+ .build()
+}
+
+private fun topRow(packageName: String): LayoutElement {
+ val faelle = Text.Builder()
+ .setText("☰ Fälle")
+ .setFontStyle(FontStyle.Builder().setSize(sp(14f)).build())
+ .setModifiers(
+ Modifiers.Builder()
+ .setClickable(launchClickable(CLICK_ID_LIST, packageName, NavCommand.OPEN_LIST))
+ .setPadding(
+ Padding.Builder().setStart(dp(8f)).setEnd(dp(8f))
+ .setTop(dp(4f)).setBottom(dp(4f)).build(),
+ )
+ .build(),
+ )
+ .build()
+ // Row has no horizontal-alignment prop; use a Box with END alignment instead.
+ return Box.Builder()
+ .setWidth(expand())
+ .setHorizontalAlignment(HORIZONTAL_ALIGN_END)
+ .setVerticalAlignment(VERTICAL_ALIGN_CENTER)
+ .addContent(faelle)
+ .build()
+}
+
+private fun middleContent(packageName: String, current: CaseEntry?): LayoutElement {
+ val label = current?.oneliner ?: current?.let { "…" } ?: "Noch kein Fall heute"
+ val textBuilder = Text.Builder()
+ .setText(label)
+ .setMaxLines(3)
+ .setFontStyle(FontStyle.Builder().setSize(sp(16f)).build())
+
+ val modifiersBuilder = Modifiers.Builder()
+ .setPadding(
+ Padding.Builder().setStart(dp(12f)).setEnd(dp(12f))
+ .setTop(dp(6f)).setBottom(dp(6f)).build(),
+ )
+ if (current != null) {
+ modifiersBuilder.setClickable(
+ launchClickable(
+ CLICK_ID_CONTINUE,
+ packageName,
+ NavCommand.OPEN_CONTINUE,
+ caseId = current.caseId,
+ ),
+ )
+ }
+ textBuilder.setModifiers(modifiersBuilder.build())
+ return textBuilder.build()
+}
+
+private fun bottomNewButton(packageName: String): LayoutElement {
+ val text = Text.Builder()
+ .setText("● Neu")
+ .setFontStyle(FontStyle.Builder().setSize(sp(14f)).build())
+ .build()
+ return Box.Builder()
+ .setWidth(expand())
+ .setHeight(dp(36f))
+ .setVerticalAlignment(VERTICAL_ALIGN_BOTTOM)
+ .setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER)
+ .setModifiers(
+ Modifiers.Builder()
+ .setClickable(launchClickable(CLICK_ID_NEW, packageName, NavCommand.OPEN_NEW))
+ .setBackground(
+ Background.Builder()
+ .setColor(argb(0xFF1F1F1F.toInt()))
+ .setCorner(Corner.Builder().setRadius(dp(18f)).build())
+ .build(),
+ )
+ .setPadding(
+ Padding.Builder().setStart(dp(12f)).setEnd(dp(12f))
+ .setTop(dp(6f)).setBottom(dp(6f)).build(),
+ )
+ .build(),
+ )
+ .addContent(text)
+ .build()
+}
+
+private fun launchClickable(
+ id: String,
+ packageName: String,
+ open: String,
+ caseId: String? = null,
+): Clickable {
+ val activity = ActionBuilders.AndroidActivity.Builder()
+ .setPackageName(packageName)
+ .setClassName("com.doctate.watch.presentation.MainActivity")
+ .addKeyToExtraMapping(
+ NavCommand.EXTRA_OPEN,
+ ActionBuilders.AndroidStringExtra.Builder().setValue(open).build(),
+ )
+ if (caseId != null) {
+ activity.addKeyToExtraMapping(
+ NavCommand.EXTRA_CASE_ID,
+ ActionBuilders.AndroidStringExtra.Builder().setValue(caseId).build(),
+ )
+ }
+ return Clickable.Builder()
+ .setId(id)
+ .setOnClick(ActionBuilders.LaunchAction.Builder().setAndroidActivity(activity.build()).build())
+ .build()
+}
+
+private fun sp(size: Float): androidx.wear.protolayout.DimensionBuilders.SpProp =
+ androidx.wear.protolayout.DimensionBuilders.SpProp.Builder().setValue(size).build()
diff --git a/watch/wearos/app/src/main/res/drawable/ic_complication.xml b/watch/wearos/app/src/main/res/drawable/ic_complication.xml
new file mode 100644
index 0000000..ee489ca
--- /dev/null
+++ b/watch/wearos/app/src/main/res/drawable/ic_complication.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/watch/wearos/app/src/main/res/drawable/tile_preview.xml b/watch/wearos/app/src/main/res/drawable/tile_preview.xml
new file mode 100644
index 0000000..d840365
--- /dev/null
+++ b/watch/wearos/app/src/main/res/drawable/tile_preview.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
diff --git a/watch/wearos/app/src/main/res/values/strings.xml b/watch/wearos/app/src/main/res/values/strings.xml
index 10b8191..8701759 100644
--- a/watch/wearos/app/src/main/res/values/strings.xml
+++ b/watch/wearos/app/src/main/res/values/strings.xml
@@ -1,4 +1,6 @@
Doctate Watch
Doctate!
+ Aktueller Fall und Schnellstart
+ Doctate
\ No newline at end of file
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
new file mode 100644
index 0000000..96a307a
--- /dev/null
+++ b/watch/wearos/app/src/test/java/com/doctate/watch/domain/CaseStoreStubTest.kt
@@ -0,0 +1,61 @@
+package com.doctate.watch.domain
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+
+class CaseStoreStubTest {
+ @Before fun setUp() {
+ CaseStoreStub.resetForTest()
+ }
+
+ @Test fun createLocal_inserts_entry_at_head() {
+ val id = CaseStoreStub.createLocal(now = 1_000L)
+ val snapshot = CaseStoreStub.cases.value
+ assertThat(snapshot).hasSize(1)
+ assertThat(snapshot[0].caseId).isEqualTo(id)
+ assertThat(snapshot[0].oneliner).isNull()
+ }
+
+ @Test fun createLocal_returns_new_case_at_head_of_list() {
+ val first = CaseStoreStub.createLocal(now = 1_000L)
+ val second = CaseStoreStub.createLocal(now = 2_000L)
+ val snapshot = CaseStoreStub.cases.value
+ assertThat(snapshot.map { it.caseId }).containsExactly(second, first).inOrder()
+ }
+
+ @Test fun markActivity_brings_older_case_to_front() {
+ val first = CaseStoreStub.createLocal(now = 1_000L)
+ val second = CaseStoreStub.createLocal(now = 2_000L)
+ CaseStoreStub.markActivity(first, now = 3_000L)
+ val snapshot = CaseStoreStub.cases.value
+ assertThat(snapshot.map { it.caseId }).containsExactly(first, second).inOrder()
+ assertThat(snapshot[0].lastActivityAt).isEqualTo(3_000L)
+ }
+
+ @Test fun markActivity_ignores_unknown_caseId() {
+ val id = CaseStoreStub.createLocal(now = 1_000L)
+ CaseStoreStub.markActivity("does-not-exist", now = 9_000L)
+ val snapshot = CaseStoreStub.cases.value
+ assertThat(snapshot).hasSize(1)
+ assertThat(snapshot[0].caseId).isEqualTo(id)
+ assertThat(snapshot[0].lastActivityAt).isEqualTo(1_000L)
+ }
+
+ @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")
+ val snapshot = CaseStoreStub.cases.value
+ assertThat(snapshot.first { it.caseId == a }.oneliner).isEqualTo("Test-Oneliner")
+ assertThat(snapshot.first { it.caseId == b }.oneliner).isNull()
+ }
+
+ @Test fun currentCase_returns_head_of_list() {
+ assertThat(CaseStoreStub.currentCase).isNull()
+ val first = CaseStoreStub.createLocal(now = 1_000L)
+ assertThat(CaseStoreStub.currentCase?.caseId).isEqualTo(first)
+ val second = CaseStoreStub.createLocal(now = 2_000L)
+ assertThat(CaseStoreStub.currentCase?.caseId).isEqualTo(second)
+ }
+}
diff --git a/watch/wearos/gradle/libs.versions.toml b/watch/wearos/gradle/libs.versions.toml
index 3a408fa..fda1a5c 100644
--- a/watch/wearos/gradle/libs.versions.toml
+++ b/watch/wearos/gradle/libs.versions.toml
@@ -7,6 +7,11 @@ composeMaterial3 = "1.5.6"
composeFoundation = "1.5.6"
composeUiTooling = "1.5.6"
wearToolingPreview = "1.0.0"
+wearTiles = "1.4.1"
+wearProtolayout = "1.4.0"
+wearComplicationDataSource = "1.2.1"
+wearComposeNavigation = "1.5.6"
+guava = "33.3.1-android"
activityCompose = "1.8.0"
coreSplashscreen = "1.2.0"
okhttp = "4.12.0"
@@ -30,6 +35,13 @@ compose-material3 = { group = "androidx.wear.compose", name = "compose-material3
compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "composeFoundation" }
compose-ui-tooling = { group = "androidx.wear.compose", name = "compose-ui-tooling", version.ref = "composeUiTooling" }
wear-tooling-preview = { group = "androidx.wear", name = "wear-tooling-preview", version.ref = "wearToolingPreview" }
+wear-tiles = { group = "androidx.wear.tiles", name = "tiles", version.ref = "wearTiles" }
+wear-protolayout = { group = "androidx.wear.protolayout", name = "protolayout", version.ref = "wearProtolayout" }
+wear-protolayout-material3 = { group = "androidx.wear.protolayout", name = "protolayout-material3", version.ref = "wearProtolayout" }
+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" }
+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" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }