From 68a77322d06b9fd403af7b5d0995c81c07063edd Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 26 Apr 2026 23:52:59 +0200 Subject: [PATCH] feat(watch): sync indicator UI + POST_NOTIFICATIONS prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CaseListScreen shows a small ☁↑N at the top whenever the worker has queue items; per-row ↑ next to the timestamp marks individual cases that are still mid-upload. Both surface the SyncStateProvider directly so the user sees the queue draining in real time. MainActivity prompts for POST_NOTIFICATIONS on Wear OS 13+ — the foreground service runs either way, but on API 33+ the persistent notification only renders if the runtime grant is given. Result is informational; we don't gate the recording flow on it (the doctor still needs to be able to dictate even after a 'deny'). --- .../watch/presentation/CaseListScreen.kt | 22 ++++++++++++++--- .../watch/presentation/MainActivity.kt | 24 ++++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt index 8d13e76..c390c6c 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseListScreen.kt @@ -37,6 +37,7 @@ fun CaseListScreen( ) { val app = LocalContext.current.applicationContext as DoctateApp val cases by app.caseStore.casesFlow.collectAsStateWithLifecycle() + val sync by app.syncStateProvider.state.collectAsStateWithLifecycle() val listState = rememberScalingLazyListState() // Display oldest-first so the newest entry sits next to the `Neu` EdgeButton. @@ -62,6 +63,17 @@ fun CaseListScreen( if (displayed.isEmpty()) { EmptyState(onNewCase) } else { + if (sync.queueLen > 0) { + Text( + text = "☁↑${sync.queueLen}", + style = MaterialTheme.typography.bodyExtraSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + textAlign = TextAlign.Center, + ) + } ScalingLazyColumn( state = listState, contentPadding = contentPadding, @@ -70,7 +82,11 @@ fun CaseListScreen( modifier = Modifier.fillMaxSize(), ) { items(displayed, key = { it.caseId }) { case -> - CaseRow(case, onTap = { onCaseTap(case.caseId) }) + CaseRow( + case = case, + pendingUpload = case.caseId in sync.pendingCaseIds, + onTap = { onCaseTap(case.caseId) }, + ) } } } @@ -78,14 +94,14 @@ fun CaseListScreen( } @Composable -private fun CaseRow(case: CaseEntry, onTap: () -> Unit) { +private fun CaseRow(case: CaseEntry, pendingUpload: Boolean, onTap: () -> Unit) { Card( onClick = onTap, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), ) { Text( - text = formatTime(case.createdAt), + text = if (pendingUpload) "${formatTime(case.createdAt)} ↑" else formatTime(case.createdAt), style = MaterialTheme.typography.bodyExtraSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.align(Alignment.CenterHorizontally), 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 692b7dd..00a5c9c 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,11 +1,16 @@ package com.doctate.watch.presentation +import android.Manifest import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import kotlinx.coroutines.flow.MutableStateFlow +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.flow.MutableStateFlow class MainActivity : ComponentActivity() { private val seq = AtomicLong(0) @@ -13,8 +18,14 @@ class MainActivity : ComponentActivity() { NavCommand.Pending(0L, NavCommand.ShowList), ) + private val notificationsLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { /* result is informational; the foreground service still runs without it, + but the persistent notification will be hidden on Wear OS 13+. */ } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + requestNotificationPermissionIfNeeded() dispatch(intent) setContent { AppNav(pendingCommand = pendingCommand) } } @@ -31,4 +42,15 @@ class MainActivity : ComponentActivity() { cmd = NavCommand.fromIntent(intent), ) } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + val granted = ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + if (!granted) { + notificationsLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } }