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 index 0ab04ae..171b646 100644 --- 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 @@ -16,7 +16,9 @@ import kotlinx.coroutines.flow.StateFlow object Routes { const val LIST = "list" + const val DETAIL = "detail/{caseId}" const val RECORDING = "recording/{caseId}" + fun detail(caseId: String) = "detail/$caseId" fun recording(caseId: String) = "recording/$caseId" } @@ -38,7 +40,7 @@ fun AppNav(pendingCommand: StateFlow) { navController.navigate(Routes.recording(id)) } is NavCommand.ContinueCase -> { - navController.navigate(Routes.recording(c.caseId)) + navController.navigate(Routes.detail(c.caseId)) } } } @@ -52,7 +54,7 @@ fun AppNav(pendingCommand: StateFlow) { composable(Routes.LIST) { CaseListScreen( onCaseTap = { caseId -> - navController.navigate(Routes.recording(caseId)) + navController.navigate(Routes.detail(caseId)) }, onNewCase = { val id = CaseStoreStub.createLocal() @@ -60,6 +62,16 @@ fun AppNav(pendingCommand: StateFlow) { }, ) } + composable( + route = Routes.DETAIL, + arguments = listOf(navArgument("caseId") { type = NavType.StringType }), + ) { backStack -> + val caseId = backStack.arguments?.getString("caseId").orEmpty() + CaseDetailScreen( + caseId = caseId, + onRecord = { navController.navigate(Routes.recording(caseId)) }, + ) + } composable( route = Routes.RECORDING, arguments = listOf(navArgument("caseId") { type = NavType.StringType }), diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseDetailScreen.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseDetailScreen.kt new file mode 100644 index 0000000..d3be326 --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/CaseDetailScreen.kt @@ -0,0 +1,133 @@ +package com.doctate.watch.presentation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.CaseStoreStub +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +private val detailDateFormat = DateTimeFormatter.ofPattern("dd.MM.yy", Locale.getDefault()) +private val detailTimeFormat = DateTimeFormatter.ofPattern("HH:mm:ss", Locale.getDefault()) + +@Composable +fun CaseDetailScreen( + caseId: String, + onRecord: () -> Unit, +) { + val cases by CaseStoreStub.cases.collectAsStateWithLifecycle() + val case = cases.firstOrNull { it.caseId == caseId } + + ScreenScaffold { contentPadding -> + if (case == null) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + "Fall nicht gefunden", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(horizontal = 16.dp), + ) { + // Top third: date label over HH:mm:ss clock. + Column( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = formatDetailDate(case.createdAt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + text = formatDetailTime(case.createdAt), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + // Middle third: prominent one-liner. + Column( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = case.oneliner ?: "…", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + // Bottom third: reserved for EdgeButton. + Spacer(modifier = Modifier.weight(1f)) + } + } + EdgeButton( + onClick = onRecord, + modifier = Modifier.align(Alignment.BottomCenter), + ) { + Box( + modifier = Modifier + .size(20.dp) + .background( + color = MaterialTheme.colorScheme.error, + shape = CircleShape, + ), + ) + } + } +} + +private fun formatDetailDate(epochMs: Long): String { + val zone = ZoneId.systemDefault() + val created = Instant.ofEpochMilli(epochMs).atZone(zone).toLocalDate() + val today = LocalDate.now(zone) + return when (created) { + today -> "Heute" + today.minusDays(1) -> "Gestern" + else -> created.format(detailDateFormat) + } +} + +private fun formatDetailTime(epochMs: Long): String = + Instant.ofEpochMilli(epochMs).atZone(ZoneId.systemDefault()).format(detailTimeFormat) 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 3f69f09..fe4a384 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 @@ -27,11 +27,6 @@ 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.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.Locale @Composable fun CaseListScreen( @@ -124,25 +119,3 @@ private fun EmptyState(onNewCase: () -> Unit) { } } -private val timeFormat = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) -private val dateTimeFormat = DateTimeFormatter.ofPattern("dd.MM.yy - HH:mm", Locale.getDefault()) - -private fun formatTime(epochMs: Long): String { - val ageMinutes = (System.currentTimeMillis() - epochMs) / 60_000L - if (ageMinutes in 0L until 60L) { - return when (ageMinutes) { - 0L -> "Gerade eben" - 1L -> "Vor 1 Minute" - else -> "Vor $ageMinutes Minuten" - } - } - val zone = ZoneId.systemDefault() - val created = Instant.ofEpochMilli(epochMs).atZone(zone) - val createdDate = created.toLocalDate() - val today = LocalDate.now(zone) - return when (createdDate) { - today -> created.format(timeFormat) - today.minusDays(1) -> "Gestern - ${created.format(timeFormat)}" - else -> created.format(dateTimeFormat) - } -} 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 7c10e01..098f076 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 @@ -3,22 +3,26 @@ package com.doctate.watch.presentation import android.Manifest import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box 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.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.font.FontFamily 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.Button -import androidx.wear.compose.material3.CircularProgressIndicator +import androidx.wear.compose.material3.EdgeButton import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text @@ -33,110 +37,83 @@ fun RecordingScreen( ), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val view = LocalView.current val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { granted -> viewModel.onPermissionResult(granted) } - LaunchedEffect(state) { + // Auto-start: entering the screen kicks off the record flow. Guard against + // re-entry — only trigger while VM is still Idle. + LaunchedEffect(Unit) { + if (state is UiState.Idle) { + viewModel.onRecordTap() + } + } + + // Request mic permission when the VM flips to RequestingPermission. + LaunchedEffect(state is UiState.RequestingPermission) { if (state is UiState.RequestingPermission) { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } } - ScreenScaffold { + // Pop on any post-Recording state: clean stop (Uploading), auto-stop + // completion (Success), or permission denial / recorder failure (Error). + val isDone = state is UiState.Uploading || + state is UiState.Success || + state is UiState.Error + if (isDone) { + LaunchedEffect(Unit) { onDone() } + } + + // Option C: keep display on while this composable is alive. + // onDispose doubles as the discard trigger for swipe-right / back — + // discardIfStillRecording() is a no-op when state is already Uploading. + DisposableEffect(Unit) { + view.keepScreenOn = true + onDispose { + view.keepScreenOn = false + viewModel.discardIfStillRecording() + } + } + + ScreenScaffold { contentPadding -> Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(top = 24.dp, bottom = 72.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() - }, + val s = state + if (s is UiState.Recording) { + Text( + text = formatDuration(s.elapsedSeconds), + style = MaterialTheme.typography.displayMedium, + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.Center, ) - is UiState.Error -> ErrorContent( - s, - onReset = { - viewModel.onResetTap() - onDone() - }, + } + } + if (state is UiState.Recording) { + EdgeButton( + onClick = { viewModel.onStopTap() }, + modifier = Modifier.align(Alignment.BottomCenter), + ) { + Box( + modifier = Modifier + .size(16.dp) + .background(MaterialTheme.colorScheme.onPrimary), ) } } } } -@Composable -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") - } -} - -@Composable -private fun StatusText(message: String) { - Text( - message, - style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center, - ) -} - -@Composable -private fun UploadingContent() { - CircularProgressIndicator() - Text( - "Uploading…", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 8.dp), - ) -} - -@Composable -private fun SuccessContent(caseId: String, onDone: () -> Unit) { - Text( - "OK", - style = MaterialTheme.typography.displaySmall, - textAlign = TextAlign.Center, - ) - Text( - caseId.take(8), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 4.dp), - ) - Button(onClick = onDone, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { - Text("Done") - } -} - -@Composable -private fun ErrorContent(state: UiState.Error, onReset: () -> Unit) { - Text( - if (state.transient) "Try again" else "Error", - style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center, - ) - Text( - state.message.take(60), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 4.dp), - ) - Button(onClick = onReset, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { - Text("OK") - } +private fun formatDuration(seconds: Int): String { + val m = seconds / 60 + val s = seconds % 60 + return "%d:%02d".format(m, s) } 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 4d78f9a..c8a39d0 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 @@ -1,5 +1,6 @@ package com.doctate.watch.presentation +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -12,6 +13,7 @@ import com.doctate.watch.net.UploadClient import com.doctate.watch.net.UploadResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -23,6 +25,7 @@ import java.text.SimpleDateFormat import java.time.Instant import java.util.Date import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean class RecordingViewModel( private val caseId: String, @@ -34,6 +37,12 @@ class RecordingViewModel( private val _state = MutableStateFlow(UiState.Idle) val state: StateFlow = _state.asStateFlow() + // Ticker coroutine that counts elapsed seconds during active recording. + private var recordingJob: Job? = null + + // Guards against double-finalization when stop, discard and auto-stop race. + private val finalizationStarted = AtomicBoolean(false) + fun onRecordTap() = dispatch(RecordingEvent.OnRecordTap) fun onPermissionResult(granted: Boolean) { @@ -45,55 +54,101 @@ class RecordingViewModel( startRecordingFlow() } + fun onStopTap() { + if (!finalizationStarted.compareAndSet(false, true)) return + recordingJob?.cancel() + recordingJob = null + dispatch(RecordingEvent.OnStopTap) + applicationScope.launch { finalizeAndUpload() } + } + + fun onDiscardTap() { + if (!finalizationStarted.compareAndSet(false, true)) return + recordingJob?.cancel() + recordingJob = null + dispatch(RecordingEvent.OnDiscardTap) + Log.i(TAG, "discard: caseId=${caseId.take(8)}") + applicationScope.launch(Dispatchers.IO) { + try { + recorder.cancel() + } catch (e: Exception) { + Log.w(TAG, "recorder.cancel() failed on discard: ${e.message}") + } + } + } + + /** + * Idempotent trigger for swipe-dismiss / back-press paths that land in + * DisposableEffect.onDispose. Clean stop already flipped the state to + * Uploading, in which case this is a no-op. + */ + fun discardIfStillRecording() { + if (_state.value is UiState.Recording) onDiscardTap() + } + fun onResetTap() = dispatch(RecordingEvent.OnResetTap) private fun startRecordingFlow() { - viewModelScope.launch { - val file = try { + finalizationStarted.set(false) + recordingJob = viewModelScope.launch { + try { withContext(Dispatchers.IO) { recorder.start() } } catch (e: Exception) { dispatch(RecordingEvent.UploadFailed("Recorder failed: ${e.message}", transient = false)) return@launch } - // Initial state already Recording(5); tick 4..0 over 5 seconds. - for (i in 4 downTo 0) { + var elapsed = 0 + while (elapsed < MAX_SECONDS) { delay(1_000) - dispatch(RecordingEvent.RecordingTick(i)) + elapsed++ + dispatch(RecordingEvent.RecordingTick(elapsed)) } - val finalFile = try { - withContext(Dispatchers.IO) { recorder.stop() } - } catch (e: Exception) { - recorder.cancel() - dispatch(RecordingEvent.UploadFailed("Stop failed: ${e.message}", transient = false)) - return@launch - } - dispatch(RecordingEvent.RecordingFinished) - - val recordedAt = Instant.now().toString() - try { - when (val result = uploadClient.upload(caseId, recordedAt, finalFile)) { - 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 -> - dispatch(RecordingEvent.UploadFailed(result.reason, transient = false)) - } - } finally { - finalFile.delete() + // Hit the safety cap: treat like an external onStopTap(). + if (finalizationStarted.compareAndSet(false, true)) { + recordingJob = null + dispatch(RecordingEvent.OnStopTap) + Log.i(TAG, "auto-stop at $MAX_SECONDS s: caseId=${caseId.take(8)}") + applicationScope.launch { finalizeAndUpload() } } } } + private suspend fun finalizeAndUpload() { + val finalFile = try { + withContext(Dispatchers.IO) { recorder.stop() } + } catch (e: Exception) { + Log.w(TAG, "recorder.stop() failed: ${e.message}") + dispatch(RecordingEvent.UploadFailed("Stop failed: ${e.message}", transient = false)) + return + } + val recordedAt = Instant.now().toString() + try { + when (val result = uploadClient.upload(caseId, recordedAt, finalFile)) { + is UploadResult.Success -> { + CaseStoreStub.markActivity(caseId) + triggerFakeOnelinerBurst(caseId) + dispatch(RecordingEvent.UploadSucceeded(caseId)) + } + is UploadResult.Transient -> { + Log.w(TAG, "upload transient fail: ${result.reason}") + dispatch(RecordingEvent.UploadFailed(result.reason, transient = true)) + } + is UploadResult.Terminal -> { + Log.w(TAG, "upload terminal fail: ${result.reason}") + dispatch(RecordingEvent.UploadFailed(result.reason, transient = false)) + } + } + } finally { + finalFile.delete() + } + } + /** - * 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". + * PoC stand-in for 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 { @@ -108,6 +163,8 @@ class RecordingViewModel( } companion object { + private const val TAG = "RecordingVM" + private const val MAX_SECONDS = 300 private val fakeLabelFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) fun factoryFor(caseId: String): ViewModelProvider.Factory = viewModelFactory { diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/TimeFormat.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/TimeFormat.kt new file mode 100644 index 0000000..4b8de61 --- /dev/null +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/TimeFormat.kt @@ -0,0 +1,41 @@ +package com.doctate.watch.presentation + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +private val timeFormat = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) +private val dateTimeFormat = DateTimeFormatter.ofPattern("dd.MM.yy - HH:mm", Locale.getDefault()) + +/** + * German relative time label for case timestamps. + * + * - < 60 min → "Vor N Minuten" (with singular/plural and "Gerade eben" for 0) + * - today → "HH:mm" + * - yesterday → "Gestern - HH:mm" + * - older → "dd.MM.yy - HH:mm" + * + * Shared by CaseListScreen rows and CaseDetailScreen header so the formats + * never drift apart. + */ +internal fun formatTime(epochMs: Long): String { + val ageMinutes = (System.currentTimeMillis() - epochMs) / 60_000L + if (ageMinutes in 0L until 60L) { + return when (ageMinutes) { + 0L -> "Gerade eben" + 1L -> "Vor 1 Minute" + else -> "Vor $ageMinutes Minuten" + } + } + val zone = ZoneId.systemDefault() + val created = Instant.ofEpochMilli(epochMs).atZone(zone) + val createdDate = created.toLocalDate() + val today = LocalDate.now(zone) + return when (createdDate) { + today -> created.format(timeFormat) + today.minusDays(1) -> "Gestern - ${created.format(timeFormat)}" + else -> created.format(dateTimeFormat) + } +} diff --git a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/UiState.kt b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/UiState.kt index 40f7c94..74b85ef 100644 --- a/watch/wearos/app/src/main/java/com/doctate/watch/presentation/UiState.kt +++ b/watch/wearos/app/src/main/java/com/doctate/watch/presentation/UiState.kt @@ -4,7 +4,7 @@ package com.doctate.watch.presentation sealed interface UiState { data object Idle : UiState data object RequestingPermission : UiState - data class Recording(val secondsLeft: Int) : UiState + data class Recording(val elapsedSeconds: Int) : UiState data object Uploading : UiState data class Success(val caseId: String) : UiState data class Error(val message: String, val transient: Boolean) : UiState @@ -15,7 +15,9 @@ sealed interface RecordingEvent { data object OnRecordTap : RecordingEvent data object PermissionGranted : RecordingEvent data object PermissionDenied : RecordingEvent - data class RecordingTick(val secondsLeft: Int) : RecordingEvent + data class RecordingTick(val elapsedSeconds: Int) : RecordingEvent + data object OnStopTap : RecordingEvent + data object OnDiscardTap : RecordingEvent data object RecordingFinished : RecordingEvent data class UploadSucceeded(val caseId: String) : RecordingEvent data class UploadFailed(val message: String, val transient: Boolean) : RecordingEvent @@ -32,13 +34,15 @@ internal fun reduce(state: UiState, event: RecordingEvent): UiState = when (stat else -> state } is UiState.RequestingPermission -> when (event) { - RecordingEvent.PermissionGranted -> UiState.Recording(secondsLeft = 5) + RecordingEvent.PermissionGranted -> UiState.Recording(elapsedSeconds = 0) RecordingEvent.PermissionDenied -> UiState.Error(message = "Microphone permission denied", transient = false) else -> state } is UiState.Recording -> when (event) { - is RecordingEvent.RecordingTick -> UiState.Recording(secondsLeft = event.secondsLeft) + is RecordingEvent.RecordingTick -> UiState.Recording(elapsedSeconds = event.elapsedSeconds) + RecordingEvent.OnStopTap -> UiState.Uploading + RecordingEvent.OnDiscardTap -> UiState.Idle RecordingEvent.RecordingFinished -> UiState.Uploading is RecordingEvent.UploadFailed -> UiState.Error(event.message, event.transient) else -> state diff --git a/watch/wearos/app/src/test/java/com/doctate/watch/presentation/UiStateReducerTest.kt b/watch/wearos/app/src/test/java/com/doctate/watch/presentation/UiStateReducerTest.kt index 352d545..3bb486d 100644 --- a/watch/wearos/app/src/test/java/com/doctate/watch/presentation/UiStateReducerTest.kt +++ b/watch/wearos/app/src/test/java/com/doctate/watch/presentation/UiStateReducerTest.kt @@ -12,7 +12,7 @@ class UiStateReducerTest { @Test fun requesting_permission_to_recording_on_grant() { val next = reduce(UiState.RequestingPermission, RecordingEvent.PermissionGranted) - assertThat(next).isEqualTo(UiState.Recording(secondsLeft = 5)) + assertThat(next).isEqualTo(UiState.Recording(elapsedSeconds = 0)) } @Test fun requesting_permission_to_error_on_denial() { @@ -21,19 +21,29 @@ class UiStateReducerTest { assertThat((next as UiState.Error).transient).isFalse() } - @Test fun recording_updates_seconds_left_on_tick() { - val next = reduce(UiState.Recording(secondsLeft = 5), RecordingEvent.RecordingTick(3)) - assertThat(next).isEqualTo(UiState.Recording(secondsLeft = 3)) + @Test fun recording_updates_elapsed_seconds_on_tick() { + val next = reduce(UiState.Recording(elapsedSeconds = 0), RecordingEvent.RecordingTick(3)) + assertThat(next).isEqualTo(UiState.Recording(elapsedSeconds = 3)) + } + + @Test fun recording_moves_to_uploading_on_stop_tap() { + val next = reduce(UiState.Recording(elapsedSeconds = 12), RecordingEvent.OnStopTap) + assertThat(next).isEqualTo(UiState.Uploading) + } + + @Test fun recording_moves_to_idle_on_discard_tap() { + val next = reduce(UiState.Recording(elapsedSeconds = 12), RecordingEvent.OnDiscardTap) + assertThat(next).isEqualTo(UiState.Idle) } @Test fun recording_moves_to_uploading_when_finished() { - val next = reduce(UiState.Recording(secondsLeft = 0), RecordingEvent.RecordingFinished) + val next = reduce(UiState.Recording(elapsedSeconds = 5), RecordingEvent.RecordingFinished) assertThat(next).isEqualTo(UiState.Uploading) } @Test fun recording_to_error_on_recorder_failure() { val next = reduce( - UiState.Recording(secondsLeft = 3), + UiState.Recording(elapsedSeconds = 3), RecordingEvent.UploadFailed("mic busy", transient = false), ) val err = next as UiState.Error @@ -67,4 +77,9 @@ class UiStateReducerTest { val next = reduce(UiState.Idle, RecordingEvent.UploadSucceeded("late ack")) assertThat(next).isEqualTo(UiState.Idle) } + + @Test fun discard_tap_outside_recording_is_noop() { + val next = reduce(UiState.Uploading, RecordingEvent.OnDiscardTap) + assertThat(next).isEqualTo(UiState.Uploading) + } }