diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e46a567..fd60295 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,7 +34,7 @@ android { applicationId = "pl.firmatpp.kierowca" minSdk = 26 targetSdk = 35 - versionCode = 42 + versionCode = 43 versionName = "1.0.41" setProperty("archivesBaseName", "pl.firmatpp.kierowca") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt b/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt index df50dd8..37be749 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt @@ -1,11 +1,12 @@ package pl.firmatpp.kierowca import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import pl.firmatpp.kierowca.ui.DriverApp import pl.firmatpp.kierowca.ui.DriverViewModel @@ -20,8 +21,10 @@ class MainActivity : ComponentActivity() { notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID) setContent { - TppKierowcaTheme { - val viewModel: DriverViewModel = viewModel() + val viewModel: DriverViewModel = viewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + TppKierowcaTheme(themeMode = state.themeMode) { DriverApp( viewModel = viewModel, initialRouteId = notificationRouteId, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt b/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt index 8a4dab5..e56da9e 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt @@ -1,6 +1,7 @@ package pl.firmatpp.kierowca.data import java.io.IOException +import java.net.UnknownHostException import retrofit2.HttpException enum class ApiErrorKind { @@ -26,6 +27,11 @@ data class ApiError( object ApiErrorMapper { fun map(throwable: Throwable): ApiError = when (throwable) { + is UnknownHostException -> ApiError( + kind = ApiErrorKind.Network, + message = "Brak internetu lub połączenia z serwerem. Sprawdź zasięg i spróbuj ponownie.", + retryable = true, + ) is IOException -> ApiError( kind = ApiErrorKind.Network, message = "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona.", @@ -39,6 +45,9 @@ object ApiErrorMapper { ) } + fun shouldReportNonFatal(throwable: Throwable): Boolean = + map(throwable).kind != ApiErrorKind.Network + fun mapHttpStatus(statusCode: Int, body: String?): ApiError { val code = body?.let { """"code"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) } val message = body?.let { """"message"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/AppPreferencesStore.kt b/app/src/main/java/pl/firmatpp/kierowca/data/AppPreferencesStore.kt new file mode 100644 index 0000000..78cdf0e --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/AppPreferencesStore.kt @@ -0,0 +1,30 @@ +package pl.firmatpp.kierowca.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import pl.firmatpp.kierowca.ui.theme.AppThemeMode + +private val Context.driverAppPreferencesDataStore by preferencesDataStore(name = "driver_app_preferences") + +class AppPreferencesStore( + private val dataStore: DataStore, +) { + constructor(context: Context) : this(context.driverAppPreferencesDataStore) + + private val themeModeKey = stringPreferencesKey("theme_mode") + + val themeMode: Flow = dataStore.data + .map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) } + + suspend fun setThemeMode(themeMode: AppThemeMode) { + dataStore.edit { preferences -> + preferences[themeModeKey] = themeMode.storedValue + } + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt index 0ecbca1..37e0390 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt @@ -3,10 +3,10 @@ package pl.firmatpp.kierowca.sync import com.google.gson.Gson import com.google.gson.JsonObject import com.google.gson.JsonParser -import java.util.concurrent.atomic.AtomicBoolean -import kotlinx.coroutines.Job +import java.util.concurrent.TimeUnit import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay @@ -16,75 +16,155 @@ import okhttp3.Request import okhttp3.WebSocket import okhttp3.WebSocketListener import pl.firmatpp.kierowca.data.DriverRepository +import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse import pl.firmatpp.kierowca.data.model.RealtimeConfigDto import pl.firmatpp.kierowca.diagnostics.AppDiagnostics -class DriverLiveSyncClient( +interface DriverLiveSyncGateway { + suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse + suspend fun storeRealtimeStatus(status: String, socketId: String?, error: String?) +} + +class DriverRepositoryLiveSyncGateway( private val repository: DriverRepository, +) : DriverLiveSyncGateway { + override suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse = + repository.broadcastAuth(socketId, channelName) + + override suspend fun storeRealtimeStatus(status: String, socketId: String?, error: String?) { + repository.storeRealtimeStatus(status, socketId, error) + } +} + +interface LiveWebSocketFactory { + fun newWebSocket(url: String, listener: WebSocketListener): WebSocket +} + +class OkHttpLiveWebSocketFactory( + private val client: OkHttpClient, +) : LiveWebSocketFactory { + override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket = + client.newWebSocket(Request.Builder().url(url).build(), listener) +} + +private enum class LiveSyncConnectionState { + Stopped, + WaitingForNetwork, + Disconnected, + Connecting, + Subscribing, + Connected, +} + +class DriverLiveSyncClient( + private val gateway: DriverLiveSyncGateway, private val onConnected: () -> Unit, private val onHint: (DriverSyncHint) -> Unit, - private val client: OkHttpClient = OkHttpClient(), + private val webSocketFactory: LiveWebSocketFactory = OkHttpLiveWebSocketFactory(defaultOkHttpClient()), private val gson: Gson = Gson(), + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + private val reconnectDelaysMs: List = DEFAULT_RECONNECT_DELAYS_MS, + private val staleTimeoutMs: Long = STALE_TIMEOUT_MS, ) { + constructor( + repository: DriverRepository, + onConnected: () -> Unit, + onHint: (DriverSyncHint) -> Unit, + client: OkHttpClient = defaultOkHttpClient(), + gson: Gson = Gson(), + ) : this( + gateway = DriverRepositoryLiveSyncGateway(repository), + onConnected = onConnected, + onHint = onHint, + webSocketFactory = OkHttpLiveWebSocketFactory(client), + gson = gson, + ) + private companion object { const val HEARTBEAT_INTERVAL_MS = 10_000L + const val STALE_TIMEOUT_MS = 30_000L + val DEFAULT_RECONNECT_DELAYS_MS = listOf(1_000L, 2_000L, 5_000L, 10_000L, 30_000L) + + fun defaultOkHttpClient(): OkHttpClient = + OkHttpClient.Builder() + .pingInterval(15, TimeUnit.SECONDS) + .build() } - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val started = AtomicBoolean(false) + private val lock = Any() + private var desiredActive: Boolean = false + private var networkAvailable: Boolean = true + private var state: LiveSyncConnectionState = LiveSyncConnectionState.Stopped private var webSocket: WebSocket? = null private var driverId: String? = null private var realtimeConfig: RealtimeConfigDto? = null private var socketId: String? = null + private var reconnectJob: Job? = null private var heartbeatJob: Job? = null + private var staleWatchdogJob: Job? = null + private var reconnectAttempt: Int = 0 + private var messageVersion: Long = 0 fun start(driverId: String, config: RealtimeConfigDto?) { - val appKey = config?.reverbAppKey?.takeIf { it.isNotBlank() } ?: return - val wsBaseUrl = config.reverbWsBaseUrl?.takeIf { it.isNotBlank() } ?: return - if (!config.reverbEnabled) return + if (!isConfigUsable(config)) return - realtimeConfig = config - this.driverId = driverId - if (!started.compareAndSet(false, true)) return + synchronized(lock) { + realtimeConfig = config + this.driverId = driverId + desiredActive = true + } + ensureConnected() + } - val wsUrl = wsBaseUrl.trimEnd('/') + - "/" + appKey + - "?protocol=7&client=android&version=1.0&flash=false" + fun ensureConnected() { + val shouldConnect = synchronized(lock) { + desiredActive && + networkAvailable && + state !in setOf( + LiveSyncConnectionState.Connecting, + LiveSyncConnectionState.Subscribing, + LiveSyncConnectionState.Connected, + ) + } + if (shouldConnect) connectNow() + } - webSocket = client.newWebSocket( - Request.Builder().url(wsUrl).build(), - object : WebSocketListener() { - override fun onMessage(webSocket: WebSocket, text: String) { - handleMessage(webSocket, text) - } + fun onNetworkAvailable(available: Boolean) { + val shouldReconnect = synchronized(lock) { + networkAvailable = available + if (!available) { + reconnectJob?.cancel() + reconnectJob = null + closeSocketLocked("network_lost") + state = if (desiredActive) LiveSyncConnectionState.WaitingForNetwork else LiveSyncConnectionState.Stopped + false + } else { + desiredActive && state == LiveSyncConnectionState.WaitingForNetwork + } + } - override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - stopHeartbeat() - reportRealtimeStatus("disconnected", reason.takeIf { it.isNotBlank() }) - started.set(false) - scheduleReconnect() - } + if (available) { + reportRealtimeStatus("reconnecting", "network_available") + } else { + reportRealtimeStatus("disconnected", "network_lost") + } - override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) { - stopHeartbeat() - AppDiagnostics.log("realtime_error: ${t.message ?: response?.message ?: "unknown"}") - reportRealtimeStatus("error", t.message ?: response?.message) - started.set(false) - scheduleReconnect() - } - }, - ) + if (shouldReconnect) connectNow(resetAttempt = true) } fun stop() { - started.set(false) - stopHeartbeat() + synchronized(lock) { + desiredActive = false + reconnectJob?.cancel() + reconnectJob = null + closeSocketLocked("client_stop") + driverId = null + realtimeConfig = null + socketId = null + reconnectAttempt = 0 + state = LiveSyncConnectionState.Stopped + } reportRealtimeStatus("disconnected", "client_stop") - webSocket?.close(1000, "logout") - webSocket = null - driverId = null - realtimeConfig = null - socketId = null } fun close() { @@ -92,35 +172,112 @@ class DriverLiveSyncClient( scope.cancel() } + private fun connectNow(resetAttempt: Boolean = false) { + val wsUrl = synchronized(lock) { + val config = realtimeConfig ?: return + val id = driverId ?: return + if (!desiredActive || !networkAvailable || !isConfigUsable(config)) return + if (state == LiveSyncConnectionState.Connecting || state == LiveSyncConnectionState.Subscribing || state == LiveSyncConnectionState.Connected) return + + if (resetAttempt) reconnectAttempt = 0 + reconnectJob?.cancel() + reconnectJob = null + socketId = null + state = LiveSyncConnectionState.Connecting + + val appKey = config.reverbAppKey.orEmpty() + val wsBaseUrl = config.reverbWsBaseUrl.orEmpty() + wsBaseUrl.trimEnd('/') + "/" + appKey + "?protocol=7&client=android&version=1.0&flash=false" + } + + val socket = webSocketFactory.newWebSocket( + wsUrl, + object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, text: String) { + handleMessage(webSocket, text) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + handleDisconnect( + status = "disconnected", + reason = reason.takeIf { it.isNotBlank() } ?: "closed", + socket = webSocket, + ) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) { + AppDiagnostics.log("realtime_error: ${t.message ?: response?.message ?: "unknown"}") + handleDisconnect( + status = "error", + reason = t.message ?: response?.message ?: "failure", + socket = webSocket, + ) + } + }, + ) + + synchronized(lock) { + webSocket = socket + } + } + private fun handleMessage(socket: WebSocket, text: String) { + val isCurrentSocket = synchronized(lock) { + if (webSocket !== socket) { + false + } else { + messageVersion += 1 + true + } + } + if (!isCurrentSocket) return + val root = runCatching { JsonParser.parseString(text).asJsonObject }.getOrNull() ?: return val event = root.string("event") ?: return when (event) { + "pusher:ping" -> { + socket.send("""{"event":"pusher:pong","data":{}}""") + restartStaleWatchdogIfConnected() + } "pusher:connection_established" -> { - socketId = root.dataObject()?.string("socket_id") ?: return - subscribe(socket, socketId ?: return) + val nextSocketId = root.dataObject()?.string("socket_id") ?: return + synchronized(lock) { + socketId = nextSocketId + state = LiveSyncConnectionState.Subscribing + } + subscribe(socket, nextSocketId) } "pusher_internal:subscription_succeeded" -> { val channel = root.string("channel") - val id = driverId ?: return + val id = synchronized(lock) { driverId } ?: return if (channel == "private-driver-mobile.$id") { + synchronized(lock) { + reconnectAttempt = 0 + state = LiveSyncConnectionState.Connected + } reportRealtimeStatus("connected") startHeartbeat() + startStaleWatchdog() onConnected() } } - "DriverMobileSyncHint" -> parseHint(root.dataObject())?.let(onHint) + "DriverMobileSyncHint" -> { + restartStaleWatchdogIfConnected() + parseHint(root.dataObject())?.let(onHint) + } + else -> restartStaleWatchdogIfConnected() } } private fun subscribe(socket: WebSocket, socketId: String) { - val id = driverId ?: return + val id = synchronized(lock) { driverId } ?: return val channel = "private-driver-mobile.$id" scope.launch { runCatching { - val auth = repository.broadcastAuth(socketId, channel).auth + val auth = gateway.broadcastAuth(socketId, channel).auth + if (!isCurrentSocket(socket)) return@launch val payload = mapOf( "event" to "pusher:subscribe", "data" to mapOf( @@ -128,11 +285,129 @@ class DriverLiveSyncClient( "auth" to auth, ), ) - socket.send(gson.toJson(payload)) + check(socket.send(gson.toJson(payload))) { "WebSocket send returned false" } + }.onFailure { throwable -> + AppDiagnostics.log("realtime_subscription_error: ${throwable.message ?: throwable::class.java.simpleName}") + socket.close(1000, "subscription_failed") + handleDisconnect("error", "subscription_failed", socket) } } } + private fun handleDisconnect(status: String, reason: String?, socket: WebSocket? = null) { + val shouldSchedule = synchronized(lock) { + if (socket != null && webSocket !== socket) return + + stopHeartbeatLocked() + stopStaleWatchdogLocked() + webSocket = null + socketId = null + + if (!desiredActive) { + state = LiveSyncConnectionState.Stopped + false + } else if (!networkAvailable) { + state = LiveSyncConnectionState.WaitingForNetwork + false + } else { + state = LiveSyncConnectionState.Disconnected + true + } + } + + reportRealtimeStatus(status, reason) + if (shouldSchedule) scheduleReconnect() + } + + private fun scheduleReconnect() { + val delayMs = synchronized(lock) { + if (!desiredActive || !networkAvailable || state == LiveSyncConnectionState.Stopped) return + if (reconnectJob?.isActive == true) return + val delay = reconnectDelaysMs.getOrElse(reconnectAttempt) { reconnectDelaysMs.last() } + reconnectAttempt += 1 + delay + } + + reconnectJob = scope.launch { + delay(delayMs) + connectNow() + } + } + + private fun startHeartbeat() { + synchronized(lock) { + heartbeatJob?.cancel() + heartbeatJob = scope.launch { + while (true) { + reportRealtimeStatus("connected") + delay(HEARTBEAT_INTERVAL_MS) + } + } + } + } + + private fun startStaleWatchdog() { + synchronized(lock) { + stopStaleWatchdogLocked() + val observedVersion = messageVersion + staleWatchdogJob = scope.launch { + delay(staleTimeoutMs) + val socketToClose = synchronized(lock) { + if (state == LiveSyncConnectionState.Connected && messageVersion == observedVersion) { + webSocket + } else { + null + } + } + + if (socketToClose != null) { + socketToClose.close(1001, "stale_connection") + handleDisconnect("error", "stale_connection", socketToClose) + ensureConnected() + } + } + } + } + + private fun restartStaleWatchdogIfConnected() { + val connected = synchronized(lock) { state == LiveSyncConnectionState.Connected } + if (connected) startStaleWatchdog() + } + + private fun isCurrentSocket(socket: WebSocket): Boolean = + synchronized(lock) { webSocket === socket } + + private fun closeSocketLocked(reason: String) { + stopHeartbeatLocked() + stopStaleWatchdogLocked() + webSocket?.close(1000, reason) + webSocket = null + socketId = null + } + + private fun stopHeartbeatLocked() { + heartbeatJob?.cancel() + heartbeatJob = null + } + + private fun stopStaleWatchdogLocked() { + staleWatchdogJob?.cancel() + staleWatchdogJob = null + } + + private fun reportRealtimeStatus(status: String, error: String? = null) { + scope.launch { + runCatching { + gateway.storeRealtimeStatus(status, socketId, error) + } + } + } + + private fun isConfigUsable(config: RealtimeConfigDto?): Boolean = + config?.reverbEnabled == true && + !config.reverbAppKey.isNullOrBlank() && + !config.reverbWsBaseUrl.isNullOrBlank() + private fun parseHint(data: JsonObject?): DriverSyncHint? { if (data == null || data.string("type") != "driver_sync_hint") return null @@ -152,37 +427,4 @@ class DriverLiveSyncClient( private fun JsonObject.string(name: String): String? = get(name)?.takeIf { !it.isJsonNull }?.asString - - private fun scheduleReconnect() { - val id = driverId ?: return - scope.launch { - delay(5_000) - if (!started.get() && driverId == id) { - start(id, realtimeConfig) - } - } - } - - private fun startHeartbeat() { - heartbeatJob?.cancel() - heartbeatJob = scope.launch { - while (started.get()) { - reportRealtimeStatus("connected") - delay(HEARTBEAT_INTERVAL_MS) - } - } - } - - private fun stopHeartbeat() { - heartbeatJob?.cancel() - heartbeatJob = null - } - - private fun reportRealtimeStatus(status: String, error: String? = null) { - scope.launch { - runCatching { - repository.storeRealtimeStatus(status, socketId, error) - } - } - } } 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 f181a77..345a715 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -146,7 +146,8 @@ import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus import pl.firmatpp.kierowca.domain.OtpCodeExtractor import pl.firmatpp.kierowca.domain.RouteDisplayMapper -import pl.firmatpp.kierowca.ui.theme.TppColors +import pl.firmatpp.kierowca.ui.theme.AppThemeMode +import pl.firmatpp.kierowca.ui.theme.TppTheme @Composable fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null, initialLeaveRequestId: String? = null) { @@ -172,7 +173,7 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null, initia when (event) { Lifecycle.Event.ON_START -> { appInForeground = true - if (state.screen == DriverScreen.Routes) viewModel.refreshRoutesSilently() + viewModel.onAppForegrounded() } Lifecycle.Event.ON_STOP -> appInForeground = false else -> Unit @@ -203,7 +204,7 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null, initia } } - Box(Modifier.fillMaxSize().background(TppColors.Surface)) { + Box(Modifier.fillMaxSize().background(TppTheme.colors.surface)) { when (state.screen) { DriverScreen.Initializing -> StartupScreen() DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp) @@ -236,6 +237,7 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null, initia } }, onOpenNotificationSettings = { openAppNotificationSettings(context) }, + onThemeModeChanged = viewModel::setThemeMode, ) DriverScreen.Detail -> DetailScreen( state, @@ -276,7 +278,7 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null, initia if (state.loading && state.screen != DriverScreen.Initializing) { Box(Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.42f)), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = TppColors.Forest) + CircularProgressIndicator(color = TppTheme.colors.forest) } } } @@ -288,7 +290,7 @@ private fun StartupScreen() { Modifier .fillMaxSize() .navigationBarsPadding() - .background(TppColors.Surface), + .background(TppTheme.colors.surface), ) { val bannerHeight = authBrandBannerHeightDp(maxHeight.value.toInt()).dp val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp @@ -307,11 +309,11 @@ private fun StartupScreen() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - CircularProgressIndicator(color = TppColors.Forest) + CircularProgressIndicator(color = TppTheme.colors.forest) Spacer(Modifier.height(20.dp)) Text( "Sprawdzanie sesji", - color = TppColors.Muted, + color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold, ) @@ -326,7 +328,7 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) { BoxWithConstraints( Modifier .fillMaxSize() - .background(TppColors.Surface) + .background(TppTheme.colors.surface) .navigationBarsPadding(), ) { val screenWidthDp = maxWidth.value.toInt() @@ -352,7 +354,7 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) { ) { Text( "Firma TPP - Kierowca", - color = TppColors.Forest, + color = TppTheme.colors.forest, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, fontSize = 14.sp, @@ -361,13 +363,13 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) { Text( "Zaloguj się", style = MaterialTheme.typography.headlineLarge, - color = TppColors.Ink, + color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(8.dp)) Text( "Wpisz numer telefonu kierowcy. Kod OTP wyślemy SMS-em.", - color = TppColors.Muted, + color = TppTheme.colors.muted, fontSize = 17.sp, fontWeight = FontWeight.Medium, ) @@ -375,13 +377,13 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)), - border = BorderStroke(1.dp, TppColors.Outline.copy(alpha = 0.72f)), - shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, TppTheme.colors.outline.copy(alpha = 0.72f)), + shape = MaterialTheme.shapes.medium, ) { Column(Modifier.fillMaxWidth().padding(cardPadding), verticalArrangement = Arrangement.spacedBy(18.dp)) { Text( "Numer telefonu", - color = TppColors.Ink, + color = TppTheme.colors.ink, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, fontSize = 18.sp, @@ -393,7 +395,7 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) { ) Text( "Dostęp tylko dla upoważnionych kierowców.", - color = TppColors.Muted, + color = TppTheme.colors.muted, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, ) @@ -403,8 +405,8 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) { modifier = Modifier .fillMaxWidth() .height(64.dp), - colors = ButtonDefaults.buttonColors(containerColor = TppColors.ContainerGreen), - shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.containerGreen), + shape = MaterialTheme.shapes.small, contentPadding = PaddingValues(horizontal = 20.dp), ) { Text( @@ -454,7 +456,7 @@ private fun BrandBannerHeader(height: Dp) { Brush.verticalGradient( 0f to Color.Transparent, 0.72f to Color.Transparent, - 1f to TppColors.Surface.copy(alpha = 0.88f), + 1f to TppTheme.colors.surface.copy(alpha = 0.88f), ), ), ) @@ -474,19 +476,19 @@ private fun PhoneNumberField(digits: String, screenWidthDp: Int, onDigitsChange: .fillMaxWidth() .height(fieldHeight) .background(Color(0xFFFEFFFC), RoundedCornerShape(2.dp)) - .border(BorderStroke(1.dp, TppColors.Outline), RoundedCornerShape(2.dp)), + .border(BorderStroke(1.dp, TppTheme.colors.outline), RoundedCornerShape(2.dp)), verticalAlignment = Alignment.CenterVertically, ) { Box( Modifier .width(prefixWidth) .fillMaxHeight() - .background(TppColors.Panel), + .background(TppTheme.colors.panel), contentAlignment = Alignment.Center, ) { - Text("+48", color = TppColors.Ink, fontSize = prefixFontSize) + Text("+48", color = TppTheme.colors.ink, fontSize = prefixFontSize) } - Box(Modifier.width(1.dp).fillMaxHeight().background(TppColors.Outline)) + Box(Modifier.width(1.dp).fillMaxHeight().background(TppTheme.colors.outline)) BasicTextField( value = digits, onValueChange = { onDigitsChange(it) }, @@ -494,7 +496,7 @@ private fun PhoneNumberField(digits: String, screenWidthDp: Int, onDigitsChange: keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), visualTransformation = PolishPhoneNumberVisualTransformation, textStyle = TextStyle( - color = TppColors.Muted, + color = TppTheme.colors.muted, fontSize = inputFontSize, fontWeight = FontWeight.Medium, ), @@ -506,7 +508,7 @@ private fun PhoneNumberField(digits: String, screenWidthDp: Int, onDigitsChange: if (digits.isBlank()) { Text( "000 000 000", - color = TppColors.Muted.copy(alpha = 0.92f), + color = TppTheme.colors.muted.copy(alpha = 0.92f), fontSize = inputFontSize, fontWeight = FontWeight.Medium, ) @@ -545,7 +547,7 @@ private fun AuthShell(title: String, subtitle: String, onBack: (() -> Unit)? = n BoxWithConstraints( Modifier .fillMaxSize() - .background(TppColors.Surface) + .background(TppTheme.colors.surface) .statusBarsPadding() .navigationBarsPadding(), ) { @@ -563,8 +565,8 @@ private fun AuthShell(title: String, subtitle: String, onBack: (() -> Unit)? = n } Spacer(Modifier.height(8.dp)) } - Text(title, style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = TppColors.Forest) - Text(subtitle, color = TppColors.Muted, modifier = Modifier.padding(top = 8.dp, bottom = 24.dp)) + Text(title, style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = TppTheme.colors.forest) + Text(subtitle, color = TppTheme.colors.muted, modifier = Modifier.padding(top = 8.dp, bottom = 24.dp)) content() } } @@ -645,7 +647,7 @@ private fun RoutesScreen( onProfile = onProfile, ) }, - containerColor = TppColors.Surface, + containerColor = TppTheme.colors.surface, ) { padding -> val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh) Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) { @@ -661,7 +663,7 @@ private fun RoutesScreen( routeListTitle(state.selectedDate), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, - color = TppColors.Ink, + color = TppTheme.colors.ink, ) DateChip(state.selectedDate) } @@ -675,7 +677,7 @@ private fun RoutesScreen( routeListTitle(state.selectedDate), style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, - color = TppColors.Ink, + color = TppTheme.colors.ink, ) DateChip(state.selectedDate) } @@ -723,8 +725,8 @@ private fun RoutesScreen( refreshing = state.refreshing, state = pullRefreshState, modifier = Modifier.align(Alignment.TopCenter), - backgroundColor = Color.White, - contentColor = TppColors.Forest, + backgroundColor = TppTheme.colors.card, + contentColor = TppTheme.colors.forest, ) } } @@ -733,11 +735,11 @@ private fun RoutesScreen( if (showPreciseLocationPermissionDialog) { AlertDialog( onDismissRequest = { showPreciseLocationPermissionDialog = false }, - title = { Text("Brak dokładnej lokalizacji", color = TppColors.Ink, fontWeight = FontWeight.Bold) }, + title = { Text("Brak dokładnej lokalizacji", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) }, text = { Text( "Aby zrobić zdjęcie, nadaj aplikacji uprawnienie do dokładnej lokalizacji.", - color = TppColors.Muted, + color = TppTheme.colors.muted, ) }, confirmButton = { @@ -747,12 +749,12 @@ private fun RoutesScreen( openAppSettings(context) }, ) { - Text("Przejdź do ustawień", color = TppColors.Forest, fontWeight = FontWeight.Bold) + Text("Przejdź do ustawień", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) } }, dismissButton = { TextButton(onClick = { showPreciseLocationPermissionDialog = false }) { - Text("Anuluj", color = TppColors.Muted) + Text("Anuluj", color = TppTheme.colors.muted) } }, ) @@ -764,9 +766,9 @@ private fun RouteDayLiveUpdateBanner(message: String?, modifier: Modifier = Modi if (message.isNullOrBlank()) return Card( - colors = CardDefaults.cardColors(containerColor = Color(0xFFEAF7EF)), - border = BorderStroke(1.dp, Color(0xFF9BD1AD)), - shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.successContainer), + border = BorderStroke(1.dp, TppTheme.colors.successOutline), + shape = MaterialTheme.shapes.medium, modifier = modifier.fillMaxWidth(), ) { Row( @@ -778,11 +780,11 @@ private fun RouteDayLiveUpdateBanner(message: String?, modifier: Modifier = Modi Modifier.size(40.dp).background(Color.White, RoundedCornerShape(6.dp)), contentAlignment = Alignment.Center, ) { - Icon(Icons.Outlined.Refresh, contentDescription = null, tint = TppColors.Forest) + Icon(Icons.Outlined.Refresh, contentDescription = null, tint = TppTheme.colors.forest) } Text( message, - color = TppColors.Forest, + color = TppTheme.colors.forest, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f), ) @@ -794,9 +796,9 @@ private fun RouteDayLiveUpdateBanner(message: String?, modifier: Modifier = Modi private fun LeaveRequestsEntryCard(requests: List, onOpen: () -> Unit) { val decisionCount = requests.count { it.status == "pending" || it.status == "cancel_requested" } Card( - colors = CardDefaults.cardColors(containerColor = Color.White), - border = BorderStroke(1.dp, TppColors.Outline), - shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + border = BorderStroke(1.dp, TppTheme.colors.outline), + shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen), ) { Row( @@ -804,18 +806,18 @@ private fun LeaveRequestsEntryCard(requests: List, onOpen horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box(Modifier.size(44.dp).background(Color(0xFFEAF7EF), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { - Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppColors.Forest) + Box(Modifier.size(44.dp).background(TppTheme.colors.successContainer, MaterialTheme.shapes.medium), contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppTheme.colors.forest) } Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { - Text("Wnioski urlopowe", color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 18.sp) + Text("Wnioski urlopowe", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 18.sp) Text( if (decisionCount > 0) "$decisionCount czeka na decyzję" else "Lista, status i nowy wniosek", - color = TppColors.Muted, + color = TppTheme.colors.muted, fontSize = 14.sp, ) } - Text("Otwórz", color = TppColors.Forest, fontWeight = FontWeight.Bold) + Text("Otwórz", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) } } } @@ -832,7 +834,7 @@ private fun LeaveRequestsScreen( val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh) Scaffold( topBar = { SimpleTopBar("Wnioski urlopowe", onBack) }, - containerColor = TppColors.Surface, + containerColor = TppTheme.colors.surface, ) { padding -> Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) { LazyColumn( @@ -845,8 +847,8 @@ private fun LeaveRequestsScreen( Button( onClick = onAdd, modifier = Modifier.fillMaxWidth().height(56.dp), - colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest), - shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), + shape = MaterialTheme.shapes.medium, ) { Icon(Icons.Outlined.CalendarToday, contentDescription = null) Spacer(Modifier.width(8.dp)) @@ -865,8 +867,8 @@ private fun LeaveRequestsScreen( refreshing = state.refreshing, state = pullRefreshState, modifier = Modifier.align(Alignment.TopCenter), - backgroundColor = Color.White, - contentColor = TppColors.Forest, + backgroundColor = TppTheme.colors.card, + contentColor = TppTheme.colors.forest, ) } } @@ -875,9 +877,9 @@ private fun LeaveRequestsScreen( @Composable private fun LeaveRequestCard(request: DriverLeaveRequestDto, onClick: () -> Unit) { Card( - colors = CardDefaults.cardColors(containerColor = Color.White), - border = BorderStroke(1.dp, TppColors.Outline), - shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + border = BorderStroke(1.dp, TppTheme.colors.outline), + shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), ) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -885,17 +887,17 @@ private fun LeaveRequestCard(request: DriverLeaveRequestDto, onClick: () -> Unit Column(Modifier.weight(1f)) { Text( "${DriverLeaveRequestUiRules.typeLabel(request.type)} · ${leaveDateRange(request.dateFrom, request.dateTo)}", - color = TppColors.Ink, + color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 17.sp, ) if (!request.note.isNullOrBlank()) { - Text(request.note, color = TppColors.Muted, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(request.note, color = TppTheme.colors.muted, maxLines = 2, overflow = TextOverflow.Ellipsis) } } LeaveStatusPill(request.status) } - Text("Szczegóły", color = TppColors.Forest, fontWeight = FontWeight.Bold, fontSize = 14.sp) + Text("Szczegóły", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold, fontSize = 14.sp) } } } @@ -907,10 +909,10 @@ private fun LeaveRequestDetailScreen( onCancel: () -> Unit, ) { val request = state.selectedLeaveRequest - Scaffold(topBar = { SimpleTopBar("Szczegóły wniosku", onBack) }, containerColor = TppColors.Surface) { padding -> + Scaffold(topBar = { SimpleTopBar("Szczegóły wniosku", onBack) }, containerColor = TppTheme.colors.surface) { padding -> if (request == null) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { - Text("Nie znaleziono wniosku", color = TppColors.Muted) + Text("Nie znaleziono wniosku", color = TppTheme.colors.muted) } return@Scaffold } @@ -923,13 +925,13 @@ private fun LeaveRequestDetailScreen( item { FeedbackAndError(state.feedback, state.error) } item { Card( - colors = CardDefaults.cardColors(containerColor = Color.White), - border = BorderStroke(1.dp, TppColors.Outline), - shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + border = BorderStroke(1.dp, TppTheme.colors.outline), + shape = MaterialTheme.shapes.medium, ) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) { - Text(DriverLeaveRequestUiRules.typeLabel(request.type), color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 22.sp) + Text(DriverLeaveRequestUiRules.typeLabel(request.type), color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 22.sp) LeaveStatusPill(request.status) } DetailLine("Zakres", leaveDateRange(request.dateFrom, request.dateTo)) @@ -942,7 +944,7 @@ private fun LeaveRequestDetailScreen( onClick = onCancel, modifier = Modifier.fillMaxWidth().height(54.dp), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB45309)), - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.medium, ) { Text(if (request.status == "approved") "Poproś o anulowanie" else "Anuluj wniosek") } @@ -951,22 +953,22 @@ private fun LeaveRequestDetailScreen( } } item { - Text("Historia", color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 18.sp) + Text("Historia", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 18.sp) } items(request.events, key = { it.id }) { event -> Card( - colors = CardDefaults.cardColors(containerColor = Color.White), - border = BorderStroke(1.dp, TppColors.Outline.copy(alpha = 0.65f)), - shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + border = BorderStroke(1.dp, TppTheme.colors.outline.copy(alpha = 0.65f)), + shape = MaterialTheme.shapes.medium, ) { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( "${DriverLeaveRequestUiRules.eventActionLabel(event.action)}${event.toStatus?.let { " · ${DriverLeaveRequestUiRules.statusLabel(it)}" } ?: ""}", - color = TppColors.Ink, + color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, ) - Text(event.createdAt?.let(::shortDateTime) ?: "-", color = TppColors.Muted, fontSize = 13.sp) - if (!event.comment.isNullOrBlank()) Text(event.comment, color = TppColors.Muted) + Text(event.createdAt?.let(::shortDateTime) ?: "-", color = TppTheme.colors.muted, fontSize = 13.sp) + if (!event.comment.isNullOrBlank()) Text(event.comment, color = TppTheme.colors.muted) } } } @@ -993,7 +995,7 @@ private fun AddLeaveRequestScreen( initialSelectedEndDateMillis = selectedEndMillis, ) - Scaffold(topBar = { SimpleTopBar("Nowy wniosek", onBack) }, containerColor = TppColors.Surface) { padding -> + Scaffold(topBar = { SimpleTopBar("Nowy wniosek", onBack) }, containerColor = TppTheme.colors.surface) { padding -> LazyColumn( Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(20.dp), @@ -1002,18 +1004,18 @@ private fun AddLeaveRequestScreen( item { FeedbackAndError(null, state.error) } item { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Typ", color = TppColors.Ink, fontWeight = FontWeight.Bold) + Text("Typ", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(state.leaveRequestsConfig?.types.orEmpty()) { type -> val active = type == state.leaveRequestType Button( onClick = { onDraft(null, null, type, null) }, colors = ButtonDefaults.buttonColors( - containerColor = if (active) TppColors.Forest else Color.White, - contentColor = if (active) Color.White else TppColors.Ink, + containerColor = if (active) TppTheme.colors.forest else Color.White, + contentColor = if (active) Color.White else TppTheme.colors.ink, ), - border = BorderStroke(1.dp, if (active) TppColors.Forest else TppColors.Outline), - shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, if (active) TppTheme.colors.forest else TppTheme.colors.outline), + shape = MaterialTheme.shapes.medium, ) { Text(DriverLeaveRequestUiRules.typeLabel(type)) } @@ -1023,9 +1025,9 @@ private fun AddLeaveRequestScreen( } item { Card( - colors = CardDefaults.cardColors(containerColor = Color.White), - border = BorderStroke(1.dp, TppColors.Outline), - shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + border = BorderStroke(1.dp, TppTheme.colors.outline), + shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth().clickable { showDateRangePicker = true }, ) { Row( @@ -1034,19 +1036,19 @@ private fun AddLeaveRequestScreen( verticalAlignment = Alignment.CenterVertically, ) { Box( - Modifier.size(44.dp).background(Color(0xFFEAF7EF), RoundedCornerShape(8.dp)), + Modifier.size(44.dp).background(TppTheme.colors.successContainer, MaterialTheme.shapes.medium), contentAlignment = Alignment.Center, ) { - Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppColors.Forest) + Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppTheme.colors.forest) } Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("Okres", color = TppColors.Ink, fontWeight = FontWeight.Bold) + Text("Okres", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) Text( leaveDateRange(state.leaveRequestDateFrom, state.leaveRequestDateTo), - color = TppColors.Muted, + color = TppTheme.colors.muted, ) } - Text("Zmień", color = TppColors.Forest, fontWeight = FontWeight.Bold) + Text("Zmień", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) } } } @@ -1062,8 +1064,8 @@ private fun AddLeaveRequestScreen( Button( onClick = onSubmit, modifier = Modifier.fillMaxWidth().height(58.dp), - colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest), - shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), + shape = MaterialTheme.shapes.medium, ) { Text("Wyślij wniosek", fontWeight = FontWeight.Bold) } @@ -1086,12 +1088,12 @@ private fun AddLeaveRequestScreen( }, enabled = dateRangePickerState.selectedStartDateMillis != null, ) { - Text("Ustaw", color = TppColors.Forest, fontWeight = FontWeight.Bold) + Text("Ustaw", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) } }, dismissButton = { TextButton(onClick = { showDateRangePicker = false }) { - Text("Anuluj", color = TppColors.Muted) + Text("Anuluj", color = TppTheme.colors.muted) } }, ) { @@ -1101,7 +1103,7 @@ private fun AddLeaveRequestScreen( Text( "Wybierz okres urlopu", modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 16.dp), - color = TppColors.Ink, + color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, ) }, @@ -1112,7 +1114,7 @@ private fun AddLeaveRequestScreen( dateRangePickerState.selectedEndDateMillis?.let(::utcMillisToLocalDateString), ), modifier = Modifier.padding(start = 24.dp, end = 12.dp, bottom = 12.dp), - color = TppColors.Muted, + color = TppTheme.colors.muted, ) }, showModeToggle = false, @@ -1128,21 +1130,21 @@ private fun SimpleTopBar(title: String, onBack: () -> Unit) { .fillMaxWidth() .statusBarsPadding() .background(Color.White) - .border(0.5.dp, TppColors.Outline.copy(alpha = 0.65f)) + .border(0.5.dp, TppTheme.colors.outline.copy(alpha = 0.65f)) .padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) { - Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz", tint = TppColors.Ink) + Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz", tint = TppTheme.colors.ink) } - Text(title, color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 20.sp) + Text(title, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 20.sp) } } @Composable private fun LeaveStatusPill(status: String) { val color = when (status) { - "approved" -> Color(0xFFEAF7EF) to TppColors.Forest + "approved" -> TppTheme.colors.successContainer to TppTheme.colors.forest "rejected" -> Color(0xFFFFEBEE) to Color(0xFFB91C1C) "cancel_requested" -> Color(0xFFFFF7ED) to Color(0xFFB45309) "cancelled", "revoked" -> Color(0xFFF1F5F9) to Color(0xFF475569) @@ -1160,8 +1162,8 @@ private fun LeaveStatusPill(status: String) { @Composable private fun DetailLine(label: String, value: String) { Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { - Text(label.uppercase(Locale("pl", "PL")), color = TppColors.Muted, fontSize = 12.sp, fontWeight = FontWeight.Bold) - Text(value, color = TppColors.Ink, fontSize = 16.sp) + Text(label.uppercase(Locale("pl", "PL")), color = TppTheme.colors.muted, fontSize = 12.sp, fontWeight = FontWeight.Bold) + Text(value, color = TppTheme.colors.ink, fontSize = 16.sp) } } @@ -1169,7 +1171,7 @@ private fun DetailLine(label: String, value: String) { private fun FeedbackAndError(feedback: String?, error: String?) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (!feedback.isNullOrBlank()) { - Text(feedback, color = TppColors.Forest, fontWeight = FontWeight.Bold) + Text(feedback, color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) } ErrorText(error) } @@ -1183,7 +1185,7 @@ private fun LeaveTextField(label: String, value: String, onValue: (String) -> Un label = { Text(label) }, minLines = minLines, modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.medium, ) } @@ -1215,14 +1217,14 @@ private fun DispatchSheetReminderCard( val queuedCount = queuedPhotoUploadCount(uploads.map { it.status }) val hasQueuedUpload = queuedCount > 0 val uploaded = reminder?.status == "uploaded" && !hasQueuedUpload - val container = if (uploaded) Color(0xFFEAF7EF) else Color.White - val border = if (uploaded) Color(0xFF9BD1AD) else TppColors.Outline + val container = if (uploaded) TppTheme.colors.successContainer else TppTheme.colors.card + val border = if (uploaded) TppTheme.colors.successOutline else TppTheme.colors.outline val actionLabel = dispatchSheetPrimaryActionLabel(reminder, hasQueuedUpload) Card( colors = CardDefaults.cardColors(containerColor = container), border = BorderStroke(1.dp, border), - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth(), ) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { @@ -1234,13 +1236,13 @@ private fun DispatchSheetReminderCard( Icon( if (uploaded) Icons.Outlined.CheckCircle else Icons.Outlined.CameraAlt, contentDescription = null, - tint = if (uploaded) TppColors.Forest else TppColors.Ink, + tint = if (uploaded) TppTheme.colors.forest else TppTheme.colors.ink, modifier = Modifier.size(28.dp), ) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( if (uploaded) "Zdjęcie karty spedycyjnej wykonane" else "Karta spedycyjna", - color = TppColors.Ink, + color = TppTheme.colors.ink, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) @@ -1250,7 +1252,7 @@ private fun DispatchSheetReminderCard( } else { "Po zakończeniu dnia zrób zdjęcie karty spedycyjnej." }, - color = TppColors.Muted, + color = TppTheme.colors.muted, style = MaterialTheme.typography.bodyMedium, ) } @@ -1261,10 +1263,10 @@ private fun DispatchSheetReminderCard( enabled = reminder?.canUpload == true && !hasQueuedUpload, modifier = Modifier.fillMaxWidth().height(56.dp), colors = ButtonDefaults.buttonColors( - containerColor = if (uploaded) TppColors.ContainerGreen else TppColors.Forest, - disabledContainerColor = TppColors.Outline, + containerColor = if (uploaded) TppTheme.colors.containerGreen else TppTheme.colors.forest, + disabledContainerColor = TppTheme.colors.outline, ), - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.medium, ) { if (hasQueuedUpload) { CircularProgressIndicator( @@ -1288,9 +1290,9 @@ private fun PhotoQueueBanner(uploads: List, onPhotoQueue: () if (count <= 0) return Card( - colors = CardDefaults.cardColors(containerColor = Color.White), - border = BorderStroke(1.dp, TppColors.Outline), - shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + border = BorderStroke(1.dp, TppTheme.colors.outline), + shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth(), ) { Row( @@ -1299,21 +1301,21 @@ private fun PhotoQueueBanner(uploads: List, onPhotoQueue: () horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Box( - Modifier.size(42.dp).background(TppColors.ContainerGreen, RoundedCornerShape(6.dp)), + Modifier.size(42.dp).background(TppTheme.colors.containerGreen, RoundedCornerShape(6.dp)), contentAlignment = Alignment.Center, ) { - Icon(Icons.Outlined.CloudUpload, contentDescription = null, tint = TppColors.Forest) + Icon(Icons.Outlined.CloudUpload, contentDescription = null, tint = TppTheme.colors.forest) } Text( "W kolejce do wysłania jest $count ${photoCountLabel(count)}.", - color = TppColors.Ink, + color = TppTheme.colors.ink, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), ) Button( onClick = onPhotoQueue, - colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest), - shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), + shape = MaterialTheme.shapes.small, contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), ) { Text("Kolejka", color = Color.White, fontWeight = FontWeight.Bold) @@ -1335,7 +1337,7 @@ private fun PhotoQueueScreen( Scaffold( topBar = { DetailHeader(onBack, title = "Kolejka zdjęć") }, - containerColor = TppColors.Surface, + containerColor = TppTheme.colors.surface, ) { padding -> LazyColumn( Modifier.fillMaxSize().padding(padding), @@ -1351,7 +1353,7 @@ private fun PhotoQueueScreen( "Zdjęcia do przesłania (${uploads.size})", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = TppColors.Ink, + color = TppTheme.colors.ink, ) } items(uploads, key = { it.clientRequestId }) { upload -> @@ -1369,9 +1371,9 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa val hasFailureDetails = canShowPhotoUploadFailureDetails(upload.status, upload.lastError) var showFailureDetails by remember { mutableStateOf(false) } Card( - colors = CardDefaults.cardColors(containerColor = Color.White), - border = BorderStroke(1.dp, TppColors.Outline), - shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + border = BorderStroke(1.dp, TppTheme.colors.outline), + shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth(), ) { Row( @@ -1383,13 +1385,13 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa model = File(upload.localPath), contentDescription = "Zdjęcie z kolejki", contentScale = ContentScale.Crop, - modifier = Modifier.size(76.dp).background(TppColors.Panel, RoundedCornerShape(6.dp)), + modifier = Modifier.size(76.dp).background(TppTheme.colors.panel, RoundedCornerShape(6.dp)), ) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Kurs #${upload.routeId}", color = TppColors.Ink, fontWeight = FontWeight.Bold) + Text("Kurs #${upload.routeId}", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) Text( upload.lastError?.lineSequence()?.firstOrNull { it.isNotBlank() }?.takeIf { hasFailureDetails } ?: status.label, - color = if (hasFailureDetails) TppColors.Error else TppColors.Muted, + color = if (hasFailureDetails) TppTheme.colors.error else TppTheme.colors.muted, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelMedium, ) @@ -1397,8 +1399,8 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa androidx.compose.material3.LinearProgressIndicator( progress = { (upload.progress.coerceIn(0, 100) / 100f) }, modifier = Modifier.fillMaxWidth(), - color = TppColors.Forest, - trackColor = TppColors.Outline.copy(alpha = 0.4f), + color = TppTheme.colors.forest, + trackColor = TppTheme.colors.outline.copy(alpha = 0.4f), ) } } @@ -1409,13 +1411,13 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa onClick = { showFailureDetails = true }, modifier = Modifier .size(40.dp) - .background(TppColors.Surface, RoundedCornerShape(20.dp)) - .border(1.dp, TppColors.Error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), + .background(TppTheme.colors.surface, RoundedCornerShape(20.dp)) + .border(1.dp, TppTheme.colors.error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), ) { Icon( Icons.Outlined.Info, contentDescription = "Pokaż szczegóły błędu", - tint = TppColors.Error, + tint = TppTheme.colors.error, modifier = Modifier.size(20.dp), ) } @@ -1423,8 +1425,8 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa if (canRetryPhotoUpload(upload.status)) { Button( onClick = { onRetryUpload(upload) }, - colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest), - shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), + shape = MaterialTheme.shapes.small, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), ) { Icon( @@ -1454,12 +1456,12 @@ private fun PhotoUploadFailureDialog(details: String, onDismiss: () -> Unit) { AlertDialog( onDismissRequest = onDismiss, title = { - Text("Dlaczego nie wysłano zdjęcia?", color = TppColors.Ink, fontWeight = FontWeight.Bold) + Text("Dlaczego nie wysłano zdjęcia?", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) }, text = { Text( details, - color = TppColors.Ink, + color = TppTheme.colors.ink, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall, modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState()), @@ -1467,7 +1469,7 @@ private fun PhotoUploadFailureDialog(details: String, onDismiss: () -> Unit) { }, confirmButton = { TextButton(onClick = onDismiss) { - Text("Zamknij", color = TppColors.Forest, fontWeight = FontWeight.Bold) + Text("Zamknij", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) } }, ) @@ -1475,9 +1477,9 @@ private fun PhotoUploadFailureDialog(details: String, onDismiss: () -> Unit) { @Composable private fun StitchHeader(height: Dp = 124.dp) { - Column(Modifier.fillMaxWidth().background(TppColors.Surface).statusBarsPadding()) { + Column(Modifier.fillMaxWidth().background(TppTheme.colors.surface).statusBarsPadding()) { BrandBannerHeader(height = height) - HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.86f), thickness = 1.dp) + HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.86f), thickness = 1.dp) } } @@ -1486,12 +1488,12 @@ private fun DateChip(date: String) { val formatter = remember { DateTimeFormatter.ofPattern("d MMM", Locale("pl", "PL")) } 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), + modifier = Modifier.background(TppTheme.colors.panel, RoundedCornerShape(12.dp)).padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppColors.Forest) - Text(label, fontFamily = FontFamily.Monospace, color = TppColors.Muted, fontWeight = FontWeight.SemiBold) + Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppTheme.colors.forest) + Text(label, fontFamily = FontFamily.Monospace, color = TppTheme.colors.muted, fontWeight = FontWeight.SemiBold) } } @@ -1529,8 +1531,8 @@ private fun RouteDateSelector(selectedDate: String, minDate: String, maxDate: St 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 + val background = if (selected) TppTheme.colors.forest else TppTheme.colors.card + val content = if (selected) Color.White else TppTheme.colors.muted Row( modifier = Modifier @@ -1560,9 +1562,9 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) { val stripColor = routeStatusColor(route.status) Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = Color.White), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, TppColors.Outline), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, TppTheme.colors.outline), ) { BoxWithConstraints(Modifier.fillMaxWidth()) { val compactWidth = maxWidth < 340.dp @@ -1583,7 +1585,7 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) { Text( "Kontrakt: #${display.contractLabel.trimStart('#')}", fontFamily = FontFamily.Monospace, - color = TppColors.Ink, + color = TppTheme.colors.ink, fontWeight = FontWeight.SemiBold, ) } @@ -1597,7 +1599,7 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) { Text( "Kontrakt: #${display.contractLabel.trimStart('#')}", fontFamily = FontFamily.Monospace, - color = TppColors.Ink, + color = TppTheme.colors.ink, fontWeight = FontWeight.SemiBold, ) } @@ -1605,12 +1607,12 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) { Spacer(Modifier.height(if (compactWidth) 18.dp else 26.dp)) RouteTimeline(display.origin, display.destination) } - HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.55f)) + HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) Button( onClick = { onRoute(route.id) }, modifier = Modifier.fillMaxWidth().padding(16.dp).height(56.dp), colors = ButtonDefaults.buttonColors(containerColor = stripColor), - shape = RoundedCornerShape(4.dp), + shape = MaterialTheme.shapes.small, ) { Text("Szczegóły →", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) } @@ -1638,19 +1640,19 @@ private fun RouteTimeline(origin: String, destination: String) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(14.dp)) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Box(Modifier.size(18.dp).background(Color.White, RoundedCornerShape(50)).padding(3.dp)) { - Box(Modifier.fillMaxSize().background(TppColors.Forest, RoundedCornerShape(50))) + Box(Modifier.fillMaxSize().background(TppTheme.colors.forest, RoundedCornerShape(50))) } - Box(Modifier.width(3.dp).height(64.dp).background(TppColors.Outline.copy(alpha = 0.8f))) - Box(Modifier.size(18.dp).background(TppColors.Navy, RoundedCornerShape(50))) + Box(Modifier.width(3.dp).height(64.dp).background(TppTheme.colors.outline.copy(alpha = 0.8f))) + Box(Modifier.size(18.dp).background(TppTheme.colors.navy, RoundedCornerShape(50))) } Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(30.dp)) { Column { - Text("SKĄD", color = TppColors.Muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) - Text(origin, style = MaterialTheme.typography.titleLarge, color = TppColors.Ink, fontWeight = FontWeight.Bold) + Text("SKĄD", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) + Text(origin, style = MaterialTheme.typography.titleLarge, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) } Column { - Text("DOKĄD", color = TppColors.Muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) - Text(destination, style = MaterialTheme.typography.titleLarge, color = TppColors.Ink, fontWeight = FontWeight.Bold) + Text("DOKĄD", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) + Text(destination, style = MaterialTheme.typography.titleLarge, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) } } } @@ -1665,6 +1667,7 @@ private fun ProfileScreen( onLeaveRequests: () -> Unit, onNewRouteNotificationsChanged: (Boolean) -> Unit, onOpenNotificationSettings: () -> Unit, + onThemeModeChanged: (AppThemeMode) -> Unit, ) { BoxWithConstraints(Modifier.fillMaxSize()) { val screenWidthDp = maxWidth.value.toInt() @@ -1681,7 +1684,7 @@ private fun ProfileScreen( onProfile = onProfile, ) }, - containerColor = TppColors.Surface, + containerColor = TppTheme.colors.surface, ) { padding -> Column( Modifier @@ -1693,17 +1696,17 @@ private fun ProfileScreen( ) { Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = Color.White), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, TppColors.Outline), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, TppTheme.colors.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)) + Icon(Icons.Outlined.Person, contentDescription = null, tint = TppTheme.colors.forest, modifier = Modifier.size(34.dp)) Text( "Witaj ${state.driver?.displayName.orEmpty().ifBlank { "Kierowco" }}", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = TppColors.Ink, + color = TppTheme.colors.ink, ) } } @@ -1712,16 +1715,16 @@ private fun ProfileScreen( } Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = Color.White), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, TppColors.Outline), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, TppTheme.colors.outline), ) { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { Text( "Ustawienia", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = TppColors.Ink, + color = TppTheme.colors.ink, ) Row( Modifier.fillMaxWidth(), @@ -1733,12 +1736,12 @@ private fun ProfileScreen( "Otrzymuj powiadomienia gdy spedytor doda nowy kurs", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, - color = TppColors.Ink, + color = TppTheme.colors.ink, ) Text( "Ta opcja może wprowadzać w błąd, ponieważ część kursów może zostać później usunięta.", style = MaterialTheme.typography.bodySmall, - color = TppColors.Muted, + color = TppTheme.colors.muted, ) } Switch( @@ -1751,19 +1754,46 @@ private fun ProfileScreen( Button( onClick = onOpenNotificationSettings, modifier = Modifier.fillMaxWidth().height(48.dp), - colors = ButtonDefaults.buttonColors(containerColor = TppColors.Navy), - shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.navy), + shape = MaterialTheme.shapes.small, ) { Text("Przejdź do ustawień", fontWeight = FontWeight.Bold) } } + HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + "Theme", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = TppTheme.colors.ink, + ) + Text( + if (state.themeMode == AppThemeMode.Material3) "Material 3" else "Aktualny", + style = MaterialTheme.typography.bodySmall, + color = TppTheme.colors.muted, + ) + } + Switch( + checked = state.themeMode == AppThemeMode.Material3, + onCheckedChange = { enabled -> + onThemeModeChanged(if (enabled) AppThemeMode.Material3 else AppThemeMode.Current) + }, + enabled = !state.loading, + ) + } } } Button( onClick = onLogout, modifier = Modifier.fillMaxWidth().height(56.dp), - colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest), - shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), + shape = MaterialTheme.shapes.small, ) { Text("Wyloguj", fontWeight = FontWeight.Bold) } @@ -1794,8 +1824,8 @@ private fun openAppNotificationSettings(context: Context) { @Composable private fun StitchBottomBar(activeScreen: DriverScreen, height: Dp = 82.dp, onRoutes: () -> Unit, onProfile: () -> Unit) { - Column(Modifier.fillMaxWidth().background(Color.White).navigationBarsPadding()) { - HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.9f), thickness = 2.dp) + Column(Modifier.fillMaxWidth().background(TppTheme.colors.card).navigationBarsPadding()) { + HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.9f), thickness = 2.dp) Row( Modifier.fillMaxWidth().height(height), horizontalArrangement = Arrangement.SpaceEvenly, @@ -1814,19 +1844,20 @@ private fun BottomNavItem(label: String, icon: ImageVector, active: Boolean, onC horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Icon(icon, contentDescription = label, tint = if (active) TppColors.Forest else TppColors.Muted) + Icon(icon, contentDescription = label, tint = if (active) TppTheme.colors.forest else TppTheme.colors.muted) Spacer(Modifier.height(6.dp)) - Text(label, color = if (active) TppColors.Forest else TppColors.Muted, fontFamily = FontFamily.Monospace) + Text(label, color = if (active) TppTheme.colors.forest else TppTheme.colors.muted, fontFamily = FontFamily.Monospace) } } +@Composable private fun routeStatusColor(status: String): Color { val normalized = status.uppercase(Locale("pl", "PL")) return when { - "NOW" in normalized -> TppColors.Navy - "ZAK" in normalized || "GOT" in normalized -> TppColors.Forest + "NOW" in normalized -> TppTheme.colors.navy + "ZAK" in normalized || "GOT" in normalized -> TppTheme.colors.forest "TRAK" in normalized || "START" in normalized -> Color(0xFFF5B800) - else -> TppColors.Forest + else -> TppTheme.colors.forest } } @@ -1887,7 +1918,7 @@ private fun DetailScreen( topBar = { DetailHeader(onBack) }, - containerColor = TppColors.Surface, + containerColor = TppTheme.colors.surface, ) { padding -> if (route == null) return@Scaffold Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) { @@ -1945,8 +1976,8 @@ private fun DetailScreen( refreshing = state.refreshing, state = pullRefreshState, modifier = Modifier.align(Alignment.TopCenter), - backgroundColor = Color.White, - contentColor = TppColors.Forest, + backgroundColor = TppTheme.colors.card, + contentColor = TppTheme.colors.forest, ) } } @@ -1955,8 +1986,8 @@ private fun DetailScreen( if (showCompleteDialog && route != null) { AlertDialog( onDismissRequest = { if (!state.completingRoute) showCompleteDialog = false }, - title = { Text("Zakończyć kurs?", color = TppColors.Ink, fontWeight = FontWeight.Bold) }, - text = { Text("Status kursu zostanie ustawiony jako zakończony.", color = TppColors.Muted) }, + title = { Text("Zakończyć kurs?", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) }, + text = { Text("Status kursu zostanie ustawiony jako zakończony.", color = TppTheme.colors.muted) }, confirmButton = { TextButton( enabled = !state.completingRoute, @@ -1965,7 +1996,7 @@ private fun DetailScreen( onCompleteRoute() }, ) { - Text("Zakończ kurs", color = TppColors.Forest, fontWeight = FontWeight.Bold) + Text("Zakończ kurs", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) } }, dismissButton = { @@ -1973,7 +2004,7 @@ private fun DetailScreen( enabled = !state.completingRoute, onClick = { showCompleteDialog = false }, ) { - Text("Anuluj", color = TppColors.Muted) + Text("Anuluj", color = TppTheme.colors.muted) } }, ) @@ -1982,11 +2013,11 @@ private fun DetailScreen( if (showPreciseLocationPermissionDialog) { AlertDialog( onDismissRequest = { showPreciseLocationPermissionDialog = false }, - title = { Text("Brak dokładnej lokalizacji", color = TppColors.Ink, fontWeight = FontWeight.Bold) }, + title = { Text("Brak dokładnej lokalizacji", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) }, text = { Text( "Aby zrobić zdjęcie, nadaj aplikacji uprawnienie do dokładnej lokalizacji.", - color = TppColors.Muted, + color = TppTheme.colors.muted, ) }, confirmButton = { @@ -1996,12 +2027,12 @@ private fun DetailScreen( openAppSettings(context) }, ) { - Text("Przejdź do ustawień", color = TppColors.Forest, fontWeight = FontWeight.Bold) + Text("Przejdź do ustawień", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) } }, dismissButton = { TextButton(onClick = { showPreciseLocationPermissionDialog = false }) { - Text("Anuluj", color = TppColors.Muted) + Text("Anuluj", color = TppTheme.colors.muted) } }, ) @@ -2010,25 +2041,25 @@ private fun DetailScreen( @Composable private fun DetailHeader(onBack: () -> Unit, title: String = "Szczegóły Trasy") { - Column(Modifier.fillMaxWidth().background(TppColors.Surface).statusBarsPadding()) { + Column(Modifier.fillMaxWidth().background(TppTheme.colors.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) + Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz", tint = TppTheme.colors.ink) } Text( title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, - color = TppColors.Ink, + color = TppTheme.colors.ink, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f).padding(start = 4.dp), ) } - HorizontalDivider(color = TppColors.Outline, thickness = 1.dp) + HorizontalDivider(color = TppTheme.colors.outline, thickness = 1.dp) } } @@ -2047,9 +2078,9 @@ private fun RouteCompletionSection( if (route.status == "ZAKOŃCZONA") { if (!feedback.isNullOrBlank()) { Card( - colors = CardDefaults.cardColors(containerColor = Color(0xFFEAF7EF)), - border = BorderStroke(1.dp, Color(0xFF9BD1AD)), - shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.successContainer), + border = BorderStroke(1.dp, TppTheme.colors.successOutline), + shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth(), ) { Row( @@ -2057,8 +2088,8 @@ private fun RouteCompletionSection( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = TppColors.Forest) - Text(feedback, color = TppColors.Forest, fontWeight = FontWeight.Bold) + Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = TppTheme.colors.forest) + Text(feedback, color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) } } } @@ -2073,10 +2104,10 @@ private fun RouteCompletionSection( enabled = isOnline && !completing, modifier = Modifier.fillMaxWidth().height(64.dp), colors = ButtonDefaults.buttonColors( - containerColor = TppColors.Forest, - disabledContainerColor = TppColors.Outline, + containerColor = TppTheme.colors.forest, + disabledContainerColor = TppTheme.colors.outline, ), - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.medium, ) { if (completing) { CircularProgressIndicator( @@ -2097,7 +2128,7 @@ private fun RouteCompletionSection( if (!isOnline) { Text( "Kurs można zakończyć tylko po połączeniu z serwerem.", - color = TppColors.Muted, + color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 4.dp), @@ -2118,9 +2149,9 @@ private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavig val stripColor = routeStatusColor(route.status) val routeDate = route.routeDate ?: selectedDate Card( - colors = CardDefaults.cardColors(containerColor = Color.White), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, TppColors.Outline), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, TppTheme.colors.outline), ) { Column { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { @@ -2139,29 +2170,29 @@ private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavig "Zlecenie #${route.contractCode ?: route.id}", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = TppColors.Ink, + color = TppTheme.colors.ink, ) } } - HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f)) + HorizontalDivider(color = TppTheme.colors.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, + iconBackground = TppTheme.colors.panel, + iconTint = TppTheme.colors.containerGreen, navigationPoint = route.originNavigation, onNavigate = onNavigate, ) - HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.45f)) + HorizontalDivider(color = TppTheme.colors.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, + iconBackground = TppTheme.colors.panel.copy(alpha = 0.72f), + iconTint = TppTheme.colors.forest, framed = true, navigationPoint = route.destinationNavigation, onNavigate = onNavigate, @@ -2223,27 +2254,27 @@ private fun RoutePointBlock( Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( label.uppercase(Locale("pl", "PL")), - color = TppColors.Muted, + color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelMedium, ) - Text(title, color = TppColors.Ink, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(title, color = TppTheme.colors.ink, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) if (framed) { - Box(Modifier.fillMaxWidth().background(TppColors.Panel, RoundedCornerShape(4.dp)).padding(12.dp)) { + Box(Modifier.fillMaxWidth().background(TppTheme.colors.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) + Text("UWAGI DO ZLECENIA", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall) + Text(subtitle, color = TppTheme.colors.ink, fontWeight = FontWeight.SemiBold) } } } else { - Text(subtitle, color = TppColors.Muted, style = MaterialTheme.typography.bodyLarge) + Text(subtitle, color = TppTheme.colors.muted, style = MaterialTheme.typography.bodyLarge) } } if (navigationPoint?.hasCoordinates() == true) { IconButton( onClick = { onNavigate(navigationPoint) }, - modifier = Modifier.size(42.dp).background(TppColors.Forest, RoundedCornerShape(21.dp)), + modifier = Modifier.size(42.dp).background(TppTheme.colors.forest, RoundedCornerShape(21.dp)), ) { Icon(Icons.Outlined.Navigation, contentDescription = "Nawiguj", tint = Color.White, modifier = Modifier.size(23.dp)) } @@ -2253,7 +2284,7 @@ private fun RoutePointBlock( @Composable private fun RouteFactsFooter(route: DriverRouteDto) { - BoxWithConstraints(Modifier.fillMaxWidth().background(TppColors.Panel)) { + BoxWithConstraints(Modifier.fillMaxWidth().background(TppTheme.colors.panel)) { val columns = routeFactColumns(maxWidth.value.toInt()) Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { @@ -2279,8 +2310,8 @@ private fun RouteFactsFooter(route: DriverRouteDto) { @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) + Text(label, color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall) + Text(value.ifBlank { "-" }, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) } } @@ -2301,19 +2332,19 @@ private fun CargoDocumentationSection( ) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column { - Text("Dokumentacja ładunku", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = TppColors.Ink) + Text("Dokumentacja ładunku", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = TppTheme.colors.ink) Spacer(Modifier.height(8.dp)) - Box(Modifier.width(116.dp).height(3.dp).background(TppColors.ContainerGreen)) + Box(Modifier.width(116.dp).height(3.dp).background(TppTheme.colors.containerGreen)) } Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { if (canManagePhotos) { if (allowGalleryUploads) { - CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppColors.ContainerGreen, onGallery) + CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen, onGallery) } - CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppColors.Forest, onCamera) + CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppTheme.colors.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) + Box(Modifier.fillMaxWidth().background(TppTheme.colors.panel, RoundedCornerShape(4.dp)).padding(14.dp)) { + Text("Zdjęcia można dodawać tylko dla dzisiejszych kursów.", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace) } } } @@ -2327,7 +2358,7 @@ private fun CargoActionButton(label: String, icon: ImageVector, color: Color, on onClick = onClick, modifier = Modifier.fillMaxWidth().height(64.dp), colors = ButtonDefaults.buttonColors(containerColor = color), - shape = RoundedCornerShape(8.dp), + shape = MaterialTheme.shapes.medium, ) { Icon(icon, contentDescription = null, tint = Color.White) Spacer(Modifier.width(10.dp)) @@ -2352,9 +2383,9 @@ private fun PhotoGrid( val visibleAttachmentCount = visiblePhotoAttachmentCount(photos.size, uploads.size) Card( - colors = CardDefaults.cardColors(containerColor = Color.White), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, TppColors.Outline), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, TppTheme.colors.outline), ) { BoxWithConstraints(Modifier.fillMaxWidth()) { val compactWidth = maxWidth < 340.dp @@ -2364,7 +2395,7 @@ private fun PhotoGrid( Column(Modifier.padding(contentPadding), verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( "Załączone zdjęcia ($visibleAttachmentCount)", - color = TppColors.Muted, + color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold, ) @@ -2410,8 +2441,8 @@ private fun PhotoGrid( pendingDelete?.let { target -> AlertDialog( onDismissRequest = { pendingDelete = null }, - title = { Text("Usunąć zdjęcie?", color = TppColors.Ink, fontWeight = FontWeight.Bold) }, - text = { Text("Czy na pewno chcesz usunąć to zdjęcie?", color = TppColors.Muted) }, + title = { Text("Usunąć zdjęcie?", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) }, + text = { Text("Czy na pewno chcesz usunąć to zdjęcie?", color = TppTheme.colors.muted) }, confirmButton = { TextButton( onClick = { @@ -2422,12 +2453,12 @@ private fun PhotoGrid( } }, ) { - Text("Usuń", color = TppColors.Error, fontWeight = FontWeight.Bold) + Text("Usuń", color = TppTheme.colors.error, fontWeight = FontWeight.Bold) } }, dismissButton = { TextButton(onClick = { pendingDelete = null }) { - Text("Anuluj", color = TppColors.Muted) + Text("Anuluj", color = TppTheme.colors.muted) } }, ) @@ -2468,7 +2499,7 @@ private fun PhotoTile( Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.72f)), contentAlignment = Alignment.Center, ) { - Text("Usuwam", color = TppColors.Muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) + Text("Usuwam", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) } } if (photo.canDelete) { @@ -2479,13 +2510,13 @@ private fun PhotoTile( .align(Alignment.TopEnd) .padding(8.dp) .size(40.dp) - .background(TppColors.Surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp)) - .border(1.dp, TppColors.Error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), + .background(TppTheme.colors.surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp)) + .border(1.dp, TppTheme.colors.error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), ) { Icon( Icons.Outlined.Delete, contentDescription = "Usuń zdjęcie", - tint = TppColors.Error, + tint = TppTheme.colors.error, modifier = Modifier.size(19.dp), ) } @@ -2512,7 +2543,7 @@ private fun PendingPhotoTile( onDismiss = { showFailureDetails = false }, ) } - Box(modifier.aspectRatio(1f).background(TppColors.Panel, RoundedCornerShape(4.dp))) { + Box(modifier.aspectRatio(1f).background(TppTheme.colors.panel, RoundedCornerShape(4.dp))) { AsyncImage( model = File(upload.localPath), contentDescription = "Zdjęcie oczekujące na zapis", @@ -2525,7 +2556,7 @@ private fun PendingPhotoTile( Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.72f)), contentAlignment = Alignment.Center, ) { - Text("Usuwam", color = TppColors.Muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) + Text("Usuwam", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) } } if (canDeleteServerPhoto) { @@ -2536,13 +2567,13 @@ private fun PendingPhotoTile( .align(Alignment.TopEnd) .padding(8.dp) .size(40.dp) - .background(TppColors.Surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp)) - .border(1.dp, TppColors.Error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), + .background(TppTheme.colors.surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp)) + .border(1.dp, TppTheme.colors.error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), ) { Icon( Icons.Outlined.Delete, contentDescription = "Usuń zdjęcie", - tint = TppColors.Error, + tint = TppTheme.colors.error, modifier = Modifier.size(19.dp), ) } @@ -2555,13 +2586,13 @@ private fun PendingPhotoTile( .align(Alignment.TopStart) .padding(8.dp) .size(40.dp) - .background(TppColors.Surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp)) - .border(1.dp, TppColors.Error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), + .background(TppTheme.colors.surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp)) + .border(1.dp, TppTheme.colors.error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), ) { Icon( Icons.Outlined.Info, contentDescription = "Pokaż szczegóły błędu wysłania", - tint = TppColors.Error, + tint = TppTheme.colors.error, modifier = Modifier.size(20.dp), ) } @@ -2574,13 +2605,13 @@ private fun PendingPhotoTile( .align(Alignment.TopEnd) .padding(8.dp) .size(40.dp) - .background(TppColors.Surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp)) - .border(1.dp, TppColors.Forest.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), + .background(TppTheme.colors.surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp)) + .border(1.dp, TppTheme.colors.forest.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), ) { Icon( Icons.Outlined.Refresh, contentDescription = "Ponów wysłanie zdjęcia", - tint = TppColors.Forest, + tint = TppTheme.colors.forest, modifier = Modifier.size(20.dp), ) } @@ -2591,7 +2622,7 @@ private fun PendingPhotoTile( ) { Text( upload.lastError?.lineSequence()?.firstOrNull { it.isNotBlank() }?.takeIf { hasFailureDetails } ?: status.label, - color = if (hasFailureDetails) TppColors.Error else TppColors.Ink, + color = if (hasFailureDetails) TppTheme.colors.error else TppTheme.colors.ink, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelMedium, @@ -2600,8 +2631,8 @@ private fun PendingPhotoTile( androidx.compose.material3.LinearProgressIndicator( progress = { (upload.progress.coerceIn(0, 100) / 100f) }, modifier = Modifier.fillMaxWidth(), - color = TppColors.Forest, - trackColor = TppColors.Outline.copy(alpha = 0.4f), + color = TppTheme.colors.forest, + trackColor = TppTheme.colors.outline.copy(alpha = 0.4f), ) } } @@ -2611,10 +2642,10 @@ private fun PendingPhotoTile( @Composable private fun EmptyPhotoState() { Box( - Modifier.fillMaxWidth().height(132.dp).background(TppColors.Panel, RoundedCornerShape(4.dp)), + Modifier.fillMaxWidth().height(132.dp).background(TppTheme.colors.panel, RoundedCornerShape(4.dp)), contentAlignment = Alignment.Center, ) { - Text("Brak załączonych zdjęć", color = TppColors.Muted, fontFamily = FontFamily.Monospace) + Text("Brak załączonych zdjęć", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace) } } @@ -2669,40 +2700,40 @@ private fun PrimaryButton(label: String, onClick: () -> Unit) { Button( onClick = onClick, modifier = Modifier.fillMaxWidth().height(56.dp), - colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest), - shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), + shape = MaterialTheme.shapes.small, ) { Text(label, fontWeight = FontWeight.Bold) } } @Composable private fun EmptyState(text: String) { - Card(colors = CardDefaults.cardColors(containerColor = Color.White), shape = RoundedCornerShape(8.dp)) { - Text(text, modifier = Modifier.padding(24.dp), color = TppColors.Muted) + Card(colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), shape = MaterialTheme.shapes.medium) { + Text(text, modifier = Modifier.padding(24.dp), color = TppTheme.colors.muted) } } @Composable private fun ErrorText(error: String?) { - if (!error.isNullOrBlank()) Text(error, color = TppColors.Error, modifier = Modifier.padding(vertical = 8.dp)) + if (!error.isNullOrBlank()) Text(error, color = TppTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp)) } @Composable private fun OfflineStaleBanner(state: DriverUiState) { - if (state.isOnline && !state.isStale) return - val syncLabel = state.lastSuccessfulSyncAtEpochMillis?.let(::formatSyncTime) ?: "brak zapisanej synchronizacji" + val message = offlineStaleBannerMessage( + isOnline = state.isOnline, + isStale = state.isStale, + syncLabel = syncLabel, + ) ?: return + Card( - colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF7E6)), - border = BorderStroke(1.dp, Color(0xFFE6B85C)), - shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.warningContainer), + border = BorderStroke(1.dp, TppTheme.colors.warningOutline), + shape = MaterialTheme.shapes.small, modifier = Modifier.fillMaxWidth(), ) { Text( - text = if (state.isOnline) { - "Dane mogą być nieaktualne. Ostatnia synchronizacja: $syncLabel." - } else { - "Brak połączenia z serwerem. Dane z $syncLabel mogą być nieaktualne." - }, + text = message, color = Color(0xFF5F4200), fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(12.dp), diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt index 757572e..e5f96e1 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -152,6 +152,15 @@ fun dispatchSheetPrimaryActionLabel(reminder: DispatchSheetReminderDto?, hasQueu else -> "Zrób zdjęcie" } +fun offlineStaleBannerMessage(isOnline: Boolean, isStale: Boolean, syncLabel: String): String? = + when { + isOnline && !isStale -> null + !isOnline && syncLabel == "brak zapisanej synchronizacji" -> + "Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie." + !isOnline -> "Brak połączenia z serwerem. Dane z $syncLabel mogą być nieaktualne." + else -> "Dane mogą być nieaktualne. Ostatnia synchronizacja: $syncLabel." + } + private fun parseIsoOffsetEpochMillis(value: String?): Long? = value?.takeIf { it.isNotBlank() }?.let { runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull() 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 9547ddf..dadc77f 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate +import pl.firmatpp.kierowca.data.AppPreferencesStore import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.DriverRepository @@ -30,6 +31,7 @@ import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.sync.DriverLiveSyncClient import pl.firmatpp.kierowca.sync.DriverSyncHint import pl.firmatpp.kierowca.sync.DriverSyncWorker +import pl.firmatpp.kierowca.ui.theme.AppThemeMode enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, AddLeaveRequest } @@ -71,11 +73,22 @@ data class DriverUiState( val isStale: Boolean = false, val lastSuccessfulSyncAtEpochMillis: Long? = null, val routeDayLiveUpdateMessage: String? = null, + val themeMode: AppThemeMode = AppThemeMode.Default, val feedback: String? = null, val error: String? = null, ) +private fun DriverUiState.withApiError(throwable: Throwable): DriverUiState { + val apiError = ApiErrorMapper.map(throwable) + return copy( + isOnline = if (apiError.kind == ApiErrorKind.Network) false else isOnline, + isStale = if (apiError.kind == ApiErrorKind.Network && lastSuccessfulSyncAtEpochMillis != null) true else isStale, + error = apiError.message, + ) +} + class DriverViewModel(application: Application) : AndroidViewModel(application) { + private val appPreferencesStore = AppPreferencesStore(application) private val repository = DriverRepository(application) private val syncRepository = DriverSyncRepository(application, repository) private val networkMonitor = NetworkMonitor(application) @@ -94,6 +107,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) val state: StateFlow = _state init { + viewModelScope.launch { + appPreferencesStore.themeMode.collect { themeMode -> + _state.update { it.copy(themeMode = themeMode) } + } + } viewModelScope.launch { photoUploadOutbox.observeQueuedUploads().collect { uploads -> _state.update { it.copy(queuedPhotoUploads = uploads) } @@ -101,6 +119,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } viewModelScope.launch { networkMonitor.isOnline.collect { online -> + liveSyncClient.onNetworkAvailable(online) _state.update { it.copy( isOnline = online, @@ -108,6 +127,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) ) } if (online && repository.hasToken()) { + liveSyncClient.ensureConnected() refreshCurrentScopeFromSyncState() } } @@ -155,6 +175,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false) + fun onAppForegrounded() { + liveSyncClient.ensureConnected() + viewModelScope.launch { checkRemoteSyncState() } + if (_state.value.screen == DriverScreen.Routes) { + refreshRoutesSilently() + } + } + fun selectRouteDate(date: String) { _state.update { it.copy(routeDayLiveUpdateMessage = null) } loadRoutes(date = date, showLoading = true, navigateToRoutes = true) @@ -209,14 +237,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) reportHandledException("load_routes", throwable, mapOf("date" to date)) _state.update { val apiError = ApiErrorMapper.map(throwable) - it.copy( + it.withApiError(throwable).copy( screen = when { apiError.kind == ApiErrorKind.Auth -> DriverScreen.Phone it.screen == DriverScreen.Initializing -> DriverScreen.Routes else -> it.screen }, - isStale = it.lastSuccessfulSyncAtEpochMillis != null, - error = apiError.message, ) } } @@ -265,7 +291,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } .onFailure { throwable -> - _state.update { it.copy(error = throwable.message ?: "Wystapil blad.") } + _state.update { it.withApiError(throwable) } } _state.update { it.copy(refreshing = false) } @@ -290,7 +316,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } .onFailure { throwable -> - _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } + _state.update { it.withApiError(throwable) } } _state.update { it.copy(refreshing = false) } } @@ -306,6 +332,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } + fun setThemeMode(themeMode: AppThemeMode) { + _state.update { it.copy(themeMode = themeMode) } + viewModelScope.launch { + runCatching { appPreferencesStore.setThemeMode(themeMode) } + .onFailure { throwable -> _state.update { it.withApiError(throwable) } } + } + } + fun openPhotoQueue() { _state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) } } @@ -334,7 +368,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) ) } } - .onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } } + .onFailure { throwable -> _state.update { it.withApiError(throwable) } } _state.update { it.copy(loading = false, refreshing = false) } } } @@ -347,7 +381,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) .onSuccess { request -> _state.update { it.copy(screen = DriverScreen.LeaveRequestDetail, selectedLeaveRequest = request, error = null) } } - .onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } } + .onFailure { throwable -> _state.update { it.withApiError(throwable) } } _state.update { it.copy(loading = false) } } } @@ -411,7 +445,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) ) } }.onFailure { throwable -> - _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } + _state.update { it.withApiError(throwable) } } _state.update { it.copy(loading = false) } } @@ -434,7 +468,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) ) } } - .onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } } + .onFailure { throwable -> _state.update { it.withApiError(throwable) } } _state.update { it.copy(loading = false) } } } @@ -445,7 +479,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(error = null) } runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata) } .onFailure { throwable -> - _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } + _state.update { it.withApiError(throwable) } } } } @@ -466,7 +500,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) metadata = metadata, ) }.onFailure { throwable -> - _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } + _state.update { it.withApiError(throwable) } } } } @@ -475,7 +509,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) viewModelScope.launch { runCatching { photoUploadOutbox.retry(upload.clientRequestId) } .onFailure { throwable -> - _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } + _state.update { it.withApiError(throwable) } } } } @@ -496,7 +530,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) ) } }.onFailure { throwable -> - _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } + _state.update { it.withApiError(throwable) } } _state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - photo.id) } } @@ -521,7 +555,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) ) } }.onFailure { throwable -> - _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } + _state.update { it.withApiError(throwable) } } _state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - serverPhotoId) } } @@ -552,7 +586,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) ) } }.onFailure { throwable -> - _state.update { it.copy(feedback = null, error = ApiErrorMapper.map(throwable).message) } + _state.update { it.withApiError(throwable).copy(feedback = null) } } _state.update { it.copy(completingRoute = false) } } @@ -607,7 +641,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) runCatching { block() } .onFailure { throwable -> reportHandledException(operation, throwable, keys) - _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } + _state.update { it.withApiError(throwable) } } _state.update { it.copy(loading = false) } } @@ -724,6 +758,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) keys: Map = emptyMap(), ) { val apiError = ApiErrorMapper.map(throwable) + if (!ApiErrorMapper.shouldReportNonFatal(throwable)) { + AppDiagnostics.log("network_failure: $operation: ${throwable.message ?: throwable::class.java.simpleName}") + return + } AppDiagnostics.reportNonFatal( throwable = throwable, operation = operation, diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/theme/AppThemeMode.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/theme/AppThemeMode.kt new file mode 100644 index 0000000..4eae5ff --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/theme/AppThemeMode.kt @@ -0,0 +1,13 @@ +package pl.firmatpp.kierowca.ui.theme + +enum class AppThemeMode(val storedValue: String) { + Current("current"), + Material3("material3"); + + companion object { + val Default: AppThemeMode = Material3 + + fun fromStoredValue(value: String?): AppThemeMode = + entries.firstOrNull { it.storedValue == value } ?: Default + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/theme/Theme.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/theme/Theme.kt index 39a51e1..5934316 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/theme/Theme.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/theme/Theme.kt @@ -1,9 +1,20 @@ package pl.firmatpp.kierowca.ui.theme +import android.app.Activity import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat object TppColors { val Forest = Color(0xFF154212) @@ -18,7 +29,71 @@ object TppColors { val Error = Color(0xFFBA1A1A) } -private val TppScheme = lightColorScheme( +@Immutable +data class TppColorTokens( + val forest: Color, + val containerGreen: Color, + val logoLeaf: Color, + val navy: Color, + val surface: Color, + val panel: Color, + val ink: Color, + val muted: Color, + val outline: Color, + val error: Color, + val card: Color, + val successContainer: Color, + val successOutline: Color, + val warningContainer: Color, + val warningOutline: Color, +) + +object TppTheme { + val colors: TppColorTokens + @Composable + @ReadOnlyComposable + get() = LocalTppColorTokens.current +} + +private val CurrentColorTokens = TppColorTokens( + forest = TppColors.Forest, + containerGreen = TppColors.ContainerGreen, + logoLeaf = TppColors.LogoLeaf, + navy = TppColors.Navy, + surface = TppColors.Surface, + panel = TppColors.Panel, + ink = TppColors.Ink, + muted = TppColors.Muted, + outline = TppColors.Outline, + error = TppColors.Error, + card = Color.White, + successContainer = Color(0xFFEAF7EF), + successOutline = Color(0xFF9BD1AD), + warningContainer = Color(0xFFFFF7E6), + warningOutline = Color(0xFFE6B85C), +) + +private val Material3ColorTokens = TppColorTokens( + forest = Color(0xFF226C2C), + containerGreen = Color(0xFF2F7D32), + logoLeaf = Color(0xFF5FA642), + navy = Color(0xFF225A86), + surface = Color(0xFFF8FBF6), + panel = Color(0xFFEAF1E6), + ink = Color(0xFF172018), + muted = Color(0xFF566252), + outline = Color(0xFFBAC8B5), + error = Color(0xFFBA1A1A), + card = Color(0xFFFFFFFF), + successContainer = Color(0xFFDFF4E2), + successOutline = Color(0xFF8EC69A), + warningContainer = Color(0xFFFFF1D6), + warningOutline = Color(0xFFE4B45B), +) + +private val LocalTppColorTokens = staticCompositionLocalOf { CurrentColorTokens } + +private val CurrentScheme = lightColorScheme( primary = TppColors.Forest, onPrimary = Color.White, primaryContainer = TppColors.ContainerGreen, @@ -33,10 +108,71 @@ private val TppScheme = lightColorScheme( error = TppColors.Error, ) +private val Material3Scheme = lightColorScheme( + primary = Material3ColorTokens.forest, + onPrimary = Color.White, + primaryContainer = Material3ColorTokens.containerGreen, + onPrimaryContainer = Color.White, + secondary = Material3ColorTokens.navy, + onSecondary = Color.White, + secondaryContainer = Color(0xFFD2E7F7), + onSecondaryContainer = Color(0xFF071E30), + tertiary = Color(0xFF7A5D00), + onTertiary = Color.White, + tertiaryContainer = Color(0xFFFFE08A), + background = Material3ColorTokens.surface, + onBackground = Material3ColorTokens.ink, + surface = Material3ColorTokens.surface, + onSurface = Material3ColorTokens.ink, + surfaceVariant = Material3ColorTokens.panel, + onSurfaceVariant = Material3ColorTokens.muted, + outline = Material3ColorTokens.outline, + outlineVariant = Color(0xFFD5DED1), + error = Material3ColorTokens.error, +) + +private val CurrentShapes = Shapes( + extraSmall = androidx.compose.foundation.shape.RoundedCornerShape(2.dp), + small = androidx.compose.foundation.shape.RoundedCornerShape(4.dp), + medium = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), + large = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), + extraLarge = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), +) + +private val Material3Shapes = Shapes( + extraSmall = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), + small = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), + medium = androidx.compose.foundation.shape.RoundedCornerShape(20.dp), + large = androidx.compose.foundation.shape.RoundedCornerShape(28.dp), + extraLarge = androidx.compose.foundation.shape.RoundedCornerShape(32.dp), +) + @Composable -fun TppKierowcaTheme(content: @Composable () -> Unit) { - MaterialTheme( - colorScheme = TppScheme, - content = content, - ) +@Suppress("DEPRECATION") +fun TppKierowcaTheme( + themeMode: AppThemeMode = AppThemeMode.Default, + content: @Composable () -> Unit, +) { + val colorTokens = if (themeMode == AppThemeMode.Material3) Material3ColorTokens else CurrentColorTokens + val colorScheme = if (themeMode == AppThemeMode.Material3) Material3Scheme else CurrentScheme + val shapes = if (themeMode == AppThemeMode.Material3) Material3Shapes else CurrentShapes + val view = LocalView.current + + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as? Activity)?.window ?: return@SideEffect + window.statusBarColor = colorTokens.surface.toArgb() + window.navigationBarColor = colorTokens.surface.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = true + WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = true + } + } + + CompositionLocalProvider(LocalTppColorTokens provides colorTokens) { + MaterialTheme( + colorScheme = colorScheme, + shapes = shapes, + content = content, + ) + } } diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..d5267c3 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +