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:
@@ -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)
|
||||
}
|
||||
|
||||
+89
-32
@@ -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
|
||||
|
||||
+21
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user