feat(watch): sync indicator UI + POST_NOTIFICATIONS prompt

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').
This commit is contained in:
2026-04-26 23:52:59 +02:00
parent 491b093b86
commit 68a77322d0
2 changed files with 42 additions and 4 deletions
@@ -37,6 +37,7 @@ fun CaseListScreen(
) { ) {
val app = LocalContext.current.applicationContext as DoctateApp val app = LocalContext.current.applicationContext as DoctateApp
val cases by app.caseStore.casesFlow.collectAsStateWithLifecycle() val cases by app.caseStore.casesFlow.collectAsStateWithLifecycle()
val sync by app.syncStateProvider.state.collectAsStateWithLifecycle()
val listState = rememberScalingLazyListState() val listState = rememberScalingLazyListState()
// Display oldest-first so the newest entry sits next to the `Neu` EdgeButton. // Display oldest-first so the newest entry sits next to the `Neu` EdgeButton.
@@ -62,6 +63,17 @@ fun CaseListScreen(
if (displayed.isEmpty()) { if (displayed.isEmpty()) {
EmptyState(onNewCase) EmptyState(onNewCase)
} else { } 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( ScalingLazyColumn(
state = listState, state = listState,
contentPadding = contentPadding, contentPadding = contentPadding,
@@ -70,7 +82,11 @@ fun CaseListScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
items(displayed, key = { it.caseId }) { case -> 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 @Composable
private fun CaseRow(case: CaseEntry, onTap: () -> Unit) { private fun CaseRow(case: CaseEntry, pendingUpload: Boolean, onTap: () -> Unit) {
Card( Card(
onClick = onTap, onClick = onTap,
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
) { ) {
Text( Text(
text = formatTime(case.createdAt), text = if (pendingUpload) "${formatTime(case.createdAt)}" else formatTime(case.createdAt),
style = MaterialTheme.typography.bodyExtraSmall, style = MaterialTheme.typography.bodyExtraSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
@@ -1,11 +1,16 @@
package com.doctate.watch.presentation package com.doctate.watch.presentation
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent 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 java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.flow.MutableStateFlow
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val seq = AtomicLong(0) private val seq = AtomicLong(0)
@@ -13,8 +18,14 @@ class MainActivity : ComponentActivity() {
NavCommand.Pending(0L, NavCommand.ShowList), 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded()
dispatch(intent) dispatch(intent)
setContent { AppNav(pendingCommand = pendingCommand) } setContent { AppNav(pendingCommand = pendingCommand) }
} }
@@ -31,4 +42,15 @@ class MainActivity : ComponentActivity() {
cmd = NavCommand.fromIntent(intent), 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)
}
}
} }