Compare commits

..
9 Commits
17 changed files with 1056 additions and 147 deletions
+7 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 4
versionName = "1.0.3"
versionCode = 12
versionName = "1.0.11"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
@@ -45,6 +45,10 @@ android {
compose = true
buildConfig = true
}
lint {
disable += "NullSafeMutableLiveData"
}
}
dependencies {
@@ -64,6 +68,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)
+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"
@@ -0,0 +1,38 @@
package pl.firmatpp.kierowca.data
import android.content.Context
import android.os.Build
import android.provider.Settings
import pl.firmatpp.kierowca.BuildConfig
import pl.firmatpp.kierowca.data.model.DriverDeviceInfo
class DeviceInfoProvider(
private val context: Context,
private val tokenStore: TokenStore,
) {
suspend fun currentDeviceInfo(): DriverDeviceInfo =
DriverDeviceInfo(
deviceId = tokenStore.deviceId(),
androidId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID),
brand = Build.BRAND,
manufacturer = Build.MANUFACTURER,
model = Build.MODEL,
device = Build.DEVICE,
product = Build.PRODUCT,
androidVersion = Build.VERSION.RELEASE,
sdkInt = Build.VERSION.SDK_INT,
appVersion = BuildConfig.VERSION_NAME,
serialNumber = readSerialNumber(),
)
private fun readSerialNumber(): String? =
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Build.getSerial()
} else {
@Suppress("DEPRECATION")
Build.SERIAL
}
}.getOrNull()
?.takeUnless { it.isBlank() || it.equals(Build.UNKNOWN, ignoreCase = true) }
}
@@ -19,32 +19,52 @@ class DriverRepository(
private val context: Context,
private val api: MobileDriverApi = ApiFactory.mobileDriverApi(),
private val tokenStore: TokenStore = TokenStore(context),
private val deviceInfoProvider: DeviceInfoProvider = DeviceInfoProvider(context, tokenStore),
) {
suspend fun requestOtp(phoneNumber: String): OtpResponse =
api.requestOtp(RequestOtpBody(phoneNumber))
suspend fun verifyOtp(phoneNumber: String, code: String, deviceName: String): DriverDto {
val response = api.verifyOtp(VerifyOtpBody(phoneNumber, code, deviceName))
val response = api.verifyOtp(VerifyOtpBody(phoneNumber, code, deviceName, deviceInfoProvider.currentDeviceInfo()))
val token = response.accessToken ?: error("Brak tokenu sesji.")
tokenStore.save(token)
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())
@@ -6,11 +6,13 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.util.UUID
private val Context.driverDataStore by preferencesDataStore(name = "driver_session")
class TokenStore(private val context: Context) {
private val tokenKey = stringPreferencesKey("access_token")
private val deviceIdKey = stringPreferencesKey("device_id")
suspend fun save(token: String) {
context.driverDataStore.edit { preferences ->
@@ -22,6 +24,21 @@ class TokenStore(private val context: Context) {
.map { it[tokenKey] }
.first()
suspend fun deviceId(): String {
val existing = context.driverDataStore.data
.map { it[deviceIdKey] }
.first()
if (!existing.isNullOrBlank()) return existing
val generated = UUID.randomUUID().toString()
context.driverDataStore.edit { preferences ->
preferences[deviceIdKey] = generated
}
return generated
}
suspend fun clear() {
context.driverDataStore.edit { it.remove(tokenKey) }
}
@@ -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>
}
@@ -6,6 +6,21 @@ data class VerifyOtpBody(
val phoneNumber: String,
val code: String,
val deviceName: String,
val device: DriverDeviceInfo,
)
data class DriverDeviceInfo(
val deviceId: String,
val androidId: String?,
val brand: String?,
val manufacturer: String?,
val model: String?,
val device: String?,
val product: String?,
val androidVersion: String?,
val sdkInt: Int,
val appVersion: String?,
val serialNumber: String?,
)
data class OtpResponse(
@@ -30,6 +45,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 +84,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 +110,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,13 +7,17 @@ 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
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -35,21 +39,29 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
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
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.Menu
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.icons.outlined.Refresh
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -67,6 +79,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -78,6 +91,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
@@ -85,16 +99,25 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import coil.compose.AsyncImage
import coil.request.ImageRequest
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
@@ -103,6 +126,35 @@ import pl.firmatpp.kierowca.ui.theme.TppColors
@Composable
fun DriverApp(viewModel: DriverViewModel) {
val state by viewModel.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
var appInForeground by remember { mutableStateOf(true) }
BackHandler(enabled = state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) {
viewModel.back()
}
DisposableEffect(lifecycleOwner, state.screen) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
appInForeground = true
if (state.screen == DriverScreen.Routes) viewModel.refreshRoutesSilently()
}
Lifecycle.Event.ON_STOP -> appInForeground = false
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
LaunchedEffect(state.screen, state.selectedDate, state.autoRefreshSeconds, appInForeground) {
if (state.screen != DriverScreen.Routes || !appInForeground) return@LaunchedEffect
val intervalMillis = state.autoRefreshSeconds.coerceIn(30, 600) * 1000L
while (true) {
delay(intervalMillis)
viewModel.refreshRoutesSilently()
}
}
SmsUserConsentEffect(
enabled = state.screen == DriverScreen.Phone || state.screen == DriverScreen.Otp,
onCode = viewModel::updateOtpCode,
@@ -112,8 +164,15 @@ fun DriverApp(viewModel: DriverViewModel) {
when (state.screen) {
DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp)
DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back)
DriverScreen.Routes -> RoutesScreen(state, viewModel::refreshRoutes, viewModel::openRoute)
DriverScreen.Detail -> DetailScreen(state, viewModel::back, viewModel::uploadPhoto, viewModel::openPhoto)
DriverScreen.Routes -> RoutesScreen(
state = state,
onRefresh = viewModel::refreshRoutesSilently,
onDate = viewModel::selectRouteDate,
onProfile = viewModel::openProfile,
onRoute = viewModel::openRoute,
)
DriverScreen.Profile -> ProfileScreen(state, viewModel::refreshRoutes, viewModel::openProfile, viewModel::logout)
DriverScreen.Detail -> DetailScreen(state, viewModel::back, viewModel::uploadPhoto, viewModel::openPhoto, viewModel::deletePhoto, viewModel::refreshSelectedRoute)
DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
}
@@ -179,87 +238,101 @@ private fun AuthShell(title: String, subtitle: String, onBack: (() -> Unit)? = n
}
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
private fun RoutesScreen(
state: DriverUiState,
onRefresh: () -> Unit,
onDate: (String) -> Unit,
onProfile: () -> Unit,
onRoute: (String) -> Unit,
) {
Scaffold(
topBar = { StitchHeader(onRefresh = onRefresh) },
bottomBar = { StitchBottomBar(driverName = state.driver?.displayName.orEmpty(), onRefresh = onRefresh) },
topBar = { StitchHeader() },
bottomBar = {
StitchBottomBar(
activeScreen = DriverScreen.Routes,
onRoutes = onRefresh,
onProfile = onProfile,
)
},
containerColor = TppColors.Surface,
) { padding ->
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Dzisiejsze Kursy",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = TppColors.Ink,
)
DateChip()
val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
routeListTitle(state.selectedDate),
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = TppColors.Ink,
)
DateChip(state.selectedDate)
}
}
item {
RouteDateSelector(
selectedDate = state.selectedDate,
minDate = state.minRouteDate,
maxDate = state.maxRouteDate,
onDate = onDate,
)
}
if (state.routes.isEmpty()) {
item { EmptyState("Brak kursow na wybrany dzien") }
} else {
items(state.routes, key = { it.id }) { route -> StitchRouteCard(route, onRoute) }
}
item { ErrorText(state.error) }
}
if (state.routes.isEmpty()) {
item { EmptyState("Brak kursow na dzisiaj") }
} else {
items(state.routes, key = { it.id }) { route -> StitchRouteCard(route, onRoute) }
}
item { ErrorText(state.error) }
PullRefreshIndicator(
refreshing = state.refreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
backgroundColor = Color.White,
contentColor = TppColors.Forest,
)
}
}
}
@Composable
private fun StitchHeader(onRefresh: () -> Unit) {
private fun StitchHeader() {
Column(Modifier.fillMaxWidth().background(Color.White).statusBarsPadding()) {
Row(
Box(
Modifier.fillMaxWidth().height(104.dp).padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
contentAlignment = Alignment.Center,
) {
IconButton(onClick = onRefresh) {
Icon(Icons.Outlined.Menu, contentDescription = "Menu", tint = TppColors.Muted, modifier = Modifier.size(32.dp))
}
TppWordmark()
IconButton(onClick = { }) {
Icon(Icons.Outlined.Person, contentDescription = "Profil", tint = TppColors.Muted, modifier = Modifier.size(32.dp))
}
TppLogo()
}
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.8f), thickness = 2.dp)
}
}
@Composable
private fun TppWordmark() {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
repeat(3) {
Box(Modifier.width(28.dp).height(4.dp).background(TppColors.Forest))
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("FIRMA", color = TppColors.LogoLeaf, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
Text("TPP", color = TppColors.Ink, style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Black)
}
}
private fun TppLogo() {
Image(
painter = painterResource(R.drawable.tpp5),
contentDescription = "TPP",
contentScale = ContentScale.Fit,
modifier = Modifier.width(142.dp).height(50.dp),
)
}
@Composable
private fun DateChip() {
private fun DateChip(date: String) {
val formatter = remember { DateTimeFormatter.ofPattern("d MMM", Locale("pl", "PL")) }
val label = remember { LocalDate.now().format(formatter).replace(".", "").replaceFirstChar { it.uppercase() } }
val label = remember(date) { LocalDate.parse(date).format(formatter).replace(".", "").replaceFirstChar { it.uppercase() } }
Row(
modifier = Modifier.background(TppColors.Panel, RoundedCornerShape(12.dp)).padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -270,6 +343,65 @@ private fun DateChip() {
}
}
@Composable
private fun RouteDateSelector(selectedDate: String, minDate: String, maxDate: String, onDate: (String) -> Unit) {
val dates = remember(minDate, maxDate) {
runCatching {
generateSequence(LocalDate.parse(minDate)) { date ->
val next = date.plusDays(1)
if (next <= LocalDate.parse(maxDate)) next else null
}.toList()
}.getOrElse { listOf(LocalDate.parse(selectedDate)) }
}
val listState = rememberLazyListState()
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,
selected = date.toString() == selectedDate,
onClick = { onDate(date.toString()) },
)
}
}
}
@Composable
private fun DateSelectorChip(date: LocalDate, selected: Boolean, onClick: () -> Unit) {
val formatter = remember { DateTimeFormatter.ofPattern("EEE d MMM", Locale("pl", "PL")) }
val label = remember(date) { date.format(formatter).replace(".", "").replaceFirstChar { it.uppercase() } }
val background = if (selected) TppColors.Forest else Color.White
val content = if (selected) Color.White else TppColors.Muted
Row(
modifier = Modifier
.background(background, RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, color = content, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold)
}
}
private fun routeListTitle(selectedDate: String): String {
val today = LocalDate.now()
val date = runCatching { LocalDate.parse(selectedDate) }.getOrDefault(today)
return when (date) {
today -> "Dzisiejsze Kursy"
today.plusDays(1) -> "Jutrzejsze Kursy"
else -> "Kursy"
}
}
@Composable
private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
val display = RouteDisplayMapper.toCard(route)
@@ -356,7 +488,60 @@ private fun RouteTimeline(origin: String, destination: String) {
}
@Composable
private fun StitchBottomBar(driverName: String, onRefresh: () -> Unit) {
private fun ProfileScreen(
state: DriverUiState,
onRoutes: () -> Unit,
onProfile: () -> Unit,
onLogout: () -> Unit,
) {
Scaffold(
topBar = { StitchHeader() },
bottomBar = {
StitchBottomBar(
activeScreen = DriverScreen.Profile,
onRoutes = onRoutes,
onProfile = onProfile,
)
},
containerColor = TppColors.Surface,
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, TppColors.Outline),
) {
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(Icons.Outlined.Person, contentDescription = null, tint = TppColors.Forest, modifier = Modifier.size(34.dp))
Text(
"Witaj ${state.driver?.displayName.orEmpty().ifBlank { "Kierowco" }}",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = TppColors.Ink,
)
}
}
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
shape = RoundedCornerShape(4.dp),
) {
Text("Wyloguj", fontWeight = FontWeight.Bold)
}
}
}
}
@Composable
private fun StitchBottomBar(activeScreen: DriverScreen, onRoutes: () -> Unit, onProfile: () -> Unit) {
Column(Modifier.fillMaxWidth().background(Color.White).navigationBarsPadding()) {
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.9f), thickness = 2.dp)
Row(
@@ -364,8 +549,8 @@ private fun StitchBottomBar(driverName: String, onRefresh: () -> Unit) {
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
BottomNavItem("Zlecenia", Icons.Outlined.LocalShipping, active = true, onClick = onRefresh)
BottomNavItem(driverName.ifBlank { "Profil" }, Icons.Outlined.Person, active = false, onClick = { })
BottomNavItem("Zlecenia", Icons.Outlined.LocalShipping, active = activeScreen == DriverScreen.Routes, onClick = onRoutes)
BottomNavItem("Profil", Icons.Outlined.Person, active = activeScreen == DriverScreen.Profile, onClick = onProfile)
}
}
}
@@ -393,77 +578,281 @@ 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 = {
TopAppBar(
modifier = Modifier.statusBarsPadding(),
title = { Text("Szczegoly kursu") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz") } },
colors = TopAppBarDefaults.topAppBarColors(containerColor = TppColors.Surface),
)
DetailHeader(onBack)
},
containerColor = TppColors.Surface,
) { padding ->
if (route == null) return@Scaffold
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { ManifestSection(route) }
item {
Text("Zdjecia ladunku", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp))
PhotoGrid(route.photos, state.imageAuthHeader, 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,
)
}
}
}
@Composable
private fun ManifestSection(route: DriverRouteDto) {
Card(colors = CardDefaults.cardColors(containerColor = Color.White), shape = RoundedCornerShape(8.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(Icons.Outlined.LocalShipping, contentDescription = null, tint = TppColors.Forest)
Text(route.relationLabel, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text("Kontrakt: ${route.contractCode ?: route.contractName ?: route.contractorName}", fontFamily = FontFamily.Monospace)
Text("Status: ${route.status}", color = TppColors.Muted)
if (!route.notes.isNullOrBlank()) Text(route.notes, color = TppColors.Muted)
private fun DetailHeader(onBack: () -> Unit) {
Column(Modifier.fillMaxWidth().background(TppColors.Surface).statusBarsPadding()) {
Row(
Modifier.fillMaxWidth().height(64.dp).padding(start = 8.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz", tint = TppColors.Ink)
}
Text(
"Szczegóły Trasy",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = TppColors.Ink,
modifier = Modifier.padding(start = 4.dp),
)
}
HorizontalDivider(color = TppColors.Outline, thickness = 1.dp)
}
}
@Composable
private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointDto) -> Unit) {
val stripColor = routeStatusColor(route.status)
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, TppColors.Outline),
) {
Column {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) {
StatusChip(route.status, stripColor)
Text(
"Zlecenie #${route.contractCode ?: route.id}",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = TppColors.Ink,
)
}
}
}
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f))
RoutePointBlock(
label = "Załadunek",
title = route.originName.ifBlank { "Nie podano miejsca załadunku" },
subtitle = route.contractorName,
icon = Icons.Outlined.Factory,
iconBackground = TppColors.Panel,
iconTint = TppColors.ContainerGreen,
navigationPoint = route.originNavigation,
onNavigate = onNavigate,
)
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.45f))
RoutePointBlock(
label = "Rozładunek",
title = route.destinationName.ifBlank { "Nie podano miejsca rozładunku" },
subtitle = route.notes?.takeIf { it.isNotBlank() } ?: "Uwagi do zlecenia pojawią się tutaj.",
icon = Icons.Outlined.LocationOn,
iconBackground = TppColors.Panel.copy(alpha = 0.72f),
iconTint = TppColors.Forest,
framed = true,
navigationPoint = route.destinationNavigation,
onNavigate = onNavigate,
)
RouteFactsFooter(route)
}
}
}
@Composable
private fun RoutePointBlock(
label: String,
title: String,
subtitle: String,
icon: ImageVector,
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)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(26.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
label.uppercase(Locale("pl", "PL")),
color = TppColors.Muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelMedium,
)
Text(title, color = TppColors.Ink, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
if (framed) {
Box(Modifier.fillMaxWidth().background(TppColors.Panel, RoundedCornerShape(4.dp)).padding(12.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("UWAGI DO ZLECENIA", color = TppColors.Muted, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall)
Text(subtitle, color = TppColors.Ink, fontWeight = FontWeight.SemiBold)
}
}
} else {
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)
}
}
}
}
@Composable
private fun RouteFactsFooter(route: DriverRouteDto) {
Column(Modifier.fillMaxWidth().background(TppColors.Panel).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
RouteFact("Typ ładunku", route.contractName ?: route.contractorName, Modifier.weight(1f))
RouteFact("Pojazd", route.truckLabel(), Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
RouteFact("Nr kontraktu", route.contractCode ?: "#${route.id}", Modifier.weight(1f))
RouteFact("Dystans", route.distanceLabel(), Modifier.weight(1f))
}
}
}
@Composable
private fun RouteFact(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, color = TppColors.Muted, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall)
Text(value.ifBlank { "-" }, color = TppColors.Ink, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun CargoDocumentationSection(
photos: List<RoutePhotoDto>,
imageAuthHeader: String?,
allowGalleryUploads: Boolean,
canManagePhotos: Boolean,
onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit,
onCamera: () -> Unit,
onGallery: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column {
Text("Dokumentacja ładunku", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = TppColors.Ink)
Spacer(Modifier.height(8.dp))
Box(Modifier.width(116.dp).height(3.dp).background(TppColors.ContainerGreen))
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
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)
}
}
}
PhotoGrid(photos, imageAuthHeader, onPhoto, onDeletePhoto)
}
}
@Composable
private fun CargoActionButton(label: String, icon: ImageVector, color: Color, onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.fillMaxWidth().height(64.dp),
colors = ButtonDefaults.buttonColors(containerColor = color),
shape = RoundedCornerShape(8.dp),
) {
Icon(icon, contentDescription = null, tint = Color.White)
Spacer(Modifier.width(10.dp))
Text(label, color = Color.White, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
}
@@ -472,42 +861,97 @@ private fun PhotoGrid(
photos: List<RoutePhotoDto>,
imageAuthHeader: String?,
onPhoto: (RoutePhotoDto) -> Unit,
onCamera: () -> Unit,
onGallery: () -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit,
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.height(360.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
val rows = ceil((photos.size.coerceAtLeast(1) / 2f).toDouble()).toInt()
val gridHeight = (rows * 172).coerceIn(172, 520).dp
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, TppColors.Outline),
) {
item { ActionTile("Aparat", Icons.Outlined.CameraAlt, onCamera) }
item { ActionTile("Galeria", Icons.Outlined.AddPhotoAlternate, onGallery) }
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) },
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
"Załączone zdjęcia (${photos.size})",
color = TppColors.Muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
)
if (photos.isEmpty()) {
EmptyPhotoState()
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.height(gridHeight),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(photos, key = { it.id }) { 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),
)
}
}
}
}
}
}
}
}
}
@Composable
private fun ActionTile(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) {
Card(
modifier = Modifier.aspectRatio(1f).clickable(onClick = onClick),
colors = CardDefaults.cardColors(containerColor = TppColors.Panel),
shape = RoundedCornerShape(12.dp),
private fun EmptyPhotoState() {
Box(
Modifier.fillMaxWidth().height(132.dp).background(TppColors.Panel, RoundedCornerShape(4.dp)),
contentAlignment = Alignment.Center,
) {
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(icon, contentDescription = label, tint = TppColors.Forest)
Text(label, color = TppColors.Muted)
}
Text("Brak załączonych zdjęć", color = TppColors.Muted, fontFamily = FontFamily.Monospace)
}
}
private fun DriverRouteDto.truckLabel(): String {
val registration = truck?.registration.orEmpty()
val trailerRegistration = truck?.trailerRegistration.orEmpty()
val name = truck?.name.orEmpty()
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 =
distanceKm?.takeIf { it > 0 }?.let { "%.1f km".format(Locale.US, it) } ?: "-"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PhotoScreen(state: DriverUiState, onBack: () -> Unit) {
@@ -560,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)
@@ -8,21 +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,
@@ -32,6 +40,7 @@ data class DriverUiState(
class DriverViewModel(application: Application) : AndroidViewModel(application) {
private val repository = DriverRepository(application)
private val _state = MutableStateFlow(DriverUiState(loading = true))
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
val state: StateFlow<DriverUiState> = _state
init {
@@ -42,6 +51,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun requestOtp(phone: String) = runLoading {
val response = repository.requestOtp(phone)
otpAutoSubmitPolicy.reset()
_state.update {
it.copy(
screen = DriverScreen.Otp,
@@ -60,19 +70,48 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
fun updateOtpCode(code: String) {
_state.update { it.copy(otpCode = code.filter(Char::isDigit).take(10)) }
val sanitized = code.filter(Char::isDigit).take(6)
_state.update { it.copy(otpCode = sanitized) }
if (otpAutoSubmitPolicy.shouldSubmit(sanitized, _state.value.loading)) {
verifyOtp(sanitized)
}
}
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 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)
}
}
}
@@ -81,9 +120,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)
}
@@ -96,6 +169,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
}
@@ -0,0 +1,27 @@
package pl.firmatpp.kierowca.ui
class OtpAutoSubmitPolicy {
private var lastSubmittedCode: String? = null
fun shouldSubmit(code: String, loading: Boolean): Boolean {
if (code.length < OTP_LENGTH) {
lastSubmittedCode = null
return false
}
if (loading || code.length != OTP_LENGTH || code == lastSubmittedCode) {
return false
}
lastSubmittedCode = code
return true
}
fun reset() {
lastSubmittedCode = null
}
private companion object {
const val OTP_LENGTH = 6
}
}
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,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))
}
}
@@ -0,0 +1,41 @@
package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class OtpAutoSubmitPolicyTest {
@Test
fun submitsWhenSixthDigitIsEntered() {
val policy = OtpAutoSubmitPolicy()
assertFalse(policy.shouldSubmit("12345", loading = false))
assertTrue(policy.shouldSubmit("123456", loading = false))
}
@Test
fun submitsSmsRetrieverCodeOnlyOnceWhileItStaysTheSame() {
val policy = OtpAutoSubmitPolicy()
assertTrue(policy.shouldSubmit("987654", loading = false))
assertFalse(policy.shouldSubmit("987654", loading = false))
}
@Test
fun allowsRetryAfterCodeIsEditedBelowSixDigits() {
val policy = OtpAutoSubmitPolicy()
assertTrue(policy.shouldSubmit("111111", loading = false))
assertFalse(policy.shouldSubmit("111111", loading = false))
assertFalse(policy.shouldSubmit("11111", loading = false))
assertTrue(policy.shouldSubmit("111111", loading = false))
}
@Test
fun doesNotSubmitWhileLoadingOrForLongerCodes() {
val policy = OtpAutoSubmitPolicy()
assertFalse(policy.shouldSubmit("123456", loading = true))
assertFalse(policy.shouldSubmit("1234567", loading = false))
}
}
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
function readSource(path) {
return readFileSync(resolve(root, path), 'utf8');
}
test('driver app sends Android device metadata during OTP verification', () => {
const models = readSource('app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt');
const repository = readSource('app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt');
const deviceInfo = readSource('app/src/main/java/pl/firmatpp/kierowca/data/DeviceInfoProvider.kt');
assert.match(models, /data class DriverDeviceInfo/);
assert.match(models, /val device: DriverDeviceInfo/);
assert.match(repository, /DeviceInfoProvider/);
assert.match(repository, /deviceInfoProvider\.currentDeviceInfo/);
assert.match(deviceInfo, /Settings\.Secure\.ANDROID_ID/);
assert.match(deviceInfo, /Build\.MANUFACTURER/);
assert.match(deviceInfo, /Build\.MODEL/);
assert.match(deviceInfo, /BuildConfig\.VERSION_NAME/);
});