Compare commits

..
3 Commits
Author SHA1 Message Date
admin bf6e76dcbf Odwzoruj szczegoly trasy ze Stitch 2026-06-30 22:00:59 +02:00
admin 18baec5020 Ukryj galerie wedlug ustawien 2026-06-30 21:52:29 +02:00
admin 2531ff2b9f Dodaj odswiezanie i wybor daty kursow 2026-06-30 21:47:10 +02:00
6 changed files with 467 additions and 96 deletions
+3 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 5
versionName = "1.0.4"
versionCode = 8
versionName = "1.0.7"
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)
@@ -30,8 +30,8 @@ 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)
@@ -16,6 +16,7 @@ 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 +29,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(
@@ -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(
@@ -13,9 +13,9 @@ 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.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -37,6 +37,7 @@ 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
@@ -47,9 +48,15 @@ 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.LocationOn
import androidx.compose.material.icons.outlined.LocalShipping
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Phone
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 +74,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
@@ -86,6 +94,9 @@ 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
@@ -94,6 +105,8 @@ import com.google.android.gms.common.api.Status
import java.io.File
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.model.DriverRouteDto
@@ -105,9 +118,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,
@@ -117,7 +156,13 @@ 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::openProfile, viewModel::openRoute)
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)
DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
@@ -185,11 +230,12 @@ 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,
) {
@@ -204,32 +250,50 @@ private fun RoutesScreen(
},
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,
)
}
}
}
@@ -258,9 +322,9 @@ private fun TppLogo() {
}
@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,
@@ -271,6 +335,57 @@ 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)) }
}
LazyRow(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)
@@ -472,12 +587,7 @@ private fun DetailScreen(
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 ->
@@ -485,23 +595,28 @@ private fun DetailScreen(
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.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))
})
CargoDocumentationSection(
photos = route.photos,
imageAuthHeader = state.imageAuthHeader,
allowGalleryUploads = state.allowGalleryUploads,
onPhoto = onPhoto,
onCamera = {
val newUri = createCameraUri(context)
cameraUri = newUri
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
cameraLauncher.launch(newUri)
} else {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
},
onGallery = {
pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
)
}
item { ErrorText(state.error) }
}
@@ -509,15 +624,196 @@ private fun DetailScreen(
}
@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) {
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,
)
}
RouteStartChip(route.startsAt)
}
}
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,
)
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,
)
RouteFactsFooter(route)
}
}
}
@Composable
private fun RouteStartChip(startsAt: String) {
Row(
modifier = Modifier.background(TppColors.Panel, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppColors.Muted, modifier = Modifier.size(18.dp))
Text(
startsAt.ifBlank { "Dziś" },
color = TppColors.Muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
private fun RoutePointBlock(
label: String,
title: String,
subtitle: String,
icon: ImageVector,
iconBackground: Color,
iconTint: Color,
framed: Boolean = false,
) {
Row(
Modifier.fillMaxWidth().padding(20.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
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)
}
}
}
}
@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,
onPhoto: (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 (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)
}
PhotoGrid(photos, imageAuthHeader, onPhoto)
}
}
@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)
}
}
@@ -526,42 +822,70 @@ private fun PhotoGrid(
photos: List<RoutePhotoDto>,
imageAuthHeader: String?,
onPhoto: (RoutePhotoDto) -> Unit,
onCamera: () -> Unit,
onGallery: () -> 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 ->
AsyncImage(
model = imageRequest(photo.url, imageAuthHeader),
contentDescription = "Zdjecie ladunku",
contentScale = ContentScale.Crop,
modifier = Modifier.aspectRatio(1f).clickable { onPhoto(photo) },
)
}
}
}
}
}
}
@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 name = truck?.name.orEmpty()
return when {
registration.isNotBlank() && name.isNotBlank() -> "$registration ($name)"
registration.isNotBlank() -> registration
name.isNotBlank() -> name
else -> "-"
}
}
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) {
@@ -8,6 +8,7 @@ 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.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
@@ -18,11 +19,17 @@ 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,
@@ -63,16 +70,40 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(otpCode = code.filter(Char::isDigit).take(10)) }
}
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)
}
}
}