Compare commits

...
3 Commits
Author SHA1 Message Date
admin c6d6392656 Refine driver leave request mobile flow 2026-07-04 23:26:26 +02:00
admin 958fb6eaa6 Bump Android driver app to 1.0.38 2026-07-04 23:18:42 +02:00
admin 2c806ef14e Add driver leave request screens 2026-07-04 23:11:08 +02:00
13 changed files with 928 additions and 6 deletions
+2 -2
View File
@@ -33,8 +33,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 38 versionCode = 40
versionName = "1.0.37" versionName = "1.0.39"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -13,14 +13,20 @@ import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var notificationRouteId by mutableStateOf<String?>(null) private var notificationRouteId by mutableStateOf<String?>(null)
private var notificationLeaveRequestId 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) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
setContent { setContent {
TppKierowcaTheme { TppKierowcaTheme {
val viewModel: DriverViewModel = viewModel() val viewModel: DriverViewModel = viewModel()
DriverApp(viewModel = viewModel, initialRouteId = notificationRouteId) DriverApp(
viewModel = viewModel,
initialRouteId = notificationRouteId,
initialLeaveRequestId = notificationLeaveRequestId,
)
} }
} }
} }
@@ -29,9 +35,11 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
} }
companion object { companion object {
const val EXTRA_ROUTE_ID = "pl.firmatpp.kierowca.EXTRA_ROUTE_ID" const val EXTRA_ROUTE_ID = "pl.firmatpp.kierowca.EXTRA_ROUTE_ID"
const val EXTRA_LEAVE_REQUEST_ID = "pl.firmatpp.kierowca.EXTRA_LEAVE_REQUEST_ID"
} }
} }
@@ -14,8 +14,11 @@ import pl.firmatpp.kierowca.data.api.MobileDriverApi
import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.OtpResponse import pl.firmatpp.kierowca.data.model.OtpResponse
@@ -51,6 +54,18 @@ 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 leaveRequests(): List<DriverLeaveRequestDto> =
api.leaveRequests(authHeader(requireToken())).data
suspend fun leaveRequest(id: String): DriverLeaveRequestDto =
api.leaveRequest(authHeader(requireToken()), id).data
suspend fun createLeaveRequest(dateFrom: String, dateTo: String, type: String, note: String?): DriverLeaveRequestDto =
api.createLeaveRequest(authHeader(requireToken()), CreateLeaveRequestBody(dateFrom, dateTo, type, note)).data
suspend fun cancelLeaveRequest(id: String, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment)).data
suspend fun completeRoute(routeId: String) = suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId) api.completeRoute(authHeader(requireToken()), routeId)
@@ -5,8 +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.CancelLeaveRequestBody
import pl.firmatpp.kierowca.data.model.CompleteRouteResponse import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestListResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse
import pl.firmatpp.kierowca.data.model.OtpResponse import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
@@ -56,6 +61,35 @@ interface MobileDriverApi {
@Query("date") date: String?, @Query("date") date: String?,
): BootstrapResponse ): BootstrapResponse
@GET("mobile/driver/leave-request-types")
suspend fun leaveRequestTypes(
@Header("Authorization") authorization: String,
): LeaveRequestTypesResponse
@GET("mobile/driver/leave-requests")
suspend fun leaveRequests(
@Header("Authorization") authorization: String,
): LeaveRequestListResponse
@POST("mobile/driver/leave-requests")
suspend fun createLeaveRequest(
@Header("Authorization") authorization: String,
@Body body: CreateLeaveRequestBody,
): LeaveRequestResponse
@GET("mobile/driver/leave-requests/{id}")
suspend fun leaveRequest(
@Header("Authorization") authorization: String,
@Path("id") id: String,
): LeaveRequestResponse
@POST("mobile/driver/leave-requests/{id}/cancel")
suspend fun cancelLeaveRequest(
@Header("Authorization") authorization: String,
@Path("id") id: String,
@Body body: CancelLeaveRequestBody,
): LeaveRequestResponse
@GET("mobile/driver/routes/{routeId}") @GET("mobile/driver/routes/{routeId}")
suspend fun route( suspend fun route(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -65,6 +65,12 @@ data class DriverAppSettingsDto(
val dispatchSheetRemindersEnabled: Boolean? = null, val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null, val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null, val dispatchSheetOnLastWorkingDay: Boolean? = null,
val leaveRequests: LeaveRequestsConfigDto? = null,
)
data class LeaveRequestsConfigDto(
val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"),
) )
data class DispatchSheetReminderDto( data class DispatchSheetReminderDto(
@@ -157,6 +163,58 @@ data class NotificationPreferencesBody(
val notifyNewRoutes: Boolean, val notifyNewRoutes: Boolean,
) )
data class LeaveRequestListResponse(
val data: List<DriverLeaveRequestDto> = emptyList(),
)
data class LeaveRequestResponse(
val data: DriverLeaveRequestDto,
)
data class LeaveRequestTypesResponse(
val data: List<LeaveRequestTypeDto> = emptyList(),
)
data class LeaveRequestTypeDto(
val value: String,
val label: String,
)
data class CreateLeaveRequestBody(
val dateFrom: String,
val dateTo: String,
val type: String,
val note: String?,
)
data class CancelLeaveRequestBody(
val comment: String? = null,
)
data class DriverLeaveRequestDto(
val id: String,
val driver: DriverDto? = null,
val dateFrom: String?,
val dateTo: String?,
val type: String,
val typeLabel: String? = null,
val note: String? = null,
val status: String,
val submittedAt: String? = null,
val decidedAt: String? = null,
val decisionComment: String? = null,
val events: List<DriverLeaveRequestEventDto> = emptyList(),
)
data class DriverLeaveRequestEventDto(
val id: String,
val action: String,
val fromStatus: String? = null,
val toStatus: String? = null,
val comment: String? = null,
val createdAt: String? = null,
)
data class RealtimeStatusBody( data class RealtimeStatusBody(
val reverbStatus: String, val reverbStatus: String,
val socketId: String? = null, val socketId: String? = null,
@@ -151,5 +151,6 @@ class DriverSyncRepository(
const val SCOPE_ROUTE_DETAIL = "route_detail" const val SCOPE_ROUTE_DETAIL = "route_detail"
const val SCOPE_SETTINGS = "settings" const val SCOPE_SETTINGS = "settings"
const val SCOPE_DISPATCH_SHEET = "dispatch_sheet" const val SCOPE_DISPATCH_SHEET = "dispatch_sheet"
const val SCOPE_LEAVE_REQUESTS = "leave_requests"
} }
} }
@@ -31,6 +31,21 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() {
return return
} }
if (data["type"] == "driver_leave_request_decision") {
LeaveRequestDecisionNotificationWorker.enqueue(
context = applicationContext,
leaveRequestId = data["leaveRequestId"],
title = data["title"],
body = data["body"],
)
DriverSyncWorker.enqueue(
context = applicationContext,
date = data["date"],
routeId = data["routeId"],
)
return
}
if (data["type"] != "driver_sync_hint") return if (data["type"] != "driver_sync_hint") return
DriverSyncWorker.enqueue( DriverSyncWorker.enqueue(
@@ -47,6 +47,10 @@ class DriverSyncWorker(
syncRepository.bootstrap(scope.date ?: date) syncRepository.bootstrap(scope.date ?: date)
refreshed = true refreshed = true
} }
DriverSyncRepository.SCOPE_LEAVE_REQUESTS -> {
syncRepository.saveSyncStates(response)
refreshed = true
}
} }
} }
} }
@@ -0,0 +1,110 @@
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.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import pl.firmatpp.kierowca.MainActivity
import pl.firmatpp.kierowca.R
class LeaveRequestDecisionNotificationWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val leaveRequestId = inputData.getString(KEY_LEAVE_REQUEST_ID)?.takeIf(String::isNotBlank)
?: return Result.success()
if (!canShowNotifications(applicationContext)) {
return Result.success()
}
createChannel(applicationContext)
val title = inputData.getString(KEY_TITLE)?.takeIf(String::isNotBlank) ?: "Decyzja w sprawie urlopu"
val body = inputData.getString(KEY_BODY)?.takeIf(String::isNotBlank) ?: "Status wniosku urlopowego został zmieniony."
val intent = Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(MainActivity.EXTRA_LEAVE_REQUEST_ID, leaveRequestId)
}
val pendingIntent = PendingIntent.getActivity(
applicationContext,
leaveRequestId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
NotificationManagerCompat.from(applicationContext).notify(leaveRequestId.hashCode(), notification)
return Result.success()
}
private fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
CHANNEL_ID,
"Wnioski urlopowe",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Powiadomienia o decyzjach w sprawie wniosków urlopowych."
}
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 = "leave_request_decisions"
private const val KEY_LEAVE_REQUEST_ID = "leaveRequestId"
private const val KEY_TITLE = "title"
private const val KEY_BODY = "body"
fun enqueue(context: Context, leaveRequestId: String?, title: String?, body: String?) {
if (leaveRequestId.isNullOrBlank()) return
val request = OneTimeWorkRequestBuilder<LeaveRequestDecisionNotificationWorker>()
.setInputData(
workDataOf(
KEY_LEAVE_REQUEST_ID to leaveRequestId,
KEY_TITLE to title,
KEY_BODY to body,
),
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"driver-leave-request-decision-$leaveRequestId",
ExistingWorkPolicy.REPLACE,
request,
)
}
}
}
@@ -74,6 +74,8 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DateRangePicker
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -86,6 +88,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDateRangePickerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -127,12 +130,14 @@ import java.io.File
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.util.Locale import java.util.Locale
import pl.firmatpp.kierowca.R import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.NavigationPointDto import pl.firmatpp.kierowca.data.model.NavigationPointDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
@@ -144,7 +149,7 @@ import pl.firmatpp.kierowca.domain.RouteDisplayMapper
import pl.firmatpp.kierowca.ui.theme.TppColors import pl.firmatpp.kierowca.ui.theme.TppColors
@Composable @Composable
fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) { fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null, initialLeaveRequestId: String? = null) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current val context = LocalContext.current
@@ -192,6 +197,11 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) {
LaunchedEffect(initialRouteId) { LaunchedEffect(initialRouteId) {
viewModel.openRouteFromNotification(initialRouteId) viewModel.openRouteFromNotification(initialRouteId)
} }
LaunchedEffect(initialLeaveRequestId, state.leaveRequestsConfig) {
if (!initialLeaveRequestId.isNullOrBlank() && DriverLeaveRequestUiRules.isFeatureVisible(state.leaveRequestsConfig)) {
viewModel.openLeaveRequest(initialLeaveRequestId)
}
}
Box(Modifier.fillMaxSize().background(TppColors.Surface)) { Box(Modifier.fillMaxSize().background(TppColors.Surface)) {
when (state.screen) { when (state.screen) {
@@ -213,6 +223,7 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) {
onRoutes = viewModel::refreshRoutes, onRoutes = viewModel::refreshRoutes,
onProfile = viewModel::openProfile, onProfile = viewModel::openProfile,
onLogout = viewModel::logout, onLogout = viewModel::logout,
onLeaveRequests = viewModel::openLeaveRequests,
onNewRouteNotificationsChanged = { enabled -> onNewRouteNotificationsChanged = { enabled ->
if (!enabled) { if (!enabled) {
viewModel.updateNewRouteNotifications(false) viewModel.updateNewRouteNotifications(false)
@@ -243,6 +254,24 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) {
onBack = viewModel::back, onBack = viewModel::back,
onRetryUpload = viewModel::retryPhotoUpload, onRetryUpload = viewModel::retryPhotoUpload,
) )
DriverScreen.LeaveRequests -> LeaveRequestsScreen(
state = state,
onBack = viewModel::back,
onRefresh = viewModel::refreshLeaveRequests,
onAdd = viewModel::openAddLeaveRequest,
onOpen = viewModel::openLeaveRequest,
)
DriverScreen.LeaveRequestDetail -> LeaveRequestDetailScreen(
state = state,
onBack = viewModel::back,
onCancel = viewModel::cancelSelectedLeaveRequest,
)
DriverScreen.AddLeaveRequest -> AddLeaveRequestScreen(
state = state,
onBack = viewModel::back,
onDraft = viewModel::updateLeaveRequestDraft,
onSubmit = viewModel::submitLeaveRequest,
)
} }
if (state.loading && state.screen != DriverScreen.Initializing) { if (state.loading && state.screen != DriverScreen.Initializing) {
@@ -761,6 +790,420 @@ private fun RouteDayLiveUpdateBanner(message: String?, modifier: Modifier = Modi
} }
} }
@Composable
private fun LeaveRequestsEntryCard(requests: List<DriverLeaveRequestDto>, onOpen: () -> Unit) {
val decisionCount = requests.count { it.status == "pending" || it.status == "cancel_requested" }
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen),
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(44.dp).background(Color(0xFFEAF7EF), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) {
Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppColors.Forest)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text("Wnioski urlopowe", color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 18.sp)
Text(
if (decisionCount > 0) "$decisionCount czeka na decyzję" else "Lista, status i nowy wniosek",
color = TppColors.Muted,
fontSize = 14.sp,
)
}
Text("Otwórz", color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun LeaveRequestsScreen(
state: DriverUiState,
onBack: () -> Unit,
onRefresh: () -> Unit,
onAdd: () -> Unit,
onOpen: (String) -> Unit,
) {
val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
Scaffold(
topBar = { SimpleTopBar("Wnioski urlopowe", onBack) },
containerColor = TppColors.Surface,
) { padding ->
Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { FeedbackAndError(state.feedback, state.error) }
item {
Button(
onClick = onAdd,
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
shape = RoundedCornerShape(8.dp),
) {
Icon(Icons.Outlined.CalendarToday, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Nowy wniosek", fontWeight = FontWeight.Bold)
}
}
if (state.leaveRequests.isEmpty()) {
item { EmptyState("Nie masz jeszcze wniosków urlopowych") }
} else {
items(state.leaveRequests, key = { it.id }) { request ->
LeaveRequestCard(request, onClick = { onOpen(request.id) })
}
}
}
PullRefreshIndicator(
refreshing = state.refreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
backgroundColor = Color.White,
contentColor = TppColors.Forest,
)
}
}
}
@Composable
private fun LeaveRequestCard(request: DriverLeaveRequestDto, onClick: () -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) {
Text(
"${DriverLeaveRequestUiRules.typeLabel(request.type)} · ${leaveDateRange(request.dateFrom, request.dateTo)}",
color = TppColors.Ink,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
)
if (!request.note.isNullOrBlank()) {
Text(request.note, color = TppColors.Muted, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
LeaveStatusPill(request.status)
}
Text("Szczegóły", color = TppColors.Forest, fontWeight = FontWeight.Bold, fontSize = 14.sp)
}
}
}
@Composable
private fun LeaveRequestDetailScreen(
state: DriverUiState,
onBack: () -> Unit,
onCancel: () -> Unit,
) {
val request = state.selectedLeaveRequest
Scaffold(topBar = { SimpleTopBar("Szczegóły wniosku", onBack) }, containerColor = TppColors.Surface) { padding ->
if (request == null) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text("Nie znaleziono wniosku", color = TppColors.Muted)
}
return@Scaffold
}
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item { FeedbackAndError(state.feedback, state.error) }
item {
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(8.dp),
) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) {
Text(DriverLeaveRequestUiRules.typeLabel(request.type), color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 22.sp)
LeaveStatusPill(request.status)
}
DetailLine("Zakres", leaveDateRange(request.dateFrom, request.dateTo))
DetailLine("Złożono", request.submittedAt?.let(::shortDateTime) ?: "-")
DetailLine("Decyzja", request.decidedAt?.let(::shortDateTime) ?: "-")
DetailLine("Notatka", request.note ?: "-")
DetailLine("Komentarz", request.decisionComment ?: "-")
if (DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) {
Button(
onClick = onCancel,
modifier = Modifier.fillMaxWidth().height(54.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB45309)),
shape = RoundedCornerShape(8.dp),
) {
Text(if (request.status == "approved") "Poproś o anulowanie" else "Anuluj wniosek")
}
}
}
}
}
item {
Text("Historia", color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 18.sp)
}
items(request.events, key = { it.id }) { event ->
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppColors.Outline.copy(alpha = 0.65f)),
shape = RoundedCornerShape(8.dp),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"${event.action}${event.toStatus?.let { " · ${DriverLeaveRequestUiRules.statusLabel(it)}" } ?: ""}",
color = TppColors.Ink,
fontWeight = FontWeight.Bold,
)
Text(event.createdAt?.let(::shortDateTime) ?: "-", color = TppColors.Muted, fontSize = 13.sp)
if (!event.comment.isNullOrBlank()) Text(event.comment, color = TppColors.Muted)
}
}
}
}
}
}
@Composable
private fun AddLeaveRequestScreen(
state: DriverUiState,
onBack: () -> Unit,
onDraft: (String?, String?, String?, String?) -> Unit,
onSubmit: () -> Unit,
) {
var showDateRangePicker by remember { mutableStateOf(false) }
val selectedStartMillis = remember(state.leaveRequestDateFrom) {
localDateStringToUtcMillis(state.leaveRequestDateFrom)
}
val selectedEndMillis = remember(state.leaveRequestDateTo) {
localDateStringToUtcMillis(state.leaveRequestDateTo)
}
val dateRangePickerState = rememberDateRangePickerState(
initialSelectedStartDateMillis = selectedStartMillis,
initialSelectedEndDateMillis = selectedEndMillis,
)
Scaffold(topBar = { SimpleTopBar("Nowy wniosek", onBack) }, containerColor = TppColors.Surface) { padding ->
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { FeedbackAndError(null, state.error) }
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Typ", color = TppColors.Ink, fontWeight = FontWeight.Bold)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(state.leaveRequestsConfig?.types.orEmpty()) { type ->
val active = type == state.leaveRequestType
Button(
onClick = { onDraft(null, null, type, null) },
colors = ButtonDefaults.buttonColors(
containerColor = if (active) TppColors.Forest else Color.White,
contentColor = if (active) Color.White else TppColors.Ink,
),
border = BorderStroke(1.dp, if (active) TppColors.Forest else TppColors.Outline),
shape = RoundedCornerShape(8.dp),
) {
Text(DriverLeaveRequestUiRules.typeLabel(type))
}
}
}
}
}
item {
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth().clickable { showDateRangePicker = true },
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier.size(44.dp).background(Color(0xFFEAF7EF), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppColors.Forest)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Okres", color = TppColors.Ink, fontWeight = FontWeight.Bold)
Text(
leaveDateRange(state.leaveRequestDateFrom, state.leaveRequestDateTo),
color = TppColors.Muted,
)
}
Text("Zmień", color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
}
}
item {
LeaveTextField(
label = "Notatka (opcjonalnie)",
value = state.leaveRequestNote,
onValue = { onDraft(null, null, null, it) },
minLines = 4,
)
}
item {
Button(
onClick = onSubmit,
modifier = Modifier.fillMaxWidth().height(58.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
shape = RoundedCornerShape(8.dp),
) {
Text("Wyślij wniosek", fontWeight = FontWeight.Bold)
}
}
}
}
if (showDateRangePicker) {
DatePickerDialog(
onDismissRequest = { showDateRangePicker = false },
confirmButton = {
TextButton(
onClick = {
val start = dateRangePickerState.selectedStartDateMillis
val end = dateRangePickerState.selectedEndDateMillis ?: start
if (start != null && end != null) {
onDraft(utcMillisToLocalDateString(start), utcMillisToLocalDateString(end), null, null)
}
showDateRangePicker = false
},
enabled = dateRangePickerState.selectedStartDateMillis != null,
) {
Text("Ustaw", color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showDateRangePicker = false }) {
Text("Anuluj", color = TppColors.Muted)
}
},
) {
DateRangePicker(
state = dateRangePickerState,
title = {
Text(
"Wybierz okres urlopu",
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 16.dp),
color = TppColors.Ink,
fontWeight = FontWeight.Bold,
)
},
headline = {
Text(
leaveDateRange(
dateRangePickerState.selectedStartDateMillis?.let(::utcMillisToLocalDateString),
dateRangePickerState.selectedEndDateMillis?.let(::utcMillisToLocalDateString),
),
modifier = Modifier.padding(start = 24.dp, end = 12.dp, bottom = 12.dp),
color = TppColors.Muted,
)
},
showModeToggle = false,
)
}
}
}
@Composable
private fun SimpleTopBar(title: String, onBack: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.statusBarsPadding()
.background(Color.White)
.border(0.5.dp, TppColors.Outline.copy(alpha = 0.65f))
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz", tint = TppColors.Ink)
}
Text(title, color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 20.sp)
}
}
@Composable
private fun LeaveStatusPill(status: String) {
val color = when (status) {
"approved" -> Color(0xFFEAF7EF) to TppColors.Forest
"rejected" -> Color(0xFFFFEBEE) to Color(0xFFB91C1C)
"cancel_requested" -> Color(0xFFFFF7ED) to Color(0xFFB45309)
"cancelled", "revoked" -> Color(0xFFF1F5F9) to Color(0xFF475569)
else -> Color(0xFFFFF8DB) to Color(0xFF7A5A00)
}
Text(
DriverLeaveRequestUiRules.statusLabel(status),
color = color.second,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
modifier = Modifier.background(color.first, RoundedCornerShape(999.dp)).padding(horizontal = 10.dp, vertical = 6.dp),
)
}
@Composable
private fun DetailLine(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(label.uppercase(Locale("pl", "PL")), color = TppColors.Muted, fontSize = 12.sp, fontWeight = FontWeight.Bold)
Text(value, color = TppColors.Ink, fontSize = 16.sp)
}
}
@Composable
private fun FeedbackAndError(feedback: String?, error: String?) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (!feedback.isNullOrBlank()) {
Text(feedback, color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
ErrorText(error)
}
}
@Composable
private fun LeaveTextField(label: String, value: String, onValue: (String) -> Unit, minLines: Int = 1) {
OutlinedTextField(
value = value,
onValueChange = onValue,
label = { Text(label) },
minLines = minLines,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
)
}
private fun leaveDateRange(from: String?, to: String?): String =
if (from == to) from ?: "-" else "${from ?: "?"} - ${to ?: "?"}"
private fun localDateStringToUtcMillis(value: String): Long? =
runCatching {
LocalDate.parse(value).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
}.getOrNull()
private fun utcMillisToLocalDateString(value: Long): String =
Instant.ofEpochMilli(value).atZone(ZoneOffset.UTC).toLocalDate().toString()
private fun shortDateTime(value: String): String =
runCatching {
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", Locale("pl", "PL"))
.format(Instant.parse(value).atZone(ZoneId.systemDefault()))
}.getOrDefault(value)
@Composable @Composable
private fun DispatchSheetReminderCard( private fun DispatchSheetReminderCard(
reminder: DispatchSheetReminderDto?, reminder: DispatchSheetReminderDto?,
@@ -1219,6 +1662,7 @@ private fun ProfileScreen(
onRoutes: () -> Unit, onRoutes: () -> Unit,
onProfile: () -> Unit, onProfile: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onLeaveRequests: () -> Unit,
onNewRouteNotificationsChanged: (Boolean) -> Unit, onNewRouteNotificationsChanged: (Boolean) -> Unit,
onOpenNotificationSettings: () -> Unit, onOpenNotificationSettings: () -> Unit,
) { ) {
@@ -1263,6 +1707,9 @@ private fun ProfileScreen(
) )
} }
} }
if (DriverLeaveRequestUiRules.isFeatureVisible(state.leaveRequestsConfig)) {
LeaveRequestsEntryCard(state.leaveRequests, onLeaveRequests)
}
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = Color.White),
@@ -0,0 +1,42 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
data class LeaveRequestsConfig(
val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"),
)
object DriverLeaveRequestUiRules {
fun isFeatureVisible(config: LeaveRequestsConfig?): Boolean =
config?.enabled == true && config.types.isNotEmpty()
fun statusLabel(status: String): String =
when (status) {
"pending" -> "Oczekuje"
"approved" -> "Zatwierdzony"
"rejected" -> "Odrzucony"
"cancel_requested" -> "Anulowanie do decyzji"
"cancelled" -> "Anulowany"
"revoked" -> "Cofnięto decyzję"
else -> status
}
fun typeLabel(type: String): String =
when (type) {
"URLOP" -> "Urlop"
"CHOROBOWE" -> "Chorobowe"
"SZKOLENIE" -> "Szkolenie"
"WOLNE" -> "Dzień wolny"
"INNE" -> "Inne"
else -> type
}
fun canCancel(status: String, dateFrom: String, today: LocalDate = LocalDate.now()): Boolean {
if (status == "pending") return true
if (status != "approved") return false
val start = runCatching { LocalDate.parse(dateFrom) }.getOrNull() ?: return false
return start.isAfter(today)
}
}
@@ -19,6 +19,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
@@ -29,7 +30,7 @@ import pl.firmatpp.kierowca.sync.DriverLiveSyncClient
import pl.firmatpp.kierowca.sync.DriverSyncHint import pl.firmatpp.kierowca.sync.DriverSyncHint
import pl.firmatpp.kierowca.sync.DriverSyncWorker import pl.firmatpp.kierowca.sync.DriverSyncWorker
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue } enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, AddLeaveRequest }
data class DriverUiState( data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing, val screen: DriverScreen = DriverScreen.Initializing,
@@ -55,6 +56,13 @@ data class DriverUiState(
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(), val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null, val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(), val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
val leaveRequestsConfig: LeaveRequestsConfig? = null,
val leaveRequests: List<DriverLeaveRequestDto> = emptyList(),
val selectedLeaveRequest: DriverLeaveRequestDto? = null,
val leaveRequestDateFrom: String = LocalDate.now().toString(),
val leaveRequestDateTo: String = LocalDate.now().toString(),
val leaveRequestType: String = "URLOP",
val leaveRequestNote: String = "",
val deletingPhotoIds: Set<String> = emptySet(), val deletingPhotoIds: Set<String> = emptySet(),
val completingRoute: Boolean = false, val completingRoute: Boolean = false,
val imageAuthHeader: String? = null, val imageAuthHeader: String? = null,
@@ -164,7 +172,6 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val settings = response.driverAppSettings val settings = response.driverAppSettings
_state.update { _state.update {
it.copy( it.copy(
screen = if (navigateToRoutes) DriverScreen.Routes else it.screen,
driver = response.session.driver, driver = response.session.driver,
routes = response.routes.today, routes = response.routes.today,
dispatchSheetReminder = response.dispatchSheetReminder, dispatchSheetReminder = response.dispatchSheetReminder,
@@ -176,6 +183,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion, allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
?: it.requirePreciseLocationForPhotos, ?: it.requirePreciseLocationForPhotos,
leaveRequestsConfig = settings?.leaveRequests?.let { config ->
LeaveRequestsConfig(enabled = config.enabled, types = config.types)
},
screen = if (
settings?.leaveRequests?.enabled != true &&
it.screen in setOf(DriverScreen.LeaveRequests, DriverScreen.LeaveRequestDetail, DriverScreen.AddLeaveRequest)
) DriverScreen.Routes else if (navigateToRoutes) DriverScreen.Routes else it.screen,
notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes, notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes,
imageAuthHeader = repository.imageAuthHeader(), imageAuthHeader = repository.imageAuthHeader(),
isStale = cached.stale || !it.isOnline, isStale = cached.stale || !it.isOnline,
@@ -292,6 +306,135 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) } _state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
} }
fun openLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = true)
}
fun refreshLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = false)
}
private fun loadLeaveRequests(showLoading: Boolean) {
viewModelScope.launch {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) }
runCatching { repository.leaveRequests() }
.onSuccess { requests ->
_state.update {
it.copy(
screen = DriverScreen.LeaveRequests,
leaveRequests = requests,
selectedLeaveRequest = requests.firstOrNull { request -> request.id == it.selectedLeaveRequest?.id } ?: it.selectedLeaveRequest,
error = null,
)
}
}
.onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } }
_state.update { it.copy(loading = false, refreshing = false) }
}
}
fun openLeaveRequest(id: String) {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) }
runCatching { repository.leaveRequest(id) }
.onSuccess { request ->
_state.update { it.copy(screen = DriverScreen.LeaveRequestDetail, selectedLeaveRequest = request, error = null) }
}
.onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } }
_state.update { it.copy(loading = false) }
}
}
fun openAddLeaveRequest() {
val config = _state.value.leaveRequestsConfig
if (!DriverLeaveRequestUiRules.isFeatureVisible(config)) return
val today = LocalDate.now().toString()
_state.update {
it.copy(
screen = DriverScreen.AddLeaveRequest,
leaveRequestDateFrom = today,
leaveRequestDateTo = today,
leaveRequestType = config?.types?.firstOrNull() ?: "URLOP",
leaveRequestNote = "",
error = null,
feedback = null,
)
}
}
fun updateLeaveRequestDraft(dateFrom: String? = null, dateTo: String? = null, type: String? = null, note: String? = null) {
_state.update {
val nextFrom = dateFrom ?: it.leaveRequestDateFrom
val nextTo = dateTo ?: it.leaveRequestDateTo
it.copy(
leaveRequestDateFrom = nextFrom,
leaveRequestDateTo = if (nextTo < nextFrom) nextFrom else nextTo,
leaveRequestType = type ?: it.leaveRequestType,
leaveRequestNote = note ?: it.leaveRequestNote,
)
}
}
fun submitLeaveRequest() {
val snapshot = _state.value
if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig)) return
if (snapshot.leaveRequestDateFrom < LocalDate.now().toString()) {
_state.update { it.copy(error = "Data od nie może być z przeszłości.") }
return
}
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) }
runCatching {
repository.createLeaveRequest(
dateFrom = snapshot.leaveRequestDateFrom,
dateTo = snapshot.leaveRequestDateTo,
type = snapshot.leaveRequestType,
note = snapshot.leaveRequestNote.takeIf { it.isNotBlank() },
)
}.onSuccess { created ->
val requests = runCatching { repository.leaveRequests() }.getOrElse { listOf(created) }
_state.update {
it.copy(
screen = DriverScreen.LeaveRequestDetail,
selectedLeaveRequest = created,
leaveRequests = requests,
feedback = "Wniosek został wysłany do decyzji.",
error = null,
)
}
}.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
}
_state.update { it.copy(loading = false) }
}
}
fun cancelSelectedLeaveRequest() {
val request = _state.value.selectedLeaveRequest ?: return
if (!DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) return
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) }
runCatching { repository.cancelLeaveRequest(request.id) }
.onSuccess { updated ->
_state.update {
it.copy(
selectedLeaveRequest = updated,
leaveRequests = it.leaveRequests.map { existing -> if (existing.id == updated.id) updated else existing },
feedback = if (updated.status == "cancel_requested") "Anulowanie wysłane do decyzji." else "Wniosek został anulowany.",
error = null,
)
}
}
.onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } }
_state.update { it.copy(loading = false) }
}
}
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) { fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
val route = _state.value.selectedRoute ?: return val route = _state.value.selectedRoute ?: return
viewModelScope.launch { viewModelScope.launch {
@@ -425,6 +568,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
when (it.screen) { when (it.screen) {
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null) DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes) DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes)
DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null)
DriverScreen.AddLeaveRequest -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null)
DriverScreen.Detail -> { DriverScreen.Detail -> {
photoUploadsJob?.cancel() photoUploadsJob?.cancel()
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList(), feedback = null) it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList(), feedback = null)
@@ -489,6 +635,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
if (snapshot.screen == DriverScreen.Detail) { if (snapshot.screen == DriverScreen.Detail) {
refreshSelectedRoute() refreshSelectedRoute()
} }
if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) {
refreshLeaveRequests()
}
} }
private suspend fun checkRemoteSyncState() { private suspend fun checkRemoteSyncState() {
@@ -530,6 +679,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently() DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_DISPATCH_SHEET -> refreshRoutesSilently() DriverSyncRepository.SCOPE_DISPATCH_SHEET -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_LEAVE_REQUESTS -> {
if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) {
refreshLeaveRequests()
}
}
} }
} }
@@ -0,0 +1,34 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DriverLeaveRequestUiRulesTest {
@Test
fun featureIsVisibleOnlyWhenServerConfigEnablesIt() {
assertFalse(DriverLeaveRequestUiRules.isFeatureVisible(null))
assertFalse(DriverLeaveRequestUiRules.isFeatureVisible(LeaveRequestsConfig(enabled = false, types = listOf("URLOP"))))
assertTrue(DriverLeaveRequestUiRules.isFeatureVisible(LeaveRequestsConfig(enabled = true, types = listOf("URLOP"))))
}
@Test
fun exposesPolishStatusLabels() {
assertEquals("Oczekuje", DriverLeaveRequestUiRules.statusLabel("pending"))
assertEquals("Anulowanie do decyzji", DriverLeaveRequestUiRules.statusLabel("cancel_requested"))
assertEquals("Cofnięto decyzję", DriverLeaveRequestUiRules.statusLabel("revoked"))
}
@Test
fun driverCanCancelPendingAndFutureApprovedRequests() {
val tomorrow = LocalDate.now().plusDays(1).toString()
val yesterday = LocalDate.now().minusDays(1).toString()
assertTrue(DriverLeaveRequestUiRules.canCancel(status = "pending", dateFrom = yesterday))
assertTrue(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = tomorrow))
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = yesterday))
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "rejected", dateFrom = tomorrow))
}
}