diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 42719a8..9884656 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "pl.firmatpp.kierowca" minSdk = 26 targetSdk = 35 - versionCode = 9 - versionName = "1.0.8" + versionCode = 10 + versionName = "1.0.9" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"") diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt index 5114807..21006e9 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt @@ -69,15 +69,25 @@ data class DriverRouteDto( val distanceKm: Double?, val notes: String?, val truck: TruckDto?, + val originNavigation: NavigationPointDto? = null, + val destinationNavigation: NavigationPointDto? = null, val photos: List = emptyList(), ) data class TruckDto( val id: String, val registration: String?, + val trailerRegistration: String? = null, val name: String?, ) +data class NavigationPointDto( + val id: String, + val label: String?, + val latitude: Double?, + val longitude: Double?, +) + data class RoutePhotoDto( val id: String, val routeId: String, 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 3dcabfc..57f4df5 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -53,6 +53,7 @@ import androidx.compose.material.icons.outlined.CalendarToday import androidx.compose.material.icons.outlined.Factory import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.LocalShipping +import androidx.compose.material.icons.outlined.Navigation import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Phone import androidx.compose.material.ExperimentalMaterialApi @@ -114,6 +115,7 @@ import java.util.Locale import pl.firmatpp.kierowca.R import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.model.DriverRouteDto +import pl.firmatpp.kierowca.data.model.NavigationPointDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.domain.OtpCodeExtractor import pl.firmatpp.kierowca.domain.RouteDisplayMapper @@ -168,7 +170,7 @@ fun DriverApp(viewModel: DriverViewModel) { onRoute = viewModel::openRoute, ) DriverScreen.Profile -> ProfileScreen(state, viewModel::refreshRoutes, viewModel::openProfile, viewModel::logout) - DriverScreen.Detail -> DetailScreen(state, viewModel::back, viewModel::uploadPhoto, viewModel::openPhoto) + DriverScreen.Detail -> DetailScreen(state, viewModel::back, viewModel::uploadPhoto, viewModel::openPhoto, viewModel::refreshSelectedRoute) DriverScreen.Photo -> PhotoScreen(state, viewModel::back) } @@ -566,16 +568,18 @@ private fun routeStatusColor(status: String): Color { } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable private fun DetailScreen( state: DriverUiState, onBack: () -> Unit, onUpload: (Uri, String, PhotoUploadMetadata) -> Unit, onPhoto: (RoutePhotoDto) -> Unit, + onRefresh: () -> Unit, ) { val context = LocalContext.current var cameraUri by remember { mutableStateOf(null) } + val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh) val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> val capturedUri = cameraUri if (ok && capturedUri != null) onUpload(capturedUri, "camera", cameraPhotoMetadata(context)) @@ -598,34 +602,43 @@ private fun DetailScreen( containerColor = TppColors.Surface, ) { padding -> if (route == null) return@Scaffold - LazyColumn( - Modifier.fillMaxSize().padding(padding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - item { ManifestSection(route) } - item { - CargoDocumentationSection( - photos = route.photos, - imageAuthHeader = state.imageAuthHeader, - allowGalleryUploads = state.allowGalleryUploads, - onPhoto = onPhoto, - onCamera = { - val newUri = createCameraUri(context) - cameraUri = newUri - val missingPermissions = cameraCapturePermissions(context) - if (missingPermissions.isEmpty()) { - cameraLauncher.launch(newUri) - } else { - cameraPermissionLauncher.launch(missingPermissions) - } - }, - onGallery = { - pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) - }, - ) + Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) } + item { + CargoDocumentationSection( + photos = route.photos, + imageAuthHeader = state.imageAuthHeader, + allowGalleryUploads = state.allowGalleryUploads, + onPhoto = onPhoto, + onCamera = { + val newUri = createCameraUri(context) + cameraUri = newUri + val missingPermissions = cameraCapturePermissions(context) + if (missingPermissions.isEmpty()) { + cameraLauncher.launch(newUri) + } else { + cameraPermissionLauncher.launch(missingPermissions) + } + }, + onGallery = { + pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + ) + } + item { ErrorText(state.error) } } - item { ErrorText(state.error) } + PullRefreshIndicator( + refreshing = state.refreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + backgroundColor = Color.White, + contentColor = TppColors.Forest, + ) } } } @@ -653,7 +666,7 @@ private fun DetailHeader(onBack: () -> Unit) { } @Composable -private fun ManifestSection(route: DriverRouteDto) { +private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointDto) -> Unit) { val stripColor = routeStatusColor(route.status) Card( colors = CardDefaults.cardColors(containerColor = Color.White), @@ -676,7 +689,6 @@ private fun ManifestSection(route: DriverRouteDto) { color = TppColors.Ink, ) } - RouteStartChip(route.startsAt) } } HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f)) @@ -687,6 +699,8 @@ private fun ManifestSection(route: DriverRouteDto) { icon = Icons.Outlined.Factory, iconBackground = TppColors.Panel, iconTint = TppColors.ContainerGreen, + navigationPoint = route.originNavigation, + onNavigate = onNavigate, ) HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.45f)) RoutePointBlock( @@ -697,29 +711,14 @@ private fun ManifestSection(route: DriverRouteDto) { iconBackground = TppColors.Panel.copy(alpha = 0.72f), iconTint = TppColors.Forest, framed = true, + navigationPoint = route.destinationNavigation, + onNavigate = onNavigate, ) RouteFactsFooter(route) } } } -@Composable -private fun RouteStartChip(startsAt: String) { - Row( - modifier = Modifier.background(TppColors.Panel, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppColors.Muted, modifier = Modifier.size(18.dp)) - Text( - startsAt.ifBlank { "Dziƛ" }, - color = TppColors.Muted, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, - ) - } -} - @Composable private fun RoutePointBlock( label: String, @@ -729,10 +728,13 @@ private fun RoutePointBlock( iconBackground: Color, iconTint: Color, framed: Boolean = false, + navigationPoint: NavigationPointDto? = null, + onNavigate: (NavigationPointDto) -> Unit = {}, ) { Row( Modifier.fillMaxWidth().padding(20.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { Box( Modifier.size(48.dp).background(iconBackground, RoundedCornerShape(24.dp)), @@ -760,6 +762,14 @@ private fun RoutePointBlock( Text(subtitle, color = TppColors.Muted, style = MaterialTheme.typography.bodyLarge) } } + if (navigationPoint?.hasCoordinates() == true) { + IconButton( + onClick = { onNavigate(navigationPoint) }, + modifier = Modifier.size(48.dp).background(TppColors.Forest, RoundedCornerShape(24.dp)), + ) { + Icon(Icons.Outlined.Navigation, contentDescription = "Nawiguj", tint = Color.White) + } + } } } @@ -880,14 +890,21 @@ private fun EmptyPhotoState() { private fun DriverRouteDto.truckLabel(): String { val registration = truck?.registration.orEmpty() + val trailerRegistration = truck?.trailerRegistration.orEmpty() val name = truck?.name.orEmpty() - return when { + val vehicle = when { registration.isNotBlank() && name.isNotBlank() -> "$registration ($name)" registration.isNotBlank() -> registration name.isNotBlank() -> name else -> "-" } + + return if (trailerRegistration.isNotBlank()) { + "$vehicle\nNaczepa: $trailerRegistration" + } else { + vehicle + } } private fun DriverRouteDto.distanceLabel(): String = @@ -945,6 +962,26 @@ private fun createCameraUri(context: Context): Uri { return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) } +private fun NavigationPointDto.hasCoordinates(): Boolean = latitude != null && longitude != null + +private fun openNavigation(context: Context, point: NavigationPointDto) { + val latitude = point.latitude ?: return + val longitude = point.longitude ?: return + val googleMapsIntent = Intent(Intent.ACTION_VIEW, Uri.parse("google.navigation:q=$latitude,$longitude")) + .setPackage("com.google.android.apps.maps") + + if (googleMapsIntent.resolveActivity(context.packageManager) != null) { + context.startActivity(googleMapsIntent) + return + } + + val label = Uri.encode(point.label?.takeIf { it.isNotBlank() } ?: "Cel") + val geoIntent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$latitude,$longitude($label)")) + if (geoIntent.resolveActivity(context.packageManager) != null) { + context.startActivity(geoIntent) + } +} + private fun cameraCapturePermissions(context: Context): Array = buildList { if (!hasPermission(context, Manifest.permission.CAMERA)) { add(Manifest.permission.CAMERA) 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 135ce5b..96f1c1a 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -113,6 +113,30 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(screen = DriverScreen.Detail, selectedRoute = response.route, error = null) } } + fun refreshSelectedRoute() { + val routeId = _state.value.selectedRoute?.id ?: return + + viewModelScope.launch { + _state.update { it.copy(refreshing = true, error = null) } + + runCatching { repository.route(routeId) } + .onSuccess { response -> + _state.update { + it.copy( + selectedRoute = response.route, + routes = it.routes.map { route -> if (route.id == routeId) response.route else route }, + error = null, + ) + } + } + .onFailure { throwable -> + _state.update { it.copy(error = throwable.message ?: "Wystapil blad.") } + } + + _state.update { it.copy(refreshing = false) } + } + } + fun openProfile() { _state.update { it.copy(screen = DriverScreen.Profile, error = null) } }