diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt index 918b904..ee77139 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt @@ -70,6 +70,9 @@ class DriverSyncRepository( ) } + suspend fun hasBootstrapCache(date: String?): Boolean = + dao.bootstrap(date ?: currentDateFallback()) != null + suspend fun routeFromCache(routeId: String): CachedValue? { val cached = dao.route(routeId) ?: return null return CachedValue( diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/sync/StartupOfflineFallbackLoader.kt b/app/src/main/java/pl/firmatpp/kierowca/data/sync/StartupOfflineFallbackLoader.kt new file mode 100644 index 0000000..09b51b5 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/sync/StartupOfflineFallbackLoader.kt @@ -0,0 +1,27 @@ +package pl.firmatpp.kierowca.data.sync + +import java.io.IOException +import kotlinx.coroutines.withTimeoutOrNull +import pl.firmatpp.kierowca.domain.StartupSessionPolicy + +class StartupOfflineFallbackLoader( + private val timeoutMillis: Long = StartupSessionPolicy.OFFLINE_FALLBACK_MILLIS, +) { + suspend fun load( + onlineLoad: suspend () -> T, + offlineLoad: suspend () -> T?, + ): T { + val onlineValue = withTimeoutOrNull(timeoutMillis) { + onlineLoad() + } + if (onlineValue != null) { + return onlineValue + } + + return offlineLoad() + ?: throw IOException( + "Serwer nie odpowiedział w ciągu ${StartupSessionPolicy.OFFLINE_FALLBACK_SECONDS} sekund " + + "i w telefonie nie ma zapisanych danych offline.", + ) + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/domain/StartupSessionPolicy.kt b/app/src/main/java/pl/firmatpp/kierowca/domain/StartupSessionPolicy.kt new file mode 100644 index 0000000..c59b8f8 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/domain/StartupSessionPolicy.kt @@ -0,0 +1,51 @@ +package pl.firmatpp.kierowca.domain + +data class StartupSessionMessage( + val title: String, + val detail: String, +) + +object StartupSessionPolicy { + const val OFFLINE_BUTTON_DELAY_SECONDS = 4 + const val OFFLINE_FALLBACK_SECONDS = 15 + const val OFFLINE_FALLBACK_MILLIS = OFFLINE_FALLBACK_SECONDS * 1_000L + + fun remainingSeconds(elapsedSeconds: Int): Int = + (OFFLINE_FALLBACK_SECONDS - elapsedSeconds).coerceAtLeast(0) + + fun shouldShowOfflineButton(elapsedSeconds: Int, offlineDataAvailable: Boolean): Boolean = + offlineDataAvailable && elapsedSeconds >= OFFLINE_BUTTON_DELAY_SECONDS + + fun message(elapsedSeconds: Int): StartupSessionMessage = when { + elapsedSeconds < 3 -> StartupSessionMessage( + title = "Sprawdzamy zapisaną sesję", + detail = "Odczytujemy bezpieczne logowanie kierowcy.", + ) + elapsedSeconds < 6 -> StartupSessionMessage( + title = "Potwierdzamy logowanie", + detail = "Łączymy telefon z serwerem.", + ) + elapsedSeconds < 9 -> StartupSessionMessage( + title = "Pobieramy aktualne kursy", + detail = "Synchronizujemy plan dnia i ustawienia aplikacji.", + ) + elapsedSeconds < 12 -> StartupSessionMessage( + title = "To trwa dłużej niż zwykle", + detail = "Przy słabym zasięgu skorzystamy z danych zapisanych w telefonie.", + ) + else -> StartupSessionMessage( + title = "Przygotowujemy tryb offline", + detail = "Za chwilę pokażemy ostatnie zapisane kursy.", + ) + } + + fun countdownLabel(elapsedSeconds: Int): String { + val remaining = remainingSeconds(elapsedSeconds) + + return if (remaining > 0) { + "Tryb offline najpóźniej za $remaining s" + } else { + "Otwieramy zapisane dane offline" + } + } +} 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 b353aed..cf20c86 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -12,9 +12,12 @@ import android.location.LocationManager import android.net.Uri import android.os.Build import android.provider.Settings +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.tween import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest @@ -115,6 +118,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.unit.Dp @@ -159,6 +163,7 @@ import pl.firmatpp.kierowca.diagnostics.DiagnosticSeverity import pl.firmatpp.kierowca.sync.LiveSyncConnectionState import pl.firmatpp.kierowca.domain.OtpCodeExtractor import pl.firmatpp.kierowca.domain.RouteDisplayMapper +import pl.firmatpp.kierowca.domain.StartupSessionPolicy import pl.firmatpp.kierowca.ui.theme.AppThemeMode import pl.firmatpp.kierowca.ui.theme.TppTheme @@ -247,7 +252,10 @@ fun DriverApp( Box(Modifier.fillMaxSize().background(TppTheme.colors.surface)) { when (state.screen) { - DriverScreen.Initializing -> StartupScreen() + DriverScreen.Initializing -> StartupScreen( + offlineDataAvailable = state.startupOfflineAvailable, + onUseOfflineNow = viewModel::useOfflineStartupDataNow, + ) DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp) DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back) DriverScreen.Routes -> RoutesScreen( @@ -392,7 +400,21 @@ fun DriverApp( } @Composable -private fun StartupScreen() { +private fun StartupScreen( + offlineDataAvailable: Boolean, + onUseOfflineNow: () -> Unit, +) { + var elapsedSeconds by remember { mutableStateOf(0) } + LaunchedEffect(Unit) { + while (elapsedSeconds < StartupSessionPolicy.OFFLINE_FALLBACK_SECONDS) { + delay(1_000L) + elapsedSeconds += 1 + } + } + val sessionMessage = StartupSessionPolicy.message(elapsedSeconds) + val countdownLabel = StartupSessionPolicy.countdownLabel(elapsedSeconds) + val showOfflineButton = StartupSessionPolicy.shouldShowOfflineButton(elapsedSeconds, offlineDataAvailable) + BoxWithConstraints( Modifier .fillMaxSize() @@ -417,13 +439,100 @@ private fun StartupScreen() { verticalArrangement = Arrangement.Center, ) { CircularProgressIndicator(color = TppTheme.colors.forest) - Spacer(Modifier.height(20.dp)) + Spacer(Modifier.height(22.dp)) Text( - "Sprawdzanie sesji", - color = TppTheme.colors.muted, + "URUCHAMIAMY APLIKACJĘ", + color = TppTheme.colors.forest, fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, ) + Spacer(Modifier.height(10.dp)) + AnimatedContent( + targetState = sessionMessage, + transitionSpec = { + fadeIn(animationSpec = tween(260)) togetherWith + fadeOut(animationSpec = tween(220)) + }, + label = "startup-session-message", + ) { message -> + Column( + modifier = Modifier.heightIn(min = 72.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + Text( + message.title, + modifier = Modifier.fillMaxWidth(), + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(6.dp)) + Text( + message.detail, + modifier = Modifier.fillMaxWidth(), + color = TppTheme.colors.muted, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } + } + Spacer(Modifier.height(14.dp)) + Card( + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.panel), + border = BorderStroke(1.dp, TppTheme.colors.outline.copy(alpha = 0.72f)), + shape = RoundedCornerShape(6.dp), + ) { + Column( + modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AnimatedContent( + targetState = countdownLabel, + transitionSpec = { + fadeIn(animationSpec = tween(180)) togetherWith + fadeOut(animationSpec = tween(140)) + }, + label = "startup-offline-countdown", + ) { label -> + Text( + label, + color = TppTheme.colors.forest, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + "Jeśli kursy były wcześniej zapisane w telefonie.", + color = TppTheme.colors.muted, + fontSize = 12.sp, + ) + } + } + AnimatedVisibility( + visible = showOfflineButton, + enter = fadeIn(animationSpec = tween(260)), + exit = fadeOut(animationSpec = tween(160)), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = onUseOfflineNow, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + border = BorderStroke(1.dp, TppTheme.colors.forest), + colors = ButtonDefaults.outlinedButtonColors(contentColor = TppTheme.colors.forest), + shape = RoundedCornerShape(6.dp), + ) { + Text( + "Przejdź do trybu offline teraz", + fontWeight = FontWeight.Bold, + ) + } + } + } } } } 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 fa8d3f7..a63414f 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -22,6 +22,7 @@ import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.sync.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.NetworkMonitor +import pl.firmatpp.kierowca.data.sync.StartupOfflineFallbackLoader import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto @@ -61,6 +62,7 @@ data class DriverUiState( val screen: DriverScreen = DriverScreen.Initializing, val loading: Boolean = true, val refreshing: Boolean = false, + val startupOfflineAvailable: Boolean = false, val phone: String = "", val otpCode: String = "", val maskedPhone: String = "", @@ -188,6 +190,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) private val appPreferencesStore = AppPreferencesStore(application) private val repository = DriverRepository(application) private val syncRepository = DriverSyncRepository(application, repository) + private val startupOfflineFallbackLoader = StartupOfflineFallbackLoader() private val networkMonitor = NetworkMonitor(application) private val photoUploadOutbox = PhotoUploadOutbox(application) private val routeActionOutbox = RouteActionOutbox(application) @@ -215,6 +218,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) private var leaveCalendarRequestGeneration: Long = 0 private var routesRequestGeneration: Long = 0 private var routeDetailRequestGeneration: Long = 0 + private var routesLoadJob: Job? = null val state: StateFlow = _state init { @@ -295,6 +299,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } viewModelScope.launch { if (repository.hasToken()) { + val offlineAvailable = syncRepository.hasBootstrapCache(_state.value.selectedDate) + _state.update { it.copy(startupOfflineAvailable = offlineAvailable) } refreshRoutes() } else { _state.update { it.copy(screen = DriverScreen.Phone, loading = false) } @@ -338,6 +344,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false) + fun useOfflineStartupDataNow() { + val snapshot = _state.value + if (snapshot.screen != DriverScreen.Initializing || !snapshot.startupOfflineAvailable) { + return + } + + loadRoutes( + date = snapshot.selectedDate, + showLoading = true, + navigateToRoutes = true, + startupOfflineOnly = true, + ) + } + fun onAppForegrounded() { DriverRuntimeSyncState.foreground = true liveSyncClient.setForeground(true) @@ -365,17 +385,31 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(routeDayLiveUpdateMessage = null) } } - private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) { + private fun loadRoutes( + date: String?, + showLoading: Boolean, + navigateToRoutes: Boolean, + startupOfflineOnly: Boolean = false, + ) { val generation = ++routesRequestGeneration - viewModelScope.launch { + routesLoadJob?.cancel() + routesLoadJob = viewModelScope.launch { + val isStartup = _state.value.screen == DriverScreen.Initializing _state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) } runCatching { - val cached = if (_state.value.isOnline) { - syncRepository.bootstrap(date) - } else { - syncRepository.bootstrapFromCache(date) + val cached = when { + startupOfflineOnly -> syncRepository.bootstrapFromCache(date) ?: throw IOException("Brak zapisanych danych dla wybranego dnia.") + !_state.value.isOnline -> syncRepository.bootstrapFromCache(date) + ?: throw IOException("Brak zapisanych danych dla wybranego dnia.") + isStartup -> { + startupOfflineFallbackLoader.load( + onlineLoad = { syncRepository.bootstrap(date) }, + offlineLoad = { syncRepository.bootstrapFromCache(date) }, + ) + } + else -> syncRepository.bootstrap(date) } val response = cached.value val settings = response.driverAppSettings diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/sync/StartupOfflineFallbackLoaderTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/sync/StartupOfflineFallbackLoaderTest.kt new file mode 100644 index 0000000..e6dc503 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/data/sync/StartupOfflineFallbackLoaderTest.kt @@ -0,0 +1,70 @@ +package pl.firmatpp.kierowca.data.sync + +import java.io.IOException +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StartupOfflineFallbackLoaderTest { + @Test + fun returnsServerDataBeforeTimeout() = runTest { + val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L) + + val result = loader.load( + onlineLoad = { "server" }, + offlineLoad = { "cache" }, + ) + + assertEquals("server", result) + } + + @Test + fun returnsCachedDataWhenServerExceedsTimeout() = runTest { + val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L) + + val result = loader.load( + onlineLoad = { + delay(2_000L) + "server" + }, + offlineLoad = { "cache" }, + ) + + assertEquals("cache", result) + } + + @Test + fun reportsMissingOfflineDataAfterTimeout() = runTest { + val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L) + + val error = runCatching { + loader.load( + onlineLoad = { + delay(2_000L) + "server" + }, + offlineLoad = { null }, + ) + }.exceptionOrNull() + + assertTrue(error is IOException) + } + + @Test + fun doesNotHideAuthenticationFailures() = runTest { + val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L) + val authenticationFailure = IllegalStateException("401") + + val error = runCatching { + loader.load( + onlineLoad = { throw authenticationFailure }, + offlineLoad = { "cache" }, + ) + }.exceptionOrNull() + + assertTrue(error is IllegalStateException) + assertEquals(authenticationFailure.message, error?.message) + } +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/domain/StartupSessionPolicyTest.kt b/app/src/test/java/pl/firmatpp/kierowca/domain/StartupSessionPolicyTest.kt new file mode 100644 index 0000000..468b6cd --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/domain/StartupSessionPolicyTest.kt @@ -0,0 +1,32 @@ +package pl.firmatpp.kierowca.domain + +import org.junit.Assert.assertEquals +import org.junit.Test + +class StartupSessionPolicyTest { + @Test + fun rotatesStartupMessagesAsWaitingTimeIncreases() { + assertEquals("Sprawdzamy zapisaną sesję", StartupSessionPolicy.message(0).title) + assertEquals("Potwierdzamy logowanie", StartupSessionPolicy.message(3).title) + assertEquals("Pobieramy aktualne kursy", StartupSessionPolicy.message(6).title) + assertEquals("To trwa dłużej niż zwykle", StartupSessionPolicy.message(9).title) + assertEquals("Przygotowujemy tryb offline", StartupSessionPolicy.message(12).title) + } + + @Test + fun countsDownToOfflineFallbackWithoutGoingBelowZero() { + assertEquals(15, StartupSessionPolicy.remainingSeconds(0)) + assertEquals(7, StartupSessionPolicy.remainingSeconds(8)) + assertEquals(0, StartupSessionPolicy.remainingSeconds(15)) + assertEquals(0, StartupSessionPolicy.remainingSeconds(30)) + assertEquals("Tryb offline najpóźniej za 5 s", StartupSessionPolicy.countdownLabel(10)) + assertEquals("Otwieramy zapisane dane offline", StartupSessionPolicy.countdownLabel(15)) + } + + @Test + fun showsManualOfflineButtonAfterFourSecondsOnlyWhenCacheExists() { + assertEquals(false, StartupSessionPolicy.shouldShowOfflineButton(3, offlineDataAvailable = true)) + assertEquals(true, StartupSessionPolicy.shouldShowOfflineButton(4, offlineDataAvailable = true)) + assertEquals(false, StartupSessionPolicy.shouldShowOfflineButton(10, offlineDataAvailable = false)) + } +}