Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a66351a6f4 | ||
|
|
b034384ab2 | ||
|
|
621745a93c | ||
|
|
bf6e76dcbf | ||
|
|
18baec5020 | ||
|
|
2531ff2b9f | ||
|
|
983a77c9fb | ||
|
|
780463cc3a | ||
|
|
3e37a61024 | ||
|
|
d4f867bf14 |
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "pl.firmatpp.kierowca"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
versionCode = 11
|
||||
versionName = "1.0.10"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
|
||||
@@ -64,6 +64,7 @@ dependencies {
|
||||
implementation(libs.camera.view)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.compose.material.icons.extended)
|
||||
implementation("androidx.compose.material:material")
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.graphics)
|
||||
@@ -71,6 +72,8 @@ dependencies {
|
||||
implementation(libs.coroutines.android)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.play.services.auth)
|
||||
implementation(libs.play.services.auth.api.phone)
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.gson)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -30,21 +30,40 @@ class DriverRepository(
|
||||
return response.driver ?: error("Brak danych kierowcy.")
|
||||
}
|
||||
|
||||
suspend fun bootstrap(): BootstrapResponse =
|
||||
api.bootstrap(authHeader(requireToken()))
|
||||
suspend fun bootstrap(date: String? = null): BootstrapResponse =
|
||||
api.bootstrap(authHeader(requireToken()), date)
|
||||
|
||||
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,12 +10,14 @@ 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
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface MobileDriverApi {
|
||||
@POST("mobile/driver/auth/request-otp")
|
||||
@@ -28,7 +30,10 @@ interface MobileDriverApi {
|
||||
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Boolean>
|
||||
|
||||
@GET("mobile/driver/bootstrap")
|
||||
suspend fun bootstrap(@Header("Authorization") authorization: String): BootstrapResponse
|
||||
suspend fun bootstrap(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Query("date") date: String?,
|
||||
): BootstrapResponse
|
||||
|
||||
@GET("mobile/driver/routes/{routeId}")
|
||||
suspend fun route(
|
||||
@@ -43,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>
|
||||
}
|
||||
|
||||
@@ -30,6 +30,17 @@ data class DriverDto(
|
||||
data class BootstrapResponse(
|
||||
val session: DriverSessionDto,
|
||||
val routes: RoutesBucketDto,
|
||||
val driverAppSettings: DriverAppSettingsDto?,
|
||||
)
|
||||
|
||||
data class DriverAppSettingsDto(
|
||||
val minDate: String,
|
||||
val maxDate: String,
|
||||
val today: String,
|
||||
val selectedDate: String,
|
||||
val maxPastDays: Int,
|
||||
val autoRefreshSeconds: Int,
|
||||
val allowGalleryUploads: Boolean?,
|
||||
)
|
||||
|
||||
data class DriverSessionDto(
|
||||
@@ -58,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,
|
||||
@@ -74,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(
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package pl.firmatpp.kierowca.domain
|
||||
|
||||
object OtpCodeExtractor {
|
||||
private val codePattern = Regex("""(?<!\d)\d{4,10}(?!\d)""")
|
||||
|
||||
fun extract(message: String): String? =
|
||||
codePattern.find(message)?.value
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -8,20 +8,29 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
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
|
||||
|
||||
enum class DriverScreen { Phone, Otp, Routes, Detail, Photo }
|
||||
enum class DriverScreen { Phone, Otp, Routes, Profile, Detail, Photo }
|
||||
|
||||
data class DriverUiState(
|
||||
val screen: DriverScreen = DriverScreen.Phone,
|
||||
val loading: Boolean = false,
|
||||
val refreshing: Boolean = false,
|
||||
val phone: String = "",
|
||||
val otpCode: String = "",
|
||||
val maskedPhone: String = "",
|
||||
val driver: DriverDto? = null,
|
||||
val routes: List<DriverRouteDto> = emptyList(),
|
||||
val selectedDate: String = LocalDate.now().toString(),
|
||||
val minRouteDate: String = LocalDate.now().minusDays(7).toString(),
|
||||
val maxRouteDate: String = LocalDate.now().plusDays(1).toString(),
|
||||
val autoRefreshSeconds: Int = 60,
|
||||
val allowGalleryUploads: Boolean = true,
|
||||
val selectedRoute: DriverRouteDto? = null,
|
||||
val selectedPhoto: RoutePhotoDto? = null,
|
||||
val imageAuthHeader: String? = null,
|
||||
@@ -45,6 +54,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
it.copy(
|
||||
screen = DriverScreen.Otp,
|
||||
phone = phone,
|
||||
otpCode = "",
|
||||
maskedPhone = response.phoneMasked.orEmpty(),
|
||||
error = null,
|
||||
)
|
||||
@@ -57,16 +67,44 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
refreshRoutes()
|
||||
}
|
||||
|
||||
fun refreshRoutes() = runLoading {
|
||||
val response = repository.bootstrap()
|
||||
_state.update {
|
||||
it.copy(
|
||||
screen = DriverScreen.Routes,
|
||||
driver = response.session.driver,
|
||||
routes = response.routes.today,
|
||||
imageAuthHeader = repository.imageAuthHeader(),
|
||||
error = null,
|
||||
)
|
||||
fun updateOtpCode(code: String) {
|
||||
_state.update { it.copy(otpCode = code.filter(Char::isDigit).take(10)) }
|
||||
}
|
||||
|
||||
fun refreshRoutes() = loadRoutes(date = _state.value.selectedDate, showLoading = true, navigateToRoutes = true)
|
||||
|
||||
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
|
||||
|
||||
fun selectRouteDate(date: String) = loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
|
||||
|
||||
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null) }
|
||||
|
||||
runCatching {
|
||||
val response = repository.bootstrap(date)
|
||||
val settings = response.driverAppSettings
|
||||
_state.update {
|
||||
it.copy(
|
||||
screen = if (navigateToRoutes) DriverScreen.Routes else it.screen,
|
||||
driver = response.session.driver,
|
||||
routes = response.routes.today,
|
||||
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
|
||||
minRouteDate = settings?.minDate ?: it.minRouteDate,
|
||||
maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
|
||||
autoRefreshSeconds = settings?.autoRefreshSeconds ?: it.autoRefreshSeconds,
|
||||
allowGalleryUploads = settings?.allowGalleryUploads ?: it.allowGalleryUploads,
|
||||
imageAuthHeader = repository.imageAuthHeader(),
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
}.onFailure { throwable ->
|
||||
_state.update { it.copy(error = throwable.message ?: "Wystapil blad.") }
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(loading = false, refreshing = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,9 +113,43 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
_state.update { it.copy(screen = DriverScreen.Detail, selectedRoute = response.route, error = null) }
|
||||
}
|
||||
|
||||
fun uploadPhoto(uri: Uri, source: String) = runLoading {
|
||||
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, 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)
|
||||
}
|
||||
|
||||
@@ -90,6 +162,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
when (it.screen) {
|
||||
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
|
||||
DriverScreen.Detail -> it.copy(screen = DriverScreen.Routes, selectedRoute = null)
|
||||
DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes)
|
||||
DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone)
|
||||
else -> it
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1010 KiB |
@@ -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,20 @@
|
||||
package pl.firmatpp.kierowca.domain
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class OtpCodeExtractorTest {
|
||||
@Test
|
||||
fun extractsFourToTenDigitOtpFromSmsBody() {
|
||||
assertEquals("1234", OtpCodeExtractor.extract("Kod TPP: 1234"))
|
||||
assertEquals("987654", OtpCodeExtractor.extract("Twoj kod weryfikacyjny to 987654."))
|
||||
assertEquals("1234567890", OtpCodeExtractor.extract("Code 1234567890"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresNumbersThatAreTooShortOrTooLong() {
|
||||
assertNull(OtpCodeExtractor.extract("Kod 123"))
|
||||
assertNull(OtpCodeExtractor.extract("Kod 12345678901"))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ datastore = "1.1.1"
|
||||
coil = "2.7.0"
|
||||
camerax = "1.4.1"
|
||||
work = "2.10.0"
|
||||
playServicesAuth = "21.6.0"
|
||||
playServicesAuthApiPhone = "18.3.0"
|
||||
junit = "4.13.2"
|
||||
|
||||
[libraries]
|
||||
@@ -40,6 +42,8 @@ coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||
play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" }
|
||||
play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" }
|
||||
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user