Compare commits

..
Author SHA1 Message Date
admin 26b17ece26 Wydaj aplikację kierowcy 1.0.73 (126) 2026-07-30 00:26:21 +02:00
admin 2e7eb7e47b Uprość obsługę urlopów w aplikacji kierowcy 2026-07-30 00:24:39 +02:00
admin 062415a75d Wydaj aplikację kierowcy 1.0.72 (125) 2026-07-29 18:07:48 +02:00
admin ab68be4c1e Napraw pętlę uruchamiania aplikacji kierowcy 2026-07-29 18:06:24 +02:00
admin 513c1785ef Wydaj aplikację kierowcy 1.0.71 (124) 2026-07-24 13:40:46 +02:00
admin e70a53eca7 Dodaj potwierdzanie odczytów tachografu 2026-07-24 13:33:27 +02:00
admin c3004a3d2e Dodaj kartę przypomnienia o tachografie 2026-07-24 13:11:16 +02:00
admin fda4b462dc Wydaj aplikację kierowcy 1.0.70 (123) 2026-07-24 12:55:12 +02:00
admin 6a1a15ade9 Add route date calendar picker 2026-07-24 12:49:32 +02:00
admin ea44cf5d2e Wydaj aplikację kierowcy 1.0.69 (122) 2026-07-24 01:30:07 +02:00
admin 984feb170a Wydaj aplikację kierowcy 1.0.68 (121) 2026-07-23 15:14:50 +02:00
admin 5691c87e91 Dostosuj aplikację do Androida 16 2026-07-23 15:14:05 +02:00
admin 227e08a671 Wydaj aplikację kierowcy 1.0.67 (120) 2026-07-23 15:03:18 +02:00
admin 3bfb5f9788 Usuń nieaktualny konflikt zakończenia kursu 2026-07-23 15:02:21 +02:00
admin bf1cf3865a Napraw synchronizację statusu kursu kierowcy 2026-07-23 14:15:34 +02:00
admin 0c3bed6213 Dostosuj lokalizację kursu do ustawień 2026-07-23 12:51:26 +02:00
admin f1cb2d618d Wydaj aplikację kierowcy 1.0.66 (119) 2026-07-23 11:35:47 +02:00
admin 8c9a3b5b37 Aktualizuj wersję aplikacji w sesji kierowcy 2026-07-23 11:33:47 +02:00
admin de8510bc79 Wydaj aplikację kierowcy 1.0.65 (118) 2026-07-23 11:33:17 +02:00
admin 34d01b312e Obsłuż adres SSH w skrypcie wydania Androida 2026-07-23 11:19:10 +02:00
admin f89332b28c Dodaj uwagi i dyktowanie na etapach trasy 2026-07-23 11:05:49 +02:00
admin 8f12bf32bc Wydaj aplikację kierowcy 1.0.64 (117) 2026-07-18 17:37:53 +02:00
admin b9ae128842 Napraw obsługę kodów OTP podczas logowania 2026-07-18 17:33:40 +02:00
admin a86dca50b8 Wydaj aplikację kierowcy 1.0.63 (116) 2026-07-18 16:34:32 +02:00
admin fc49eae512 Zatrzymaj aktywny kurs po wygaśnięciu sesji 2026-07-18 16:29:34 +02:00
46 changed files with 4333 additions and 299 deletions
+1
View File
@@ -1,4 +1,5 @@
.gradle/ .gradle/
.kotlin/
build/ build/
local.properties local.properties
app/google-services.json app/google-services.json
+4 -4
View File
@@ -22,7 +22,7 @@ val keystoreProperties = Properties().apply {
android { android {
namespace = "pl.firmatpp.kierowca" namespace = "pl.firmatpp.kierowca"
compileSdk = 35 compileSdk = 36
fun configValue(name: String, default: String): String = fun configValue(name: String, default: String): String =
providers.gradleProperty(name) providers.gradleProperty(name)
@@ -33,9 +33,9 @@ android {
defaultConfig { defaultConfig {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 36
versionCode = 115 versionCode = 126
versionName = "1.0.62" versionName = "1.0.73"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+11
View File
@@ -2,12 +2,23 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<queries>
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
<application <application
android:allowBackup="false" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -50,7 +50,10 @@ object ApiErrorMapper {
map(throwable).kind != ApiErrorKind.Network map(throwable).kind != ApiErrorKind.Network
fun mapHttpStatus(statusCode: Int, body: String?): ApiError { fun mapHttpStatus(statusCode: Int, body: String?): ApiError {
val code = body?.let { """"code"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) } val code = body?.let {
""""code"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1)
?: """"reason"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1)
}
val message = body?.let { """"message"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) } val message = body?.let { """"message"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) }
val retryable = body?.let { """"retryable"\s*:\s*(true|false)""".toRegex().find(it)?.groupValues?.getOrNull(1)?.toBooleanStrictOrNull() } val retryable = body?.let { """"retryable"\s*:\s*(true|false)""".toRegex().find(it)?.groupValues?.getOrNull(1)?.toBooleanStrictOrNull() }
@@ -81,16 +84,21 @@ object ApiErrorMapper {
} }
val defaultRetryable = statusCode == 429 || statusCode in 500..599 val defaultRetryable = statusCode == 429 || statusCode in 500..599
val resolvedRetryable = retryable ?: defaultRetryable val resolvedRetryable = retryable ?: defaultRetryable
val resolvedMessage = message?.takeIf { it.isNotBlank() } ?: when (kind) { val resolvedMessage = when {
code == "INVALID_OTP" -> "Kod jest nieprawidłowy lub wygasł. Użyj kodu z najnowszego SMS-a."
kind == ApiErrorKind.RateLimited -> "Za dużo prób. Odczekaj chwilę i spróbuj ponownie."
!message.isNullOrBlank() -> message
else -> when (kind) {
ApiErrorKind.Auth -> "Sesja wygasła. Zaloguj się ponownie." ApiErrorKind.Auth -> "Sesja wygasła. Zaloguj się ponownie."
ApiErrorKind.Forbidden -> "Brak uprawnień do tej operacji." ApiErrorKind.Forbidden -> "Brak uprawnień do tej operacji."
ApiErrorKind.Validation -> "Serwer odrzucił operację. Nie została zapisana." ApiErrorKind.Validation -> "Serwer odrzucił operację. Nie została zapisana."
ApiErrorKind.Conflict -> "Operacja jest w konflikcie z aktualnym stanem danych." ApiErrorKind.Conflict -> "Operacja jest w konflikcie z aktualnym stanem danych."
ApiErrorKind.RateLimited -> "Za dużo prób. Aplikacja spróbuje ponownie później." ApiErrorKind.RateLimited -> "Za dużo prób. Odczekaj chwilę i spróbuj ponownie."
ApiErrorKind.Server -> "Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie." ApiErrorKind.Server -> "Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie."
ApiErrorKind.Network -> "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona." ApiErrorKind.Network -> "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona."
ApiErrorKind.Unknown -> "Wystąpił błąd. Operacja nie została potwierdzona." ApiErrorKind.Unknown -> "Wystąpił błąd. Operacja nie została potwierdzona."
} }
}
return ApiError( return ApiError(
kind = kind, kind = kind,
@@ -103,7 +111,11 @@ object ApiErrorMapper {
} }
private fun mapHttpException(exception: HttpException): ApiError { private fun mapHttpException(exception: HttpException): ApiError {
val body = runCatching { exception.response()?.errorBody()?.string() }.getOrNull() val body = runCatching {
val source = exception.response()?.errorBody()?.source() ?: return@runCatching null
source.request(Long.MAX_VALUE)
source.buffer.clone().readUtf8()
}.getOrNull()
return mapHttpStatus(exception.code(), body) return mapHttpStatus(exception.code(), body)
} }
} }
@@ -20,6 +20,7 @@ class AppPreferencesStore(
private val themeModeKey = stringPreferencesKey("theme_mode") private val themeModeKey = stringPreferencesKey("theme_mode")
private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled") private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled")
private val routeLocationEnabledKey = booleanPreferencesKey("route_location_enabled")
val themeMode: Flow<AppThemeMode> = dataStore.data val themeMode: Flow<AppThemeMode> = dataStore.data
.map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) } .map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) }
@@ -27,6 +28,9 @@ class AppPreferencesStore(
val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true } .map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true }
val routeLocationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeLocationEnabledKey] ?: false }
suspend fun setThemeMode(themeMode: AppThemeMode) { suspend fun setThemeMode(themeMode: AppThemeMode) {
dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences[themeModeKey] = themeMode.storedValue preferences[themeModeKey] = themeMode.storedValue
@@ -38,4 +42,10 @@ class AppPreferencesStore(
preferences[routeProgressNotificationEnabledKey] = enabled preferences[routeProgressNotificationEnabledKey] = enabled
} }
} }
suspend fun setRouteLocationEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[routeLocationEnabledKey] = enabled
}
}
} }
@@ -22,17 +22,6 @@ class DeviceInfoProvider(
androidVersion = Build.VERSION.RELEASE, androidVersion = Build.VERSION.RELEASE,
sdkInt = Build.VERSION.SDK_INT, sdkInt = Build.VERSION.SDK_INT,
appVersion = BuildConfig.VERSION_NAME, appVersion = BuildConfig.VERSION_NAME,
serialNumber = readSerialNumber(), serialNumber = null,
) )
private fun readSerialNumber(): String? =
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Build.getSerial()
} else {
@Suppress("DEPRECATION")
Build.SERIAL
}
}.getOrNull()
?.takeUnless { it.isBlank() || it.equals(Build.UNKNOWN, ignoreCase = true) }
} }
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.data
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import java.util.UUID import java.util.UUID
@@ -12,6 +13,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
import pl.firmatpp.kierowca.data.api.ApiFactory import pl.firmatpp.kierowca.data.api.ApiFactory
import pl.firmatpp.kierowca.data.api.MobileDriverApi import pl.firmatpp.kierowca.data.api.MobileDriverApi
import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
@@ -19,8 +21,12 @@ import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeaveDocumentDto
import pl.firmatpp.kierowca.data.model.DriverLeaveForecastDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
import pl.firmatpp.kierowca.data.model.FinishRouteBody import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.LeaveForecastBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.OtpResponse import pl.firmatpp.kierowca.data.model.OtpResponse
@@ -33,6 +39,7 @@ import pl.firmatpp.kierowca.data.model.RoutePointsResponse
import pl.firmatpp.kierowca.data.model.RouteResponse import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.SyncStateResponse import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.VerifyOtpBody import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -56,24 +63,104 @@ class DriverRepository(
suspend fun bootstrap(date: String? = null): BootstrapResponse = suspend fun bootstrap(date: String? = null): BootstrapResponse =
api.bootstrap(authHeader(requireToken()), date) api.bootstrap(authHeader(requireToken()), date)
suspend fun acknowledgeTachographReminder(taskIds: List<String>): TachographReminderDto =
api.acknowledgeTachographReminder(
authHeader(requireToken()),
AcknowledgeTachographReminderBody(taskIds),
).tachographReminder
suspend fun route(routeId: String): RouteResponse = suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId) api.route(authHeader(requireToken()), routeId)
suspend fun leaveRequests(): List<DriverLeaveRequestDto> = suspend fun leaveRequests(): List<DriverLeaveRequestDto> =
api.leaveRequests(authHeader(requireToken())).data api.leaveRequests(authHeader(requireToken())).data
suspend fun leaveSummary(): DriverLeaveSummaryDto =
api.leaveSummary(authHeader(requireToken())).data
suspend fun leaveForecast(
dateFrom: String,
dateTo: String,
type: String,
requestMode: String,
requestedQuantity: Int?,
): DriverLeaveForecastDto =
api.leaveForecast(
authHeader(requireToken()),
LeaveForecastBody(type, dateFrom, dateTo, requestMode, requestedQuantity),
).data
suspend fun leaveCalendar(from: String, to: String): List<DriverLeaveCalendarEntryDto> = suspend fun leaveCalendar(from: String, to: String): List<DriverLeaveCalendarEntryDto> =
api.leaveCalendar(authHeader(requireToken()), from, to).data api.leaveCalendar(authHeader(requireToken()), from, to).data
suspend fun leaveRequest(id: String): DriverLeaveRequestDto = suspend fun leaveRequest(id: String): DriverLeaveRequestDto =
api.leaveRequest(authHeader(requireToken()), id).data api.leaveRequest(authHeader(requireToken()), id).data
suspend fun createLeaveRequest(dateFrom: String, dateTo: String, type: String, note: String?): DriverLeaveRequestDto = suspend fun createLeaveRequest(
api.createLeaveRequest(authHeader(requireToken()), CreateLeaveRequestBody(dateFrom, dateTo, type, note)).data dateFrom: String,
dateTo: String,
type: String,
requestMode: String,
requestedQuantity: Int?,
details: Map<String, String>,
idempotencyKey: String,
note: String?,
): DriverLeaveRequestDto =
api.createLeaveRequest(
authHeader(requireToken()),
CreateLeaveRequestBody(
dateFrom = dateFrom,
dateTo = dateTo,
type = type,
requestMode = requestMode,
requestedQuantity = requestedQuantity,
details = details,
idempotencyKey = idempotencyKey,
note = note,
),
).data
suspend fun cancelLeaveRequest(id: String, expectedVersion: Long, comment: String? = null): DriverLeaveRequestDto = suspend fun cancelLeaveRequest(id: String, expectedVersion: Long, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment, expectedVersion)).data api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment, expectedVersion)).data
suspend fun leaveDocuments(id: String): List<DriverLeaveDocumentDto> =
api.leaveDocuments(authHeader(requireToken()), id).data
suspend fun uploadLeaveDocument(id: String, documentType: String, uri: Uri): DriverLeaveDocumentDto {
val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "application/octet-stream"
val fileMetadata = resolver.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
null,
null,
null,
)?.use { cursor ->
if (!cursor.moveToFirst()) {
null
} else {
cursor.getString(0) to cursor.getLong(1)
}
}
if ((fileMetadata?.second ?: 0L) > 20L * 1024L * 1024L) {
error("Dokument jest za duży. Maksymalny rozmiar pliku to 20 MB.")
}
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Nie można odczytać wybranego dokumentu.")
val originalName = fileMetadata?.first
?.takeIf { it.isNotBlank() }
?: "dokument"
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val file = MultipartBody.Part.createFormData("file", originalName, body)
return api.uploadLeaveDocument(
authHeader(requireToken()),
id,
file,
documentType.toPlainTextBody(),
).data
}
suspend fun completeRoute(routeId: String) = suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId) api.completeRoute(authHeader(requireToken()), routeId)
@@ -1,11 +1,19 @@
package pl.firmatpp.kierowca.data.api package pl.firmatpp.kierowca.data.api
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import pl.firmatpp.kierowca.BuildConfig import pl.firmatpp.kierowca.BuildConfig
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
internal const val DRIVER_APP_VERSION_HEADER = "X-Driver-App-Version"
internal fun Request.withDriverAppVersion(appVersion: String): Request =
newBuilder()
.header(DRIVER_APP_VERSION_HEADER, appVersion)
.build()
object ApiFactory { object ApiFactory {
fun mobileDriverApi(): MobileDriverApi { fun mobileDriverApi(): MobileDriverApi {
val logging = HttpLoggingInterceptor().apply { val logging = HttpLoggingInterceptor().apply {
@@ -13,6 +21,9 @@ object ApiFactory {
} }
val client = OkHttpClient.Builder() val client = OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(chain.request().withDriverAppVersion(BuildConfig.VERSION_NAME))
}
.addInterceptor(logging) .addInterceptor(logging)
.build() .build()
@@ -3,6 +3,8 @@ package pl.firmatpp.kierowca.data.api
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderBody
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
@@ -11,9 +13,14 @@ import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.FinishRouteBody import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.LeaveCalendarResponse import pl.firmatpp.kierowca.data.model.LeaveCalendarResponse
import pl.firmatpp.kierowca.data.model.LeaveDocumentListResponse
import pl.firmatpp.kierowca.data.model.LeaveDocumentResponse
import pl.firmatpp.kierowca.data.model.LeaveForecastBody
import pl.firmatpp.kierowca.data.model.LeaveForecastResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestListResponse import pl.firmatpp.kierowca.data.model.LeaveRequestListResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestResponse import pl.firmatpp.kierowca.data.model.LeaveRequestResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse
import pl.firmatpp.kierowca.data.model.LeaveSummaryResponse
import pl.firmatpp.kierowca.data.model.OtpResponse import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
@@ -67,11 +74,28 @@ interface MobileDriverApi {
@Query("date") date: String?, @Query("date") date: String?,
): BootstrapResponse ): BootstrapResponse
@POST("mobile/driver/tachograph-reminder/acknowledge")
suspend fun acknowledgeTachographReminder(
@Header("Authorization") authorization: String,
@Body body: AcknowledgeTachographReminderBody,
): AcknowledgeTachographReminderResponse
@GET("mobile/driver/leave-request-types") @GET("mobile/driver/leave-request-types")
suspend fun leaveRequestTypes( suspend fun leaveRequestTypes(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
): LeaveRequestTypesResponse ): LeaveRequestTypesResponse
@GET("mobile/driver/leave-summary")
suspend fun leaveSummary(
@Header("Authorization") authorization: String,
): LeaveSummaryResponse
@POST("mobile/driver/leave-forecast")
suspend fun leaveForecast(
@Header("Authorization") authorization: String,
@Body body: LeaveForecastBody,
): LeaveForecastResponse
@GET("mobile/driver/leave-requests") @GET("mobile/driver/leave-requests")
suspend fun leaveRequests( suspend fun leaveRequests(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -96,6 +120,21 @@ interface MobileDriverApi {
@Path("id") id: String, @Path("id") id: String,
): LeaveRequestResponse ): LeaveRequestResponse
@GET("mobile/driver/leave-requests/{id}/documents")
suspend fun leaveDocuments(
@Header("Authorization") authorization: String,
@Path("id") id: String,
): LeaveDocumentListResponse
@Multipart
@POST("mobile/driver/leave-requests/{id}/documents")
suspend fun uploadLeaveDocument(
@Header("Authorization") authorization: String,
@Path("id") id: String,
@Part file: MultipartBody.Part,
@Part("documentType") documentType: RequestBody,
): LeaveDocumentResponse
@POST("mobile/driver/leave-requests/{id}/cancel") @POST("mobile/driver/leave-requests/{id}/cancel")
suspend fun cancelLeaveRequest( suspend fun cancelLeaveRequest(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -47,6 +47,7 @@ data class BootstrapResponse(
val routes: RoutesBucketDto, val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?, val driverAppSettings: DriverAppSettingsDto?,
val dispatchSheetReminder: DispatchSheetReminderDto? = null, val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val tachographReminder: TachographReminderDto? = null,
val notificationPreferences: NotificationPreferencesDto? = null, val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null, val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null, val syncState: SyncStateResponse? = null,
@@ -72,6 +73,7 @@ data class DriverAppSettingsDto(
val allowGalleryUploads: Boolean?, val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?, val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?, val requirePreciseLocationForPhotos: Boolean?,
val routeLocationMode: String? = null,
val loadingPhotoRequirement: String? = null, val loadingPhotoRequirement: String? = null,
val loadingWeightRequirement: String? = null, val loadingWeightRequirement: String? = null,
val unloadingPhotoRequirement: String? = null, val unloadingPhotoRequirement: String? = null,
@@ -79,6 +81,7 @@ data class DriverAppSettingsDto(
val dispatchSheetRemindersEnabled: Boolean? = null, val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null, val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null, val dispatchSheetOnLastWorkingDay: Boolean? = null,
val tachographRemindersEnabled: Boolean? = null,
val leaveRequests: LeaveRequestsConfigDto? = null, val leaveRequests: LeaveRequestsConfigDto? = null,
) )
@@ -98,6 +101,30 @@ data class DispatchSheetReminderDto(
val canUpload: Boolean = false, val canUpload: Boolean = false,
) )
data class TachographReminderDto(
val enabled: Boolean = false,
val visible: Boolean = false,
val title: String? = null,
val message: String? = null,
val confirmationLabel: String? = null,
val tasks: List<TachographReminderTaskDto>? = null,
)
data class TachographReminderTaskDto(
val id: String,
val type: String,
val label: String,
)
data class AcknowledgeTachographReminderBody(
val taskIds: List<String>,
)
data class AcknowledgeTachographReminderResponse(
val ok: Boolean,
val tachographReminder: TachographReminderDto,
)
data class DispatchSheetPhotoDto( data class DispatchSheetPhotoDto(
val id: String, val id: String,
val driverId: String, val driverId: String,
@@ -196,13 +223,94 @@ data class LeaveRequestTypesResponse(
data class LeaveRequestTypeDto( data class LeaveRequestTypeDto(
val value: String, val value: String,
val code: String? = null,
val ruleCode: String? = null,
val label: String, val label: String,
val category: String? = null,
val unit: String? = null,
val poolCode: String? = null,
val requestModes: List<String> = listOf("days"),
val requiredFields: List<String> = emptyList(),
val requiredDocuments: List<String> = emptyList(),
val fieldOptions: Map<String, Map<String, String>> = emptyMap(),
val description: String? = null,
val decisionMode: String? = null,
val payrollMode: String? = null,
val legalBasis: String? = null,
)
data class LeaveSummaryResponse(
val data: DriverLeaveSummaryDto,
)
data class DriverLeaveSummaryDto(
val pools: List<DriverLeavePoolDto> = emptyList(),
val dailyNormMinutes: Int = 480,
val balanceConfigured: Boolean = false,
val overdueQuantity: Int = 0,
val availableTypes: List<LeaveRequestTypeDto> = emptyList(),
val reminders: List<DriverLeaveReminderDto> = emptyList(),
)
data class DriverLeavePoolDto(
val id: Int,
val poolCode: String,
val year: Int,
val unit: String,
val calculatedQuantity: Int = 0,
val confirmedQuantity: Int? = null,
val availableQuantity: Int = 0,
val reservedQuantity: Int = 0,
val usedQuantity: Int = 0,
val grantedQuantity: Int = 0,
val overdue: Boolean = false,
val status: String,
)
data class DriverLeaveReminderDto(
val code: String,
val message: String,
val poolId: Int? = null,
)
data class LeaveForecastBody(
val type: String,
val dateFrom: String,
val dateTo: String,
val requestMode: String,
val requestedQuantity: Int? = null,
)
data class LeaveForecastResponse(
val data: DriverLeaveForecastDto,
)
data class DriverLeaveForecastDto(
val ruleCode: String,
val quantityUnit: String,
val days: List<DriverLeaveForecastDayDto> = emptyList(),
val workingDaysCount: Int = 0,
val requestedQuantity: Int = 0,
val availableQuantity: Int = 0,
val balanceConfigured: Boolean = false,
val sufficientBalance: Boolean = true,
val pools: List<DriverLeavePoolDto> = emptyList(),
)
data class DriverLeaveForecastDayDto(
val date: String,
val quantity: Int,
val source: String? = null,
) )
data class CreateLeaveRequestBody( data class CreateLeaveRequestBody(
val dateFrom: String, val dateFrom: String,
val dateTo: String, val dateTo: String,
val type: String, val type: String,
val requestMode: String = "days",
val requestedQuantity: Int? = null,
val details: Map<String, String> = emptyMap(),
val idempotencyKey: String? = null,
val note: String?, val note: String?,
) )
@@ -217,7 +325,12 @@ data class DriverLeaveRequestDto(
val dateFrom: String?, val dateFrom: String?,
val dateTo: String?, val dateTo: String?,
val type: String, val type: String,
val ruleCode: String? = null,
val typeLabel: String? = null, val typeLabel: String? = null,
val requestMode: String = "days",
val requestedQuantity: Int? = null,
val rule: DriverLeaveRuleSnapshotDto? = null,
val details: Map<String, String> = emptyMap(),
val note: String? = null, val note: String? = null,
val status: String, val status: String,
val version: Long = 1, val version: Long = 1,
@@ -228,6 +341,54 @@ data class DriverLeaveRequestDto(
val decidedAt: String? = null, val decidedAt: String? = null,
val decisionComment: String? = null, val decisionComment: String? = null,
val events: List<DriverLeaveRequestEventDto> = emptyList(), val events: List<DriverLeaveRequestEventDto> = emptyList(),
val allocations: List<DriverLeaveAllocationDto> = emptyList(),
val balanceConfigurationRequired: Boolean = false,
val missingDocumentTypes: List<String> = emptyList(),
)
data class DriverLeaveRuleSnapshotDto(
val code: String? = null,
val label: String? = null,
val unit: String? = null,
val decisionMode: String? = null,
val payrollMode: String? = null,
val poolCode: String? = null,
val rule: DriverLeaveRuleRequirementsDto? = null,
val legalBasis: String? = null,
)
data class DriverLeaveRuleRequirementsDto(
val requestModes: List<String> = listOf("days"),
val requiredFields: List<String> = emptyList(),
val requiredDocuments: List<String> = emptyList(),
val fieldOptions: Map<String, Map<String, String>> = emptyMap(),
val description: String? = null,
)
data class DriverLeaveAllocationDto(
val id: Int,
val date: String?,
val quantity: Int,
val status: String,
val poolId: Int? = null,
val sourceYear: Int? = null,
)
data class LeaveDocumentListResponse(
val data: List<DriverLeaveDocumentDto> = emptyList(),
)
data class LeaveDocumentResponse(
val data: DriverLeaveDocumentDto,
)
data class DriverLeaveDocumentDto(
val id: Int,
val documentType: String,
val originalName: String,
val mimeType: String? = null,
val sizeBytes: Long = 0,
val uploadedAt: String? = null,
) )
data class DriverLeaveCalendarEntryDto( data class DriverLeaveCalendarEntryDto(
@@ -278,7 +439,9 @@ data class DriverRouteDto(
val driverStatus: String? = null, val driverStatus: String? = null,
val operationalStatus: String? = null, val operationalStatus: String? = null,
val loadingWeight: Double? = null, val loadingWeight: Double? = null,
val loadingNotes: String? = null,
val unloadingWeight: Double? = null, val unloadingWeight: Double? = null,
val unloadingNotes: String? = null,
val trackingStatus: String? = null, val trackingStatus: String? = null,
val arrivedAt: String? = null, val arrivedAt: String? = null,
val distanceKm: Double?, val distanceKm: Double?,
@@ -352,6 +515,7 @@ data class StartRouteBody(
val loadingWeight: Double?, val loadingWeight: Double?,
val occurredAt: String, val occurredAt: String,
val photoClientRequestIds: List<String>, val photoClientRequestIds: List<String>,
val loadingNotes: String? = null,
) )
data class FinishRouteBody( data class FinishRouteBody(
@@ -359,6 +523,7 @@ data class FinishRouteBody(
val unloadingWeight: Double?, val unloadingWeight: Double?,
val occurredAt: String, val occurredAt: String,
val photoClientRequestIds: List<String>, val photoClientRequestIds: List<String>,
val unloadingNotes: String? = null,
) )
data class RouteActionResponse( data class RouteActionResponse(
@@ -5,6 +5,7 @@ import com.google.gson.Gson
import java.io.IOException import java.io.IOException
import java.time.LocalDate import java.time.LocalDate
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RouteResponse import pl.firmatpp.kierowca.data.model.RouteResponse
@@ -38,6 +39,7 @@ class DriverSyncRepository(
saveSyncStates(response.syncState) saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis()) CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
cachedBootstrap(requestedDate, throwable) cachedBootstrap(requestedDate, throwable)
} }
} }
@@ -58,6 +60,7 @@ class DriverSyncRepository(
saveSyncStates(response.syncState) saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis()) CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
cachedRoute(routeId, throwable) cachedRoute(routeId, throwable)
} }
@@ -21,7 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
DriverRouteCacheEntity::class, DriverRouteCacheEntity::class,
DriverSyncStateEntity::class, DriverSyncStateEntity::class,
], ],
version = 6, version = 7,
exportSchema = false, exportSchema = false,
) )
abstract class DriverDatabase : RoomDatabase() { abstract class DriverDatabase : RoomDatabase() {
@@ -202,13 +202,26 @@ abstract class DriverDatabase : RoomDatabase() {
} }
} }
private val migration6To7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE route_actions ADD COLUMN notes TEXT")
}
}
fun get(context: Context): DriverDatabase = fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) { instance ?: synchronized(this) {
instance ?: Room.databaseBuilder( instance ?: Room.databaseBuilder(
context.applicationContext, context.applicationContext,
DriverDatabase::class.java, DriverDatabase::class.java,
"driver-local-outbox.db", "driver-local-outbox.db",
).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5, migration5To6).build().also { instance = it } ).addMigrations(
migration1To2,
migration2To3,
migration3To4,
migration4To5,
migration5To6,
migration6To7,
).build().also { instance = it }
} }
} }
} }
@@ -14,6 +14,61 @@ interface RouteActionDao {
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1") @Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
suspend fun find(clientActionId: String): RouteActionEntity? suspend fun find(clientActionId: String): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND action = 'start'
AND createdAtEpochMillis < :beforeEpochMillis
ORDER BY createdAtEpochMillis DESC
LIMIT 1
""",
)
suspend fun latestStartBefore(routeId: String, beforeEpochMillis: Long): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND action = 'finish'
AND createdAtEpochMillis > :afterEpochMillis
AND status != 'CONFIRMED'
ORDER BY createdAtEpochMillis ASC
""",
)
suspend fun unconfirmedFinishesAfter(routeId: String, afterEpochMillis: Long): List<RouteActionEntity>
@Query(
"""
SELECT EXISTS(
SELECT 1 FROM route_actions
WHERE routeId = :routeId
AND action = 'finish'
AND status = 'CONFIRMED'
AND createdAtEpochMillis > :afterEpochMillis
)
""",
)
suspend fun hasConfirmedFinishAfter(routeId: String, afterEpochMillis: Long): Boolean
@Query(
"""
UPDATE route_actions
SET status = 'CONFIRMED',
lastError = NULL,
updatedAtEpochMillis = :updatedAt
WHERE routeId = :routeId
AND action = 'finish'
AND createdAtEpochMillis < :beforeEpochMillis
AND status != 'CONFIRMED'
""",
)
suspend fun confirmSupersededFinishes(
routeId: String,
beforeEpochMillis: Long,
updatedAt: Long = System.currentTimeMillis(),
)
@Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'") @Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'")
suspend fun allUnsent(): List<RouteActionEntity> suspend fun allUnsent(): List<RouteActionEntity>
@@ -38,4 +38,5 @@ data class RouteActionEntity(
val lastError: String? = null, val lastError: String? = null,
val createdAtEpochMillis: Long = System.currentTimeMillis(), val createdAtEpochMillis: Long = System.currentTimeMillis(),
val updatedAtEpochMillis: Long = System.currentTimeMillis(), val updatedAtEpochMillis: Long = System.currentTimeMillis(),
val notes: String? = null,
) )
@@ -43,17 +43,28 @@ class RouteActionOutbox(
enqueueWorker(clientActionId) enqueueWorker(clientActionId)
} }
suspend fun enqueueStart(routeId: String, loadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity = suspend fun enqueueStart(
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds) routeId: String,
loadingWeight: Double?,
photoClientRequestIds: List<String>,
notes: String? = null,
): RouteActionEntity =
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds, notes)
suspend fun enqueueFinish(routeId: String, unloadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity = suspend fun enqueueFinish(
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds) routeId: String,
unloadingWeight: Double?,
photoClientRequestIds: List<String>,
notes: String? = null,
): RouteActionEntity =
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds, notes)
private suspend fun enqueue( private suspend fun enqueue(
action: String, action: String,
routeId: String, routeId: String,
weight: Double?, weight: Double?,
photoClientRequestIds: List<String>, photoClientRequestIds: List<String>,
notes: String?,
): RouteActionEntity { ): RouteActionEntity {
dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7)) dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))
val entity = RouteActionEntity( val entity = RouteActionEntity(
@@ -63,6 +74,7 @@ class RouteActionOutbox(
weight = weight, weight = weight,
occurredAt = Instant.now().toString(), occurredAt = Instant.now().toString(),
photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()), photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()),
notes = notes?.trim()?.takeIf { it.isNotEmpty() },
) )
dao.upsert(entity) dao.upsert(entity)
@@ -8,9 +8,11 @@ import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.FinishRouteBody import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.RouteActionResponse import pl.firmatpp.kierowca.data.model.RouteActionResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
@@ -31,6 +33,33 @@ class RouteActionWorker(
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry() if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure() val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure()
val action = dao.find(clientActionId) ?: return Result.failure() val action = dao.find(clientActionId) ?: return Result.failure()
if (action.status == RouteActionStatus.Confirmed) return Result.success()
val startPrerequisite = if (action.action == RouteActionType.Finish) {
dao.latestStartBefore(action.routeId, action.createdAtEpochMillis)
} else {
null
}
when (routeActionPrerequisiteState(action, startPrerequisite)) {
RouteActionPrerequisiteState.Waiting -> {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.Pending,
lastError = "Czekam na potwierdzenie rozpoczęcia kursu.",
attemptIncrement = 0,
)
return Result.retry()
}
RouteActionPrerequisiteState.Failed -> {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.FailedConflict,
lastError = "Najpierw trzeba potwierdzić rozpoczęcie kursu.",
attemptIncrement = 0,
)
return Result.failure()
}
RouteActionPrerequisiteState.Ready -> Unit
}
val photoClientRequestIds = photoClientRequestIds(action) val photoClientRequestIds = photoClientRequestIds(action)
val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) } val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) }
@@ -72,12 +101,25 @@ class RouteActionWorker(
lastError = null, lastError = null,
attemptIncrement = 0, attemptIncrement = 0,
) )
if (action.action == RouteActionType.Finish) {
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
}
syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate) syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate)
DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id) DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id)
if (action.action == RouteActionType.Start) {
val outbox = RouteActionOutbox(applicationContext)
dao.unconfirmedFinishesAfter(action.routeId, action.createdAtEpochMillis)
.forEach { outbox.enqueueWorker(it.clientActionId) }
}
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation() throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
val serverRoute = if (error.kind == ApiErrorKind.Conflict) {
runCatching { repository.route(action.routeId).route }.getOrNull()
} else {
null
}
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
operation = "route_action_worker", operation = "route_action_worker",
@@ -89,8 +131,30 @@ class RouteActionWorker(
"api_error_code" to error.code, "api_error_code" to error.code,
"api_status_code" to error.statusCode, "api_status_code" to error.statusCode,
"retryable" to error.retryable, "retryable" to error.retryable,
"server_route_status" to serverRoute?.driverLifecycleStatus(),
), ),
) )
val supersededByConfirmedFinish = action.action == RouteActionType.Finish &&
dao.hasConfirmedFinishAfter(action.routeId, action.createdAtEpochMillis)
if (serverRoute != null &&
(
routeActionMatchesServer(action, photoClientRequestIds, serverRoute) ||
routeActionWasSuperseded(action, supersededByConfirmedFinish, serverRoute)
)
) {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.Confirmed,
lastError = null,
attemptIncrement = 0,
)
if (action.action == RouteActionType.Finish) {
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
}
syncRepository.cacheConfirmedRoute(serverRoute, serverRoute.routeDate)
DriverSyncWorker.enqueue(applicationContext, serverRoute.routeDate, serverRoute.id)
return@getOrElse Result.success()
}
val status = when { val status = when {
error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict
error.retryable -> RouteActionStatus.FailedRetryable error.retryable -> RouteActionStatus.FailedRetryable
@@ -99,7 +163,7 @@ class RouteActionWorker(
dao.updateStatus( dao.updateStatus(
clientActionId = clientActionId, clientActionId = clientActionId,
status = status, status = status,
lastError = error.message, lastError = routeActionConflictMessage(error.message, serverRoute),
attemptIncrement = 0, attemptIncrement = 0,
) )
@@ -116,6 +180,7 @@ class RouteActionWorker(
loadingWeight = action.weight, loadingWeight = action.weight,
occurredAt = action.occurredAt, occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds, photoClientRequestIds = photoClientRequestIds,
loadingNotes = action.notes,
), ),
) )
RouteActionType.Finish -> repository.finishRoute( RouteActionType.Finish -> repository.finishRoute(
@@ -125,6 +190,7 @@ class RouteActionWorker(
unloadingWeight = action.weight, unloadingWeight = action.weight,
occurredAt = action.occurredAt, occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds, photoClientRequestIds = photoClientRequestIds,
unloadingNotes = action.notes,
), ),
) )
else -> error("Nieznana akcja kursu: ${action.action}") else -> error("Nieznana akcja kursu: ${action.action}")
@@ -144,3 +210,84 @@ class RouteActionWorker(
fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId" fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId"
} }
} }
internal fun routeActionMatchesServer(
action: RouteActionEntity,
photoClientRequestIds: List<String>,
serverRoute: DriverRouteDto,
): Boolean {
val lifecycleMatches = when (action.action) {
RouteActionType.Start -> serverRoute.driverLifecycleStatus() in setOf("W TRAKCIE", "ZAKOŃCZONA")
RouteActionType.Finish -> serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
else -> false
}
if (!lifecycleMatches) return false
val serverWeight = when (action.action) {
RouteActionType.Start -> serverRoute.loadingWeight
RouteActionType.Finish -> serverRoute.unloadingWeight
else -> null
}
val serverNotes = when (action.action) {
RouteActionType.Start -> serverRoute.loadingNotes
RouteActionType.Finish -> serverRoute.unloadingNotes
else -> null
}
val weightMatches = when {
action.weight == null && serverWeight == null -> true
action.weight == null || serverWeight == null -> false
else -> kotlin.math.abs(action.weight - serverWeight) < 0.0005
}
val notesMatch = action.notes.normalizedRouteActionNotes() == serverNotes.normalizedRouteActionNotes()
val serverPhotoRequestIds = serverRoute.photos
.mapNotNull { it.clientRequestId?.trim()?.takeIf(String::isNotBlank) }
.toSet()
val photosMatch = photoClientRequestIds.all(serverPhotoRequestIds::contains)
return weightMatches && notesMatch && photosMatch
}
internal fun routeActionWasSuperseded(
action: RouteActionEntity,
hasConfirmedFinishAfter: Boolean,
serverRoute: DriverRouteDto,
): Boolean =
action.action == RouteActionType.Finish &&
hasConfirmedFinishAfter &&
serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
internal fun routeActionConflictMessage(message: String, serverRoute: DriverRouteDto?): String {
val serverStatus = serverRoute?.driverLifecycleStatus() ?: return message
val operationalStatus = serverRoute.operationalStatus
?.takeIf(String::isNotBlank)
?.let { ", status operacyjny: $it" }
.orEmpty()
return "$message Status kursu na serwerze: $serverStatus$operationalStatus. Dane w telefonie pozostają zachowane."
}
private fun String?.normalizedRouteActionNotes(): String? =
this?.trim()?.takeIf(String::isNotEmpty)
internal enum class RouteActionPrerequisiteState {
Ready,
Waiting,
Failed,
}
internal fun routeActionPrerequisiteState(
action: RouteActionEntity,
precedingStart: RouteActionEntity?,
): RouteActionPrerequisiteState {
if (action.action != RouteActionType.Finish || precedingStart == null) {
return RouteActionPrerequisiteState.Ready
}
return when (precedingStart.status) {
RouteActionStatus.Confirmed -> RouteActionPrerequisiteState.Ready
RouteActionStatus.FailedConflict,
RouteActionStatus.FailedPermanent,
-> RouteActionPrerequisiteState.Failed
else -> RouteActionPrerequisiteState.Waiting
}
}
@@ -67,9 +67,11 @@ class RoutePointWorker(
"retryable" to error.retryable, "retryable" to error.retryable,
), ),
) )
if (shouldStopRouteTrackingAfterPointError(error.kind, error.code)) {
ActiveRouteTrackingService.stop(applicationContext, routeId)
}
if (isInactiveRoutePointError(error.code)) { if (isInactiveRoutePointError(error.code)) {
dao.delete(pointIds) dao.delete(pointIds)
ActiveRouteTrackingService.stop(applicationContext, routeId)
continue continue
} }
@@ -105,3 +107,6 @@ class RoutePointWorker(
} }
internal fun isInactiveRoutePointError(code: String?): Boolean = code == "ROUTE_POINTS_INVALID_STATUS" internal fun isInactiveRoutePointError(code: String?): Boolean = code == "ROUTE_POINTS_INVALID_STATUS"
internal fun shouldStopRouteTrackingAfterPointError(kind: ApiErrorKind, code: String?): Boolean =
kind == ApiErrorKind.Auth || isInactiveRoutePointError(code)
@@ -229,18 +229,28 @@ class DiagnosticSnapshotProvider(private val context: Context) {
val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val links = activeNetwork?.let(manager::getLinkProperties) val links = activeNetwork?.let(manager::getLinkProperties)
val roaming = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING))
} else {
"niedostępne"
}
val mtu = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
links?.mtu?.toString() ?: "brak"
} else {
"niedostępne"
}
return listOf( return listOf(
DiagnosticEntry("Transport", transports.joinToString()), DiagnosticEntry("Transport", transports.joinToString()),
DiagnosticEntry("Internet zweryfikowany", yesNo(validated), if (validated) DiagnosticSeverity.Good else DiagnosticSeverity.Error), DiagnosticEntry("Internet zweryfikowany", yesNo(validated), if (validated) DiagnosticSeverity.Good else DiagnosticSeverity.Error),
DiagnosticEntry("NET_CAPABILITY_INTERNET", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET))), DiagnosticEntry("NET_CAPABILITY_INTERNET", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET))),
DiagnosticEntry("NET_CAPABILITY_VALIDATED", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))), DiagnosticEntry("NET_CAPABILITY_VALIDATED", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))),
DiagnosticEntry("Sieć mierzona", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))), DiagnosticEntry("Sieć mierzona", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))),
DiagnosticEntry("Roaming", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING))), DiagnosticEntry("Roaming", roaming),
DiagnosticEntry("Pasmo downstream / upstream", "${capabilities.linkDownstreamBandwidthKbps} / ${capabilities.linkUpstreamBandwidthKbps} kb/s"), DiagnosticEntry("Pasmo downstream / upstream", "${capabilities.linkDownstreamBandwidthKbps} / ${capabilities.linkUpstreamBandwidthKbps} kb/s"),
DiagnosticEntry("Interfejs", links?.interfaceName ?: "brak"), DiagnosticEntry("Interfejs", links?.interfaceName ?: "brak"),
DiagnosticEntry("Adresy IP", links?.linkAddresses?.joinToString { it.toString() }?.ifBlank { "brak" } ?: "brak"), DiagnosticEntry("Adresy IP", links?.linkAddresses?.joinToString { it.toString() }?.ifBlank { "brak" } ?: "brak"),
DiagnosticEntry("DNS", links?.dnsServers?.joinToString { it.hostAddress ?: it.toString() }?.ifBlank { "brak" } ?: "brak"), DiagnosticEntry("DNS", links?.dnsServers?.joinToString { it.hostAddress ?: it.toString() }?.ifBlank { "brak" } ?: "brak"),
DiagnosticEntry("MTU", links?.mtu?.toString() ?: "brak"), DiagnosticEntry("MTU", mtu),
DiagnosticEntry("Proxy", links?.httpProxy?.toString() ?: "brak"), DiagnosticEntry("Proxy", links?.httpProxy?.toString() ?: "brak"),
) )
} }
@@ -57,7 +57,11 @@ class LeaveRequestDecisionNotificationWorker(
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build() .build()
try {
NotificationManagerCompat.from(applicationContext).notify(leaveRequestId.hashCode(), notification) NotificationManagerCompat.from(applicationContext).notify(leaveRequestId.hashCode(), notification)
} catch (_: SecurityException) {
return Result.success()
}
return Result.success() return Result.success()
} }
@@ -73,7 +73,11 @@ class NewRouteNotificationWorker(
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build() .build()
try {
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification) NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
} catch (_: SecurityException) {
return@runCatching Result.success()
}
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation() throwable.rethrowIfCancellation()
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.tracking package pl.firmatpp.kierowca.tracking
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -54,6 +55,11 @@ class ActiveRouteTrackingService : Service(), LocationListener {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action != ACTION_STOP && !hasLocationPermission()) {
stopSelf()
return START_NOT_STICKY
}
when (intent?.action) { when (intent?.action) {
ACTION_STOP -> { ACTION_STOP -> {
val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
@@ -69,7 +75,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
routeId = id routeId = id
arrived = true arrived = true
runCatching { locationManager.removeUpdates(this) } runCatching { locationManager.removeUpdates(this) }
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) {
return START_NOT_STICKY
}
} }
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -97,7 +105,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
?: totalDistanceMeters ?: totalDistanceMeters
progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled
if (routeChanged) arrived = false if (routeChanged) arrived = false
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) {
return START_NOT_STICKY
}
if (!arrived) requestLocationUpdates() if (!arrived) requestLocationUpdates()
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -113,9 +123,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) { if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) {
arrived = true arrived = true
runCatching { locationManager.removeUpdates(this) } runCatching { locationManager.removeUpdates(this) }
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) return
} else { } else {
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) return
} }
val (battery, charging) = batterySnapshot() val (battery, charging) = batterySnapshot()
@@ -136,6 +146,13 @@ class ActiveRouteTrackingService : Service(), LocationListener {
override fun onDestroy() { override fun onDestroy() {
runCatching { locationManager.removeUpdates(this) } runCatching { locationManager.removeUpdates(this) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
getSystemService(NotificationManager::class.java).cancel(NOTIFICATION_ID)
getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE) getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
.edit() .edit()
.remove(ACTIVE_ROUTE_ID) .remove(ACTIVE_ROUTE_ID)
@@ -144,6 +161,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
super.onDestroy() super.onDestroy()
} }
@SuppressLint("MissingPermission")
private fun requestLocationUpdates() { private fun requestLocationUpdates() {
if (!hasLocationPermission() || routeId.isNullOrBlank() || driverId.isNullOrBlank()) { if (!hasLocationPermission() || routeId.isNullOrBlank() || driverId.isNullOrBlank()) {
return return
@@ -197,12 +215,26 @@ class ActiveRouteTrackingService : Service(), LocationListener {
return builder.build() return builder.build()
} }
private fun startForegroundCompat(notification: Notification) { private fun startForegroundCompat(notification: Notification): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && hasLocationPermission()) { if (!hasLocationPermission()) {
stopSelf()
return false
}
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
} else { } else {
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
} }
true
} catch (_: SecurityException) {
stopSelf()
false
} catch (_: IllegalStateException) {
stopSelf()
false
}
} }
private fun ensureNotificationChannel() { private fun ensureNotificationChannel() {
@@ -213,7 +245,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
"Aktywny kurs", "Aktywny kurs",
NotificationManager.IMPORTANCE_LOW, NotificationManager.IMPORTANCE_LOW,
).apply { ).apply {
description = "Postęp aktywnego kursu i lokalizacja w tle" description = "Informacje o aktywnym kursie"
} }
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
} }
@@ -292,6 +324,8 @@ class ActiveRouteTrackingService : Service(), LocationListener {
private const val ACTIVE_ROUTE_ID = "active_route_id" private const val ACTIVE_ROUTE_ID = "active_route_id"
fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) { fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) {
if (!hasLocationPermission(context)) return
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply { val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
action = ACTION_START action = ACTION_START
putExtra(EXTRA_ROUTE_ID, route.id) putExtra(EXTRA_ROUTE_ID, route.id)
@@ -302,7 +336,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
route.destinationNavigation?.longitude?.let { putExtra(EXTRA_DESTINATION_LON, it) } route.destinationNavigation?.longitude?.let { putExtra(EXTRA_DESTINATION_LON, it) }
route.distanceKm?.takeIf { it > 0.0 }?.let { putExtra(EXTRA_TOTAL_DISTANCE_METERS, it * 1000.0) } route.distanceKm?.takeIf { it > 0.0 }?.let { putExtra(EXTRA_TOTAL_DISTANCE_METERS, it * 1000.0) }
} }
ContextCompat.startForegroundService(context, intent) runCatching { ContextCompat.startForegroundService(context, intent) }
} }
fun stop(context: Context, routeId: String? = null) { fun stop(context: Context, routeId: String? = null) {
@@ -318,11 +352,20 @@ class ActiveRouteTrackingService : Service(), LocationListener {
} }
fun markArrived(context: Context, routeId: String) { fun markArrived(context: Context, routeId: String) {
if (!hasLocationPermission(context)) return
val activeRouteId = context.getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
.getString(ACTIVE_ROUTE_ID, null)
if (activeRouteId != routeId) return
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply { val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
action = ACTION_MARK_ARRIVED action = ACTION_MARK_ARRIVED
putExtra(EXTRA_ROUTE_ID, routeId) putExtra(EXTRA_ROUTE_ID, routeId)
} }
ContextCompat.startForegroundService(context, intent) runCatching { ContextCompat.startForegroundService(context, intent) }
} }
private fun hasLocationPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
} }
} }
File diff suppressed because it is too large Load Diff
@@ -2,24 +2,36 @@ package pl.firmatpp.kierowca.ui
import java.time.LocalDate import java.time.LocalDate
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeavePoolDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
import pl.firmatpp.kierowca.data.model.LeaveRequestTypeDto
data class LeaveRequestsConfig( data class LeaveRequestsConfig(
val enabled: Boolean = false, val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"), val types: List<String> = listOf("URLOP"),
) )
data class AnnualLeaveTotals(
val availableMinutes: Int,
val reservedMinutes: Int,
val usedMinutes: Int,
val grantedMinutes: Int,
val overdueMinutes: Int,
)
object DriverLeaveRequestUiRules { object DriverLeaveRequestUiRules {
fun isFeatureVisible(config: LeaveRequestsConfig?): Boolean = fun isFeatureVisible(config: LeaveRequestsConfig?): Boolean =
config?.enabled == true && config.types.isNotEmpty() config?.enabled == true && config.types.isNotEmpty()
fun statusLabel(status: String): String = fun statusLabel(status: String): String =
when (status) { when (status) {
"pending" -> "Oczekuje" "pending" -> "Czeka na decyzję"
"approved" -> "Zatwierdzony" "approved" -> "Zaakceptowany"
"rejected" -> "Odrzucony" "rejected" -> "Odrzucony"
"cancel_requested" -> "Anulowanie do decyzji" "cancel_requested" -> "Czeka na anulowanie"
"cancelled" -> "Anulowany" "cancelled" -> "Anulowany"
"revoked" -> "Cofnięto decyzję" "revoked" -> "Akceptacja cofnięta"
else -> "Nieznany status" else -> "Nieznany status"
} }
@@ -33,19 +45,160 @@ object DriverLeaveRequestUiRules {
"cancellation_requested" -> "Kierowca poprosił o anulowanie" "cancellation_requested" -> "Kierowca poprosił o anulowanie"
"cancellation_approved" -> "Anulowanie zatwierdzone" "cancellation_approved" -> "Anulowanie zatwierdzone"
"cancellation_rejected" -> "Anulowanie odrzucone" "cancellation_rejected" -> "Anulowanie odrzucone"
"period_amended" -> "Zmieniono termin wniosku"
"interrupted" -> "Nieobecność została przerwana"
else -> "Aktualizacja wniosku" else -> "Aktualizacja wniosku"
} }
fun typeLabel(type: String): String = fun typeLabel(type: String): String =
when (type) { when (type) {
"URLOP" -> "Urlop" "URLOP", "ANNUAL" -> "Urlop wypoczynkowy"
"CHOROBOWE" -> "Chorobowe" "ANNUAL_ON_DEMAND" -> "Urlop na żądanie"
"SZKOLENIE" -> "Szkolenie" "ANNUAL_DISABILITY" -> "Dodatkowy urlop"
"CHOROBOWE", "SICKNESS" -> "Niezdolność do pracy"
"SZKOLENIE", "TRAINING" -> "Urlop szkoleniowy"
"WOLNE" -> "Dzień wolny" "WOLNE" -> "Dzień wolny"
"INNE" -> "Inne" "INNE", "OTHER_OPERATIONAL" -> "Inna nieobecność"
"FORCE_MAJEURE" -> "Siła wyższa"
"CHILD_CARE_14" -> "Opieka nad dzieckiem do 14 lat"
"CARE_LEAVE" -> "Urlop opiekuńczy"
"MATERNITY" -> "Urlop macierzyński"
"MATERNITY_EQUIVALENT" -> "Urlop na warunkach macierzyńskiego"
"MATERNITY_SUPPLEMENTARY" -> "Uzupełniający urlop macierzyński"
"PARENTAL" -> "Urlop rodzicielski"
"PATERNITY" -> "Urlop ojcowski"
"CHILDCARE_UNPAID" -> "Urlop wychowawczy"
"OCCASIONAL" -> "Zwolnienie okolicznościowe"
"REHABILITATION" -> "Turnus rehabilitacyjny"
"UNPAID" -> "Urlop bezpłatny"
"PREGNANCY_EXAMS" -> "Badania w ciąży"
"OCCUPATIONAL_EXAMS" -> "Badania pracownicze"
"BLOOD_DONATION" -> "Zwolnienie krwiodawcy"
"AUTHORITY_SUMMONS" -> "Wezwanie urzędowe"
"RESCUE_DUTIES" -> "Obowiązki ratownicze"
"MILITARY_DUTIES" -> "Obowiązki wojskowe"
else -> type else -> type
} }
fun requestType(request: DriverLeaveRequestDto): String =
request.typeLabel?.takeIf { it.isNotBlank() }
?: request.rule?.label?.takeIf { it.isNotBlank() }
?: typeLabel(request.ruleCode ?: request.type)
fun annualTotals(summary: DriverLeaveSummaryDto?): AnnualLeaveTotals {
val annualPools = summary?.pools.orEmpty().filter {
it.poolCode == "ANNUAL" && it.unit == "minutes" && it.status == "active"
}
return AnnualLeaveTotals(
availableMinutes = annualPools.sumOf { it.availableQuantity },
reservedMinutes = annualPools.sumOf { it.reservedQuantity },
usedMinutes = annualPools.sumOf { it.usedQuantity },
grantedMinutes = annualPools.sumOf { it.grantedQuantity },
overdueMinutes = annualPools.filter { it.year < LocalDate.now().year }.sumOf { it.availableQuantity },
)
}
fun annualPools(summary: DriverLeaveSummaryDto?): List<DriverLeavePoolDto> =
summary?.pools.orEmpty()
.filter { it.poolCode == "ANNUAL" && it.status == "active" }
.sortedByDescending { it.year }
fun formatQuantity(quantity: Int, unit: String, dailyNormMinutes: Int = 480): String {
if (unit == "days") return dayCountLabel(quantity)
val safeNorm = dailyNormMinutes.coerceAtLeast(1)
val fullDays = quantity / safeNorm
val remainingMinutes = quantity % safeNorm
val hours = quantity / 60
val minutes = quantity % 60
val dayPart = when {
fullDays == 0 && remainingMinutes == 0 -> "0 dni"
remainingMinutes == 0 -> dayCountLabel(fullDays)
fullDays == 0 -> "mniej niż 1 dzień"
else -> "${dayCountLabel(fullDays)} + ${formatHours(remainingMinutes)}"
}
return "$dayPart · ${hours} godz.${if (minutes > 0) " ${minutes} min" else ""}"
}
fun formatHours(minutes: Int): String {
val hours = minutes / 60
val remainder = minutes % 60
return when {
hours == 0 -> "$remainder min"
remainder == 0 -> "$hours godz."
else -> "$hours godz. $remainder min"
}
}
fun dayCountLabel(days: Int): String {
val suffix = when {
days == 1 -> "dzień"
days % 10 in 2..4 && days % 100 !in 12..14 -> "dni"
else -> "dni"
}
return "$days $suffix"
}
fun modeLabel(mode: String): String = if (mode == "hours") "Kilka godzin" else "Całe dni"
fun decisionExplanation(type: LeaveRequestTypeDto): String =
when (type.decisionMode) {
"mandatory_registration" -> "Po spełnieniu wymaganych warunków wniosek jest rejestrowany zgodnie z przepisami."
"external_event" -> "Nieobecność wymaga potwierdzenia odpowiednim dokumentem."
else -> "Wniosek zostanie przekazany do osoby, która podejmie decyzję."
}
fun fieldLabel(field: String): String =
when (field) {
"childBirthDate" -> "Data urodzenia dziecka"
"settlementMode" -> "Sposób wykorzystania w tym roku"
"careRecipientName" -> "Imię i nazwisko osoby wymagającej opieki"
"careRecipientRelationship" -> "Kim jest dla Ciebie ta osoba"
"careReason" -> "Dlaczego potrzebna jest opieka"
"careRecipientAddress" -> "Adres osoby wymagającej opieki"
"childrenBornCount" -> "Liczba dzieci urodzonych przy jednym porodzie"
"entitlementEventKey" -> "Opis zdarzenia"
"partNumber" -> "Która to część urlopu"
"childIdentityKey" -> "Imię dziecka"
"occasionType" -> "Rodzaj zdarzenia rodzinnego"
"examType" -> "Rodzaj egzaminu lub nauki"
"educationProgramKey" -> "Nazwa szkoły lub programu"
"classification" -> "Czego dotyczy nieobecność"
else -> "Dodatkowa informacja"
}
fun fieldHint(field: String): String? =
when (field) {
"childBirthDate" -> "Wpisz datę w formacie RRRR-MM-DD, np. 2020-05-12"
"entitlementEventKey" -> "Np. poród z 15.06.2026"
"partNumber" -> "Np. 1"
"childIdentityKey" -> "Wystarczy imię i rok urodzenia"
"educationProgramKey" -> "Np. Technikum Transportowe w Poznaniu"
"classification" -> "Krótko opisz powód, aby kadry mogły go prawidłowo zakwalifikować"
else -> null
}
fun documentLabel(type: String): String =
when (type) {
"disability_certificate" -> "Orzeczenie o niepełnosprawności"
"parent_declaration" -> "Oświadczenie rodzica"
"birth_document", "birth_or_adoption_document" -> "Dokument potwierdzający urodzenie lub przysposobienie"
"adoption_or_care_document" -> "Dokument przysposobienia lub przyjęcia na wychowanie"
"hospitalization_certificate" -> "Zaświadczenie o hospitalizacji"
"parental_leave_declaration", "childcare_leave_declaration" -> "Oświadczenie dotyczące urlopu rodzicielskiego"
"event_document" -> "Dokument potwierdzający zdarzenie"
"training_confirmation" -> "Potwierdzenie nauki lub egzaminu"
"rehabilitation_referral" -> "Skierowanie na turnus rehabilitacyjny"
"medical_referral", "medical_referral_or_confirmation" -> "Skierowanie lub potwierdzenie badania"
"blood_donation_certificate" -> "Zaświadczenie o oddaniu krwi"
"authority_summons" -> "Wezwanie urzędowe"
"rescue_service_confirmation" -> "Potwierdzenie służby ratowniczej"
"military_document" -> "Dokument wojskowy"
"external_sickness_confirmation" -> "Potwierdzenie niezdolności do pracy"
else -> "Wymagany dokument"
}
fun canCancel(status: String, dateFrom: String, today: LocalDate = LocalDate.now()): Boolean { fun canCancel(status: String, dateFrom: String, today: LocalDate = LocalDate.now()): Boolean {
if (status == "pending") return true if (status == "pending") return true
if (status != "approved") return false if (status != "approved") return false
@@ -22,13 +22,20 @@ internal val routeActionVisibleStatuses = setOf(
RouteActionStatus.FailedPermanent, RouteActionStatus.FailedPermanent,
) )
private val routeActionProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed private val routeActionDataProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
private val routeActionLifecycleProjectionStatuses = setOf(
RouteActionStatus.Pending,
RouteActionStatus.WaitingForPhotos,
RouteActionStatus.Syncing,
RouteActionStatus.FailedRetryable,
RouteActionStatus.Confirmed,
)
fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute { fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute {
val visible = actions val visible = actions
.filter { it.routeId == route.id && it.status in routeActionVisibleStatuses } .filter { it.routeId == route.id && it.status in routeActionVisibleStatuses }
val projectable = actions val projectable = actions
.filter { it.routeId == route.id && it.status in routeActionProjectionStatuses } .filter { it.routeId == route.id && it.status in routeActionDataProjectionStatuses }
var projectedRoute = route var projectedRoute = route
var loadingWeightPending = false var loadingWeightPending = false
var unloadingWeightPending = false var unloadingWeightPending = false
@@ -39,23 +46,33 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
when (action.action) { when (action.action) {
RouteActionType.Start -> { RouteActionType.Start -> {
projectedRoute = projectedRoute.copy( projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
loadingWeight = action.weight, loadingWeight = action.weight,
trackingStatus = "active", loadingNotes = action.notes,
photos = projectedRoute.safePhotos(), photos = projectedRoute.safePhotos(),
) )
if (action.status in routeActionLifecycleProjectionStatuses) {
projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
trackingStatus = "active",
)
}
loadingWeightPending = action.status != RouteActionStatus.Confirmed loadingWeightPending = action.status != RouteActionStatus.Confirmed
} }
RouteActionType.Finish -> { RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy( projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
unloadingWeight = action.weight, unloadingWeight = action.weight,
trackingStatus = "finished", unloadingNotes = action.notes,
completedAt = action.occurredAt,
photos = projectedRoute.safePhotos(), photos = projectedRoute.safePhotos(),
) )
if (action.status in routeActionLifecycleProjectionStatuses) {
projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
trackingStatus = "finished",
completedAt = action.occurredAt,
)
}
unloadingWeightPending = action.status != RouteActionStatus.Confirmed unloadingWeightPending = action.status != RouteActionStatus.Confirmed
} }
} }
@@ -14,6 +14,8 @@ import kotlin.math.abs
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.TachographReminderTaskDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
@@ -25,6 +27,7 @@ import pl.firmatpp.kierowca.data.upload.RouteActionType
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM") private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
private val routeWeightKilogramInputThreshold = BigDecimal("1000") private val routeWeightKilogramInputThreshold = BigDecimal("1000")
private const val routeWeightMaxTons = 999.999 private const val routeWeightMaxTons = 999.999
const val routeStageNotesMaxLength = 2000
private const val googleMapsPackage = "com.google.android.apps.maps" private const val googleMapsPackage = "com.google.android.apps.maps"
data class NavigationIntentCandidate( data class NavigationIntentCandidate(
@@ -39,6 +42,42 @@ data class AppUpdateBannerUi(
val downloaded: Boolean, val downloaded: Boolean,
) )
object RouteLocationMode {
const val Disabled = "disabled"
const val Optional = "optional"
const val Required = "required"
}
fun normalizeRouteLocationMode(value: String?): String = when (value) {
RouteLocationMode.Disabled,
RouteLocationMode.Required,
-> value
else -> RouteLocationMode.Optional
}
fun shouldTrackRouteLocation(
mode: String,
optionalLocationEnabled: Boolean,
hasLocationPermission: Boolean,
): Boolean = hasLocationPermission && when (normalizeRouteLocationMode(mode)) {
RouteLocationMode.Disabled -> false
RouteLocationMode.Required -> true
else -> optionalLocationEnabled
}
fun routeStartRequiresLocationPermission(
mode: String,
hasLocationPermission: Boolean,
): Boolean = normalizeRouteLocationMode(mode) == RouteLocationMode.Required && !hasLocationPermission
fun shouldWarnAboutRequiredRouteLocation(
mode: String,
hasActiveRoute: Boolean,
hasLocationPermission: Boolean,
): Boolean = normalizeRouteLocationMode(mode) == RouteLocationMode.Required &&
hasActiveRoute &&
!hasLocationPermission
fun appUpdateBannerUi( fun appUpdateBannerUi(
currentVersionCode: Int, currentVersionCode: Int,
latestVersionCode: Int?, latestVersionCode: Int?,
@@ -239,6 +278,8 @@ fun routeStageRequirementIsVisible(requirement: String): Boolean =
fun routeStageRequirementIsRequired(requirement: String): Boolean = fun routeStageRequirementIsRequired(requirement: String): Boolean =
normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required
fun normalizeRouteStageNotes(value: String): String = value.take(routeStageNotesMaxLength)
fun parseRouteWeightTons(weightText: String): Double? { fun parseRouteWeightTons(weightText: String): Double? {
val enteredWeight = weightText.trim().replace(',', '.').toBigDecimalOrNull() ?: return null val enteredWeight = weightText.trim().replace(',', '.').toBigDecimalOrNull() ?: return null
val tons = if (enteredWeight.abs() >= routeWeightKilogramInputThreshold) { val tons = if (enteredWeight.abs() >= routeWeightKilogramInputThreshold) {
@@ -632,6 +673,16 @@ fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLo
fun shouldShowDispatchSheetReminderCard(reminder: DispatchSheetReminderDto?): Boolean = fun shouldShowDispatchSheetReminderCard(reminder: DispatchSheetReminderDto?): Boolean =
reminder?.dueToday == true && reminder.status in setOf("missing", "uploaded") reminder?.dueToday == true && reminder.status in setOf("missing", "uploaded")
fun shouldShowTachographReminderCard(reminder: TachographReminderDto?): Boolean =
reminder?.enabled == true &&
reminder.visible &&
!reminder.title.isNullOrBlank() &&
!reminder.message.isNullOrBlank() &&
!reminder.tasks.isNullOrEmpty()
fun defaultTachographConfirmationSelection(tasks: List<TachographReminderTaskDto>): Set<String> =
if (tasks.size == 1) setOf(tasks.single().id) else emptySet()
fun dispatchSheetPrimaryActionLabel(reminder: DispatchSheetReminderDto?, hasQueuedUpload: Boolean): String = fun dispatchSheetPrimaryActionLabel(reminder: DispatchSheetReminderDto?, hasQueuedUpload: Boolean): String =
when { when {
hasQueuedUpload -> "Wysyłam zdjęcie..." hasQueuedUpload -> "Wysyłam zdjęcie..."
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,337 @@
package pl.firmatpp.kierowca.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material.icons.outlined.StopCircle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import pl.firmatpp.kierowca.ui.theme.TppTheme
internal data class VoiceDictationUiState(
val isListening: Boolean = false,
val message: String = "Możesz wpisać uwagę albo podyktować ją po polsku.",
val isError: Boolean = false,
)
internal fun mergeDictationText(existingText: String, recognizedText: String): String {
val existing = existingText.trimEnd()
val recognized = recognizedText.trim()
if (recognized.isBlank()) return normalizeRouteStageNotes(existingText)
return normalizeRouteStageNotes(
if (existing.isBlank()) recognized else "$existing $recognized",
)
}
@Composable
internal fun VoiceNotesField(
value: String,
onValueChange: (String) -> Unit,
stageLabel: String,
enabled: Boolean,
onListeningChanged: (Boolean) -> Unit = {},
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var dictationState by remember { mutableStateOf(VoiceDictationUiState()) }
val currentValue by rememberUpdatedState(value)
val currentOnValueChange by rememberUpdatedState(onValueChange)
val controller = remember(context) { AndroidLiveVoiceDictation(context.applicationContext) }
val startDictation = {
controller.start(
existingText = currentValue,
onText = currentOnValueChange,
onState = { dictationState = it },
)
}
val microphonePermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
startDictation()
} else {
dictationState = VoiceDictationUiState(
message = "Brak dostępu do mikrofonu. Możesz nadal wpisać uwagę ręcznie.",
isError = true,
)
}
}
LaunchedEffect(dictationState.isListening) {
onListeningChanged(dictationState.isListening)
}
DisposableEffect(controller) {
onDispose {
onListeningChanged(false)
controller.destroy()
}
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedTextField(
value = value,
onValueChange = { currentOnValueChange(normalizeRouteStageNotes(it)) },
label = { Text("Uwagi do $stageLabel (opcjonalnie)") },
placeholder = { Text("Np. oczekiwanie, uszkodzenie, problem z dokumentami…") },
supportingText = {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Zapisujemy tylko tekst uwagi.")
Text("${value.length}/$routeStageNotesMaxLength")
}
},
readOnly = dictationState.isListening,
enabled = enabled,
minLines = 3,
maxLines = 6,
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = {
if (dictationState.isListening) {
controller.stop()
} else if (
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
) {
startDictation()
} else {
microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
},
enabled = enabled,
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp),
) {
Icon(
imageVector = if (dictationState.isListening) Icons.Outlined.StopCircle else Icons.Outlined.Mic,
contentDescription = null,
)
Text(
text = if (dictationState.isListening) "Zakończ dyktowanie" else "Dyktuj uwagę",
modifier = Modifier.padding(start = 10.dp),
fontWeight = FontWeight.Bold,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (dictationState.isListening) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = TppTheme.colors.forest,
)
}
Text(
text = dictationState.message,
color = if (dictationState.isError) Color(0xFFB42318) else TppTheme.colors.muted,
)
}
}
}
private class AndroidLiveVoiceDictation(
private val context: Context,
) : RecognitionListener {
private var recognizer: SpeechRecognizer? = null
private var prefix: String = ""
private var onText: ((String) -> Unit)? = null
private var onState: ((VoiceDictationUiState) -> Unit)? = null
private var listening = false
fun start(
existingText: String,
onText: (String) -> Unit,
onState: (VoiceDictationUiState) -> Unit,
) {
if (! SpeechRecognizer.isRecognitionAvailable(context)) {
onState(
VoiceDictationUiState(
message = "Na tym telefonie nie ma usługi rozpoznawania mowy. Wpisz uwagę ręcznie.",
isError = true,
),
)
return
}
this.prefix = existingText
this.onText = onText
this.onState = onState
if (recognizer == null) {
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also {
it.setRecognitionListener(this)
}
}
listening = true
emitState("Uruchamiam mikrofon…")
runCatching {
recognizer?.startListening(recognitionIntent())
}.onFailure {
listening = false
emitState("Nie udało się uruchomić dyktowania. Spróbuj ponownie.", isError = true)
}
}
fun stop() {
if (! listening) return
emitState("Kończę i sprawdzam rozpoznany tekst…")
recognizer?.stopListening()
}
fun destroy() {
listening = false
onText = null
onState = null
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
override fun onReadyForSpeech(params: Bundle?) {
emitState("Słucham… Mów normalnie. Rozpoznany tekst pojawia się wyżej na żywo.")
}
override fun onBeginningOfSpeech() {
emitState("Rozpoznaję mowę na żywo…")
}
override fun onRmsChanged(rmsdB: Float) = Unit
override fun onBufferReceived(buffer: ByteArray?) = Unit
override fun onEndOfSpeech() {
emitState("Sprawdzam rozpoznany tekst…")
}
override fun onError(error: Int) {
listening = false
emitState(speechRecognitionErrorMessage(error), isError = true)
}
override fun onResults(results: Bundle?) {
val recognized = bestRecognitionResult(results)
if (recognized.isNotBlank()) {
onText?.invoke(mergeDictationText(prefix, recognized))
}
listening = false
emitState(
if (recognized.isBlank()) {
"Nie wykryto tekstu. Naciśnij „Dyktuj uwagę” i spróbuj ponownie."
} else {
"Rozpoznano: „${recognized.take(160)}”. Możesz teraz poprawić tekst."
},
isError = recognized.isBlank(),
)
}
override fun onPartialResults(partialResults: Bundle?) {
val recognized = bestRecognitionResult(partialResults)
if (recognized.isBlank()) return
onText?.invoke(mergeDictationText(prefix, recognized))
emitState("Na żywo: „${recognized.take(160)}")
}
override fun onEvent(eventType: Int, params: Bundle?) = Unit
private fun recognitionIntent(): Intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "pl-PL")
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, false)
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
putExtra(
RecognizerIntent.EXTRA_ENABLE_FORMATTING,
RecognizerIntent.FORMATTING_OPTIMIZE_LATENCY,
)
putExtra(RecognizerIntent.EXTRA_HIDE_PARTIAL_TRAILING_PUNCTUATION, true)
}
}
private fun bestRecognitionResult(bundle: Bundle?): String =
bundle
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
?.firstOrNull()
.orEmpty()
.trim()
private fun emitState(message: String, isError: Boolean = false) {
onState?.invoke(
VoiceDictationUiState(
isListening = listening,
message = message,
isError = isError,
),
)
}
}
internal fun speechRecognitionErrorMessage(error: Int): String = when (error) {
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ->
"Brak dostępu do mikrofonu. Możesz wpisać uwagę ręcznie."
SpeechRecognizer.ERROR_SPEECH_TIMEOUT ->
"Nie usłyszałem mowy. Naciśnij „Dyktuj uwagę” i spróbuj ponownie."
SpeechRecognizer.ERROR_NO_MATCH ->
"Nie rozpoznano słów. Mów wyraźnie i spróbuj ponownie."
SpeechRecognizer.ERROR_NETWORK,
SpeechRecognizer.ERROR_NETWORK_TIMEOUT ->
"Rozpoznawanie mowy nie ma teraz połączenia. Spróbuj ponownie lub wpisz uwagę."
SpeechRecognizer.ERROR_RECOGNIZER_BUSY ->
"Mikrofon jest teraz zajęty. Odczekaj chwilę i spróbuj ponownie."
SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED,
SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE ->
"Polskie rozpoznawanie mowy nie jest dostępne na tym telefonie."
else ->
"Nie udało się rozpoznać mowy. Spróbuj ponownie lub wpisz uwagę ręcznie."
}
@@ -0,0 +1,45 @@
package pl.firmatpp.kierowca.ui
class OtpAttemptCoordinator {
private var currentAttemptId: Long = 0L
private var bufferedCode: String? = null
fun beginAttempt(): Long {
currentAttemptId += 1L
bufferedCode = null
return currentAttemptId
}
fun isCurrent(attemptId: Long): Boolean = attemptId == currentAttemptId
fun bufferRetrievedCode(attemptId: Long, code: String): Boolean {
if (!isCurrent(attemptId) || !isRetrievedOtpCode(code)) return false
bufferedCode = code
return true
}
fun takeBufferedCode(attemptId: Long): String? {
if (!isCurrent(attemptId)) return null
return bufferedCode.also { bufferedCode = null }
}
fun discard(attemptId: Long) {
if (isCurrent(attemptId)) bufferedCode = null
}
}
internal fun sanitizeOtpCode(code: String): String =
code.filter(Char::isDigit).take(OTP_MAX_LENGTH)
internal fun isRetrievedOtpCode(code: String): Boolean =
code.length in OTP_MIN_LENGTH..OTP_MAX_LENGTH && code.all(Char::isDigit)
internal fun otpResendDelaySeconds(availableAtEpochMillis: Long, nowEpochMillis: Long): Int {
val remainingMillis = (availableAtEpochMillis - nowEpochMillis).coerceAtLeast(0L)
return ((remainingMillis + 999L) / 1_000L).toInt()
}
internal const val OTP_MIN_LENGTH = 4
internal const val OTP_DEFAULT_LENGTH = 6
internal const val OTP_MAX_LENGTH = 10
internal const val OTP_RESEND_COOLDOWN_MILLIS = 30_000L
@@ -4,14 +4,20 @@ class OtpAutoSubmitPolicy {
private var lastSubmittedCode: String? = null private var lastSubmittedCode: String? = null
fun shouldSubmit(code: String, loading: Boolean): Boolean { fun shouldSubmit(code: String, loading: Boolean): Boolean {
if (code.length < OTP_LENGTH) { return shouldSubmitCompleteCode(code, loading, code.length == OTP_DEFAULT_LENGTH)
}
fun shouldSubmitRetrieved(code: String, loading: Boolean): Boolean {
return shouldSubmitCompleteCode(code, loading, isRetrievedOtpCode(code))
}
private fun shouldSubmitCompleteCode(code: String, loading: Boolean, complete: Boolean): Boolean {
if (!complete) {
lastSubmittedCode = null lastSubmittedCode = null
return false return false
} }
if (loading || code.length != OTP_LENGTH || code == lastSubmittedCode) { if (loading || code == lastSubmittedCode) return false
return false
}
lastSubmittedCode = code lastSubmittedCode = code
return true return true
@@ -20,8 +26,4 @@ class OtpAutoSubmitPolicy {
fun reset() { fun reset() {
lastSubmittedCode = null lastSubmittedCode = null
} }
private companion object {
const val OTP_LENGTH = 6
}
} }
@@ -0,0 +1,97 @@
package pl.firmatpp.kierowca.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.CancellationSignal
import android.os.Looper
import androidx.core.content.ContextCompat
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
private const val PHOTO_LOCATION_PROVIDER_TIMEOUT_MILLIS = 5_000L
internal suspend fun currentPhotoLocation(context: Context): Location? {
if (!hasAnyLocationPermission(context)) return null
val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null
val hasFineLocation = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
val providers = buildList {
if (hasFineLocation) add(LocationManager.GPS_PROVIDER)
add(LocationManager.NETWORK_PROVIDER)
}.filter { provider -> runCatching { manager.isProviderEnabled(provider) }.getOrDefault(false) }
for (provider in providers) {
val location = withTimeoutOrNull(PHOTO_LOCATION_PROVIDER_TIMEOUT_MILLIS) {
manager.awaitSingleLocation(context, provider)
}
if (location != null) return location
}
return bestLastKnownLocation(manager)
}
@SuppressLint("MissingPermission")
private suspend fun LocationManager.awaitSingleLocation(
context: Context,
provider: String,
): Location? = suspendCancellableCoroutine { continuation ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val cancellationSignal = CancellationSignal()
continuation.invokeOnCancellation { cancellationSignal.cancel() }
runCatching {
getCurrentLocation(provider, cancellationSignal, context.mainExecutor) { location ->
if (continuation.isActive) continuation.resume(location)
}
}.onFailure {
if (continuation.isActive) continuation.resume(null)
}
return@suspendCancellableCoroutine
}
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
removeUpdates(this)
if (continuation.isActive) continuation.resume(location)
}
}
continuation.invokeOnCancellation { runCatching { removeUpdates(listener) } }
runCatching {
@Suppress("DEPRECATION")
requestSingleUpdate(provider, listener, Looper.getMainLooper())
}.onFailure {
if (continuation.isActive) continuation.resume(null)
}
}
private fun hasAnyLocationPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
@SuppressLint("MissingPermission")
private fun bestLastKnownLocation(manager: LocationManager): Location? {
val providers = listOf(
LocationManager.GPS_PROVIDER,
LocationManager.NETWORK_PROVIDER,
LocationManager.PASSIVE_PROVIDER,
)
return providers.mapNotNull { provider ->
runCatching {
if (manager.isProviderEnabled(provider) || provider == LocationManager.PASSIVE_PROVIDER) {
manager.getLastKnownLocation(provider)
} else {
null
}
}.getOrNull()
}.maxByOrNull { it.time }
}
@@ -3,12 +3,16 @@ package pl.firmatpp.kierowca.data
import java.io.IOException import java.io.IOException
import java.net.UnknownHostException import java.net.UnknownHostException
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assert.fail import org.junit.Assert.fail
import org.junit.Test import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
class ApiErrorMapperTest { class ApiErrorMapperTest {
@Test @Test
@@ -84,4 +88,30 @@ class ApiErrorMapperTest {
assertEquals("Storage timeout", error.message) assertEquals("Storage timeout", error.message)
assertEquals(body, error.responseBody) assertEquals(body, error.responseBody)
} }
@Test
fun mapsInvalidOtpReasonToActionableMessage() {
val error = ApiErrorMapper.mapHttpStatus(422, """{"ok":false,"reason":"INVALID_OTP"}""")
assertEquals("INVALID_OTP", error.code)
assertEquals("Kod jest nieprawidłowy lub wygasł. Użyj kodu z najnowszego SMS-a.", error.message)
}
@Test
fun translatesRateLimitResponses() {
val error = ApiErrorMapper.mapHttpStatus(429, """{"message":"Too Many Attempts."}""")
assertEquals(ApiErrorKind.RateLimited, error.kind)
assertEquals("Za dużo prób. Odczekaj chwilę i spróbuj ponownie.", error.message)
}
@Test
fun preservesApiProblemWhenSameExceptionIsMappedMoreThanOnce() {
val body = """{"ok":false,"reason":"INVALID_OTP"}"""
.toResponseBody("application/json".toMediaType())
val exception = HttpException(Response.error<Any>(422, body))
assertEquals("INVALID_OTP", ApiErrorMapper.map(exception).code)
assertEquals("INVALID_OTP", ApiErrorMapper.map(exception).code)
}
} }
@@ -0,0 +1,83 @@
package pl.firmatpp.kierowca.data
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.LeaveSummaryResponse
class DriverLeaveApiContractTest {
private val gson = Gson()
@Test
fun parsesNewLeaveSummaryWithoutTechnicalFallbacks() {
val response = gson.fromJson(
"""
{
"data": {
"dailyNormMinutes": 480,
"balanceConfigured": true,
"overdueQuantity": 480,
"pools": [{
"id": 7,
"poolCode": "ANNUAL",
"year": 2026,
"unit": "minutes",
"availableQuantity": 7680,
"reservedQuantity": 960,
"usedQuantity": 960,
"grantedQuantity": 9600,
"status": "active"
}],
"availableTypes": [{
"value": "URLOP",
"code": "ANNUAL",
"label": "Urlop wypoczynkowy",
"requestModes": ["days", "hours"],
"requiredFields": [],
"requiredDocuments": [],
"fieldOptions": {},
"description": "Urlop rozliczany według grafiku."
}]
}
}
""".trimIndent(),
LeaveSummaryResponse::class.java,
)
assertEquals(480, response.data.dailyNormMinutes)
assertEquals(7680, response.data.pools.single().availableQuantity)
assertEquals("URLOP", response.data.availableTypes.single().value)
assertEquals("ANNUAL", response.data.availableTypes.single().code)
assertTrue("hours" in response.data.availableTypes.single().requestModes)
}
@Test
fun parsesSensitiveOwnRequestDetailsAsFormValues() {
val request = gson.fromJson(
"""
{
"id": "12",
"dateFrom": "2026-08-03",
"dateTo": "2026-08-03",
"type": "WOLNE",
"ruleCode": "CHILD_CARE_14",
"requestMode": "days",
"requestedQuantity": 480,
"details": {
"childrenBornCount": 1,
"childBirthDate": "2020-05-12"
},
"status": "pending",
"missingDocumentTypes": ["parent_declaration"]
}
""".trimIndent(),
DriverLeaveRequestDto::class.java,
)
assertEquals("1", request.details["childrenBornCount"])
assertEquals("2020-05-12", request.details["childBirthDate"])
assertEquals(listOf("parent_declaration"), request.missingDocumentTypes)
}
}
@@ -0,0 +1,19 @@
package pl.firmatpp.kierowca.data.api
import okhttp3.Request
import org.junit.Assert.assertEquals
import org.junit.Test
class ApiFactoryTest {
@Test
fun `request contains current driver app version header`() {
val request = Request.Builder()
.url("https://api.example.test/mobile/driver/bootstrap")
.header(DRIVER_APP_VERSION_HEADER, "old-version")
.build()
val updated = request.withDriverAppVersion("1.0.65")
assertEquals("1.0.65", updated.header(DRIVER_APP_VERSION_HEADER))
}
}
@@ -0,0 +1,61 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteActionPrerequisiteTest {
@Test
fun finishWaitsUntilEarlierStartIsConfirmed() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val pendingStart = action(RouteActionType.Start, RouteActionStatus.Syncing, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Waiting,
routeActionPrerequisiteState(finish, pendingStart),
)
}
@Test
fun finishRunsAfterEarlierStartIsConfirmed() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val confirmedStart = action(RouteActionType.Start, RouteActionStatus.Confirmed, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Ready,
routeActionPrerequisiteState(finish, confirmedStart),
)
}
@Test
fun failedStartBlocksFinishInsteadOfSendingItOutOfOrder() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val failedStart = action(RouteActionType.Start, RouteActionStatus.FailedConflict, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Failed,
routeActionPrerequisiteState(finish, failedStart),
)
}
@Test
fun finishWithoutLocalStartCanUseServerLifecycleState() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
assertEquals(
RouteActionPrerequisiteState.Ready,
routeActionPrerequisiteState(finish, null),
)
}
private fun action(type: String, status: String, createdAt: Long): RouteActionEntity =
RouteActionEntity(
clientActionId = "$type-$createdAt",
routeId = "route-1",
action = type,
weight = 12.5,
occurredAt = "2026-07-23T10:00:00Z",
photoClientRequestIdsJson = "[]",
status = status,
createdAtEpochMillis = createdAt,
)
}
@@ -0,0 +1,135 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class RouteActionServerReconciliationTest {
@Test
fun matchingFinishedRouteConfirmsDuplicateFinishSafely() {
assertTrue(
routeActionMatchesServer(
action = finish(weight = 11.8, notes = "Uszkodzona plomba."),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(
status = "ZAKOŃCZONA",
weight = 11.8,
notes = "Uszkodzona plomba.",
photoClientRequestIds = listOf("photo-1"),
),
),
)
}
@Test
fun differingNotesKeepConflictVisibleAndPreserveLocalData() {
assertFalse(
routeActionMatchesServer(
action = finish(weight = 11.8, notes = "Uwagi z telefonu"),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(
status = "ZAKOŃCZONA",
weight = 11.8,
notes = "Inna uwaga na serwerze",
photoClientRequestIds = listOf("photo-1"),
),
),
)
}
@Test
fun missingServerPhotoDoesNotDiscardLocalFinish() {
assertFalse(
routeActionMatchesServer(
action = finish(weight = null, notes = null),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(status = "ZAKOŃCZONA", weight = null, notes = null),
),
)
}
@Test
fun laterConfirmedFinishSupersedesAnOlderConflictingFinish() {
assertTrue(
routeActionWasSuperseded(
action = finish(weight = 11.8, notes = "Starsza uwaga"),
hasConfirmedFinishAfter = true,
serverRoute = route(
status = "ZAKOŃCZONA",
weight = null,
notes = null,
),
),
)
}
@Test
fun completedServerRouteAloneDoesNotDiscardConflictingLocalData() {
assertFalse(
routeActionWasSuperseded(
action = finish(weight = 11.8, notes = "Nadal ważna uwaga"),
hasConfirmedFinishAfter = false,
serverRoute = route(
status = "ZAKOŃCZONA",
weight = null,
notes = null,
),
),
)
}
private fun finish(weight: Double?, notes: String?): RouteActionEntity =
RouteActionEntity(
clientActionId = "finish-1",
routeId = "route-1",
action = RouteActionType.Finish,
weight = weight,
occurredAt = "2026-07-23T10:00:00Z",
photoClientRequestIdsJson = """["photo-1"]""",
notes = notes,
)
private fun route(
status: String,
weight: Double?,
notes: String?,
photoClientRequestIds: List<String> = emptyList(),
): DriverRouteDto =
DriverRouteDto(
id = "route-1",
routeDate = "2026-07-23",
startsAt = "",
originName = "Baza",
destinationName = "Cel",
contractorName = "TPP",
contractName = "Kontrakt",
contractCode = "TPP-1",
relationLabel = "Baza → Cel",
status = status,
driverStatus = status,
unloadingWeight = weight,
unloadingNotes = notes,
distanceKm = 1.0,
notes = null,
truck = null,
photos = photoClientRequestIds.mapIndexed { index, requestId ->
RoutePhotoDto(
id = "photo-$index",
routeId = "route-1",
clientRequestId = requestId,
source = "camera",
stage = "unloading",
mimeType = "image/jpeg",
size = 1,
url = "https://example.test/photo-$index",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
createdAt = null,
)
},
)
}
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.ApiErrorKind
class RoutePointWorkerRulesTest { class RoutePointWorkerRulesTest {
@Test @Test
@@ -15,4 +16,14 @@ class RoutePointWorkerRulesTest {
assertFalse(isInactiveRoutePointError("SERVER_UNAVAILABLE")) assertFalse(isInactiveRoutePointError("SERVER_UNAVAILABLE"))
assertFalse(isInactiveRoutePointError(null)) assertFalse(isInactiveRoutePointError(null))
} }
@Test
fun revokedSessionStopsRouteTracking() {
assertTrue(shouldStopRouteTrackingAfterPointError(ApiErrorKind.Auth, null))
}
@Test
fun temporaryFailureKeepsRouteTrackingActive() {
assertFalse(shouldStopRouteTrackingAfterPointError(ApiErrorKind.Server, "SERVER_UNAVAILABLE"))
}
} }
@@ -7,6 +7,8 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarDriverDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarDriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeavePoolDto
import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
class DriverLeaveRequestUiRulesTest { class DriverLeaveRequestUiRulesTest {
@Test @Test
@@ -18,15 +20,35 @@ class DriverLeaveRequestUiRulesTest {
@Test @Test
fun exposesPolishStatusLabels() { fun exposesPolishStatusLabels() {
assertEquals("Oczekuje", DriverLeaveRequestUiRules.statusLabel("pending")) assertEquals("Czeka na decyzję", DriverLeaveRequestUiRules.statusLabel("pending"))
assertEquals("Zatwierdzony", DriverLeaveRequestUiRules.statusLabel("approved")) assertEquals("Zaakceptowany", DriverLeaveRequestUiRules.statusLabel("approved"))
assertEquals("Odrzucony", DriverLeaveRequestUiRules.statusLabel("rejected")) assertEquals("Odrzucony", DriverLeaveRequestUiRules.statusLabel("rejected"))
assertEquals("Anulowanie do decyzji", DriverLeaveRequestUiRules.statusLabel("cancel_requested")) assertEquals("Czeka na anulowanie", DriverLeaveRequestUiRules.statusLabel("cancel_requested"))
assertEquals("Anulowany", DriverLeaveRequestUiRules.statusLabel("cancelled")) assertEquals("Anulowany", DriverLeaveRequestUiRules.statusLabel("cancelled"))
assertEquals("Cofnięto decyzję", DriverLeaveRequestUiRules.statusLabel("revoked")) assertEquals("Akceptacja cofnięta", DriverLeaveRequestUiRules.statusLabel("revoked"))
assertEquals("Nieznany status", DriverLeaveRequestUiRules.statusLabel("some_raw_status")) assertEquals("Nieznany status", DriverLeaveRequestUiRules.statusLabel("some_raw_status"))
} }
@Test
fun explainsAnnualBalanceInDaysAndHours() {
val summary = DriverLeaveSummaryDto(
dailyNormMinutes = 480,
balanceConfigured = true,
pools = listOf(
annualPool(year = 2025, available = 480, reserved = 0, used = 480, granted = 960),
annualPool(year = 2026, available = 3_840, reserved = 960, used = 960, granted = 5_760),
),
)
val totals = DriverLeaveRequestUiRules.annualTotals(summary)
assertEquals(4_320, totals.availableMinutes)
assertEquals(960, totals.reservedMinutes)
assertEquals(1_440, totals.usedMinutes)
assertEquals("9 dni · 72 godz.", DriverLeaveRequestUiRules.formatQuantity(totals.availableMinutes, "minutes", 480))
assertEquals("2 dni + 2 godz. · 18 godz.", DriverLeaveRequestUiRules.formatQuantity(1_080, "minutes", 480))
}
@Test @Test
fun exposesPolishEventActionLabels() { fun exposesPolishEventActionLabels() {
assertEquals("Wniosek złożony", DriverLeaveRequestUiRules.eventActionLabel("submitted")) assertEquals("Wniosek złożony", DriverLeaveRequestUiRules.eventActionLabel("submitted"))
@@ -105,4 +127,23 @@ class DriverLeaveRequestUiRulesTest {
phoneNumber = "+48 600 700 800", phoneNumber = "+48 600 700 800",
), ),
) )
private fun annualPool(
year: Int,
available: Int,
reserved: Int,
used: Int,
granted: Int,
): DriverLeavePoolDto =
DriverLeavePoolDto(
id = year,
poolCode = "ANNUAL",
year = year,
unit = "minutes",
availableQuantity = available,
reservedQuantity = reserved,
usedQuantity = used,
grantedQuantity = granted,
status = "active",
)
} }
@@ -17,11 +17,19 @@ class DriverRouteProjectionTest {
fun pendingStartProjectsPlannedRouteAsActiveWithLoadingWeight() { fun pendingStartProjectsPlannedRouteAsActiveWithLoadingWeight() {
val projection = projectDriverRoute( val projection = projectDriverRoute(
route = route(status = "ZAPLANOWANA"), route = route(status = "ZAPLANOWANA"),
actions = listOf(action(RouteActionType.Start, RouteActionStatus.Pending, weight = 12.5)), actions = listOf(
action(
RouteActionType.Start,
RouteActionStatus.Pending,
weight = 12.5,
notes = "Oczekiwanie na dokument WZ.",
),
),
) )
assertEquals("W TRAKCIE", projection.route.status) assertEquals("W TRAKCIE", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight) assertEquals(12.5, projection.route.loadingWeight)
assertEquals("Oczekiwanie na dokument WZ.", projection.route.loadingNotes)
assertEquals("active", projection.route.trackingStatus) assertEquals("active", projection.route.trackingStatus)
assertTrue(projection.loadingWeightPending) assertTrue(projection.loadingWeightPending)
assertFalse(projection.unloadingWeightPending) assertFalse(projection.unloadingWeightPending)
@@ -32,11 +40,20 @@ class DriverRouteProjectionTest {
fun pendingFinishProjectsActiveRouteAsFinishedWithUnloadingWeight() { fun pendingFinishProjectsActiveRouteAsFinishedWithUnloadingWeight() {
val projection = projectDriverRoute( val projection = projectDriverRoute(
route = route(status = "W TRAKCIE", loadingWeight = 12.5), route = route(status = "W TRAKCIE", loadingWeight = 12.5),
actions = listOf(action(RouteActionType.Finish, RouteActionStatus.Syncing, weight = 11.8, occurredAt = "2026-06-30T09:10:00Z")), actions = listOf(
action(
RouteActionType.Finish,
RouteActionStatus.Syncing,
weight = 11.8,
notes = "Uszkodzona plomba.",
occurredAt = "2026-06-30T09:10:00Z",
),
),
) )
assertEquals("ZAKOŃCZONA", projection.route.status) assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight) assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("Uszkodzona plomba.", projection.route.unloadingNotes)
assertEquals("finished", projection.route.trackingStatus) assertEquals("finished", projection.route.trackingStatus)
assertEquals("2026-06-30T09:10:00Z", projection.route.completedAt) assertEquals("2026-06-30T09:10:00Z", projection.route.completedAt)
assertTrue(projection.unloadingWeightPending) assertTrue(projection.unloadingWeightPending)
@@ -108,16 +125,25 @@ class DriverRouteProjectionTest {
} }
@Test @Test
fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() { fun conflictKeepsLocalRouteValuesButPreservesServerLifecycleStatus() {
val projection = projectDriverRoute( val projection = projectDriverRoute(
route = route(status = "W TRAKCIE", loadingWeight = 12.5), route = route(status = "W TRAKCIE", loadingWeight = 12.5),
actions = listOf(action(RouteActionType.Finish, RouteActionStatus.FailedConflict, weight = 11.8)), actions = listOf(
action(
RouteActionType.Finish,
RouteActionStatus.FailedConflict,
weight = 11.8,
notes = "Uszkodzona plomba.",
),
),
) )
val steps = routeFlowSteps(projection.route, projection.visibleActions) val steps = routeFlowSteps(projection.route, projection.visibleActions)
val callout = routeSyncCallout(projection.visibleActions) val callout = routeSyncCallout(projection.visibleActions)
assertEquals("ZAKOŃCZONA", projection.route.status) assertEquals("W TRAKCIE", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight) assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("Uszkodzona plomba.", projection.route.unloadingNotes)
assertTrue(projection.unloadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state) assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state)
assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text) assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false) assertTrue(callout?.isConflict ?: false)
@@ -131,8 +157,9 @@ class DriverRouteProjectionTest {
) )
val steps = routeFlowSteps(projection.route, projection.visibleActions) val steps = routeFlowSteps(projection.route, projection.visibleActions)
assertEquals("W TRAKCIE", projection.route.status) assertEquals("ZAPLANOWANA", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight) assertEquals(12.5, projection.route.loadingWeight)
assertTrue(projection.loadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state) assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state)
} }
@@ -174,6 +201,7 @@ class DriverRouteProjectionTest {
type: String, type: String,
status: String, status: String,
weight: Double, weight: Double,
notes: String? = null,
occurredAt: String = "2026-06-30T07:40:00Z", occurredAt: String = "2026-06-30T07:40:00Z",
createdAt: Long = 1000, createdAt: Long = 1000,
): RouteActionEntity = ): RouteActionEntity =
@@ -186,6 +214,7 @@ class DriverRouteProjectionTest {
photoClientRequestIdsJson = "[]", photoClientRequestIdsJson = "[]",
status = status, status = status,
createdAtEpochMillis = createdAt, createdAtEpochMillis = createdAt,
notes = notes,
) )
private companion object { private companion object {
@@ -8,6 +8,8 @@ import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.TachographReminderTaskDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.RouteActionEntity import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionStatus
@@ -16,6 +18,29 @@ import pl.firmatpp.kierowca.data.upload.RouteActionType
class DriverUiRulesTest { class DriverUiRulesTest {
private val today = LocalDate.parse("2026-06-30") private val today = LocalDate.parse("2026-06-30")
@Test
fun disabledRouteLocationNeverStartsTracking() {
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = true, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = false, hasLocationPermission = true))
}
@Test
fun optionalRouteLocationFollowsDriverChoiceAndPermission() {
assertTrue(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = true, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = false, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = true, hasLocationPermission = false))
}
@Test
fun requiredRouteLocationBlocksStartAndWarnsOnlyWhenNeeded() {
assertTrue(routeStartRequiresLocationPermission(RouteLocationMode.Required, hasLocationPermission = false))
assertFalse(routeStartRequiresLocationPermission(RouteLocationMode.Required, hasLocationPermission = true))
assertFalse(routeStartRequiresLocationPermission(RouteLocationMode.Optional, hasLocationPermission = false))
assertTrue(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Required, hasActiveRoute = true, hasLocationPermission = false))
assertFalse(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Required, hasActiveRoute = false, hasLocationPermission = false))
assertFalse(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Optional, hasActiveRoute = true, hasLocationPermission = false))
}
@Test @Test
fun allowsPhotoActionsOnlyForTodayRouteDate() { fun allowsPhotoActionsOnlyForTodayRouteDate() {
assertTrue(canManageRoutePhotos("2026-06-30", today)) assertTrue(canManageRoutePhotos("2026-06-30", today))
@@ -387,6 +412,57 @@ class DriverUiRulesTest {
assertFalse(shouldShowDispatchSheetReminderCard(null)) assertFalse(shouldShowDispatchSheetReminderCard(null))
} }
@Test
fun showsTachographCardOnlyForCompleteReminderEnabledByBackend() {
assertTrue(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = true,
visible = true,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = false,
visible = true,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = true,
visible = false,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(shouldShowTachographReminderCard(null))
}
@Test
fun preselectsOnlySingleTachographConfirmationTask() {
val driverCard = tachographTask()
val vehicle = TachographReminderTaskDto(
id = "vehicle:12:2026-07-31",
type = "vehicle_tachograph",
label = "Tachograf pojazdu",
)
assertEquals(setOf(driverCard.id), defaultTachographConfirmationSelection(listOf(driverCard)))
assertEquals(emptySet<String>(), defaultTachographConfirmationSelection(listOf(driverCard, vehicle)))
}
@Test @Test
fun explainsOfflineStateWithoutCachedSync() { fun explainsOfflineStateWithoutCachedSync() {
assertEquals( assertEquals(
@@ -794,4 +870,11 @@ class DriverUiRulesTest {
photo = null, photo = null,
canUpload = dueToday, canUpload = dueToday,
) )
private fun tachographTask(): TachographReminderTaskDto =
TachographReminderTaskDto(
id = "driver-card:1:2026-07-31",
type = "driver_card",
label = "Karta kierowcy",
)
} }
@@ -43,6 +43,55 @@ class DriverUiStateTest {
assertEquals(ServerConnectionState.Degraded, failed.serverConnectionState) assertEquals(ServerConnectionState.Degraded, failed.serverConnectionState)
} }
@Test
fun silentRefreshCannotReplaceActiveStartupLoad() {
assertFalse(
shouldStartRoutesLoad(
screen = DriverScreen.Initializing,
navigateToRoutes = false,
startupOfflineOnly = false,
),
)
assertFalse(
shouldStartRoutesLoad(
screen = DriverScreen.Routes,
navigateToRoutes = false,
startupOfflineOnly = false,
startupLoadInProgress = true,
),
)
}
@Test
fun startupAndManualOfflineLoadsRemainAvailable() {
assertTrue(
shouldStartRoutesLoad(
screen = DriverScreen.Initializing,
navigateToRoutes = true,
startupOfflineOnly = false,
),
)
assertTrue(
shouldStartRoutesLoad(
screen = DriverScreen.Initializing,
navigateToRoutes = false,
startupOfflineOnly = true,
),
)
}
@Test
fun successfulBootstrapAlwaysLeavesInitializingScreen() {
assertEquals(
DriverScreen.Routes,
screenAfterBootstrap(
currentScreen = DriverScreen.Initializing,
navigateToRoutes = false,
leaveRequestsEnabled = true,
),
)
}
@Test @Test
fun refreshingLeaveRequestsKeepsDetailScreenOpenAndUpdatesSelectedRequest() { fun refreshingLeaveRequestsKeepsDetailScreenOpenAndUpdatesSelectedRequest() {
val state = DriverUiState( val state = DriverUiState(
@@ -0,0 +1,30 @@
package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class LiveVoiceDictationTest {
@Test
fun recognizedTextIsAppendedToExistingTypedNote() {
assertEquals(
"Oczekiwanie na dokumenty kierowca odebrał WZ",
mergeDictationText("Oczekiwanie na dokumenty ", " kierowca odebrał WZ "),
)
}
@Test
fun recognizedTextCanStartAnEmptyNote() {
assertEquals(
"Uszkodzona plomba",
mergeDictationText("", " Uszkodzona plomba "),
)
}
@Test
fun routeNotesAreLimitedToApiMaximum() {
assertEquals(
routeStageNotesMaxLength,
normalizeRouteStageNotes("a".repeat(routeStageNotesMaxLength + 20)).length,
)
}
}
@@ -0,0 +1,48 @@
package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class OtpAttemptCoordinatorTest {
@Test
fun buffersSmsReceivedBeforeOtpRequestCompletes() {
val coordinator = OtpAttemptCoordinator()
val attemptId = coordinator.beginAttempt()
assertTrue(coordinator.bufferRetrievedCode(attemptId, "123456"))
assertEquals("123456", coordinator.takeBufferedCode(attemptId))
assertNull(coordinator.takeBufferedCode(attemptId))
}
@Test
fun ignoresConsentResultFromPreviousRequest() {
val coordinator = OtpAttemptCoordinator()
val previousAttempt = coordinator.beginAttempt()
val currentAttempt = coordinator.beginAttempt()
assertFalse(coordinator.bufferRetrievedCode(previousAttempt, "111111"))
assertTrue(coordinator.bufferRetrievedCode(currentAttempt, "222222"))
assertEquals("222222", coordinator.takeBufferedCode(currentAttempt))
}
@Test
fun acceptsOnlyOtpLengthsSupportedByApi() {
val coordinator = OtpAttemptCoordinator()
val attemptId = coordinator.beginAttempt()
assertFalse(coordinator.bufferRetrievedCode(attemptId, "123"))
assertTrue(coordinator.bufferRetrievedCode(attemptId, "1234"))
assertTrue(coordinator.bufferRetrievedCode(attemptId, "1234567890"))
assertFalse(coordinator.bufferRetrievedCode(attemptId, "12345678901"))
}
@Test
fun roundsResendCooldownUpToFullSeconds() {
assertEquals(30, otpResendDelaySeconds(40_000L, 10_000L))
assertEquals(1, otpResendDelaySeconds(10_001L, 10_000L))
assertEquals(0, otpResendDelaySeconds(10_000L, 10_001L))
}
}
@@ -38,4 +38,13 @@ class OtpAutoSubmitPolicyTest {
assertFalse(policy.shouldSubmit("123456", loading = true)) assertFalse(policy.shouldSubmit("123456", loading = true))
assertFalse(policy.shouldSubmit("1234567", loading = false)) assertFalse(policy.shouldSubmit("1234567", loading = false))
} }
@Test
fun submitsCompleteRetrievedCodesAcceptedByApi() {
val policy = OtpAutoSubmitPolicy()
assertTrue(policy.shouldSubmitRetrieved("1234", loading = false))
assertTrue(policy.shouldSubmitRetrieved("1234567890", loading = false))
assertFalse(policy.shouldSubmitRetrieved("123", loading = false))
}
} }
@@ -0,0 +1,43 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteDatePickerBoundsTest {
@Test
fun usesTheSameMinAndMaxDatesAsTheRouteSelector() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-24",
minDate = "2026-07-17",
maxDate = "2026-07-26",
)
assertEquals(LocalDate.parse("2026-07-24"), bounds.selectedDate)
assertEquals(LocalDate.parse("2026-07-17"), bounds.minDate)
assertEquals(LocalDate.parse("2026-07-26"), bounds.maxDate)
}
@Test
fun clampsSelectedDateToAllowedRange() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-30",
minDate = "2026-07-17",
maxDate = "2026-07-26",
)
assertEquals(LocalDate.parse("2026-07-26"), bounds.selectedDate)
}
@Test
fun normalizesAccidentallyReversedServerBounds() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-24",
minDate = "2026-07-26",
maxDate = "2026-07-17",
)
assertEquals(LocalDate.parse("2026-07-17"), bounds.minDate)
assertEquals(LocalDate.parse("2026-07-26"), bounds.maxDate)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
[versions] [versions]
agp = "8.7.3" agp = "8.10.1"
kotlin = "2.1.10" kotlin = "2.1.10"
composeBom = "2026.03.00" composeBom = "2026.03.00"
activityCompose = "1.9.3" activityCompose = "1.9.3"
+16 -2
View File
@@ -138,6 +138,14 @@ if [[ "$sanitized_remote" =~ ^(https?)://([^/]+)/(.+)$ ]]; then
detected_protocol="${BASH_REMATCH[1]}" detected_protocol="${BASH_REMATCH[1]}"
detected_host="${BASH_REMATCH[2]}" detected_host="${BASH_REMATCH[2]}"
detected_repository="${BASH_REMATCH[3]%.git}" detected_repository="${BASH_REMATCH[3]%.git}"
elif [[ "$remote_url" =~ ^ssh://([^/@]+@)?([^/:]+)(:[0-9]+)?/(.+)$ ]]; then
detected_protocol="https"
detected_host="${BASH_REMATCH[2]}"
detected_repository="${BASH_REMATCH[4]%.git}"
elif [[ "$remote_url" =~ ^([^/@:]+@)?([^/:]+):(.+)$ ]]; then
detected_protocol="https"
detected_host="${BASH_REMATCH[2]}"
detected_repository="${BASH_REMATCH[3]%.git}"
else else
detected_protocol="https" detected_protocol="https"
detected_host="" detected_host=""
@@ -147,14 +155,20 @@ fi
gitea_base_url="${GITEA_BASE_URL:-${detected_protocol}://${detected_host}}" gitea_base_url="${GITEA_BASE_URL:-${detected_protocol}://${detected_host}}"
gitea_repository="${GITEA_REPOSITORY:-$detected_repository}" gitea_repository="${GITEA_REPOSITORY:-$detected_repository}"
gitea_base_url="${gitea_base_url%/}" gitea_base_url="${gitea_base_url%/}"
[[ "$gitea_base_url" =~ ^https?://[^/]+$ ]] || { echo "Ustaw poprawne GITEA_BASE_URL." >&2; exit 1; } if [[ "$gitea_base_url" =~ ^(https?)://([^/]+)$ ]]; then
gitea_api_protocol="${BASH_REMATCH[1]}"
gitea_api_host="${BASH_REMATCH[2]}"
else
echo "Ustaw poprawne GITEA_BASE_URL." >&2
exit 1
fi
[[ "$gitea_repository" =~ ^[^/]+/[^/]+$ ]] || { echo "Ustaw poprawne GITEA_REPOSITORY=owner/repository." >&2; exit 1; } [[ "$gitea_repository" =~ ^[^/]+/[^/]+$ ]] || { echo "Ustaw poprawne GITEA_REPOSITORY=owner/repository." >&2; exit 1; }
auth_args=() auth_args=()
if [[ -n "${GITEA_TOKEN:-}" ]]; then if [[ -n "${GITEA_TOKEN:-}" ]]; then
auth_args=(-H "Authorization: token $GITEA_TOKEN") auth_args=(-H "Authorization: token $GITEA_TOKEN")
else else
credential="$(printf 'protocol=%s\nhost=%s\n\n' "$detected_protocol" "$detected_host" | git credential fill)" credential="$(printf 'protocol=%s\nhost=%s\n\n' "$gitea_api_protocol" "$gitea_api_host" | git credential fill)"
gitea_username="$(printf '%s\n' "$credential" | sed -n 's/^username=//p')" gitea_username="$(printf '%s\n' "$credential" | sed -n 's/^username=//p')"
gitea_password="$(printf '%s\n' "$credential" | sed -n 's/^password=//p')" gitea_password="$(printf '%s\n' "$credential" | sed -n 's/^password=//p')"
[[ -n "$gitea_username" && -n "$gitea_password" ]] || { [[ -n "$gitea_username" && -n "$gitea_password" ]] || {