Split recording flow into case detail + recording screens

Tap on a case in the list (or Tile continue-case) now opens a new
CaseDetailScreen showing date + oneliner + record EdgeButton. Only
hitting record enters RecordingScreen, which is itself trimmed to
just a live mm:ss counter and a stop EdgeButton. Swipe-right on the
recording screen discards the in-flight recording (no confirmation).
"New case" button still bypasses the detail step.

State machine: Recording now counts elapsedSeconds (up) instead of
secondsLeft. New OnStopTap and OnDiscardTap events, new reducer
transitions. Safety cap at 300 s.

ViewModel: startRecordingFlow split into a cancellable ticker job
plus a separate finalizeAndUpload run in applicationScope so the
upload survives the pop that follows a clean stop. AtomicBoolean
finalizationStarted guards against double-finalization between the
stop, discard, and auto-stop paths.

Display: FLAG_KEEP_SCREEN_ON on the recording screen via
view.keepScreenOn. DisposableEffect.onDispose doubles as the
discard trigger for swipe-right / back / host destruction;
discardIfStillRecording() is a no-op after a clean stop.

CaseDetailScreen lays out three vertical thirds: date/time at top
(Heute/Gestern/dd.MM.yy + HH:mm:ss), oneliner in the middle,
EdgeButton at the bottom. formatTime extracted to TimeFormat.kt
so list and detail never drift apart.
This commit is contained in:
2026-04-24 15:42:52 +02:00
parent 028342d4a4
commit edcee399b4
8 changed files with 371 additions and 159 deletions
@@ -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<NavCommand.Pending>) {
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<NavCommand.Pending>) {
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<NavCommand.Pending>) {
},
)
}
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 }),
@@ -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)
@@ -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)
}
}
@@ -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)
}
@@ -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>(UiState.Idle)
val state: StateFlow<UiState> = _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 {
@@ -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)
}
}
@@ -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
@@ -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)
}
}