Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca512f5993 | ||
|
|
64d6f5a41d | ||
|
|
a3d39764e2 | ||
|
|
705b8904a4 | ||
|
|
541dda292f | ||
|
|
fa98451533 | ||
|
|
ee9d8cdbaf | ||
|
|
76326bbc27 | ||
|
|
ed02da031b | ||
|
|
a4e24da717 | ||
|
|
ab84205bec |
@@ -5,3 +5,5 @@ app/google-services.json
|
|||||||
app/google-services.json:Zone.Identifier
|
app/google-services.json:Zone.Identifier
|
||||||
*/build/
|
*/build/
|
||||||
*.iml
|
*.iml
|
||||||
|
keystore.properties
|
||||||
|
*.jks
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.io.File
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
@@ -9,6 +12,13 @@ if (file("google-services.json").exists()) {
|
|||||||
apply(plugin = "com.google.gms.google-services")
|
apply(plugin = "com.google.gms.google-services")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||||
|
val keystoreProperties = Properties().apply {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystorePropertiesFile.inputStream().use(::load)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "pl.firmatpp.kierowca"
|
namespace = "pl.firmatpp.kierowca"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -23,8 +33,9 @@ android {
|
|||||||
applicationId = "pl.firmatpp.kierowca"
|
applicationId = "pl.firmatpp.kierowca"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 18
|
versionCode = 34
|
||||||
versionName = "1.0.17"
|
versionName = "1.0.33"
|
||||||
|
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
|
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
|
||||||
@@ -32,12 +43,34 @@ android {
|
|||||||
buildConfigField("String", "REVERB_WS_BASE_URL", "\"${configValue("REVERB_WS_BASE_URL", "wss://api-intranet.firmatpp.pl/app/")}\"")
|
buildConfigField("String", "REVERB_WS_BASE_URL", "\"${configValue("REVERB_WS_BASE_URL", "wss://api-intranet.firmatpp.pl/app/")}\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
fun requiredSigningProperty(name: String): String =
|
||||||
|
keystoreProperties.getProperty(name)
|
||||||
|
?: throw GradleException("Missing '$name' in keystore.properties")
|
||||||
|
|
||||||
|
val configuredStoreFile = requiredSigningProperty("storeFile")
|
||||||
|
storeFile = if (File(configuredStoreFile).isAbsolute) {
|
||||||
|
file(configuredStoreFile)
|
||||||
|
} else {
|
||||||
|
rootProject.file(configuredStoreFile)
|
||||||
|
}
|
||||||
|
storePassword = requiredSigningProperty("storePassword")
|
||||||
|
keyAlias = requiredSigningProperty("keyAlias")
|
||||||
|
keyPassword = requiredSigningProperty("keyPassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "SYMBOL_TABLE"
|
||||||
|
}
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<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" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package pl.firmatpp.kierowca
|
package pl.firmatpp.kierowca
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
@@ -9,13 +12,26 @@ import pl.firmatpp.kierowca.ui.DriverViewModel
|
|||||||
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
|
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private var notificationRouteId by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
|
||||||
setContent {
|
setContent {
|
||||||
TppKierowcaTheme {
|
TppKierowcaTheme {
|
||||||
val viewModel: DriverViewModel = viewModel()
|
val viewModel: DriverViewModel = viewModel()
|
||||||
DriverApp(viewModel = viewModel)
|
DriverApp(viewModel = viewModel, initialRouteId = notificationRouteId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: android.content.Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_ROUTE_ID = "pl.firmatpp.kierowca.EXTRA_ROUTE_ID"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ data class ApiError(
|
|||||||
val retryable: Boolean,
|
val retryable: Boolean,
|
||||||
val code: String? = null,
|
val code: String? = null,
|
||||||
val statusCode: Int? = null,
|
val statusCode: Int? = null,
|
||||||
|
val responseBody: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
object ApiErrorMapper {
|
object ApiErrorMapper {
|
||||||
@@ -48,10 +49,17 @@ object ApiErrorMapper {
|
|||||||
code = code,
|
code = code,
|
||||||
message = message,
|
message = message,
|
||||||
retryable = retryable,
|
retryable = retryable,
|
||||||
|
responseBody = body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun mapProblem(statusCode: Int, code: String?, message: String?, retryable: Boolean?): ApiError {
|
fun mapProblem(
|
||||||
|
statusCode: Int,
|
||||||
|
code: String?,
|
||||||
|
message: String?,
|
||||||
|
retryable: Boolean?,
|
||||||
|
responseBody: String? = null,
|
||||||
|
): ApiError {
|
||||||
val kind = when (statusCode) {
|
val kind = when (statusCode) {
|
||||||
401 -> ApiErrorKind.Auth
|
401 -> ApiErrorKind.Auth
|
||||||
403 -> ApiErrorKind.Forbidden
|
403 -> ApiErrorKind.Forbidden
|
||||||
@@ -80,6 +88,7 @@ object ApiErrorMapper {
|
|||||||
retryable = resolvedRetryable,
|
retryable = resolvedRetryable,
|
||||||
code = code,
|
code = code,
|
||||||
statusCode = statusCode,
|
statusCode = statusCode,
|
||||||
|
responseBody = responseBody,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ import pl.firmatpp.kierowca.data.model.BootstrapResponse
|
|||||||
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.DriverDto
|
import pl.firmatpp.kierowca.data.model.DriverDto
|
||||||
|
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
|
||||||
|
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
|
||||||
import pl.firmatpp.kierowca.data.model.OtpResponse
|
import pl.firmatpp.kierowca.data.model.OtpResponse
|
||||||
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
|
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
|
||||||
import pl.firmatpp.kierowca.data.model.PushTokenBody
|
import pl.firmatpp.kierowca.data.model.PushTokenBody
|
||||||
|
import pl.firmatpp.kierowca.data.model.RealtimeStatusBody
|
||||||
import pl.firmatpp.kierowca.data.model.RequestOtpBody
|
import pl.firmatpp.kierowca.data.model.RequestOtpBody
|
||||||
import pl.firmatpp.kierowca.data.model.RouteResponse
|
import pl.firmatpp.kierowca.data.model.RouteResponse
|
||||||
import pl.firmatpp.kierowca.data.model.SyncStateResponse
|
import pl.firmatpp.kierowca.data.model.SyncStateResponse
|
||||||
@@ -46,6 +49,12 @@ class DriverRepository(
|
|||||||
suspend fun route(routeId: String): RouteResponse =
|
suspend fun route(routeId: String): RouteResponse =
|
||||||
api.route(authHeader(requireToken()), routeId)
|
api.route(authHeader(requireToken()), routeId)
|
||||||
|
|
||||||
|
suspend fun completeRoute(routeId: String) =
|
||||||
|
api.completeRoute(authHeader(requireToken()), routeId)
|
||||||
|
|
||||||
|
suspend fun updateNotificationPreferences(notifyNewRoutes: Boolean): NotificationPreferencesDto =
|
||||||
|
api.updateNotificationPreferences(authHeader(requireToken()), NotificationPreferencesBody(notifyNewRoutes))
|
||||||
|
|
||||||
suspend fun syncState(date: String?, routeId: String? = null): SyncStateResponse =
|
suspend fun syncState(date: String?, routeId: String? = null): SyncStateResponse =
|
||||||
api.syncState(authHeader(requireToken()), date, routeId)
|
api.syncState(authHeader(requireToken()), date, routeId)
|
||||||
|
|
||||||
@@ -56,6 +65,10 @@ class DriverRepository(
|
|||||||
api.storePushToken(authHeader(requireToken()), PushTokenBody(token))
|
api.storePushToken(authHeader(requireToken()), PushTokenBody(token))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun storeRealtimeStatus(status: String, socketId: String? = null, error: String? = null) {
|
||||||
|
api.storeRealtimeStatus(authHeader(requireToken()), RealtimeStatusBody(status, socketId, error))
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun deletePushToken() {
|
suspend fun deletePushToken() {
|
||||||
val token = tokenStore.read() ?: return
|
val token = tokenStore.read() ?: return
|
||||||
runCatching { api.deletePushToken(authHeader(token)) }
|
runCatching { api.deletePushToken(authHeader(token)) }
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import okhttp3.RequestBody
|
|||||||
import pl.firmatpp.kierowca.data.model.BootstrapResponse
|
import pl.firmatpp.kierowca.data.model.BootstrapResponse
|
||||||
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.CompleteRouteResponse
|
||||||
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.NotificationPreferencesDto
|
||||||
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
|
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
|
||||||
import pl.firmatpp.kierowca.data.model.PushTokenBody
|
import pl.firmatpp.kierowca.data.model.PushTokenBody
|
||||||
|
import pl.firmatpp.kierowca.data.model.RealtimeStatusBody
|
||||||
import pl.firmatpp.kierowca.data.model.RequestOtpBody
|
import pl.firmatpp.kierowca.data.model.RequestOtpBody
|
||||||
import pl.firmatpp.kierowca.data.model.RouteResponse
|
import pl.firmatpp.kierowca.data.model.RouteResponse
|
||||||
import pl.firmatpp.kierowca.data.model.SyncStateResponse
|
import pl.firmatpp.kierowca.data.model.SyncStateResponse
|
||||||
@@ -34,6 +38,17 @@ interface MobileDriverApi {
|
|||||||
@POST("mobile/driver/auth/logout")
|
@POST("mobile/driver/auth/logout")
|
||||||
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Boolean>
|
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Boolean>
|
||||||
|
|
||||||
|
@GET("mobile/driver/notification-preferences")
|
||||||
|
suspend fun notificationPreferences(
|
||||||
|
@Header("Authorization") authorization: String,
|
||||||
|
): NotificationPreferencesDto
|
||||||
|
|
||||||
|
@PUT("mobile/driver/notification-preferences")
|
||||||
|
suspend fun updateNotificationPreferences(
|
||||||
|
@Header("Authorization") authorization: String,
|
||||||
|
@Body body: NotificationPreferencesBody,
|
||||||
|
): NotificationPreferencesDto
|
||||||
|
|
||||||
@GET("mobile/driver/bootstrap")
|
@GET("mobile/driver/bootstrap")
|
||||||
suspend fun bootstrap(
|
suspend fun bootstrap(
|
||||||
@Header("Authorization") authorization: String,
|
@Header("Authorization") authorization: String,
|
||||||
@@ -46,6 +61,12 @@ interface MobileDriverApi {
|
|||||||
@Path("routeId") routeId: String,
|
@Path("routeId") routeId: String,
|
||||||
): RouteResponse
|
): RouteResponse
|
||||||
|
|
||||||
|
@POST("mobile/driver/routes/{routeId}/complete")
|
||||||
|
suspend fun completeRoute(
|
||||||
|
@Header("Authorization") authorization: String,
|
||||||
|
@Path("routeId") routeId: String,
|
||||||
|
): CompleteRouteResponse
|
||||||
|
|
||||||
@GET("mobile/driver/sync-state")
|
@GET("mobile/driver/sync-state")
|
||||||
suspend fun syncState(
|
suspend fun syncState(
|
||||||
@Header("Authorization") authorization: String,
|
@Header("Authorization") authorization: String,
|
||||||
@@ -65,6 +86,12 @@ interface MobileDriverApi {
|
|||||||
@Body body: PushTokenBody,
|
@Body body: PushTokenBody,
|
||||||
): Map<String, Boolean>
|
): Map<String, Boolean>
|
||||||
|
|
||||||
|
@PUT("mobile/driver/realtime-status")
|
||||||
|
suspend fun storeRealtimeStatus(
|
||||||
|
@Header("Authorization") authorization: String,
|
||||||
|
@Body body: RealtimeStatusBody,
|
||||||
|
): Map<String, Boolean>
|
||||||
|
|
||||||
@DELETE("mobile/driver/push-token")
|
@DELETE("mobile/driver/push-token")
|
||||||
suspend fun deletePushToken(
|
suspend fun deletePushToken(
|
||||||
@Header("Authorization") authorization: String,
|
@Header("Authorization") authorization: String,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ data class BootstrapResponse(
|
|||||||
val session: DriverSessionDto,
|
val session: DriverSessionDto,
|
||||||
val routes: RoutesBucketDto,
|
val routes: RoutesBucketDto,
|
||||||
val driverAppSettings: DriverAppSettingsDto?,
|
val driverAppSettings: DriverAppSettingsDto?,
|
||||||
|
val notificationPreferences: NotificationPreferencesDto? = null,
|
||||||
|
val realtime: RealtimeConfigDto? = null,
|
||||||
val syncState: SyncStateResponse? = null,
|
val syncState: SyncStateResponse? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,6 +59,8 @@ data class DriverAppSettingsDto(
|
|||||||
val maxPastDays: Int,
|
val maxPastDays: Int,
|
||||||
val autoRefreshSeconds: Int,
|
val autoRefreshSeconds: Int,
|
||||||
val allowGalleryUploads: Boolean?,
|
val allowGalleryUploads: Boolean?,
|
||||||
|
val allowRouteCompletion: Boolean?,
|
||||||
|
val requirePreciseLocationForPhotos: Boolean?,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DriverSessionDto(
|
data class DriverSessionDto(
|
||||||
@@ -64,6 +68,16 @@ data class DriverSessionDto(
|
|||||||
val lastSyncAt: String,
|
val lastSyncAt: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class NotificationPreferencesDto(
|
||||||
|
val notifyNewRoutes: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RealtimeConfigDto(
|
||||||
|
val reverbEnabled: Boolean = false,
|
||||||
|
val reverbAppKey: String? = null,
|
||||||
|
val reverbWsBaseUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
data class RoutesBucketDto(
|
data class RoutesBucketDto(
|
||||||
val today: List<DriverRouteDto> = emptyList(),
|
val today: List<DriverRouteDto> = emptyList(),
|
||||||
)
|
)
|
||||||
@@ -73,6 +87,14 @@ data class RouteResponse(
|
|||||||
val syncState: SyncStateResponse? = null,
|
val syncState: SyncStateResponse? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class CompleteRouteResponse(
|
||||||
|
val ok: Boolean,
|
||||||
|
val alreadyCompleted: Boolean,
|
||||||
|
val message: String?,
|
||||||
|
val completedAt: String?,
|
||||||
|
val route: DriverRouteDto,
|
||||||
|
)
|
||||||
|
|
||||||
data class SyncStateResponse(
|
data class SyncStateResponse(
|
||||||
val scopes: List<SyncScopeDto> = emptyList(),
|
val scopes: List<SyncScopeDto> = emptyList(),
|
||||||
)
|
)
|
||||||
@@ -100,8 +122,19 @@ data class PushTokenBody(
|
|||||||
val platform: String = "android",
|
val platform: String = "android",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class NotificationPreferencesBody(
|
||||||
|
val notifyNewRoutes: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RealtimeStatusBody(
|
||||||
|
val reverbStatus: String,
|
||||||
|
val socketId: String? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
data class DriverRouteDto(
|
data class DriverRouteDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
val routeDate: String? = null,
|
||||||
val startsAt: String,
|
val startsAt: String,
|
||||||
val originName: String,
|
val originName: String,
|
||||||
val destinationName: String,
|
val destinationName: String,
|
||||||
@@ -113,6 +146,8 @@ data class DriverRouteDto(
|
|||||||
val distanceKm: Double?,
|
val distanceKm: Double?,
|
||||||
val notes: String?,
|
val notes: String?,
|
||||||
val truck: TruckDto?,
|
val truck: TruckDto?,
|
||||||
|
val completedAt: String? = null,
|
||||||
|
val completedByDriver: DriverDto? = null,
|
||||||
val originNavigation: NavigationPointDto? = null,
|
val originNavigation: NavigationPointDto? = null,
|
||||||
val destinationNavigation: NavigationPointDto? = null,
|
val destinationNavigation: NavigationPointDto? = null,
|
||||||
val photos: List<RoutePhotoDto> = emptyList(),
|
val photos: List<RoutePhotoDto> = emptyList(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.google.gson.Gson
|
|||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import pl.firmatpp.kierowca.data.DriverRepository
|
import pl.firmatpp.kierowca.data.DriverRepository
|
||||||
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.RouteResponse
|
import pl.firmatpp.kierowca.data.model.RouteResponse
|
||||||
import pl.firmatpp.kierowca.data.model.SyncScopeDto
|
import pl.firmatpp.kierowca.data.model.SyncScopeDto
|
||||||
import pl.firmatpp.kierowca.data.model.SyncStateResponse
|
import pl.firmatpp.kierowca.data.model.SyncStateResponse
|
||||||
@@ -59,6 +60,32 @@ class DriverSyncRepository(
|
|||||||
cachedRoute(routeId, throwable)
|
cachedRoute(routeId, throwable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun cacheConfirmedRoute(route: DriverRouteDto, date: String?) {
|
||||||
|
val syncedAt = System.currentTimeMillis()
|
||||||
|
dao.upsertRoute(
|
||||||
|
DriverRouteCacheEntity(
|
||||||
|
routeId = route.id,
|
||||||
|
payloadJson = gson.toJson(RouteResponse(route = route)),
|
||||||
|
checksum = null,
|
||||||
|
version = null,
|
||||||
|
syncedAtEpochMillis = syncedAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val selectedDate = date ?: return
|
||||||
|
val cachedBootstrap = dao.bootstrap(selectedDate) ?: return
|
||||||
|
val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java)
|
||||||
|
val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item }
|
||||||
|
dao.upsertBootstrap(
|
||||||
|
cachedBootstrap.copy(
|
||||||
|
payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))),
|
||||||
|
checksum = null,
|
||||||
|
version = null,
|
||||||
|
syncedAtEpochMillis = syncedAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse =
|
suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse =
|
||||||
repository.syncState(date, routeId)
|
repository.syncState(date, routeId)
|
||||||
|
|
||||||
|
|||||||
@@ -39,10 +39,9 @@ interface PhotoUploadDao {
|
|||||||
SELECT * FROM photo_uploads
|
SELECT * FROM photo_uploads
|
||||||
WHERE routeId = :routeId
|
WHERE routeId = :routeId
|
||||||
AND status = 'CONFIRMED'
|
AND status = 'CONFIRMED'
|
||||||
AND clientRequestId IN (:clientRequestIds)
|
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
suspend fun confirmedForServerPhotos(routeId: String, clientRequestIds: List<String>): List<PhotoUploadEntity>
|
suspend fun confirmedForRoute(routeId: String): List<PhotoUploadEntity>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package pl.firmatpp.kierowca.data.upload
|
||||||
|
|
||||||
|
import pl.firmatpp.kierowca.data.ApiError
|
||||||
|
import pl.firmatpp.kierowca.data.ApiErrorKind
|
||||||
|
|
||||||
|
object PhotoUploadFailureDetails {
|
||||||
|
fun fromApiError(error: ApiError): String {
|
||||||
|
val type = when (error.kind) {
|
||||||
|
ApiErrorKind.Network -> "błąd komunikacji"
|
||||||
|
ApiErrorKind.Server,
|
||||||
|
ApiErrorKind.RateLimited,
|
||||||
|
ApiErrorKind.Validation,
|
||||||
|
ApiErrorKind.Forbidden,
|
||||||
|
ApiErrorKind.Auth,
|
||||||
|
ApiErrorKind.Conflict,
|
||||||
|
-> "błąd serwera"
|
||||||
|
ApiErrorKind.Unknown -> "błąd aplikacji"
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildString {
|
||||||
|
appendLine("Typ: $type")
|
||||||
|
appendLine("Komunikat: ${error.message}")
|
||||||
|
error.statusCode?.let { appendLine("HTTP: $it") }
|
||||||
|
error.code?.takeIf { it.isNotBlank() }?.let { appendLine("Kod: $it") }
|
||||||
|
error.responseBody
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let {
|
||||||
|
appendLine("Odpowiedź serwera:")
|
||||||
|
append(it.take(MAX_RESPONSE_CHARS))
|
||||||
|
if (it.length > MAX_RESPONSE_CHARS) append("...")
|
||||||
|
}
|
||||||
|
}.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun local(message: String): String =
|
||||||
|
"Typ: błąd aplikacji\nKomunikat: $message"
|
||||||
|
|
||||||
|
private const val MAX_RESPONSE_CHARS = 1200
|
||||||
|
}
|
||||||
@@ -80,10 +80,7 @@ class PhotoUploadOutbox(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
|
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
|
||||||
val confirmedRequestIds = route.photos.mapNotNull { it.clientRequestId }.distinct()
|
dao.confirmedForRoute(route.id).forEach { upload ->
|
||||||
if (confirmedRequestIds.isEmpty()) return
|
|
||||||
|
|
||||||
dao.confirmedForServerPhotos(route.id, confirmedRequestIds).forEach { upload ->
|
|
||||||
File(upload.localPath).delete()
|
File(upload.localPath).delete()
|
||||||
dao.delete(upload.clientRequestId)
|
dao.delete(upload.clientRequestId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ class PhotoUploadWorker(
|
|||||||
val file = File(upload.localPath)
|
val file = File(upload.localPath)
|
||||||
|
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
|
val message = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane."
|
||||||
dao.updateStatus(
|
dao.updateStatus(
|
||||||
clientRequestId = clientRequestId,
|
clientRequestId = clientRequestId,
|
||||||
status = PhotoUploadStatus.FailedPermanent.storageValue,
|
status = PhotoUploadStatus.FailedPermanent.storageValue,
|
||||||
progress = 0,
|
progress = 0,
|
||||||
lastError = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane.",
|
lastError = PhotoUploadFailureDetails.local(message),
|
||||||
attemptIncrement = 0,
|
attemptIncrement = 0,
|
||||||
)
|
)
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
@@ -68,7 +69,7 @@ class PhotoUploadWorker(
|
|||||||
clientRequestId = clientRequestId,
|
clientRequestId = clientRequestId,
|
||||||
status = status.storageValue,
|
status = status.storageValue,
|
||||||
progress = 0,
|
progress = 0,
|
||||||
lastError = error.message,
|
lastError = PhotoUploadFailureDetails.fromApiError(error),
|
||||||
attemptIncrement = 0,
|
attemptIncrement = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
|
|
||||||
override fun onMessageReceived(message: RemoteMessage) {
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
val data = message.data
|
val data = message.data
|
||||||
|
if (data["type"] == "driver_new_route_notification") {
|
||||||
|
NewRouteNotificationWorker.enqueue(
|
||||||
|
context = applicationContext,
|
||||||
|
routeId = data["routeId"],
|
||||||
|
date = data["date"],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (data["type"] != "driver_sync_hint") return
|
if (data["type"] != "driver_sync_hint") return
|
||||||
|
|
||||||
DriverSyncWorker.enqueue(
|
DriverSyncWorker.enqueue(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.google.gson.Gson
|
|||||||
import com.google.gson.JsonObject
|
import com.google.gson.JsonObject
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -14,8 +15,8 @@ import okhttp3.OkHttpClient
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
import pl.firmatpp.kierowca.BuildConfig
|
|
||||||
import pl.firmatpp.kierowca.data.DriverRepository
|
import pl.firmatpp.kierowca.data.DriverRepository
|
||||||
|
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
|
||||||
|
|
||||||
class DriverLiveSyncClient(
|
class DriverLiveSyncClient(
|
||||||
private val repository: DriverRepository,
|
private val repository: DriverRepository,
|
||||||
@@ -28,14 +29,21 @@ class DriverLiveSyncClient(
|
|||||||
private val started = AtomicBoolean(false)
|
private val started = AtomicBoolean(false)
|
||||||
private var webSocket: WebSocket? = null
|
private var webSocket: WebSocket? = null
|
||||||
private var driverId: String? = null
|
private var driverId: String? = null
|
||||||
|
private var realtimeConfig: RealtimeConfigDto? = null
|
||||||
|
private var socketId: String? = null
|
||||||
|
private var heartbeatJob: Job? = null
|
||||||
|
|
||||||
fun start(driverId: String) {
|
fun start(driverId: String, config: RealtimeConfigDto?) {
|
||||||
if (BuildConfig.REVERB_APP_KEY.isBlank()) return
|
val appKey = config?.reverbAppKey?.takeIf { it.isNotBlank() } ?: return
|
||||||
|
val wsBaseUrl = config.reverbWsBaseUrl?.takeIf { it.isNotBlank() } ?: return
|
||||||
|
if (!config.reverbEnabled) return
|
||||||
|
|
||||||
|
realtimeConfig = config
|
||||||
this.driverId = driverId
|
this.driverId = driverId
|
||||||
if (!started.compareAndSet(false, true)) return
|
if (!started.compareAndSet(false, true)) return
|
||||||
|
|
||||||
val wsUrl = BuildConfig.REVERB_WS_BASE_URL.trimEnd('/') +
|
val wsUrl = wsBaseUrl.trimEnd('/') +
|
||||||
"/" + BuildConfig.REVERB_APP_KEY +
|
"/" + appKey +
|
||||||
"?protocol=7&client=android&version=1.0&flash=false"
|
"?protocol=7&client=android&version=1.0&flash=false"
|
||||||
|
|
||||||
webSocket = client.newWebSocket(
|
webSocket = client.newWebSocket(
|
||||||
@@ -46,11 +54,15 @@ class DriverLiveSyncClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
stopHeartbeat()
|
||||||
|
reportRealtimeStatus("disconnected", reason.takeIf { it.isNotBlank() })
|
||||||
started.set(false)
|
started.set(false)
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||||
|
stopHeartbeat()
|
||||||
|
reportRealtimeStatus("error", t.message ?: response?.message)
|
||||||
started.set(false)
|
started.set(false)
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
@@ -60,9 +72,13 @@ class DriverLiveSyncClient(
|
|||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
started.set(false)
|
started.set(false)
|
||||||
|
stopHeartbeat()
|
||||||
|
reportRealtimeStatus("disconnected", "client_stop")
|
||||||
webSocket?.close(1000, "logout")
|
webSocket?.close(1000, "logout")
|
||||||
webSocket = null
|
webSocket = null
|
||||||
driverId = null
|
driverId = null
|
||||||
|
realtimeConfig = null
|
||||||
|
socketId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
@@ -76,10 +92,18 @@ class DriverLiveSyncClient(
|
|||||||
|
|
||||||
when (event) {
|
when (event) {
|
||||||
"pusher:connection_established" -> {
|
"pusher:connection_established" -> {
|
||||||
val socketId = root.dataObject()?.string("socket_id") ?: return
|
socketId = root.dataObject()?.string("socket_id") ?: return
|
||||||
subscribe(socket, socketId)
|
subscribe(socket, socketId ?: return)
|
||||||
|
}
|
||||||
|
"pusher_internal:subscription_succeeded" -> {
|
||||||
|
val channel = root.string("channel")
|
||||||
|
val id = driverId ?: return
|
||||||
|
if (channel == "private-driver-mobile.$id") {
|
||||||
|
reportRealtimeStatus("connected")
|
||||||
|
startHeartbeat()
|
||||||
onConnected()
|
onConnected()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
"DriverMobileSyncHint" -> parseHint(root.dataObject())?.let(onHint)
|
"DriverMobileSyncHint" -> parseHint(root.dataObject())?.let(onHint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +152,30 @@ class DriverLiveSyncClient(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
delay(5_000)
|
delay(5_000)
|
||||||
if (!started.get() && driverId == id) {
|
if (!started.get() && driverId == id) {
|
||||||
start(id)
|
start(id, realtimeConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startHeartbeat() {
|
||||||
|
heartbeatJob?.cancel()
|
||||||
|
heartbeatJob = scope.launch {
|
||||||
|
while (started.get()) {
|
||||||
|
reportRealtimeStatus("connected")
|
||||||
|
delay(30_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopHeartbeat() {
|
||||||
|
heartbeatJob?.cancel()
|
||||||
|
heartbeatJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reportRealtimeStatus(status: String, error: String? = null) {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
repository.storeRealtimeStatus(status, socketId, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package pl.firmatpp.kierowca.sync
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.util.Locale
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
||||||
|
|
||||||
|
object NewRouteNotificationFormatter {
|
||||||
|
private val polishLocale = Locale("pl", "PL")
|
||||||
|
private val monthFormatter = DateTimeFormatter.ofPattern("d MMMM", polishLocale)
|
||||||
|
|
||||||
|
fun title(routeDate: String?, today: LocalDate = LocalDate.now()): String {
|
||||||
|
val label = humanDate(routeDate, today) ?: return "Nowy kurs"
|
||||||
|
|
||||||
|
return "Nowy kurs: $label"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun collapsedText(route: DriverRouteDto): String =
|
||||||
|
listOf(route.originName, route.destinationName)
|
||||||
|
.filter(String::isNotBlank)
|
||||||
|
.joinToString(" → ")
|
||||||
|
.ifBlank { route.relationLabel }
|
||||||
|
.let { relation ->
|
||||||
|
val contract = route.contractName?.takeIf(String::isNotBlank) ?: route.contractCode
|
||||||
|
if (contract.isNullOrBlank()) relation else "$relation · $contract"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun expandedText(route: DriverRouteDto): String {
|
||||||
|
val contract = route.contractName?.takeIf(String::isNotBlank) ?: route.contractCode ?: "Brak nazwy kontraktu"
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
"Skąd: ${route.originName.ifBlank { "Nie podano" }}",
|
||||||
|
"Dokąd: ${route.destinationName.ifBlank { "Nie podano" }}",
|
||||||
|
"Kontrakt: $contract",
|
||||||
|
).joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun humanDate(routeDate: String?, today: LocalDate): String? {
|
||||||
|
val date = runCatching { routeDate?.let(LocalDate::parse) }.getOrNull() ?: return null
|
||||||
|
|
||||||
|
return when {
|
||||||
|
date == today -> "dzisiaj"
|
||||||
|
date == today.plusDays(1) -> "jutro"
|
||||||
|
date.isAfter(today.plusDays(1)) && !date.isAfter(today.plusDays(7)) ->
|
||||||
|
"w ${weekdayAccusative(date.dayOfWeek)}"
|
||||||
|
else -> date.format(monthFormatter).lowercase(polishLocale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun weekdayAccusative(dayOfWeek: DayOfWeek): String =
|
||||||
|
when (dayOfWeek) {
|
||||||
|
DayOfWeek.MONDAY -> "poniedziałek"
|
||||||
|
DayOfWeek.TUESDAY -> "wtorek"
|
||||||
|
DayOfWeek.WEDNESDAY -> "środę"
|
||||||
|
DayOfWeek.THURSDAY -> "czwartek"
|
||||||
|
DayOfWeek.FRIDAY -> "piątek"
|
||||||
|
DayOfWeek.SATURDAY -> "sobotę"
|
||||||
|
DayOfWeek.SUNDAY -> "niedzielę"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package pl.firmatpp.kierowca.sync
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.workDataOf
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import pl.firmatpp.kierowca.MainActivity
|
||||||
|
import pl.firmatpp.kierowca.R
|
||||||
|
import pl.firmatpp.kierowca.data.ApiErrorKind
|
||||||
|
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
||||||
|
import pl.firmatpp.kierowca.data.DriverRepository
|
||||||
|
import retrofit2.HttpException
|
||||||
|
|
||||||
|
class NewRouteNotificationWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
private val repository = DriverRepository(appContext)
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val routeId = inputData.getString(KEY_ROUTE_ID)?.takeIf(String::isNotBlank) ?: return Result.success()
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
val route = repository.route(routeId).route
|
||||||
|
if (!canShowNotifications(applicationContext)) {
|
||||||
|
return@runCatching Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
createChannel(applicationContext)
|
||||||
|
val contentTitle = NewRouteNotificationFormatter.title(route.routeDate ?: inputData.getString(KEY_DATE))
|
||||||
|
val contentText = NewRouteNotificationFormatter.collapsedText(route)
|
||||||
|
val expandedText = NewRouteNotificationFormatter.expandedText(route)
|
||||||
|
val intent = Intent(applicationContext, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
putExtra(MainActivity.EXTRA_ROUTE_ID, route.id)
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
route.id.hashCode(),
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle(contentTitle)
|
||||||
|
.setContentText(contentText)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedText))
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
|
||||||
|
Result.success()
|
||||||
|
}.getOrElse { throwable ->
|
||||||
|
if (throwable is HttpException && throwable.code() == 404) {
|
||||||
|
Result.success()
|
||||||
|
} else {
|
||||||
|
val error = ApiErrorMapper.map(throwable)
|
||||||
|
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannel(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Nowe kursy",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
|
).apply {
|
||||||
|
description = "Powiadomienia o nowych kursach dodanych przez spedytora."
|
||||||
|
}
|
||||||
|
context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canShowNotifications(context: Context): Boolean {
|
||||||
|
val hasRuntimePermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
return hasRuntimePermission && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "new_routes"
|
||||||
|
private const val KEY_ROUTE_ID = "routeId"
|
||||||
|
private const val KEY_DATE = "date"
|
||||||
|
|
||||||
|
fun enqueue(context: Context, routeId: String?, date: String?) {
|
||||||
|
if (routeId.isNullOrBlank()) return
|
||||||
|
|
||||||
|
val request = OneTimeWorkRequestBuilder<NewRouteNotificationWorker>()
|
||||||
|
.setInputData(workDataOf(KEY_ROUTE_ID to routeId, KEY_DATE to date))
|
||||||
|
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
"driver-new-route-notification-$routeId",
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,64 @@
|
|||||||
package pl.firmatpp.kierowca.ui
|
package pl.firmatpp.kierowca.ui
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
||||||
|
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
|
||||||
|
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
|
||||||
|
|
||||||
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
|
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
|
||||||
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
|
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
|
||||||
|
|
||||||
|
fun canCompleteRouteFromDriverApp(
|
||||||
|
route: DriverRouteDto,
|
||||||
|
selectedDate: String,
|
||||||
|
today: LocalDate = LocalDate.now(),
|
||||||
|
): Boolean =
|
||||||
|
canManageRoutePhotos(selectedDate, today)
|
||||||
|
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
|
||||||
|
|
||||||
fun inlinePhotoGridRows(photoCount: Int): Int =
|
fun inlinePhotoGridRows(photoCount: Int): Int =
|
||||||
if (photoCount <= 0) 0 else (photoCount + 1) / 2
|
if (photoCount <= 0) 0 else (photoCount + 1) / 2
|
||||||
|
|
||||||
|
fun authBrandBannerHeightDp(screenHeightDp: Int): Int =
|
||||||
|
when {
|
||||||
|
screenHeightDp < 640 -> 118
|
||||||
|
screenHeightDp < 740 -> 150
|
||||||
|
else -> 190
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appBrandHeaderHeightDp(screenHeightDp: Int): Int =
|
||||||
|
when {
|
||||||
|
screenHeightDp < 640 -> 92
|
||||||
|
screenHeightDp < 740 -> 108
|
||||||
|
else -> 124
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bottomNavigationHeightDp(screenHeightDp: Int): Int =
|
||||||
|
when {
|
||||||
|
screenHeightDp < 640 -> 72
|
||||||
|
screenHeightDp < 740 -> 78
|
||||||
|
else -> 82
|
||||||
|
}
|
||||||
|
|
||||||
|
fun screenHorizontalPaddingDp(screenWidthDp: Int): Int =
|
||||||
|
when {
|
||||||
|
screenWidthDp < 360 -> 14
|
||||||
|
screenWidthDp < 420 -> 16
|
||||||
|
else -> 20
|
||||||
|
}
|
||||||
|
|
||||||
|
fun phonePrefixWidthDp(screenWidthDp: Int): Int =
|
||||||
|
when {
|
||||||
|
screenWidthDp < 360 -> 70
|
||||||
|
screenWidthDp < 420 -> 82
|
||||||
|
else -> 94
|
||||||
|
}
|
||||||
|
|
||||||
|
fun routeFactColumns(screenWidthDp: Int): Int =
|
||||||
|
if (screenWidthDp < 360) 1 else 2
|
||||||
|
|
||||||
fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int =
|
fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int =
|
||||||
serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0)
|
serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0)
|
||||||
|
|
||||||
@@ -23,6 +73,37 @@ fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Lo
|
|||||||
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
|
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
|
||||||
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
|
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
|
||||||
|
|
||||||
|
fun canRetryPhotoUpload(status: String): Boolean =
|
||||||
|
status == "FAILED_RETRYABLE"
|
||||||
|
|
||||||
|
fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean =
|
||||||
|
status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank()
|
||||||
|
|
||||||
|
fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
|
||||||
|
if (upload.localPath.isBlank()) return null
|
||||||
|
|
||||||
|
return RoutePhotoDto(
|
||||||
|
id = upload.clientRequestId,
|
||||||
|
routeId = upload.routeId,
|
||||||
|
clientRequestId = upload.clientRequestId,
|
||||||
|
contentSha256 = upload.contentSha256,
|
||||||
|
source = upload.source,
|
||||||
|
mimeType = upload.mimeType,
|
||||||
|
size = upload.size,
|
||||||
|
url = File(upload.localPath).toURI().toString(),
|
||||||
|
takenAt = upload.takenAt,
|
||||||
|
latitude = upload.latitude,
|
||||||
|
longitude = upload.longitude,
|
||||||
|
locationAccuracyMeters = upload.locationAccuracyMeters,
|
||||||
|
createdAt = null,
|
||||||
|
canDelete = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null,
|
||||||
|
deleteUntil = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean =
|
||||||
|
!requirePreciseLocation || hasFineLocation
|
||||||
|
|
||||||
private fun parseIsoOffsetEpochMillis(value: String?): Long? =
|
private fun parseIsoOffsetEpochMillis(value: String?): Long? =
|
||||||
value?.takeIf { it.isNotBlank() }?.let {
|
value?.takeIf { it.isNotBlank() }?.let {
|
||||||
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
|
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
|
||||||
|
|||||||
@@ -42,15 +42,21 @@ data class DriverUiState(
|
|||||||
val maxRouteDate: String = LocalDate.now().plusDays(1).toString(),
|
val maxRouteDate: String = LocalDate.now().plusDays(1).toString(),
|
||||||
val autoRefreshSeconds: Int = 60,
|
val autoRefreshSeconds: Int = 60,
|
||||||
val allowGalleryUploads: Boolean = true,
|
val allowGalleryUploads: Boolean = true,
|
||||||
|
val allowRouteCompletion: Boolean = false,
|
||||||
|
val requirePreciseLocationForPhotos: Boolean = false,
|
||||||
|
val notifyNewRoutes: Boolean = false,
|
||||||
|
val notificationPermissionDenied: Boolean = false,
|
||||||
val selectedRoute: DriverRouteDto? = null,
|
val selectedRoute: DriverRouteDto? = null,
|
||||||
val selectedPhoto: RoutePhotoDto? = null,
|
val selectedPhoto: RoutePhotoDto? = null,
|
||||||
val photoUploads: List<PhotoUploadEntity> = emptyList(),
|
val photoUploads: List<PhotoUploadEntity> = emptyList(),
|
||||||
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
|
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
|
||||||
val deletingPhotoIds: Set<String> = emptySet(),
|
val deletingPhotoIds: Set<String> = emptySet(),
|
||||||
|
val completingRoute: Boolean = false,
|
||||||
val imageAuthHeader: String? = null,
|
val imageAuthHeader: String? = null,
|
||||||
val isOnline: Boolean = true,
|
val isOnline: Boolean = true,
|
||||||
val isStale: Boolean = false,
|
val isStale: Boolean = false,
|
||||||
val lastSuccessfulSyncAtEpochMillis: Long? = null,
|
val lastSuccessfulSyncAtEpochMillis: Long? = null,
|
||||||
|
val feedback: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -135,7 +141,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) {
|
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null) }
|
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) }
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
val cached = syncRepository.bootstrap(date)
|
val cached = syncRepository.bootstrap(date)
|
||||||
@@ -151,6 +157,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
|
maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
|
||||||
autoRefreshSeconds = settings?.autoRefreshSeconds ?: it.autoRefreshSeconds,
|
autoRefreshSeconds = settings?.autoRefreshSeconds ?: it.autoRefreshSeconds,
|
||||||
allowGalleryUploads = settings?.allowGalleryUploads ?: it.allowGalleryUploads,
|
allowGalleryUploads = settings?.allowGalleryUploads ?: it.allowGalleryUploads,
|
||||||
|
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
|
||||||
|
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
|
||||||
|
?: it.requirePreciseLocationForPhotos,
|
||||||
|
notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes,
|
||||||
imageAuthHeader = repository.imageAuthHeader(),
|
imageAuthHeader = repository.imageAuthHeader(),
|
||||||
isStale = cached.stale || !it.isOnline,
|
isStale = cached.stale || !it.isOnline,
|
||||||
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
|
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
|
||||||
@@ -158,7 +168,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
response.session.driver.id.let { driverId ->
|
response.session.driver.id.let { driverId ->
|
||||||
liveSyncClient.start(driverId)
|
liveSyncClient.start(driverId, response.realtime)
|
||||||
registerPushTokenIfAvailable(driverId)
|
registerPushTokenIfAvailable(driverId)
|
||||||
}
|
}
|
||||||
}.onFailure { throwable ->
|
}.onFailure { throwable ->
|
||||||
@@ -190,6 +200,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
it.copy(
|
it.copy(
|
||||||
screen = DriverScreen.Detail,
|
screen = DriverScreen.Detail,
|
||||||
selectedRoute = response.route,
|
selectedRoute = response.route,
|
||||||
|
feedback = null,
|
||||||
isStale = cached.stale || !it.isOnline,
|
isStale = cached.stale || !it.isOnline,
|
||||||
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
|
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
|
||||||
error = null,
|
error = null,
|
||||||
@@ -202,7 +213,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
val routeId = _state.value.selectedRoute?.id ?: return
|
val routeId = _state.value.selectedRoute?.id ?: return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(refreshing = true, error = null) }
|
_state.update { it.copy(refreshing = true, feedback = null, error = null) }
|
||||||
|
|
||||||
runCatching { syncRepository.route(routeId) }
|
runCatching { syncRepository.route(routeId) }
|
||||||
.onSuccess { cached ->
|
.onSuccess { cached ->
|
||||||
@@ -230,6 +241,36 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
_state.update { it.copy(screen = DriverScreen.Profile, error = null) }
|
_state.update { it.copy(screen = DriverScreen.Profile, error = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateNewRouteNotifications(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(refreshing = true, error = null, notificationPermissionDenied = false) }
|
||||||
|
runCatching { repository.updateNotificationPreferences(enabled) }
|
||||||
|
.onSuccess { preferences ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
notifyNewRoutes = preferences.notifyNewRoutes,
|
||||||
|
feedback = if (preferences.notifyNewRoutes) "Powiadomienia o nowych kursach są włączone." else "Powiadomienia o nowych kursach są wyłączone.",
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { throwable ->
|
||||||
|
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
|
||||||
|
}
|
||||||
|
_state.update { it.copy(refreshing = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markNotificationPermissionDenied() {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
notifyNewRoutes = false,
|
||||||
|
notificationPermissionDenied = true,
|
||||||
|
error = "Aby otrzymywać powiadomienia o nowych kursach, włącz powiadomienia w ustawieniach aplikacji.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun openPhotoQueue() {
|
fun openPhotoQueue() {
|
||||||
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
|
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
|
||||||
}
|
}
|
||||||
@@ -301,10 +342,46 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun completeSelectedRoute() {
|
||||||
|
val route = _state.value.selectedRoute ?: return
|
||||||
|
if (_state.value.completingRoute || !_state.value.isOnline) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(completingRoute = true, feedback = null, error = null) }
|
||||||
|
runCatching {
|
||||||
|
val completed = repository.completeRoute(route.id).route
|
||||||
|
syncRepository.cacheConfirmedRoute(completed, _state.value.selectedDate)
|
||||||
|
val refreshed = runCatching { syncRepository.route(route.id) }
|
||||||
|
.getOrNull()
|
||||||
|
?.takeUnless { it.stale }
|
||||||
|
?.value
|
||||||
|
?.route
|
||||||
|
?: completed
|
||||||
|
photoUploadOutbox.discardConfirmedServerPhotos(refreshed)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedRoute = refreshed,
|
||||||
|
routes = it.routes.map { item -> if (item.id == route.id) refreshed else item },
|
||||||
|
feedback = "Kurs zakończony",
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.onFailure { throwable ->
|
||||||
|
_state.update { it.copy(feedback = null, error = ApiErrorMapper.map(throwable).message) }
|
||||||
|
}
|
||||||
|
_state.update { it.copy(completingRoute = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun openPhoto(photo: RoutePhotoDto) {
|
fun openPhoto(photo: RoutePhotoDto) {
|
||||||
_state.update { it.copy(screen = DriverScreen.Photo, selectedPhoto = photo) }
|
_state.update { it.copy(screen = DriverScreen.Photo, selectedPhoto = photo) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openRouteFromNotification(routeId: String?) {
|
||||||
|
if (routeId.isNullOrBlank()) return
|
||||||
|
openRoute(routeId)
|
||||||
|
}
|
||||||
|
|
||||||
fun back() {
|
fun back() {
|
||||||
_state.update {
|
_state.update {
|
||||||
when (it.screen) {
|
when (it.screen) {
|
||||||
@@ -312,7 +389,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
|
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
|
||||||
DriverScreen.Detail -> {
|
DriverScreen.Detail -> {
|
||||||
photoUploadsJob?.cancel()
|
photoUploadsJob?.cancel()
|
||||||
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList())
|
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList(), feedback = null)
|
||||||
}
|
}
|
||||||
DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes)
|
DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes)
|
||||||
DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone)
|
DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package pl.firmatpp.kierowca.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.input.OffsetMapping
|
||||||
|
import androidx.compose.ui.text.input.TransformedText
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
|
||||||
|
object PolishPhoneNumberVisualTransformation : VisualTransformation {
|
||||||
|
override fun filter(text: AnnotatedString): TransformedText {
|
||||||
|
val rawDigits = text.text.filter(Char::isDigit).take(9)
|
||||||
|
val formatted = rawDigits.chunked(3).joinToString(" ")
|
||||||
|
|
||||||
|
return TransformedText(
|
||||||
|
AnnotatedString(formatted),
|
||||||
|
object : OffsetMapping {
|
||||||
|
override fun originalToTransformed(offset: Int): Int {
|
||||||
|
val safeOffset = offset.coerceIn(0, rawDigits.length)
|
||||||
|
val spacesBeforeOffset = when {
|
||||||
|
safeOffset <= 3 -> 0
|
||||||
|
safeOffset <= 6 -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return (safeOffset + spacesBeforeOffset).coerceAtMost(formatted.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun transformedToOriginal(offset: Int): Int {
|
||||||
|
val safeOffset = offset.coerceIn(0, formatted.length)
|
||||||
|
|
||||||
|
return formatted
|
||||||
|
.take(safeOffset)
|
||||||
|
.count(Char::isDigit)
|
||||||
|
.coerceAtMost(rawDigits.length)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,4 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
<vector
|
||||||
<solid android:color="#F7FAF8" />
|
android:height="108dp"
|
||||||
</shape>
|
android:width="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M0,0h108v108h-108z"/>
|
||||||
|
</vector>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#0B192E</color>
|
||||||
|
</resources>
|
||||||
@@ -39,4 +39,16 @@ class ApiErrorMapperTest {
|
|||||||
assertTrue(error.retryable)
|
assertTrue(error.retryable)
|
||||||
assertEquals("Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie.", error.message)
|
assertEquals("Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie.", error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keepsRawServerResponseForTroubleshooting() {
|
||||||
|
val body = """{"code":"PHOTO_SERVER_UNAVAILABLE","message":"Storage timeout","retryable":true}"""
|
||||||
|
val error = ApiErrorMapper.mapHttpStatus(503, body)
|
||||||
|
|
||||||
|
assertEquals(ApiErrorKind.Server, error.kind)
|
||||||
|
assertEquals(503, error.statusCode)
|
||||||
|
assertEquals("PHOTO_SERVER_UNAVAILABLE", error.code)
|
||||||
|
assertEquals("Storage timeout", error.message)
|
||||||
|
assertEquals(body, error.responseBody)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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.ApiError
|
||||||
|
import pl.firmatpp.kierowca.data.ApiErrorKind
|
||||||
|
|
||||||
|
class PhotoUploadFailureDetailsTest {
|
||||||
|
@Test
|
||||||
|
fun describesServerFailureWithStatusCodeBackendCodeAndResponseBody() {
|
||||||
|
val details = PhotoUploadFailureDetails.fromApiError(
|
||||||
|
ApiError(
|
||||||
|
kind = ApiErrorKind.Server,
|
||||||
|
message = "Storage timeout",
|
||||||
|
retryable = true,
|
||||||
|
code = "PHOTO_STORAGE_FAILED",
|
||||||
|
statusCode = 503,
|
||||||
|
responseBody = """{"code":"PHOTO_STORAGE_FAILED","message":"Storage timeout"}""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(details.contains("Typ: błąd serwera"))
|
||||||
|
assertTrue(details.contains("HTTP: 503"))
|
||||||
|
assertTrue(details.contains("Kod: PHOTO_STORAGE_FAILED"))
|
||||||
|
assertTrue(details.contains("Odpowiedź serwera:"))
|
||||||
|
assertTrue(details.contains("Storage timeout"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun describesNetworkFailureWithoutHttpStatus() {
|
||||||
|
val details = PhotoUploadFailureDetails.fromApiError(
|
||||||
|
ApiError(
|
||||||
|
kind = ApiErrorKind.Network,
|
||||||
|
message = "Nie udało się połączyć z serwerem.",
|
||||||
|
retryable = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(details.contains("Typ: błąd komunikacji"))
|
||||||
|
assertTrue(details.contains("Nie udało się połączyć z serwerem."))
|
||||||
|
assertFalse(details.contains("HTTP:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package pl.firmatpp.kierowca.sync
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
||||||
|
|
||||||
|
class NewRouteNotificationFormatterTest {
|
||||||
|
@Test
|
||||||
|
fun formatsHumanRouteDates() {
|
||||||
|
val today = LocalDate.of(2026, 7, 2)
|
||||||
|
|
||||||
|
assertEquals("Nowy kurs: dzisiaj", NewRouteNotificationFormatter.title("2026-07-02", today))
|
||||||
|
assertEquals("Nowy kurs: jutro", NewRouteNotificationFormatter.title("2026-07-03", today))
|
||||||
|
assertEquals("Nowy kurs: w sobotę", NewRouteNotificationFormatter.title("2026-07-04", today))
|
||||||
|
assertEquals("Nowy kurs: 12 lipca", NewRouteNotificationFormatter.title("2026-07-12", today))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun formatsCollapsedAndExpandedRouteText() {
|
||||||
|
val route = DriverRouteDto(
|
||||||
|
id = "42",
|
||||||
|
routeDate = "2026-07-03",
|
||||||
|
startsAt = "09:15",
|
||||||
|
originName = "TPP Baza Krakow",
|
||||||
|
destinationName = "Instalacja Odpadowa Slask",
|
||||||
|
contractorName = "TPP Baza Krakow",
|
||||||
|
contractName = "Odpady przemyslowe",
|
||||||
|
contractCode = "TPP-ODP-24",
|
||||||
|
relationLabel = "TPP Baza Krakow -> Instalacja Odpadowa Slask",
|
||||||
|
status = "ZAPLANOWANA",
|
||||||
|
distanceKm = 91.4,
|
||||||
|
notes = null,
|
||||||
|
truck = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"TPP Baza Krakow → Instalacja Odpadowa Slask · Odpady przemyslowe",
|
||||||
|
NewRouteNotificationFormatter.collapsedText(route),
|
||||||
|
)
|
||||||
|
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Skąd: TPP Baza Krakow"))
|
||||||
|
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Dokąd: Instalacja Odpadowa Slask"))
|
||||||
|
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Kontrakt: Odpady przemyslowe"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import org.junit.Assert.assertEquals
|
|||||||
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.model.DriverRouteDto
|
||||||
|
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
|
||||||
|
|
||||||
class DriverUiRulesTest {
|
class DriverUiRulesTest {
|
||||||
private val today = LocalDate.parse("2026-06-30")
|
private val today = LocalDate.parse("2026-06-30")
|
||||||
@@ -22,6 +24,16 @@ class DriverUiRulesTest {
|
|||||||
assertFalse(canManageRoutePhotos("not-a-date", today))
|
assertFalse(canManageRoutePhotos("not-a-date", today))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allowsRouteCompletionOnlyForTodayPlannedOrInProgressRoutes() {
|
||||||
|
assertTrue(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-30", today))
|
||||||
|
assertTrue(canCompleteRouteFromDriverApp(route(status = "W TRAKCIE"), "2026-06-30", today))
|
||||||
|
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAKOŃCZONA"), "2026-06-30", today))
|
||||||
|
assertFalse(canCompleteRouteFromDriverApp(route(status = "NIEROZPOZNANA"), "2026-06-30", today))
|
||||||
|
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-07-01", today))
|
||||||
|
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
|
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
|
||||||
assertEquals(0, inlinePhotoGridRows(0))
|
assertEquals(0, inlinePhotoGridRows(0))
|
||||||
@@ -31,6 +43,38 @@ class DriverUiRulesTest {
|
|||||||
assertEquals(3, inlinePhotoGridRows(5))
|
assertEquals(3, inlinePhotoGridRows(5))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun usesShorterChromeOnCompactScreens() {
|
||||||
|
assertEquals(118, authBrandBannerHeightDp(600))
|
||||||
|
assertEquals(150, authBrandBannerHeightDp(690))
|
||||||
|
assertEquals(190, authBrandBannerHeightDp(780))
|
||||||
|
|
||||||
|
assertEquals(92, appBrandHeaderHeightDp(600))
|
||||||
|
assertEquals(108, appBrandHeaderHeightDp(690))
|
||||||
|
assertEquals(124, appBrandHeaderHeightDp(780))
|
||||||
|
|
||||||
|
assertEquals(72, bottomNavigationHeightDp(600))
|
||||||
|
assertEquals(78, bottomNavigationHeightDp(690))
|
||||||
|
assertEquals(82, bottomNavigationHeightDp(780))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reducesHorizontalSpaceOnNarrowScreens() {
|
||||||
|
assertEquals(14, screenHorizontalPaddingDp(340))
|
||||||
|
assertEquals(16, screenHorizontalPaddingDp(390))
|
||||||
|
assertEquals(20, screenHorizontalPaddingDp(440))
|
||||||
|
|
||||||
|
assertEquals(70, phonePrefixWidthDp(340))
|
||||||
|
assertEquals(82, phonePrefixWidthDp(390))
|
||||||
|
assertEquals(94, phonePrefixWidthDp(440))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stacksDenseRouteFactsOnNarrowScreens() {
|
||||||
|
assertEquals(1, routeFactColumns(340))
|
||||||
|
assertEquals(2, routeFactColumns(390))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun countsServerPhotosAndLocalUploadsAsVisibleAttachments() {
|
fun countsServerPhotosAndLocalUploadsAsVisibleAttachments() {
|
||||||
assertEquals(0, visiblePhotoAttachmentCount(serverPhotoCount = 0, localUploadCount = 0))
|
assertEquals(0, visiblePhotoAttachmentCount(serverPhotoCount = 0, localUploadCount = 0))
|
||||||
@@ -87,4 +131,78 @@ class DriverUiRulesTest {
|
|||||||
assertEquals(null, confirmedUploadServerPhotoId("UPLOADING", "42"))
|
assertEquals(null, confirmedUploadServerPhotoId("UPLOADING", "42"))
|
||||||
assertEquals(null, confirmedUploadServerPhotoId("FAILED_RETRYABLE", "42"))
|
assertEquals(null, confirmedUploadServerPhotoId("FAILED_RETRYABLE", "42"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allowsRetryActionOnlyForRetryableFailedUploads() {
|
||||||
|
assertTrue(canRetryPhotoUpload("FAILED_RETRYABLE"))
|
||||||
|
assertFalse(canRetryPhotoUpload("PENDING"))
|
||||||
|
assertFalse(canRetryPhotoUpload("UPLOADING"))
|
||||||
|
assertFalse(canRetryPhotoUpload("VERIFYING"))
|
||||||
|
assertFalse(canRetryPhotoUpload("CONFIRMED"))
|
||||||
|
assertFalse(canRetryPhotoUpload("FAILED_PERMANENT"))
|
||||||
|
assertFalse(canRetryPhotoUpload("CANCELLED"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsFailureDetailsOnlyForFailedUploadsWithErrorMessage() {
|
||||||
|
assertTrue(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", "Typ: błąd serwera"))
|
||||||
|
assertTrue(canShowPhotoUploadFailureDetails("FAILED_PERMANENT", "Typ: błąd aplikacji"))
|
||||||
|
assertFalse(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", null))
|
||||||
|
assertFalse(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", ""))
|
||||||
|
assertFalse(canShowPhotoUploadFailureDetails("UPLOADING", "Typ: błąd serwera"))
|
||||||
|
assertFalse(canShowPhotoUploadFailureDetails("CONFIRMED", "Typ: błąd serwera"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createsFullScreenPreviewForLocalUploadBeforeRouteRefresh() {
|
||||||
|
val photo = localUploadPreviewPhoto(upload(status = "CONFIRMED", serverPhotoId = "55"))
|
||||||
|
|
||||||
|
requireNotNull(photo)
|
||||||
|
assertEquals("local-upload-123", photo.id)
|
||||||
|
assertEquals("10", photo.routeId)
|
||||||
|
assertEquals("file:/tmp/photo.jpg", photo.url)
|
||||||
|
assertEquals("2026-07-01T08:00:00Z", photo.takenAt)
|
||||||
|
assertEquals(50.1, photo.latitude ?: 0.0, 0.0)
|
||||||
|
assertEquals(true, photo.canDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun preciseLocationRequirementAllowsCameraOnlyWithFineLocation() {
|
||||||
|
assertTrue(canLaunchCameraWithLocationPolicy(requirePreciseLocation = false, hasFineLocation = false))
|
||||||
|
assertTrue(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = true))
|
||||||
|
assertFalse(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun route(status: String): DriverRouteDto =
|
||||||
|
DriverRouteDto(
|
||||||
|
id = "1",
|
||||||
|
startsAt = "",
|
||||||
|
originName = "Baza",
|
||||||
|
destinationName = "Instalacja",
|
||||||
|
contractorName = "TPP",
|
||||||
|
contractName = "Kontrakt",
|
||||||
|
contractCode = "TPP-1",
|
||||||
|
relationLabel = "Baza -> Instalacja",
|
||||||
|
status = status,
|
||||||
|
distanceKm = 1.0,
|
||||||
|
notes = null,
|
||||||
|
truck = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun upload(status: String, serverPhotoId: String? = null): PhotoUploadEntity =
|
||||||
|
PhotoUploadEntity(
|
||||||
|
clientRequestId = "local-upload-123",
|
||||||
|
routeId = "10",
|
||||||
|
localPath = "/tmp/photo.jpg",
|
||||||
|
source = "camera",
|
||||||
|
takenAt = "2026-07-01T08:00:00Z",
|
||||||
|
latitude = 50.1,
|
||||||
|
longitude = 19.9,
|
||||||
|
locationAccuracyMeters = 8.0,
|
||||||
|
mimeType = "image/jpeg",
|
||||||
|
size = 1200,
|
||||||
|
contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
status = status,
|
||||||
|
serverPhotoId = serverPhotoId,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package pl.firmatpp.kierowca.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PolishPhoneNumberVisualTransformationTest {
|
||||||
|
@Test
|
||||||
|
fun formatsPhoneDigitsWithoutChangingUnderlyingTextOffsets() {
|
||||||
|
val transformed = PolishPhoneNumberVisualTransformation.filter(AnnotatedString("1234"))
|
||||||
|
|
||||||
|
assertEquals("123 4", transformed.text.text)
|
||||||
|
assertEquals(5, transformed.offsetMapping.originalToTransformed(4))
|
||||||
|
assertEquals(4, transformed.offsetMapping.transformedToOriginal(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsOffsetsAcrossBothInsertedSpaces() {
|
||||||
|
val transformed = PolishPhoneNumberVisualTransformation.filter(AnnotatedString("123456789"))
|
||||||
|
|
||||||
|
assertEquals("123 456 789", transformed.text.text)
|
||||||
|
assertEquals(7, transformed.offsetMapping.originalToTransformed(6))
|
||||||
|
assertEquals(11, transformed.offsetMapping.originalToTransformed(9))
|
||||||
|
assertEquals(6, transformed.offsetMapping.transformedToOriginal(7))
|
||||||
|
assertEquals(9, transformed.offsetMapping.transformedToOriginal(11))
|
||||||
|
}
|
||||||
|
}
|
||||||