Compare commits

...
3 Commits
Author SHA1 Message Date
admin a66351a6f4 Ogranicz zdjęcia do dzisiejszych kursów 2026-06-30 22:44:14 +02:00
admin b034384ab2 Dodaj nawigacje i odswiezanie szczegolow trasy 2026-06-30 22:16:49 +02:00
admin 621745a93c Wysylaj lokalizacje zdjec z aparatu 2026-06-30 22:10:07 +02:00
11 changed files with 372 additions and 70 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 8
versionName = "1.0.7"
versionCode = 11
versionName = "1.0.10"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
+2
View File
@@ -2,6 +2,8 @@
<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.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:allowBackup="false"
@@ -36,15 +36,34 @@ class DriverRepository(
suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId)
suspend fun uploadPhoto(routeId: String, uri: Uri, source: String): PhotoUploadResponse {
suspend fun uploadPhoto(
routeId: String,
uri: Uri,
source: String,
metadata: PhotoUploadMetadata = PhotoUploadMetadata(),
): PhotoUploadResponse {
val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "image/jpeg"
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } ?: error("Nie mozna odczytac zdjecia.")
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val photo = MultipartBody.Part.createFormData("photo", "ladunek.jpg", body)
val sourceBody = source.toRequestBody("text/plain".toMediaTypeOrNull())
val metadataParts = metadata.toMultipartTextParts()
return api.uploadPhoto(authHeader(requireToken()), routeId, photo, sourceBody)
return api.uploadPhoto(
authHeader(requireToken()),
routeId,
photo,
sourceBody,
metadataParts["takenAt"],
metadataParts["latitude"],
metadataParts["longitude"],
metadataParts["locationAccuracyMeters"],
)
}
suspend fun deletePhoto(photoId: String) {
api.deletePhoto(authHeader(requireToken()), photoId)
}
suspend fun logout() {
@@ -0,0 +1,21 @@
package pl.firmatpp.kierowca.data
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
data class PhotoUploadMetadata(
val takenAt: String? = null,
val latitude: Double? = null,
val longitude: Double? = null,
val locationAccuracyMeters: Double? = null,
)
fun PhotoUploadMetadata.toMultipartTextParts(): Map<String, RequestBody> = buildMap {
takenAt?.let { put("takenAt", it.toPlainTextBody()) }
latitude?.let { put("latitude", it.toString().toPlainTextBody()) }
longitude?.let { put("longitude", it.toString().toPlainTextBody()) }
locationAccuracyMeters?.let { put("locationAccuracyMeters", it.toString().toPlainTextBody()) }
}
private fun String.toPlainTextBody(): RequestBody = toRequestBody("text/plain".toMediaTypeOrNull())
@@ -10,6 +10,7 @@ import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.model.VerifyOtpResponse
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
@@ -47,5 +48,15 @@ interface MobileDriverApi {
@Path("routeId") routeId: String,
@Part photo: MultipartBody.Part,
@Part("source") source: RequestBody,
@Part("takenAt") takenAt: RequestBody?,
@Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?,
@Part("locationAccuracyMeters") locationAccuracyMeters: RequestBody?,
): PhotoUploadResponse
@DELETE("mobile/driver/photos/{photoId}")
suspend fun deletePhoto(
@Header("Authorization") authorization: String,
@Path("photoId") photoId: String,
): Map<String, Boolean>
}
@@ -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<RoutePhotoDto> = 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,
@@ -85,7 +95,13 @@ data class RoutePhotoDto(
val mimeType: String?,
val size: Long,
val url: String,
val takenAt: String?,
val latitude: Double?,
val longitude: Double?,
val locationAccuracyMeters: Double?,
val createdAt: String?,
val canDelete: Boolean = false,
val deleteUntil: String? = null,
)
data class PhotoUploadResponse(
@@ -7,6 +7,8 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.net.Uri
import android.os.Build
import androidx.activity.compose.BackHandler
@@ -42,6 +44,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddPhotoAlternate
@@ -49,8 +52,10 @@ import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.CalendarToday
import androidx.compose.material.icons.outlined.Factory
import androidx.compose.material.icons.outlined.Delete
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
@@ -103,13 +108,16 @@ import com.google.android.gms.auth.api.phone.SmsRetriever
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.common.api.Status
import java.io.File
import java.time.Instant
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.math.ceil
import kotlinx.coroutines.delay
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
@@ -164,7 +172,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::deletePhoto, viewModel::refreshSelectedRoute)
DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
}
@@ -345,8 +353,16 @@ private fun RouteDateSelector(selectedDate: String, minDate: String, maxDate: St
}.toList()
}.getOrElse { listOf(LocalDate.parse(selectedDate)) }
}
val listState = rememberLazyListState()
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
LaunchedEffect(dates, selectedDate) {
val selectedIndex = dates.indexOfFirst { it.toString() == selectedDate }
if (selectedIndex >= 0) {
listState.scrollToItem(selectedIndex)
}
}
LazyRow(state = listState, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(dates, key = { it.toString() }) { date ->
DateSelectorChip(
date = date,
@@ -562,28 +578,34 @@ 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) -> Unit,
onUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit,
onRefresh: () -> Unit,
) {
val context = LocalContext.current
var cameraUri by remember { mutableStateOf<Uri?>(null) }
val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
val capturedUri = cameraUri
if (ok && capturedUri != null) onUpload(capturedUri, "camera")
if (ok && capturedUri != null) onUpload(capturedUri, "camera", cameraPhotoMetadata(context))
cameraUri = null
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
val pendingUri = cameraUri
if (granted && pendingUri != null) cameraLauncher.launch(pendingUri)
val cameraGranted = grants[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA)
if (cameraGranted && pendingUri != null) cameraLauncher.launch(pendingUri)
}
val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) onUpload(uri, "gallery")
if (uri != null) onUpload(uri, "gallery", PhotoUploadMetadata())
}
val route = state.selectedRoute
val canManagePhotos = canManageRoutePhotos(state.selectedDate)
Scaffold(
topBar = {
@@ -592,33 +614,45 @@ 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
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
cameraLauncher.launch(newUri)
} else {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
},
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,
canManagePhotos = canManagePhotos,
onPhoto = onPhoto,
onDeletePhoto = onDeletePhoto,
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,
)
}
}
}
@@ -646,7 +680,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),
@@ -669,7 +703,6 @@ private fun ManifestSection(route: DriverRouteDto) {
color = TppColors.Ink,
)
}
RouteStartChip(route.startsAt)
}
}
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f))
@@ -680,6 +713,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(
@@ -690,29 +725,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,
@@ -722,10 +742,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)),
@@ -753,6 +776,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)
}
}
}
}
@@ -783,7 +814,9 @@ private fun CargoDocumentationSection(
photos: List<RoutePhotoDto>,
imageAuthHeader: String?,
allowGalleryUploads: Boolean,
canManagePhotos: Boolean,
onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit,
onCamera: () -> Unit,
onGallery: () -> Unit,
) {
@@ -794,12 +827,18 @@ private fun CargoDocumentationSection(
Box(Modifier.width(116.dp).height(3.dp).background(TppColors.ContainerGreen))
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (allowGalleryUploads) {
CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppColors.ContainerGreen, onGallery)
if (canManagePhotos) {
if (allowGalleryUploads) {
CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppColors.ContainerGreen, onGallery)
}
CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppColors.Forest, onCamera)
} else {
Box(Modifier.fillMaxWidth().background(TppColors.Panel, RoundedCornerShape(4.dp)).padding(14.dp)) {
Text("Zdjęcia można dodawać tylko dla dzisiejszych kursów.", color = TppColors.Muted, fontFamily = FontFamily.Monospace)
}
}
CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppColors.Forest, onCamera)
}
PhotoGrid(photos, imageAuthHeader, onPhoto)
PhotoGrid(photos, imageAuthHeader, onPhoto, onDeletePhoto)
}
}
@@ -822,6 +861,7 @@ private fun PhotoGrid(
photos: List<RoutePhotoDto>,
imageAuthHeader: String?,
onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit,
) {
val rows = ceil((photos.size.coerceAtLeast(1) / 2f).toDouble()).toInt()
val gridHeight = (rows * 172).coerceIn(172, 520).dp
@@ -848,12 +888,31 @@ private fun PhotoGrid(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(photos, key = { it.id }) { photo ->
AsyncImage(
model = imageRequest(photo.url, imageAuthHeader),
contentDescription = "Zdjecie ladunku",
contentScale = ContentScale.Crop,
modifier = Modifier.aspectRatio(1f).clickable { onPhoto(photo) },
)
Box(Modifier.aspectRatio(1f)) {
AsyncImage(
model = imageRequest(photo.url, imageAuthHeader),
contentDescription = "Zdjecie ladunku",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clickable { onPhoto(photo) },
)
if (photo.canDelete) {
IconButton(
onClick = { onDeletePhoto(photo) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.size(34.dp)
.background(TppColors.Error, RoundedCornerShape(17.dp)),
) {
Icon(
Icons.Outlined.Delete,
contentDescription = "Usuń zdjęcie",
tint = Color.White,
modifier = Modifier.size(18.dp),
)
}
}
}
}
}
}
@@ -873,14 +932,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 =
@@ -938,6 +1004,75 @@ 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<String> = buildList {
if (!hasPermission(context, Manifest.permission.CAMERA)) {
add(Manifest.permission.CAMERA)
}
if (!hasLocationPermission(context)) {
add(Manifest.permission.ACCESS_FINE_LOCATION)
add(Manifest.permission.ACCESS_COARSE_LOCATION)
}
}.toTypedArray()
private fun cameraPhotoMetadata(context: Context): PhotoUploadMetadata {
val location = bestLastKnownLocation(context)
return PhotoUploadMetadata(
takenAt = Instant.now().toString(),
latitude = location?.latitude,
longitude = location?.longitude,
locationAccuracyMeters = location?.accuracy?.toDouble(),
)
}
private fun hasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
private fun hasLocationPermission(context: Context): Boolean =
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private fun bestLastKnownLocation(context: Context): Location? {
if (!hasLocationPermission(context)) return null
val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null
val providers = listOf(
LocationManager.GPS_PROVIDER,
LocationManager.NETWORK_PROVIDER,
LocationManager.PASSIVE_PROVIDER,
)
return providers.mapNotNull { provider ->
runCatching {
if (manager.isProviderEnabled(provider) || provider == LocationManager.PASSIVE_PROVIDER) {
manager.getLastKnownLocation(provider)
} else {
null
}
}.getOrNull()
}.maxByOrNull { it.time }
}
@Composable
private fun SmsUserConsentEffect(enabled: Boolean, onCode: (String) -> Unit) {
val context = LocalContext.current
@@ -0,0 +1,6 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalDate
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
@@ -112,13 +113,43 @@ 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) }
}
fun uploadPhoto(uri: Uri, source: String) = runLoading {
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) = runLoading {
val route = _state.value.selectedRoute ?: return@runLoading
repository.uploadPhoto(route.id, uri, source)
repository.uploadPhoto(route.id, uri, source, metadata)
openRoute(route.id)
}
fun deletePhoto(photo: RoutePhotoDto) = runLoading {
val route = _state.value.selectedRoute ?: return@runLoading
repository.deletePhoto(photo.id)
openRoute(route.id)
}
@@ -0,0 +1,38 @@
package pl.firmatpp.kierowca.data
import okio.Buffer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class PhotoUploadMetadataTest {
@Test
fun convertsPresentCameraMetadataToMultipartTextParts() {
val parts = PhotoUploadMetadata(
takenAt = "2026-06-30T07:38:12Z",
latitude = 50.0619474,
longitude = 19.9368564,
locationAccuracyMeters = 8.5,
).toMultipartTextParts()
assertEquals("2026-06-30T07:38:12Z", parts.getValue("takenAt").readUtf8())
assertEquals("50.0619474", parts.getValue("latitude").readUtf8())
assertEquals("19.9368564", parts.getValue("longitude").readUtf8())
assertEquals("8.5", parts.getValue("locationAccuracyMeters").readUtf8())
}
@Test
fun skipsLocationPartsWhenMetadataIsEmpty() {
val parts = PhotoUploadMetadata().toMultipartTextParts()
assertFalse(parts.containsKey("latitude"))
assertFalse(parts.containsKey("longitude"))
assertFalse(parts.containsKey("locationAccuracyMeters"))
}
private fun okhttp3.RequestBody.readUtf8(): String {
val buffer = Buffer()
writeTo(buffer)
return buffer.readUtf8()
}
}
@@ -0,0 +1,23 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DriverUiRulesTest {
private val today = LocalDate.parse("2026-06-30")
@Test
fun allowsPhotoActionsOnlyForTodayRouteDate() {
assertTrue(canManageRoutePhotos("2026-06-30", today))
assertFalse(canManageRoutePhotos("2026-06-29", today))
assertFalse(canManageRoutePhotos("2026-07-01", today))
}
@Test
fun blocksPhotoActionsWhenDateIsInvalid() {
assertFalse(canManageRoutePhotos("", today))
assertFalse(canManageRoutePhotos("not-a-date", today))
}
}