From f89332b28c5b2333816d982fb67181e93e38e03a Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 23 Jul 2026 11:05:49 +0200 Subject: [PATCH] Dodaj uwagi i dyktowanie na etapach trasy --- app/src/main/AndroidManifest.xml | 7 + .../kierowca/data/model/DriverModels.kt | 4 + .../kierowca/data/upload/DriverDatabase.kt | 17 +- .../kierowca/data/upload/RouteActionEntity.kt | 1 + .../kierowca/data/upload/RouteActionOutbox.kt | 20 +- .../kierowca/data/upload/RouteActionWorker.kt | 2 + .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 64 +++- .../kierowca/ui/DriverRouteProjection.kt | 2 + .../pl/firmatpp/kierowca/ui/DriverUiRules.kt | 3 + .../firmatpp/kierowca/ui/DriverViewModel.kt | 34 +- .../kierowca/ui/LiveVoiceDictation.kt | 337 ++++++++++++++++++ .../kierowca/ui/DriverRouteProjectionTest.kt | 23 +- .../kierowca/ui/LiveVoiceDictationTest.kt | 30 ++ 13 files changed, 531 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/pl/firmatpp/kierowca/ui/LiveVoiceDictation.kt create mode 100644 app/src/test/java/pl/firmatpp/kierowca/ui/LiveVoiceDictationTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d4cad20..c0f6007 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,12 +2,19 @@ + + + + + + + , + val loadingNotes: String? = null, ) data class FinishRouteBody( @@ -359,6 +362,7 @@ data class FinishRouteBody( val unloadingWeight: Double?, val occurredAt: String, val photoClientRequestIds: List, + val unloadingNotes: String? = null, ) data class RouteActionResponse( diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt index efe277c..6be680d 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt @@ -21,7 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity DriverRouteCacheEntity::class, DriverSyncStateEntity::class, ], - version = 6, + version = 7, exportSchema = false, ) abstract class DriverDatabase : RoomDatabase() { @@ -202,13 +202,26 @@ abstract class DriverDatabase : RoomDatabase() { } } + private val migration6To7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE route_actions ADD COLUMN notes TEXT") + } + } + fun get(context: Context): DriverDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, DriverDatabase::class.java, "driver-local-outbox.db", - ).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5, migration5To6).build().also { instance = it } + ).addMigrations( + migration1To2, + migration2To3, + migration3To4, + migration4To5, + migration5To6, + migration6To7, + ).build().also { instance = it } } } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt index 41906fc..f280705 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt @@ -38,4 +38,5 @@ data class RouteActionEntity( val lastError: String? = null, val createdAtEpochMillis: Long = System.currentTimeMillis(), val updatedAtEpochMillis: Long = System.currentTimeMillis(), + val notes: String? = null, ) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt index 0b73014..cb28c62 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt @@ -43,17 +43,28 @@ class RouteActionOutbox( enqueueWorker(clientActionId) } - suspend fun enqueueStart(routeId: String, loadingWeight: Double?, photoClientRequestIds: List): RouteActionEntity = - enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds) + suspend fun enqueueStart( + routeId: String, + loadingWeight: Double?, + photoClientRequestIds: List, + notes: String? = null, + ): RouteActionEntity = + enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds, notes) - suspend fun enqueueFinish(routeId: String, unloadingWeight: Double?, photoClientRequestIds: List): RouteActionEntity = - enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds) + suspend fun enqueueFinish( + routeId: String, + unloadingWeight: Double?, + photoClientRequestIds: List, + notes: String? = null, + ): RouteActionEntity = + enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds, notes) private suspend fun enqueue( action: String, routeId: String, weight: Double?, photoClientRequestIds: List, + notes: String?, ): RouteActionEntity { dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7)) val entity = RouteActionEntity( @@ -63,6 +74,7 @@ class RouteActionOutbox( weight = weight, occurredAt = Instant.now().toString(), photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()), + notes = notes?.trim()?.takeIf { it.isNotEmpty() }, ) dao.upsert(entity) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt index 31aad84..5fc0be0 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt @@ -116,6 +116,7 @@ class RouteActionWorker( loadingWeight = action.weight, occurredAt = action.occurredAt, photoClientRequestIds = photoClientRequestIds, + loadingNotes = action.notes, ), ) RouteActionType.Finish -> repository.finishRoute( @@ -125,6 +126,7 @@ class RouteActionWorker( unloadingWeight = action.weight, occurredAt = action.occurredAt, photoClientRequestIds = photoClientRequestIds, + unloadingNotes = action.notes, ), ) else -> error("Nieznana akcja kursu: ${action.action}") diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index c12a812..814da06 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -346,10 +346,12 @@ fun DriverApp( weightLabel = "Tonaż załadunku", submitLabel = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) "Dalej: tonaż" else "Potwierdź załadunek", showWeightInput = false, + showNotesInput = !routeStageRequirementIsVisible(state.loadingWeightRequirement), showPhotoActions = routeStageRequirementIsVisible(state.loadingPhotoRequirement), showPhotoGrid = routeStageRequirementIsVisible(state.loadingPhotoRequirement), onBack = viewModel::back, onWeightChange = viewModel::updateRouteStageWeight, + onNotesChange = viewModel::updateRouteStageNotes, onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading") }, onPhoto = viewModel::openPhoto, onDeleteUpload = viewModel::deletePhotoUpload, @@ -363,10 +365,12 @@ fun DriverApp( weightLabel = "Tonaż załadunku", submitLabel = "Potwierdź załadunek", showWeightInput = routeStageRequirementIsVisible(state.loadingWeightRequirement), + showNotesInput = true, showPhotoActions = false, showPhotoGrid = false, onBack = viewModel::back, onWeightChange = viewModel::updateRouteStageWeight, + onNotesChange = viewModel::updateRouteStageNotes, onUpload = { _, _, _ -> }, onPhoto = viewModel::openPhoto, onDeleteUpload = viewModel::deletePhotoUpload, @@ -380,10 +384,12 @@ fun DriverApp( weightLabel = "Tonaż rozładunku", submitLabel = "Potwierdź rozładunek", showWeightInput = routeStageRequirementIsVisible(state.unloadingWeightRequirement), + showNotesInput = true, showPhotoActions = routeStageRequirementIsVisible(state.unloadingPhotoRequirement), showPhotoGrid = routeStageRequirementIsVisible(state.unloadingPhotoRequirement), onBack = viewModel::back, onWeightChange = viewModel::updateRouteStageWeight, + onNotesChange = viewModel::updateRouteStageNotes, onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading") }, onPhoto = viewModel::openPhoto, onDeleteUpload = viewModel::deletePhotoUpload, @@ -2867,10 +2873,12 @@ private fun RouteStageScreen( weightLabel: String, submitLabel: String, showWeightInput: Boolean = true, + showNotesInput: Boolean = true, showPhotoActions: Boolean = true, showPhotoGrid: Boolean = true, onBack: () -> Unit, onWeightChange: (String) -> Unit, + onNotesChange: (String) -> Unit, onUpload: (Uri, String, PhotoUploadMetadata) -> Unit, onPhoto: (RoutePhotoDto) -> Unit, onDeleteUpload: (PhotoUploadEntity) -> Unit, @@ -2880,6 +2888,7 @@ private fun RouteStageScreen( val context = LocalContext.current var cameraUri by remember { mutableStateOf(null) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } + var voiceDictationActive by remember(stage) { mutableStateOf(false) } val route = state.displaySelectedRoute val stagePhotos = route?.photos?.let { routePhotosForStage(it, stage) }.orEmpty() val stageUploads = state.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage } @@ -2908,6 +2917,11 @@ private fun RouteStageScreen( photoRequirement = photoRequirement, ) } + val effectiveSubmitBlocker = if (voiceDictationActive) { + "Zakończ dyktowanie przed zatwierdzeniem." + } else { + submitBlocker + } val weightBlocker = submitBlocker?.takeIf { it.contains("wag", ignoreCase = true) || it.contains("popraw", ignoreCase = true) } val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> @@ -2947,7 +2961,7 @@ private fun RouteStageScreen( bottomBar = { RouteStageSubmitBar( submitLabel = submitLabel, - submitBlocker = submitBlocker, + submitBlocker = effectiveSubmitBlocker, onSubmit = onSubmit, ) }, @@ -3004,6 +3018,18 @@ private fun RouteStageScreen( ) } } + if (showNotesInput) { + item { + VoiceNotesField( + value = state.routeStageNotesText, + onValueChange = onNotesChange, + stageLabel = if (stage == "loading") "załadunku" else "rozładunku", + enabled = !state.loading, + onListeningChanged = { voiceDictationActive = it }, + modifier = Modifier.fillMaxWidth(), + ) + } + } if (showPhotoActions) { item { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -3768,6 +3794,7 @@ private fun CargoDocumentationSection( RouteStageDocumentationBlock( title = "Załadunek", weight = route.loadingWeight, + notes = route.loadingNotes, photos = loadingPhotos, uploads = loadingUploads, deletingPhotoIds = deletingPhotoIds, @@ -3778,6 +3805,7 @@ private fun CargoDocumentationSection( RouteStageDocumentationBlock( title = "Rozładunek", weight = route.unloadingWeight, + notes = route.unloadingNotes, photos = unloadingPhotos, uploads = unloadingUploads, deletingPhotoIds = deletingPhotoIds, @@ -3789,6 +3817,7 @@ private fun CargoDocumentationSection( RouteStageDocumentationBlock( title = "Pozostałe", weight = null, + notes = null, photos = otherPhotos, uploads = otherUploads, deletingPhotoIds = deletingPhotoIds, @@ -3804,6 +3833,7 @@ private fun CargoDocumentationSection( private fun RouteStageDocumentationBlock( title: String, weight: Double?, + notes: String?, photos: List, uploads: List, deletingPhotoIds: Set, @@ -3837,6 +3867,38 @@ private fun RouteStageDocumentationBlock( } } } + if (!notes.isNullOrBlank()) { + Column( + Modifier + .fillMaxWidth() + .background(TppTheme.colors.panel, RoundedCornerShape(4.dp)) + .border(1.dp, TppTheme.colors.outline.copy(alpha = 0.55f), RoundedCornerShape(4.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + Text( + "UWAGA KIEROWCY", + color = TppTheme.colors.muted, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelSmall, + ) + Text( + notes, + color = TppTheme.colors.ink, + fontWeight = FontWeight.SemiBold, + ) + if (weightPending) { + Text( + "uwaga czeka na wysłanie", + color = TppTheme.colors.containerGreen, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } if (count == 0) { Box( Modifier diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt index 7b7fc67..3bd3579 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt @@ -42,6 +42,7 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List): status = "W TRAKCIE", driverStatus = "W TRAKCIE", loadingWeight = action.weight, + loadingNotes = action.notes, trackingStatus = "active", photos = projectedRoute.safePhotos(), ) @@ -52,6 +53,7 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List): status = "ZAKOŃCZONA", driverStatus = "ZAKOŃCZONA", unloadingWeight = action.weight, + unloadingNotes = action.notes, trackingStatus = "finished", completedAt = action.occurredAt, photos = projectedRoute.safePhotos(), diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt index 5b78cdd..4602a93 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -25,6 +25,7 @@ import pl.firmatpp.kierowca.data.upload.RouteActionType private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM") private val routeWeightKilogramInputThreshold = BigDecimal("1000") private const val routeWeightMaxTons = 999.999 +const val routeStageNotesMaxLength = 2000 private const val googleMapsPackage = "com.google.android.apps.maps" data class NavigationIntentCandidate( @@ -239,6 +240,8 @@ fun routeStageRequirementIsVisible(requirement: String): Boolean = fun routeStageRequirementIsRequired(requirement: String): Boolean = normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required +fun normalizeRouteStageNotes(value: String): String = value.take(routeStageNotesMaxLength) + fun parseRouteWeightTons(weightText: String): Double? { val enteredWeight = weightText.trim().replace(',', '.').toBigDecimalOrNull() ?: return null val tons = if (enteredWeight.abs() >= routeWeightKilogramInputThreshold) { diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index b6fdf82..0cb8e95 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -91,6 +91,7 @@ data class DriverUiState( val selectedRoute: DriverRouteDto? = null, val selectedPhoto: RoutePhotoDto? = null, val routeStageWeightText: String = "", + val routeStageNotesText: String = "", val routeActions: List = emptyList(), val visibleRouteActions: List = emptyList(), val photoUploads: List = emptyList(), @@ -575,6 +576,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) DriverScreen.FinishRoute -> response.route.unloadingWeight?.toString().orEmpty() else -> it.routeStageWeightText }, + routeStageNotesText = when (targetScreen) { + DriverScreen.LoadingWeight -> response.route.loadingNotes.orEmpty() + DriverScreen.FinishRoute -> response.route.unloadingNotes.orEmpty() + else -> it.routeStageNotesText + }, feedback = null, isStale = cached.stale || !it.isOnline, lastServerSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastServerSyncAtEpochMillis, @@ -1113,6 +1119,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) it.copy( screen = targetScreen, routeStageWeightText = "", + routeStageNotesText = route.loadingNotes.orEmpty(), feedback = null, error = null, ) @@ -1157,6 +1164,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) it.copy( screen = DriverScreen.FinishRoute, routeStageWeightText = route.unloadingWeight?.toString().orEmpty(), + routeStageNotesText = route.unloadingNotes.orEmpty(), feedback = null, error = null, ) @@ -1168,6 +1176,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(routeStageWeightText = normalized) } } + fun updateRouteStageNotes(value: String) { + _state.update { it.copy(routeStageNotesText = normalizeRouteStageNotes(value)) } + } + fun correctFailedRouteAction() { val snapshot = _state.value val action = (snapshot.visibleRouteActions + snapshot.routeActions) @@ -1233,6 +1245,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } else { null } + val notes = normalizeRouteStageNotes(snapshot.routeStageNotesText).trim().takeIf { it.isNotBlank() } val stagePhotos = routePhotosForStage(route.photos, stage) val stageUploads = routeStageUsableUploads( snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }, @@ -1279,21 +1292,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) runCatching { val updatedRoute = when (stage) { "loading" -> { - routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds) + routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds, notes) route.copy( status = "W TRAKCIE", driverStatus = "W TRAKCIE", loadingWeight = weight, + loadingNotes = notes, trackingStatus = "active", photos = route.safePhotos(), ) } "unloading" -> { - routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds) + routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds, notes) route.copy( status = "ZAKOŃCZONA", driverStatus = "ZAKOŃCZONA", unloadingWeight = weight, + unloadingNotes = notes, trackingStatus = "finished", completedAt = Instant.now().toString(), photos = route.safePhotos(), @@ -1308,6 +1323,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) selectedRoute = updatedRoute, routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item }, routeStageWeightText = "", + routeStageNotesText = "", feedback = if (stage == "loading") { if (snapshot.isOnline) "Potwierdzam załadunek z serwerem." else "Załadunek zapisano w telefonie. Wyślemy go po odzyskaniu internetu." } else { @@ -1486,9 +1502,19 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) when (it.screen) { DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null) DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes) - DriverScreen.StartRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null) + DriverScreen.StartRoute -> it.copy( + screen = DriverScreen.Detail, + routeStageWeightText = "", + routeStageNotesText = "", + feedback = null, + ) DriverScreen.LoadingWeight -> it.copy(screen = DriverScreen.StartRoute, feedback = null) - DriverScreen.FinishRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null) + DriverScreen.FinishRoute -> it.copy( + screen = DriverScreen.Detail, + routeStageWeightText = "", + routeStageNotesText = "", + feedback = null, + ) DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes) DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null) DriverScreen.LeaveCalendar -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null) diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/LiveVoiceDictation.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/LiveVoiceDictation.kt new file mode 100644 index 0000000..6c77406 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/LiveVoiceDictation.kt @@ -0,0 +1,337 @@ +package pl.firmatpp.kierowca.ui + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Mic +import androidx.compose.material.icons.outlined.StopCircle +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import pl.firmatpp.kierowca.ui.theme.TppTheme + +internal data class VoiceDictationUiState( + val isListening: Boolean = false, + val message: String = "Możesz wpisać uwagę albo podyktować ją po polsku.", + val isError: Boolean = false, +) + +internal fun mergeDictationText(existingText: String, recognizedText: String): String { + val existing = existingText.trimEnd() + val recognized = recognizedText.trim() + if (recognized.isBlank()) return normalizeRouteStageNotes(existingText) + + return normalizeRouteStageNotes( + if (existing.isBlank()) recognized else "$existing $recognized", + ) +} + +@Composable +internal fun VoiceNotesField( + value: String, + onValueChange: (String) -> Unit, + stageLabel: String, + enabled: Boolean, + onListeningChanged: (Boolean) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var dictationState by remember { mutableStateOf(VoiceDictationUiState()) } + val currentValue by rememberUpdatedState(value) + val currentOnValueChange by rememberUpdatedState(onValueChange) + val controller = remember(context) { AndroidLiveVoiceDictation(context.applicationContext) } + + val startDictation = { + controller.start( + existingText = currentValue, + onText = currentOnValueChange, + onState = { dictationState = it }, + ) + } + val microphonePermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) { + startDictation() + } else { + dictationState = VoiceDictationUiState( + message = "Brak dostępu do mikrofonu. Możesz nadal wpisać uwagę ręcznie.", + isError = true, + ) + } + } + + LaunchedEffect(dictationState.isListening) { + onListeningChanged(dictationState.isListening) + } + + DisposableEffect(controller) { + onDispose { + onListeningChanged(false) + controller.destroy() + } + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedTextField( + value = value, + onValueChange = { currentOnValueChange(normalizeRouteStageNotes(it)) }, + label = { Text("Uwagi do $stageLabel (opcjonalnie)") }, + placeholder = { Text("Np. oczekiwanie, uszkodzenie, problem z dokumentami…") }, + supportingText = { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Zapisujemy tylko tekst uwagi.") + Text("${value.length}/$routeStageNotesMaxLength") + } + }, + readOnly = dictationState.isListening, + enabled = enabled, + minLines = 3, + maxLines = 6, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedButton( + onClick = { + if (dictationState.isListening) { + controller.stop() + } else if ( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) { + startDictation() + } else { + microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + }, + enabled = enabled, + modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp), + ) { + Icon( + imageVector = if (dictationState.isListening) Icons.Outlined.StopCircle else Icons.Outlined.Mic, + contentDescription = null, + ) + Text( + text = if (dictationState.isListening) "Zakończ dyktowanie" else "Dyktuj uwagę", + modifier = Modifier.padding(start = 10.dp), + fontWeight = FontWeight.Bold, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (dictationState.isListening) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = TppTheme.colors.forest, + ) + } + Text( + text = dictationState.message, + color = if (dictationState.isError) Color(0xFFB42318) else TppTheme.colors.muted, + ) + } + } +} + +private class AndroidLiveVoiceDictation( + private val context: Context, +) : RecognitionListener { + private var recognizer: SpeechRecognizer? = null + private var prefix: String = "" + private var onText: ((String) -> Unit)? = null + private var onState: ((VoiceDictationUiState) -> Unit)? = null + private var listening = false + + fun start( + existingText: String, + onText: (String) -> Unit, + onState: (VoiceDictationUiState) -> Unit, + ) { + if (! SpeechRecognizer.isRecognitionAvailable(context)) { + onState( + VoiceDictationUiState( + message = "Na tym telefonie nie ma usługi rozpoznawania mowy. Wpisz uwagę ręcznie.", + isError = true, + ), + ) + return + } + + this.prefix = existingText + this.onText = onText + this.onState = onState + + if (recognizer == null) { + recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { + it.setRecognitionListener(this) + } + } + + listening = true + emitState("Uruchamiam mikrofon…") + runCatching { + recognizer?.startListening(recognitionIntent()) + }.onFailure { + listening = false + emitState("Nie udało się uruchomić dyktowania. Spróbuj ponownie.", isError = true) + } + } + + fun stop() { + if (! listening) return + emitState("Kończę i sprawdzam rozpoznany tekst…") + recognizer?.stopListening() + } + + fun destroy() { + listening = false + onText = null + onState = null + recognizer?.cancel() + recognizer?.destroy() + recognizer = null + } + + override fun onReadyForSpeech(params: Bundle?) { + emitState("Słucham… Mów normalnie. Rozpoznany tekst pojawia się wyżej na żywo.") + } + + override fun onBeginningOfSpeech() { + emitState("Rozpoznaję mowę na żywo…") + } + + override fun onRmsChanged(rmsdB: Float) = Unit + + override fun onBufferReceived(buffer: ByteArray?) = Unit + + override fun onEndOfSpeech() { + emitState("Sprawdzam rozpoznany tekst…") + } + + override fun onError(error: Int) { + listening = false + emitState(speechRecognitionErrorMessage(error), isError = true) + } + + override fun onResults(results: Bundle?) { + val recognized = bestRecognitionResult(results) + if (recognized.isNotBlank()) { + onText?.invoke(mergeDictationText(prefix, recognized)) + } + listening = false + emitState( + if (recognized.isBlank()) { + "Nie wykryto tekstu. Naciśnij „Dyktuj uwagę” i spróbuj ponownie." + } else { + "Rozpoznano: „${recognized.take(160)}”. Możesz teraz poprawić tekst." + }, + isError = recognized.isBlank(), + ) + } + + override fun onPartialResults(partialResults: Bundle?) { + val recognized = bestRecognitionResult(partialResults) + if (recognized.isBlank()) return + + onText?.invoke(mergeDictationText(prefix, recognized)) + emitState("Na żywo: „${recognized.take(160)}”") + } + + override fun onEvent(eventType: Int, params: Bundle?) = Unit + + private fun recognitionIntent(): Intent = + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_LANGUAGE, "pl-PL") + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) + putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, false) + putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + putExtra( + RecognizerIntent.EXTRA_ENABLE_FORMATTING, + RecognizerIntent.FORMATTING_OPTIMIZE_LATENCY, + ) + putExtra(RecognizerIntent.EXTRA_HIDE_PARTIAL_TRAILING_PUNCTUATION, true) + } + } + + private fun bestRecognitionResult(bundle: Bundle?): String = + bundle + ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + ?.firstOrNull() + .orEmpty() + .trim() + + private fun emitState(message: String, isError: Boolean = false) { + onState?.invoke( + VoiceDictationUiState( + isListening = listening, + message = message, + isError = isError, + ), + ) + } +} + +internal fun speechRecognitionErrorMessage(error: Int): String = when (error) { + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> + "Brak dostępu do mikrofonu. Możesz wpisać uwagę ręcznie." + SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> + "Nie usłyszałem mowy. Naciśnij „Dyktuj uwagę” i spróbuj ponownie." + SpeechRecognizer.ERROR_NO_MATCH -> + "Nie rozpoznano słów. Mów wyraźnie i spróbuj ponownie." + SpeechRecognizer.ERROR_NETWORK, + SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> + "Rozpoznawanie mowy nie ma teraz połączenia. Spróbuj ponownie lub wpisz uwagę." + SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> + "Mikrofon jest teraz zajęty. Odczekaj chwilę i spróbuj ponownie." + SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED, + SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> + "Polskie rozpoznawanie mowy nie jest dostępne na tym telefonie." + else -> + "Nie udało się rozpoznać mowy. Spróbuj ponownie lub wpisz uwagę ręcznie." +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt index e0e5b90..f75e6a1 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt @@ -17,11 +17,19 @@ class DriverRouteProjectionTest { fun pendingStartProjectsPlannedRouteAsActiveWithLoadingWeight() { val projection = projectDriverRoute( route = route(status = "ZAPLANOWANA"), - actions = listOf(action(RouteActionType.Start, RouteActionStatus.Pending, weight = 12.5)), + actions = listOf( + action( + RouteActionType.Start, + RouteActionStatus.Pending, + weight = 12.5, + notes = "Oczekiwanie na dokument WZ.", + ), + ), ) assertEquals("W TRAKCIE", projection.route.status) assertEquals(12.5, projection.route.loadingWeight) + assertEquals("Oczekiwanie na dokument WZ.", projection.route.loadingNotes) assertEquals("active", projection.route.trackingStatus) assertTrue(projection.loadingWeightPending) assertFalse(projection.unloadingWeightPending) @@ -32,11 +40,20 @@ class DriverRouteProjectionTest { fun pendingFinishProjectsActiveRouteAsFinishedWithUnloadingWeight() { val projection = projectDriverRoute( route = route(status = "W TRAKCIE", loadingWeight = 12.5), - actions = listOf(action(RouteActionType.Finish, RouteActionStatus.Syncing, weight = 11.8, occurredAt = "2026-06-30T09:10:00Z")), + actions = listOf( + action( + RouteActionType.Finish, + RouteActionStatus.Syncing, + weight = 11.8, + notes = "Uszkodzona plomba.", + occurredAt = "2026-06-30T09:10:00Z", + ), + ), ) assertEquals("ZAKOŃCZONA", projection.route.status) assertEquals(11.8, projection.route.unloadingWeight) + assertEquals("Uszkodzona plomba.", projection.route.unloadingNotes) assertEquals("finished", projection.route.trackingStatus) assertEquals("2026-06-30T09:10:00Z", projection.route.completedAt) assertTrue(projection.unloadingWeightPending) @@ -174,6 +191,7 @@ class DriverRouteProjectionTest { type: String, status: String, weight: Double, + notes: String? = null, occurredAt: String = "2026-06-30T07:40:00Z", createdAt: Long = 1000, ): RouteActionEntity = @@ -186,6 +204,7 @@ class DriverRouteProjectionTest { photoClientRequestIdsJson = "[]", status = status, createdAtEpochMillis = createdAt, + notes = notes, ) private companion object { diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/LiveVoiceDictationTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/LiveVoiceDictationTest.kt new file mode 100644 index 0000000..0a75b6e --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/LiveVoiceDictationTest.kt @@ -0,0 +1,30 @@ +package pl.firmatpp.kierowca.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LiveVoiceDictationTest { + @Test + fun recognizedTextIsAppendedToExistingTypedNote() { + assertEquals( + "Oczekiwanie na dokumenty kierowca odebrał WZ", + mergeDictationText("Oczekiwanie na dokumenty ", " kierowca odebrał WZ "), + ) + } + + @Test + fun recognizedTextCanStartAnEmptyNote() { + assertEquals( + "Uszkodzona plomba", + mergeDictationText("", " Uszkodzona plomba "), + ) + } + + @Test + fun routeNotesAreLimitedToApiMaximum() { + assertEquals( + routeStageNotesMaxLength, + normalizeRouteStageNotes("a".repeat(routeStageNotesMaxLength + 20)).length, + ) + } +}