From 2531ff2b9ff748414b3a513423934fb6c4450ed2 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 30 Jun 2026 21:47:10 +0200 Subject: [PATCH] Dodaj odswiezanie i wybor daty kursow --- app/build.gradle.kts | 5 +- .../kierowca/data/DriverRepository.kt | 4 +- .../kierowca/data/api/MobileDriverApi.kt | 6 +- .../kierowca/data/model/DriverModels.kt | 10 ++ .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 170 +++++++++++++++--- .../firmatpp/kierowca/ui/DriverViewModel.kt | 49 +++-- 6 files changed, 200 insertions(+), 44 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9e2e7fa..8f9de74 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "pl.firmatpp.kierowca" minSdk = 26 targetSdk = 35 - versionCode = 5 - versionName = "1.0.4" + versionCode = 6 + versionName = "1.0.5" 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) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt index 14a2514..3f23079 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt @@ -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) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt index b3b85a0..be7ee5f 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt @@ -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 @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( diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt index 167a202..91131b6 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt @@ -30,6 +30,16 @@ 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, ) data class DriverSessionDto( diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index 2cbdf1b..e68b85e 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -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 @@ -50,6 +51,10 @@ import androidx.compose.material.icons.outlined.CalendarToday 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 +72,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 +92,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 +103,7 @@ import com.google.android.gms.common.api.Status import java.io.File import java.time.LocalDate import java.time.format.DateTimeFormatter +import kotlinx.coroutines.delay import java.util.Locale import pl.firmatpp.kierowca.R import pl.firmatpp.kierowca.data.model.DriverRouteDto @@ -105,9 +115,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 +153,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 +227,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 +247,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 +319,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 +332,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) diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index 1035701..996ad42 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -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,16 @@ 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 = 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 selectedRoute: DriverRouteDto? = null, val selectedPhoto: RoutePhotoDto? = null, val imageAuthHeader: String? = null, @@ -63,16 +69,39 @@ 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, + imageAuthHeader = repository.imageAuthHeader(), + error = null, + ) + } + }.onFailure { throwable -> + _state.update { it.copy(error = throwable.message ?: "Wystapil blad.") } + } + + _state.update { + it.copy(loading = false, refreshing = false) + } } }