Compare commits

..
2 Commits
Author SHA1 Message Date
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
9 changed files with 271 additions and 59 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 8 versionCode = 10
versionName = "1.0.7" versionName = "1.0.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"") 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"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" /> <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 <application
android:allowBackup="false" android:allowBackup="false"
@@ -36,15 +36,30 @@ class DriverRepository(
suspend fun route(routeId: String): RouteResponse = suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId) 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 resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "image/jpeg" val mimeType = resolver.getType(uri) ?: "image/jpeg"
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } ?: error("Nie mozna odczytac zdjecia.") val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } ?: error("Nie mozna odczytac zdjecia.")
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull()) val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val photo = MultipartBody.Part.createFormData("photo", "ladunek.jpg", body) val photo = MultipartBody.Part.createFormData("photo", "ladunek.jpg", body)
val sourceBody = source.toRequestBody("text/plain".toMediaTypeOrNull()) 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 logout() { 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())
@@ -47,5 +47,9 @@ interface MobileDriverApi {
@Path("routeId") routeId: String, @Path("routeId") routeId: String,
@Part photo: MultipartBody.Part, @Part photo: MultipartBody.Part,
@Part("source") source: RequestBody, @Part("source") source: RequestBody,
@Part("takenAt") takenAt: RequestBody?,
@Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?,
@Part("locationAccuracyMeters") locationAccuracyMeters: RequestBody?,
): PhotoUploadResponse ): PhotoUploadResponse
} }
@@ -69,15 +69,25 @@ data class DriverRouteDto(
val distanceKm: Double?, val distanceKm: Double?,
val notes: String?, val notes: String?,
val truck: TruckDto?, val truck: TruckDto?,
val originNavigation: NavigationPointDto? = null,
val destinationNavigation: NavigationPointDto? = null,
val photos: List<RoutePhotoDto> = emptyList(), val photos: List<RoutePhotoDto> = emptyList(),
) )
data class TruckDto( data class TruckDto(
val id: String, val id: String,
val registration: String?, val registration: String?,
val trailerRegistration: String? = null,
val name: String?, val name: String?,
) )
data class NavigationPointDto(
val id: String,
val label: String?,
val latitude: Double?,
val longitude: Double?,
)
data class RoutePhotoDto( data class RoutePhotoDto(
val id: String, val id: String,
val routeId: String, val routeId: String,
@@ -85,6 +95,10 @@ data class RoutePhotoDto(
val mimeType: String?, val mimeType: String?,
val size: Long, val size: Long,
val url: String, val url: String,
val takenAt: String?,
val latitude: Double?,
val longitude: Double?,
val locationAccuracyMeters: Double?,
val createdAt: String?, val createdAt: String?,
) )
@@ -7,6 +7,8 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
@@ -51,6 +53,7 @@ import androidx.compose.material.icons.outlined.CalendarToday
import androidx.compose.material.icons.outlined.Factory import androidx.compose.material.icons.outlined.Factory
import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.LocalShipping 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.Person
import androidx.compose.material.icons.outlined.Phone import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
@@ -103,13 +106,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.CommonStatusCodes
import com.google.android.gms.common.api.Status import com.google.android.gms.common.api.Status
import java.io.File import java.io.File
import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.math.ceil import kotlin.math.ceil
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.util.Locale import java.util.Locale
import pl.firmatpp.kierowca.R import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DriverRouteDto 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.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.domain.OtpCodeExtractor import pl.firmatpp.kierowca.domain.OtpCodeExtractor
import pl.firmatpp.kierowca.domain.RouteDisplayMapper import pl.firmatpp.kierowca.domain.RouteDisplayMapper
@@ -164,7 +170,7 @@ fun DriverApp(viewModel: DriverViewModel) {
onRoute = viewModel::openRoute, onRoute = viewModel::openRoute,
) )
DriverScreen.Profile -> ProfileScreen(state, viewModel::refreshRoutes, viewModel::openProfile, viewModel::logout) 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) DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
} }
@@ -562,26 +568,30 @@ private fun routeStatusColor(status: String): Color {
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable @Composable
private fun DetailScreen( private fun DetailScreen(
state: DriverUiState, state: DriverUiState,
onBack: () -> Unit, onBack: () -> Unit,
onUpload: (Uri, String) -> Unit, onUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
onPhoto: (RoutePhotoDto) -> Unit, onPhoto: (RoutePhotoDto) -> Unit,
onRefresh: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
var cameraUri by remember { mutableStateOf<Uri?>(null) } var cameraUri by remember { mutableStateOf<Uri?>(null) }
val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
val capturedUri = cameraUri 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 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 -> val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) onUpload(uri, "gallery") if (uri != null) onUpload(uri, "gallery", PhotoUploadMetadata())
} }
val route = state.selectedRoute val route = state.selectedRoute
@@ -592,33 +602,43 @@ private fun DetailScreen(
containerColor = TppColors.Surface, containerColor = TppColors.Surface,
) { padding -> ) { padding ->
if (route == null) return@Scaffold if (route == null) return@Scaffold
LazyColumn( Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
Modifier.fillMaxSize().padding(padding), LazyColumn(
contentPadding = PaddingValues(16.dp), Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(24.dp), contentPadding = PaddingValues(16.dp),
) { verticalArrangement = Arrangement.spacedBy(24.dp),
item { ManifestSection(route) } ) {
item { item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) }
CargoDocumentationSection( item {
photos = route.photos, CargoDocumentationSection(
imageAuthHeader = state.imageAuthHeader, photos = route.photos,
allowGalleryUploads = state.allowGalleryUploads, imageAuthHeader = state.imageAuthHeader,
onPhoto = onPhoto, allowGalleryUploads = state.allowGalleryUploads,
onCamera = { onPhoto = onPhoto,
val newUri = createCameraUri(context) onCamera = {
cameraUri = newUri val newUri = createCameraUri(context)
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { cameraUri = newUri
cameraLauncher.launch(newUri) val missingPermissions = cameraCapturePermissions(context)
} else { if (missingPermissions.isEmpty()) {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA) cameraLauncher.launch(newUri)
} } else {
}, cameraPermissionLauncher.launch(missingPermissions)
onGallery = { }
pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
}, 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 +666,7 @@ private fun DetailHeader(onBack: () -> Unit) {
} }
@Composable @Composable
private fun ManifestSection(route: DriverRouteDto) { private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointDto) -> Unit) {
val stripColor = routeStatusColor(route.status) val stripColor = routeStatusColor(route.status)
Card( Card(
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = Color.White),
@@ -669,7 +689,6 @@ private fun ManifestSection(route: DriverRouteDto) {
color = TppColors.Ink, color = TppColors.Ink,
) )
} }
RouteStartChip(route.startsAt)
} }
} }
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f)) HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f))
@@ -680,6 +699,8 @@ private fun ManifestSection(route: DriverRouteDto) {
icon = Icons.Outlined.Factory, icon = Icons.Outlined.Factory,
iconBackground = TppColors.Panel, iconBackground = TppColors.Panel,
iconTint = TppColors.ContainerGreen, iconTint = TppColors.ContainerGreen,
navigationPoint = route.originNavigation,
onNavigate = onNavigate,
) )
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.45f)) HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.45f))
RoutePointBlock( RoutePointBlock(
@@ -690,29 +711,14 @@ private fun ManifestSection(route: DriverRouteDto) {
iconBackground = TppColors.Panel.copy(alpha = 0.72f), iconBackground = TppColors.Panel.copy(alpha = 0.72f),
iconTint = TppColors.Forest, iconTint = TppColors.Forest,
framed = true, framed = true,
navigationPoint = route.destinationNavigation,
onNavigate = onNavigate,
) )
RouteFactsFooter(route) 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 @Composable
private fun RoutePointBlock( private fun RoutePointBlock(
label: String, label: String,
@@ -722,10 +728,13 @@ private fun RoutePointBlock(
iconBackground: Color, iconBackground: Color,
iconTint: Color, iconTint: Color,
framed: Boolean = false, framed: Boolean = false,
navigationPoint: NavigationPointDto? = null,
onNavigate: (NavigationPointDto) -> Unit = {},
) { ) {
Row( Row(
Modifier.fillMaxWidth().padding(20.dp), Modifier.fillMaxWidth().padding(20.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( Box(
Modifier.size(48.dp).background(iconBackground, RoundedCornerShape(24.dp)), Modifier.size(48.dp).background(iconBackground, RoundedCornerShape(24.dp)),
@@ -753,6 +762,14 @@ private fun RoutePointBlock(
Text(subtitle, color = TppColors.Muted, style = MaterialTheme.typography.bodyLarge) 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)
}
}
} }
} }
@@ -873,14 +890,21 @@ private fun EmptyPhotoState() {
private fun DriverRouteDto.truckLabel(): String { private fun DriverRouteDto.truckLabel(): String {
val registration = truck?.registration.orEmpty() val registration = truck?.registration.orEmpty()
val trailerRegistration = truck?.trailerRegistration.orEmpty()
val name = truck?.name.orEmpty() val name = truck?.name.orEmpty()
return when { val vehicle = when {
registration.isNotBlank() && name.isNotBlank() -> "$registration ($name)" registration.isNotBlank() && name.isNotBlank() -> "$registration ($name)"
registration.isNotBlank() -> registration registration.isNotBlank() -> registration
name.isNotBlank() -> name name.isNotBlank() -> name
else -> "-" else -> "-"
} }
return if (trailerRegistration.isNotBlank()) {
"$vehicle\nNaczepa: $trailerRegistration"
} else {
vehicle
}
} }
private fun DriverRouteDto.distanceLabel(): String = private fun DriverRouteDto.distanceLabel(): String =
@@ -938,6 +962,75 @@ private fun createCameraUri(context: Context): Uri {
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) 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 @Composable
private fun SmsUserConsentEffect(enabled: Boolean, onCode: (String) -> Unit) { private fun SmsUserConsentEffect(enabled: Boolean, onCode: (String) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate import java.time.LocalDate
import pl.firmatpp.kierowca.data.DriverRepository 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.DriverDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
@@ -112,13 +113,37 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(screen = DriverScreen.Detail, selectedRoute = response.route, error = null) } _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() { fun openProfile() {
_state.update { it.copy(screen = DriverScreen.Profile, error = null) } _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 val route = _state.value.selectedRoute ?: return@runLoading
repository.uploadPhoto(route.id, uri, source) repository.uploadPhoto(route.id, uri, source, metadata)
openRoute(route.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()
}
}