Dodaj uwagi i dyktowanie na etapach trasy
This commit is contained in:
@@ -2,12 +2,19 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.speech.RecognitionService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
|
||||
@@ -278,7 +278,9 @@ data class DriverRouteDto(
|
||||
val driverStatus: String? = null,
|
||||
val operationalStatus: String? = null,
|
||||
val loadingWeight: Double? = null,
|
||||
val loadingNotes: String? = null,
|
||||
val unloadingWeight: Double? = null,
|
||||
val unloadingNotes: String? = null,
|
||||
val trackingStatus: String? = null,
|
||||
val arrivedAt: String? = null,
|
||||
val distanceKm: Double?,
|
||||
@@ -352,6 +354,7 @@ data class StartRouteBody(
|
||||
val loadingWeight: Double?,
|
||||
val occurredAt: String,
|
||||
val photoClientRequestIds: List<String>,
|
||||
val loadingNotes: String? = null,
|
||||
)
|
||||
|
||||
data class FinishRouteBody(
|
||||
@@ -359,6 +362,7 @@ data class FinishRouteBody(
|
||||
val unloadingWeight: Double?,
|
||||
val occurredAt: String,
|
||||
val photoClientRequestIds: List<String>,
|
||||
val unloadingNotes: String? = null,
|
||||
)
|
||||
|
||||
data class RouteActionResponse(
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -43,17 +43,28 @@ class RouteActionOutbox(
|
||||
enqueueWorker(clientActionId)
|
||||
}
|
||||
|
||||
suspend fun enqueueStart(routeId: String, loadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity =
|
||||
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds)
|
||||
suspend fun enqueueStart(
|
||||
routeId: String,
|
||||
loadingWeight: Double?,
|
||||
photoClientRequestIds: List<String>,
|
||||
notes: String? = null,
|
||||
): RouteActionEntity =
|
||||
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds, notes)
|
||||
|
||||
suspend fun enqueueFinish(routeId: String, unloadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity =
|
||||
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds)
|
||||
suspend fun enqueueFinish(
|
||||
routeId: String,
|
||||
unloadingWeight: Double?,
|
||||
photoClientRequestIds: List<String>,
|
||||
notes: String? = null,
|
||||
): RouteActionEntity =
|
||||
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds, notes)
|
||||
|
||||
private suspend fun enqueue(
|
||||
action: String,
|
||||
routeId: String,
|
||||
weight: Double?,
|
||||
photoClientRequestIds: List<String>,
|
||||
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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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<Uri?>(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<RoutePhotoDto>,
|
||||
uploads: List<PhotoUploadEntity>,
|
||||
deletingPhotoIds: Set<String>,
|
||||
@@ -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
|
||||
|
||||
@@ -42,6 +42,7 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
|
||||
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<RouteActionEntity>):
|
||||
status = "ZAKOŃCZONA",
|
||||
driverStatus = "ZAKOŃCZONA",
|
||||
unloadingWeight = action.weight,
|
||||
unloadingNotes = action.notes,
|
||||
trackingStatus = "finished",
|
||||
completedAt = action.occurredAt,
|
||||
photos = projectedRoute.safePhotos(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -91,6 +91,7 @@ data class DriverUiState(
|
||||
val selectedRoute: DriverRouteDto? = null,
|
||||
val selectedPhoto: RoutePhotoDto? = null,
|
||||
val routeStageWeightText: String = "",
|
||||
val routeStageNotesText: String = "",
|
||||
val routeActions: List<RouteActionEntity> = emptyList(),
|
||||
val visibleRouteActions: List<RouteActionEntity> = emptyList(),
|
||||
val photoUploads: List<PhotoUploadEntity> = 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)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user