Add Wear OS Tile and Complication support - PoC

This commit introduces support for Wear OS Tiles and Complications,
enabling users to view case information and launch the app directly from
their watch face.

Key changes include:
- **New Complication Service:** `DoctateComplicationService.kt` provides
  a simple complication that displays "Doctate" and launches the app's
  case list upon tap.
- **New Tile Service:** `DoctateTileService.kt` serves a dynamic tile
  showing the current case's one-liner. It includes tappable areas to
  navigate to the case list, create a new case, or continue an existing
  one.
- **UI Navigation:** `AppNav.kt` and related files (`CaseListScreen.kt`,
  `MainActivity.kt`, `NavCommand.kt`) set up the navigation structure
  for the Wear OS app, handling intents from the Tile and Complication.
- **Data Structures:** `CaseEntry.kt` defines the minimal data structure
  for case information displayed on the watch.
- **In-Memory Data Store:** `CaseStoreStub.kt` acts as a temporary,
  in-memory store for case data, ensuring consistency across the
  activity, Tile, and Complication.
- **Dependency Updates:** Added necessary Wear OS libraries to
  `build.gradle.kts` and `libs.versions.toml`.
- **AndroidManifest:** Configured the `MainActivity` to handle
  `singleTask` launch mode and added necessary intent filters for
  complications.
- **Demo Data:** Seeded `CaseStoreStub` with demo data for initial
  testing and visualization.
This commit is contained in:
2026-04-23 23:07:03 +02:00
parent cea8ed8c06
commit b02cc0c870
19 changed files with 841 additions and 29 deletions
+7
View File
@@ -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)
@@ -34,6 +34,7 @@
<activity
android:name=".presentation.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@style/MainActivityTheme.Starting">
<intent-filter>
@@ -42,6 +43,38 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".tile.DoctateTileService"
android:description="@string/tile_description"
android:exported="true"
android:icon="@drawable/tile_preview"
android:label="@string/tile_label"
android:permission="com.google.android.wearable.permission.BIND_TILE_PROVIDER">
<intent-filter>
<action android:name="androidx.wear.tiles.action.BIND_TILE_PROVIDER" />
</intent-filter>
<meta-data
android:name="androidx.wear.tiles.PREVIEW"
android:resource="@drawable/tile_preview" />
</service>
<service
android:name=".complication.DoctateComplicationService"
android:exported="true"
android:icon="@drawable/ic_complication"
android:label="@string/app_name"
android:permission="com.google.android.wearable.permission.BIND_COMPLICATION_PROVIDER">
<intent-filter>
<action android:name="android.support.wearable.complications.ACTION_COMPLICATION_UPDATE_REQUEST" />
</intent-filter>
<meta-data
android:name="android.support.wearable.complications.SUPPORTED_TYPES"
android:value="SHORT_TEXT" />
<meta-data
android:name="android.support.wearable.complications.UPDATE_PERIOD_SECONDS"
android:value="0" />
</service>
</application>
</manifest>
@@ -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()
}
}
@@ -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,
)
}
}
@@ -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?,
)
@@ -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<List<CaseEntry>>(emptyList())
val cases: StateFlow<List<CaseEntry>> = _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
}
}
@@ -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<NavCommand.Pending>) {
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() })
}
}
}
}
}
@@ -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))
@@ -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),
)
}
}
@@ -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
}
}
}
@@ -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")
}
}
@@ -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>(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,
)
}
}
@@ -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<TileBuilders.Tile> {
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<ResourceBuilders.Resources> {
val resources = ResourceBuilders.Resources.Builder()
.setVersion(RESOURCES_VERSION)
.build()
return Futures.immediateFuture(resources)
}
}
@@ -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()
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/colorForeground">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM9,17l-5,-5 1.41,-1.41L9,14.17l9.59,-9.59L20,6l-11,11z" />
</vector>
@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="192dp"
android:height="192dp"
android:viewportWidth="192"
android:viewportHeight="192">
<!-- Dark circular tile background. -->
<path
android:fillColor="#1F1F1F"
android:pathData="M96,96m-96,0a96,96 0,1 1,192 0a96,96 0,1 1,-192 0" />
<!-- Stylised "D" glyph to hint at Doctate branding. -->
<path
android:fillColor="#E6E0E9"
android:pathData="M64,56 L64,136 L96,136 C119.196,136 136,119.196 136,96 C136,72.804 119.196,56 96,56 L64,56 Z M80,72 L96,72 C109.255,72 120,82.745 120,96 C120,109.255 109.255,120 96,120 L80,120 L80,72 Z" />
<!-- Bottom accent bar to suggest the "● Neu" EdgeButton. -->
<path
android:fillColor="#E6E0E9"
android:pathData="M58,160 L134,160 A10,10 0 0 1 134,180 L58,180 A10,10 0 0 1 58,160 Z" />
</vector>
@@ -1,4 +1,6 @@
<resources>
<string name="app_name">Doctate Watch</string>
<string name="hello_world">Doctate!</string>
<string name="tile_description">Aktueller Fall und Schnellstart</string>
<string name="tile_label">Doctate</string>
</resources>
@@ -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)
}
}
+12
View File
@@ -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" }