Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 = 28
|
||||||
versionName = "1.0.17"
|
versionName = "1.0.27"
|
||||||
|
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"
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ 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 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)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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.PhotoUploadResponse
|
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
|
||||||
import pl.firmatpp.kierowca.data.model.PushTokenBody
|
import pl.firmatpp.kierowca.data.model.PushTokenBody
|
||||||
@@ -46,6 +47,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,
|
||||||
|
|||||||
@@ -57,6 +57,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(
|
||||||
@@ -73,6 +75,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(),
|
||||||
)
|
)
|
||||||
@@ -113,6 +123,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)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import android.location.Location
|
|||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.PickVisualMediaRequest
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
@@ -51,6 +52,7 @@ import androidx.compose.material.icons.outlined.AddPhotoAlternate
|
|||||||
import androidx.compose.material.icons.outlined.ArrowBack
|
import androidx.compose.material.icons.outlined.ArrowBack
|
||||||
import androidx.compose.material.icons.outlined.CameraAlt
|
import androidx.compose.material.icons.outlined.CameraAlt
|
||||||
import androidx.compose.material.icons.outlined.CalendarToday
|
import androidx.compose.material.icons.outlined.CalendarToday
|
||||||
|
import androidx.compose.material.icons.outlined.CheckCircle
|
||||||
import androidx.compose.material.icons.outlined.CloudUpload
|
import androidx.compose.material.icons.outlined.CloudUpload
|
||||||
import androidx.compose.material.icons.outlined.Factory
|
import androidx.compose.material.icons.outlined.Factory
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
@@ -59,6 +61,7 @@ import androidx.compose.material.icons.outlined.LocalShipping
|
|||||||
import androidx.compose.material.icons.outlined.Navigation
|
import androidx.compose.material.icons.outlined.Navigation
|
||||||
import androidx.compose.material.icons.outlined.Person
|
import androidx.compose.material.icons.outlined.Person
|
||||||
import androidx.compose.material.icons.outlined.Phone
|
import androidx.compose.material.icons.outlined.Phone
|
||||||
|
import androidx.compose.material.icons.outlined.Refresh
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||||
import androidx.compose.material.pullrefresh.pullRefresh
|
import androidx.compose.material.pullrefresh.pullRefresh
|
||||||
@@ -90,6 +93,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
@@ -100,6 +104,7 @@ import androidx.compose.ui.text.font.FontFamily
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
@@ -191,6 +196,7 @@ fun DriverApp(viewModel: DriverViewModel) {
|
|||||||
viewModel::deleteConfirmedUpload,
|
viewModel::deleteConfirmedUpload,
|
||||||
viewModel::retryPhotoUpload,
|
viewModel::retryPhotoUpload,
|
||||||
viewModel::refreshSelectedRoute,
|
viewModel::refreshSelectedRoute,
|
||||||
|
viewModel::completeSelectedRoute,
|
||||||
)
|
)
|
||||||
DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
|
DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
|
||||||
DriverScreen.PhotoQueue -> PhotoQueueScreen(
|
DriverScreen.PhotoQueue -> PhotoQueueScreen(
|
||||||
@@ -213,98 +219,105 @@ private fun StartupScreen() {
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.statusBarsPadding()
|
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
.padding(32.dp),
|
.background(TppColors.Surface),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
) {
|
||||||
Image(
|
Column(Modifier.fillMaxWidth().statusBarsPadding()) {
|
||||||
painter = painterResource(R.drawable.tpp5),
|
BrandBannerHeader(height = 178.dp)
|
||||||
contentDescription = "Firma TPP",
|
}
|
||||||
contentScale = ContentScale.Fit,
|
Column(
|
||||||
modifier = Modifier
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(120.dp),
|
.padding(horizontal = 32.dp),
|
||||||
)
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
Spacer(Modifier.height(32.dp))
|
verticalArrangement = Arrangement.Center,
|
||||||
CircularProgressIndicator(color = TppColors.Forest)
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
Text(
|
|
||||||
"Sprawdzanie sesji",
|
|
||||||
color = TppColors.Muted,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
)
|
||||||
|
{
|
||||||
|
CircularProgressIndicator(color = TppColors.Forest)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Text(
|
||||||
|
"Sprawdzanie sesji",
|
||||||
|
color = TppColors.Muted,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
||||||
var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) }
|
var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) }
|
||||||
Box(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(TppColors.Surface)
|
.background(TppColors.Surface)
|
||||||
.statusBarsPadding()
|
.navigationBarsPadding(),
|
||||||
.navigationBarsPadding()
|
|
||||||
.padding(24.dp),
|
|
||||||
) {
|
) {
|
||||||
Card(
|
Column(Modifier.fillMaxWidth().statusBarsPadding()) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
BrandBannerHeader(height = 190.dp)
|
||||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)),
|
}
|
||||||
border = BorderStroke(1.dp, TppColors.Outline),
|
Column(
|
||||||
shape = RoundedCornerShape(4.dp),
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(start = 24.dp, top = 28.dp, end = 24.dp, bottom = 28.dp),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
) {
|
) {
|
||||||
Column(
|
Text(
|
||||||
Modifier
|
"Firma TPP - Kierowca",
|
||||||
.fillMaxSize()
|
color = TppColors.Forest,
|
||||||
.verticalScroll(rememberScrollState())
|
fontFamily = FontFamily.Monospace,
|
||||||
.padding(horizontal = 28.dp, vertical = 36.dp),
|
fontWeight = FontWeight.Bold,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
"Zaloguj się",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
color = TppColors.Ink,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Wpisz numer telefonu kierowcy. Kod OTP wyślemy SMS-em.",
|
||||||
|
color = TppColors.Muted,
|
||||||
|
fontSize = 17.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)),
|
||||||
|
border = BorderStroke(1.dp, TppColors.Outline.copy(alpha = 0.72f)),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
) {
|
) {
|
||||||
Image(
|
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||||
painter = painterResource(R.drawable.tpp5),
|
|
||||||
contentDescription = "Firma TPP",
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(132.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(42.dp))
|
|
||||||
Text(
|
|
||||||
"Zaloguj się",
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
color = TppColors.Muted,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(76.dp))
|
|
||||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
|
||||||
Text(
|
Text(
|
||||||
"Numer Telefonu",
|
"Numer telefonu",
|
||||||
color = Color.Black,
|
color = TppColors.Ink,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 23.sp,
|
fontSize = 18.sp,
|
||||||
)
|
)
|
||||||
PhoneNumberField(
|
PhoneNumberField(
|
||||||
digits = phoneDigits,
|
digits = phoneDigits,
|
||||||
onDigitsChange = { phoneDigits = polishPhoneDigits(it) },
|
onDigitsChange = { phoneDigits = polishPhoneDigits(it) },
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Otrzymasz kod SMS do weryfikacji.",
|
"Dostęp tylko dla upoważnionych kierowców.",
|
||||||
color = TppColors.Muted,
|
color = TppColors.Muted,
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 18.sp,
|
fontSize = 15.sp,
|
||||||
)
|
)
|
||||||
ErrorText(state.error)
|
ErrorText(state.error)
|
||||||
Spacer(Modifier.height(if (state.error.isNullOrBlank()) 22.dp else 8.dp))
|
|
||||||
Button(
|
Button(
|
||||||
onClick = { onSubmit("+48$phoneDigits") },
|
onClick = { onSubmit("+48$phoneDigits") },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(72.dp),
|
.height(64.dp),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = TppColors.ContainerGreen),
|
colors = ButtonDefaults.buttonColors(containerColor = TppColors.ContainerGreen),
|
||||||
shape = RoundedCornerShape(4.dp),
|
shape = RoundedCornerShape(4.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 20.dp),
|
contentPadding = PaddingValues(horizontal = 20.dp),
|
||||||
@@ -313,7 +326,7 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
|||||||
"Wyślij kod OTP",
|
"Wyślij kod OTP",
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 22.sp,
|
fontSize = 20.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,6 +335,46 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BrandBannerHeader(height: Dp) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height)
|
||||||
|
.background(Color(0xFFFAFCF1)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.tpp_banner),
|
||||||
|
contentDescription = "Firma TPP",
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.matchParentSize(),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(
|
||||||
|
Brush.horizontalGradient(
|
||||||
|
0f to Color(0xFFFAFCF1).copy(alpha = 0.56f),
|
||||||
|
0.18f to Color.Transparent,
|
||||||
|
0.82f to Color.Transparent,
|
||||||
|
1f to Color(0xFFFAFCF1).copy(alpha = 0.56f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0f to Color.Transparent,
|
||||||
|
0.72f to Color.Transparent,
|
||||||
|
1f to TppColors.Surface.copy(alpha = 0.88f),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
|
private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
@@ -343,10 +396,11 @@ private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
Box(Modifier.width(1.dp).fillMaxHeight().background(TppColors.Outline))
|
Box(Modifier.width(1.dp).fillMaxHeight().background(TppColors.Outline))
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = formatPolishPhoneDigits(digits),
|
value = digits,
|
||||||
onValueChange = { onDigitsChange(it) },
|
onValueChange = { onDigitsChange(it) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||||
|
visualTransformation = PolishPhoneNumberVisualTransformation,
|
||||||
textStyle = TextStyle(
|
textStyle = TextStyle(
|
||||||
color = TppColors.Muted,
|
color = TppColors.Muted,
|
||||||
fontSize = 28.sp,
|
fontSize = 28.sp,
|
||||||
@@ -592,9 +646,21 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (status == PhotoUploadStatus.FailedRetryable) {
|
if (canRetryPhotoUpload(upload.status)) {
|
||||||
TextButton(onClick = { onRetryUpload(upload) }) {
|
Button(
|
||||||
Text("Ponów", color = TppColors.Forest, fontWeight = FontWeight.Bold)
|
onClick = { onRetryUpload(upload) },
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Ponów", color = Color.White, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,27 +669,12 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StitchHeader() {
|
private fun StitchHeader() {
|
||||||
Column(Modifier.fillMaxWidth().background(Color.White).statusBarsPadding()) {
|
Column(Modifier.fillMaxWidth().background(TppColors.Surface).statusBarsPadding()) {
|
||||||
Box(
|
BrandBannerHeader(height = 124.dp)
|
||||||
Modifier.fillMaxWidth().height(104.dp).padding(horizontal = 24.dp),
|
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.86f), thickness = 1.dp)
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
TppLogo()
|
|
||||||
}
|
|
||||||
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.8f), thickness = 2.dp)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TppLogo() {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.tpp5),
|
|
||||||
contentDescription = "TPP",
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
modifier = Modifier.width(142.dp).height(50.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DateChip(date: String) {
|
private fun DateChip(date: String) {
|
||||||
val formatter = remember { DateTimeFormatter.ofPattern("d MMM", Locale("pl", "PL")) }
|
val formatter = remember { DateTimeFormatter.ofPattern("d MMM", Locale("pl", "PL")) }
|
||||||
@@ -884,18 +935,37 @@ private fun DetailScreen(
|
|||||||
onDeleteUpload: (PhotoUploadEntity) -> Unit,
|
onDeleteUpload: (PhotoUploadEntity) -> Unit,
|
||||||
onRetryUpload: (PhotoUploadEntity) -> Unit,
|
onRetryUpload: (PhotoUploadEntity) -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
|
onCompleteRoute: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var cameraUri by remember { mutableStateOf<Uri?>(null) }
|
var cameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
var showCompleteDialog by remember { mutableStateOf(false) }
|
||||||
|
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
|
||||||
val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
|
val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
|
||||||
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
|
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
|
||||||
val capturedUri = cameraUri
|
val capturedUri = cameraUri
|
||||||
if (ok && capturedUri != null) onUpload(capturedUri, "camera", cameraPhotoMetadata(context))
|
if (
|
||||||
|
ok &&
|
||||||
|
capturedUri != null &&
|
||||||
|
canLaunchCameraWithLocationPolicy(
|
||||||
|
requirePreciseLocation = state.requirePreciseLocationForPhotos,
|
||||||
|
hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
onUpload(capturedUri, "camera", cameraPhotoMetadata(context))
|
||||||
|
}
|
||||||
cameraUri = null
|
cameraUri = null
|
||||||
}
|
}
|
||||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
|
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
|
||||||
val pendingUri = cameraUri
|
val pendingUri = cameraUri
|
||||||
val cameraGranted = grants[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA)
|
val cameraGranted = grants[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA)
|
||||||
|
val fineLocationGranted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
|
||||||
|
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
if (!canLaunchCameraWithLocationPolicy(state.requirePreciseLocationForPhotos, fineLocationGranted)) {
|
||||||
|
showPreciseLocationPermissionDialog = true
|
||||||
|
cameraUri = null
|
||||||
|
return@rememberLauncherForActivityResult
|
||||||
|
}
|
||||||
if (cameraGranted && pendingUri != null) cameraLauncher.launch(pendingUri)
|
if (cameraGranted && pendingUri != null) cameraLauncher.launch(pendingUri)
|
||||||
}
|
}
|
||||||
val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
||||||
@@ -919,6 +989,17 @@ private fun DetailScreen(
|
|||||||
) {
|
) {
|
||||||
item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) }
|
item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) }
|
||||||
item { OfflineStaleBanner(state) }
|
item { OfflineStaleBanner(state) }
|
||||||
|
item {
|
||||||
|
RouteCompletionSection(
|
||||||
|
route = route,
|
||||||
|
selectedDate = state.selectedDate,
|
||||||
|
allowRouteCompletion = state.allowRouteCompletion,
|
||||||
|
isOnline = state.isOnline,
|
||||||
|
completing = state.completingRoute,
|
||||||
|
feedback = state.feedback,
|
||||||
|
onComplete = { showCompleteDialog = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
CargoDocumentationSection(
|
CargoDocumentationSection(
|
||||||
photos = route.photos,
|
photos = route.photos,
|
||||||
@@ -934,7 +1015,10 @@ private fun DetailScreen(
|
|||||||
onCamera = {
|
onCamera = {
|
||||||
val newUri = createCameraUri(context)
|
val newUri = createCameraUri(context)
|
||||||
cameraUri = newUri
|
cameraUri = newUri
|
||||||
val missingPermissions = cameraCapturePermissions(context)
|
val missingPermissions = cameraCapturePermissions(
|
||||||
|
context = context,
|
||||||
|
requirePreciseLocation = state.requirePreciseLocationForPhotos,
|
||||||
|
)
|
||||||
if (missingPermissions.isEmpty()) {
|
if (missingPermissions.isEmpty()) {
|
||||||
cameraLauncher.launch(newUri)
|
cameraLauncher.launch(newUri)
|
||||||
} else {
|
} else {
|
||||||
@@ -957,6 +1041,61 @@ private fun DetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showCompleteDialog && route != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { if (!state.completingRoute) showCompleteDialog = false },
|
||||||
|
title = { Text("Zakończyć kurs?", color = TppColors.Ink, fontWeight = FontWeight.Bold) },
|
||||||
|
text = { Text("Status kursu zostanie ustawiony jako zakończony.", color = TppColors.Muted) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !state.completingRoute,
|
||||||
|
onClick = {
|
||||||
|
showCompleteDialog = false
|
||||||
|
onCompleteRoute()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Zakończ kurs", color = TppColors.Forest, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !state.completingRoute,
|
||||||
|
onClick = { showCompleteDialog = false },
|
||||||
|
) {
|
||||||
|
Text("Anuluj", color = TppColors.Muted)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showPreciseLocationPermissionDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showPreciseLocationPermissionDialog = false },
|
||||||
|
title = { Text("Brak dokładnej lokalizacji", color = TppColors.Ink, fontWeight = FontWeight.Bold) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"Aby zrobić zdjęcie, nadaj aplikacji uprawnienie do dokładnej lokalizacji.",
|
||||||
|
color = TppColors.Muted,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showPreciseLocationPermissionDialog = false
|
||||||
|
openAppSettings(context)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Przejdź do ustawień", color = TppColors.Forest, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showPreciseLocationPermissionDialog = false }) {
|
||||||
|
Text("Anuluj", color = TppColors.Muted)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -981,6 +1120,86 @@ private fun DetailHeader(onBack: () -> Unit, title: String = "Szczegóły Trasy"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RouteCompletionSection(
|
||||||
|
route: DriverRouteDto,
|
||||||
|
selectedDate: String,
|
||||||
|
allowRouteCompletion: Boolean,
|
||||||
|
isOnline: Boolean,
|
||||||
|
completing: Boolean,
|
||||||
|
feedback: String?,
|
||||||
|
onComplete: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (!allowRouteCompletion) return
|
||||||
|
|
||||||
|
if (route.status == "ZAKOŃCZONA") {
|
||||||
|
if (!feedback.isNullOrBlank()) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color(0xFFEAF7EF)),
|
||||||
|
border = BorderStroke(1.dp, Color(0xFF9BD1AD)),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(14.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = TppColors.Forest)
|
||||||
|
Text(feedback, color = TppColors.Forest, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canCompleteRouteFromDriverApp(route, selectedDate)) return
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
border = BorderStroke(1.dp, TppColors.Outline),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = onComplete,
|
||||||
|
enabled = isOnline && !completing,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(58.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = TppColors.Forest,
|
||||||
|
disabledContainerColor = TppColors.Outline,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
) {
|
||||||
|
if (completing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = Color.White)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
if (completing) "Kończę kurs..." else "Zakończ kurs",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!isOnline) {
|
||||||
|
Text(
|
||||||
|
"Kurs można zakończyć tylko po połączeniu z serwerem.",
|
||||||
|
color = TppColors.Muted,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun photoCountLabel(count: Int): String =
|
private fun photoCountLabel(count: Int): String =
|
||||||
when {
|
when {
|
||||||
count == 1 -> "zdjęcie"
|
count == 1 -> "zdjęcie"
|
||||||
@@ -1218,6 +1437,7 @@ private fun PhotoGrid(
|
|||||||
is PhotoGridItem.Upload -> PendingPhotoTile(
|
is PhotoGridItem.Upload -> PendingPhotoTile(
|
||||||
upload = item.upload,
|
upload = item.upload,
|
||||||
isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true,
|
isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true,
|
||||||
|
onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) },
|
||||||
onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
|
onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
|
||||||
onRetry = onRetryUpload,
|
onRetry = onRetryUpload,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
@@ -1324,6 +1544,7 @@ private fun PhotoTile(
|
|||||||
private fun PendingPhotoTile(
|
private fun PendingPhotoTile(
|
||||||
upload: PhotoUploadEntity,
|
upload: PhotoUploadEntity,
|
||||||
isDeleting: Boolean,
|
isDeleting: Boolean,
|
||||||
|
onPhoto: (PhotoUploadEntity) -> Unit,
|
||||||
onDelete: (PhotoUploadEntity) -> Unit,
|
onDelete: (PhotoUploadEntity) -> Unit,
|
||||||
onRetry: (PhotoUploadEntity) -> Unit,
|
onRetry: (PhotoUploadEntity) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -1335,7 +1556,7 @@ private fun PendingPhotoTile(
|
|||||||
model = File(upload.localPath),
|
model = File(upload.localPath),
|
||||||
contentDescription = "Zdjęcie oczekujące na zapis",
|
contentDescription = "Zdjęcie oczekujące na zapis",
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().clickable { onPhoto(upload) },
|
||||||
)
|
)
|
||||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.34f)))
|
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.34f)))
|
||||||
if (isDeleting) {
|
if (isDeleting) {
|
||||||
@@ -1365,6 +1586,25 @@ private fun PendingPhotoTile(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (canRetryPhotoUpload(upload.status)) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { onRetry(upload) },
|
||||||
|
enabled = !isDeleting,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(8.dp)
|
||||||
|
.size(40.dp)
|
||||||
|
.background(TppColors.Surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp))
|
||||||
|
.border(1.dp, TppColors.Forest.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Refresh,
|
||||||
|
contentDescription = "Ponów wysłanie zdjęcia",
|
||||||
|
tint = TppColors.Forest,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Color.White.copy(alpha = 0.94f)).padding(10.dp),
|
Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Color.White.copy(alpha = 0.94f)).padding(10.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -1384,16 +1624,6 @@ private fun PendingPhotoTile(
|
|||||||
trackColor = TppColors.Outline.copy(alpha = 0.4f),
|
trackColor = TppColors.Outline.copy(alpha = 0.4f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (status == PhotoUploadStatus.FailedRetryable) {
|
|
||||||
Button(
|
|
||||||
onClick = { onRetry(upload) },
|
|
||||||
modifier = Modifier.fillMaxWidth().height(40.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
|
|
||||||
shape = RoundedCornerShape(4.dp),
|
|
||||||
) {
|
|
||||||
Text("Ponów", color = Color.White, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1513,9 +1743,6 @@ private fun polishPhoneDigits(value: String): String {
|
|||||||
return withoutCountryCode.take(9)
|
return withoutCountryCode.take(9)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatPolishPhoneDigits(value: String): String =
|
|
||||||
value.chunked(3).joinToString(" ")
|
|
||||||
|
|
||||||
private fun createCameraUri(context: Context): Uri {
|
private fun createCameraUri(context: Context): Uri {
|
||||||
val dir = File(context.cacheDir, "camera").apply { mkdirs() }
|
val dir = File(context.cacheDir, "camera").apply { mkdirs() }
|
||||||
val file = File.createTempFile("ladunek-", ".jpg", dir)
|
val file = File.createTempFile("ladunek-", ".jpg", dir)
|
||||||
@@ -1542,11 +1769,15 @@ private fun openNavigation(context: Context, point: NavigationPointDto) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cameraCapturePermissions(context: Context): Array<String> = buildList {
|
private fun cameraCapturePermissions(context: Context, requirePreciseLocation: Boolean): Array<String> = buildList {
|
||||||
if (!hasPermission(context, Manifest.permission.CAMERA)) {
|
if (!hasPermission(context, Manifest.permission.CAMERA)) {
|
||||||
add(Manifest.permission.CAMERA)
|
add(Manifest.permission.CAMERA)
|
||||||
}
|
}
|
||||||
if (!hasLocationPermission(context)) {
|
if (requirePreciseLocation) {
|
||||||
|
if (!hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) {
|
||||||
|
add(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
}
|
||||||
|
} else if (!hasLocationPermission(context)) {
|
||||||
add(Manifest.permission.ACCESS_FINE_LOCATION)
|
add(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
add(Manifest.permission.ACCESS_COARSE_LOCATION)
|
add(Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||||
}
|
}
|
||||||
@@ -1570,6 +1801,14 @@ private fun hasLocationPermission(context: Context): Boolean =
|
|||||||
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
|
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
|
||||||
hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
|
hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||||
|
|
||||||
|
private fun openAppSettings(context: Context) {
|
||||||
|
val intent = Intent(
|
||||||
|
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||||
|
Uri.fromParts("package", context.packageName, null),
|
||||||
|
)
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
private fun bestLastKnownLocation(context: Context): Location? {
|
private fun bestLastKnownLocation(context: Context): Location? {
|
||||||
if (!hasLocationPermission(context)) return null
|
if (!hasLocationPermission(context)) return null
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -23,6 +35,34 @@ 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 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,19 @@ 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 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 +139,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 +155,9 @@ 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,
|
||||||
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,
|
||||||
@@ -190,6 +197,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 +210,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 ->
|
||||||
@@ -301,6 +309,37 @@ 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) }
|
||||||
}
|
}
|
||||||
@@ -312,7 +351,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,4 @@
|
|||||||
<?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">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
<solid android:color="#F7FAF8" />
|
<solid android:color="#07172B" />
|
||||||
</shape>
|
</shape>
|
||||||
|
|||||||
@@ -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"/>
|
||||||
</adaptive-icon>
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||||
|
</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"/>
|
||||||
</adaptive-icon>
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||||
|
</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>
|
||||||
@@ -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))
|
||||||
@@ -87,4 +99,68 @@ 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 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))
|
||||||
|
}
|
||||||
|
}
|
||||||