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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -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,8 +44,6 @@ fun RecordingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
DoctateWatchTheme {
|
||||
AppScaffold {
|
||||
ScreenScaffold {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
@@ -50,21 +51,37 @@ fun RecordingScreen(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
when (val s = state) {
|
||||
UiState.Idle -> IdleContent(onRecord = viewModel::onRecordTap)
|
||||
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, onReset = viewModel::onResetTap)
|
||||
is UiState.Error -> ErrorContent(s, onReset = viewModel::onResetTap)
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
+30
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user