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" }