diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt index 1360524..ef3f996 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt @@ -71,8 +71,8 @@ class DriverRepository( 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 cancelLeaveRequest(id: String, expectedVersion: Long, comment: String? = null): DriverLeaveRequestDto = + api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment, expectedVersion)).data suspend fun completeRoute(routeId: String) = api.completeRoute(authHeader(requireToken()), routeId) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt index fa3f4b9..a88412d 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt @@ -193,6 +193,7 @@ data class CreateLeaveRequestBody( data class CancelLeaveRequestBody( val comment: String? = null, + val expectedVersion: Long? = null, ) data class DriverLeaveRequestDto( @@ -204,6 +205,10 @@ data class DriverLeaveRequestDto( val typeLabel: String? = null, val note: String? = null, val status: String, + val version: Long = 1, + val updatedAt: String? = null, + val allowedDecisions: List = emptyList(), + val canCancel: Boolean = false, val submittedAt: String? = null, val decidedAt: String? = null, val decisionComment: String? = null, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt index 430c21e..d4dd2b7 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt @@ -98,21 +98,25 @@ class DriverSyncRepository( suspend fun saveSyncStates(response: SyncStateResponse?) { response?.scopes.orEmpty().forEach { scope -> - dao.upsertSyncState( - DriverSyncStateEntity( - key = syncStateKey(scope.scope, scope.date, scope.routeId), - scope = scope.scope, - date = scope.date, - routeId = scope.routeId, - checksum = scope.checksum, - version = scope.version, - computedAt = scope.computedAt, - syncedAtEpochMillis = System.currentTimeMillis(), - ), - ) + saveSyncScope(scope) } } + suspend fun saveSyncScope(scope: SyncScopeDto) { + dao.upsertSyncState( + DriverSyncStateEntity( + key = syncStateKey(scope.scope, scope.date, scope.routeId), + scope = scope.scope, + date = scope.date, + routeId = scope.routeId, + checksum = scope.checksum, + version = scope.version, + computedAt = scope.computedAt, + syncedAtEpochMillis = System.currentTimeMillis(), + ), + ) + } + suspend fun clearCache() { dao.clearBootstrap() dao.clearRoutes() @@ -166,5 +170,6 @@ class DriverSyncRepository( const val SCOPE_SETTINGS = "settings" const val SCOPE_DISPATCH_SHEET = "dispatch_sheet" const val SCOPE_LEAVE_REQUESTS = "leave_requests" + const val SCOPE_LEAVE_CALENDAR = "leave_calendar" } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt index 37e0390..9ae4660 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClient.kt @@ -272,20 +272,22 @@ class DriverLiveSyncClient( private fun subscribe(socket: WebSocket, socketId: String) { val id = synchronized(lock) { driverId } ?: return - val channel = "private-driver-mobile.$id" + val channels = listOf("private-driver-mobile.$id", "private-driver-mobile.0") scope.launch { runCatching { - val auth = gateway.broadcastAuth(socketId, channel).auth - if (!isCurrentSocket(socket)) return@launch - val payload = mapOf( - "event" to "pusher:subscribe", - "data" to mapOf( - "channel" to channel, - "auth" to auth, - ), - ) - check(socket.send(gson.toJson(payload))) { "WebSocket send returned false" } + channels.forEach { channel -> + val auth = gateway.broadcastAuth(socketId, channel).auth + if (!isCurrentSocket(socket)) return@launch + val payload = mapOf( + "event" to "pusher:subscribe", + "data" to mapOf( + "channel" to channel, + "auth" to auth, + ), + ) + check(socket.send(gson.toJson(payload))) { "WebSocket send returned false" } + } }.onFailure { throwable -> AppDiagnostics.log("realtime_subscription_error: ${throwable.message ?: throwable::class.java.simpleName}") socket.close(1000, "subscription_failed") diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt index 7ed74b7..8094a29 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt @@ -52,6 +52,10 @@ class DriverSyncWorker( syncRepository.saveSyncStates(response) refreshed = true } + DriverSyncRepository.SCOPE_LEAVE_CALENDAR -> { + syncRepository.saveSyncStates(response) + refreshed = true + } } } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index 7fd59f5..87efc40 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex import java.time.LocalDate import java.time.Instant import pl.firmatpp.kierowca.data.AppPreferencesStore @@ -121,12 +122,17 @@ internal fun DriverUiState.withLoadedLeaveRequests( requests: List, navigateToList: Boolean, ): DriverUiState { + val currentById = leaveRequests.associateBy { it.id } + val merged = requests.map { incoming -> + currentById[incoming.id]?.takeIf { current -> current.version > incoming.version } ?: incoming + }.toMutableList() + leaveRequests.filter { current -> requests.none { it.id == current.id } }.forEach(merged::add) val refreshedSelectedRequest = selectedLeaveRequest?.let { selected -> - requests.firstOrNull { request -> request.id == selected.id } ?: selected + merged.firstOrNull { request -> request.id == selected.id }?.takeIf { it.version >= selected.version } ?: selected } return copy( screen = if (navigateToList) DriverScreen.LeaveRequests else screen, - leaveRequests = requests, + leaveRequests = merged, selectedLeaveRequest = refreshedSelectedRequest, error = null, ) @@ -151,6 +157,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) private var routeActionsJob: Job? = null private var dispatchSheetUploadsJob: Job? = null private var pushTokenRegisteredForDriverId: String? = null + private val leaveMutationMutex = Mutex() + private var pendingLeaveHint: DriverSyncHint? = null + private var leaveCalendarRequestGeneration: Long = 0 val state: StateFlow = _state init { @@ -419,24 +428,22 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun openLeaveRequests() { if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return - loadLeaveRequests(showLoading = true, navigateToList = true) + viewModelScope.launch { loadLeaveRequests(showLoading = true, navigateToList = true) } } fun refreshLeaveRequests() { if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return - loadLeaveRequests(showLoading = false, navigateToList = false) + viewModelScope.launch { loadLeaveRequests(showLoading = false, navigateToList = false) } } - private fun loadLeaveRequests(showLoading: Boolean, navigateToList: Boolean) { - viewModelScope.launch { - _state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) } - runCatching { repository.leaveRequests() } - .onSuccess { requests -> - _state.update { it.withLoadedLeaveRequests(requests, navigateToList) } - } - .onFailure { throwable -> _state.update { it.withApiError(throwable) } } - _state.update { it.copy(loading = false, refreshing = false) } - } + private suspend fun loadLeaveRequests(showLoading: Boolean, navigateToList: Boolean): Boolean { + _state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) } + val result = runCatching { repository.leaveRequests() } + result.onSuccess { requests -> + _state.update { it.withLoadedLeaveRequests(requests, navigateToList) } + }.onFailure { throwable -> _state.update { it.withApiError(throwable) } } + _state.update { it.copy(loading = false, refreshing = false) } + return result.isSuccess } fun openLeaveRequest(id: String) { @@ -550,6 +557,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) runCatching { LocalDate.parse(snapshot.leaveCalendarLoadedUntil).plusDays(1) }.getOrDefault(LocalDate.now()) } val to = from.plusMonths(6).minusDays(1) + val generation = ++leaveCalendarRequestGeneration viewModelScope.launch { _state.update { @@ -562,6 +570,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } runCatching { repository.leaveCalendar(from.toString(), to.toString()) } .onSuccess { entries -> + if (generation != leaveCalendarRequestGeneration) return@onSuccess _state.update { val merged = (it.leaveCalendarEntries + entries) .distinctBy { entry -> entry.id } @@ -575,6 +584,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } .onFailure { throwable -> + if (generation != leaveCalendarRequestGeneration) return@onFailure _state.update { it.withApiError(throwable).copy(leaveCalendarLoading = false) } } } @@ -583,13 +593,16 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun submitLeaveRequest() { val snapshot = _state.value if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig)) return + if (!leaveMutationMutex.tryLock()) return if (snapshot.leaveRequestDateFrom < LocalDate.now().toString()) { _state.update { it.copy(error = "Data od nie może być z przeszłości.") } + leaveMutationMutex.unlock() return } viewModelScope.launch { _state.update { it.copy(loading = true, error = null, feedback = null) } + try { runCatching { repository.createLeaveRequest( dateFrom = snapshot.leaveRequestDateFrom, @@ -612,16 +625,22 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.withApiError(throwable) } } _state.update { it.copy(loading = false) } + } finally { + leaveMutationMutex.unlock() + consumePendingLeaveHint() + } } } fun cancelSelectedLeaveRequest() { val request = _state.value.selectedLeaveRequest ?: return if (!DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) return + if (!leaveMutationMutex.tryLock()) return viewModelScope.launch { _state.update { it.copy(loading = true, error = null, feedback = null) } - runCatching { repository.cancelLeaveRequest(request.id) } + try { + runCatching { repository.cancelLeaveRequest(request.id, request.version) } .onSuccess { updated -> _state.update { it.copy( @@ -632,8 +651,26 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) ) } } - .onFailure { throwable -> _state.update { it.withApiError(throwable) } } + .onFailure { throwable -> + val apiError = ApiErrorMapper.map(throwable) + if (apiError.kind == ApiErrorKind.Conflict && apiError.code == "leave_request_stale") { + runCatching { repository.leaveRequest(request.id) } + .onSuccess { current -> + _state.update { + it.withLoadedLeaveRequests(listOf(current), navigateToList = false) + .copy(error = "Wniosek został wcześniej zmieniony. Pokazujemy aktualny stan.") + } + } + .onFailure { refreshError -> _state.update { it.withApiError(refreshError) } } + } else { + _state.update { it.withApiError(throwable) } + } + } _state.update { it.copy(loading = false) } + } finally { + leaveMutationMutex.unlock() + consumePendingLeaveHint() + } } } @@ -1061,12 +1098,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) val snapshot = _state.value val routeId = snapshot.selectedRoute?.id val remote = runCatching { syncRepository.fetchSyncState(snapshot.selectedDate, routeId) }.getOrNull() ?: return - val needsRefresh = remote.scopes.any { syncRepository.shouldRefresh(it) } + val staleScopes = remote.scopes.filter { syncRepository.shouldRefresh(it) } - if (needsRefresh) { - refreshCurrentScopeFromSyncState() - } else { + if (staleScopes.isEmpty()) { syncRepository.saveSyncStates(remote) + return + } + + staleScopes.forEach { scope -> + handleSyncHint( + DriverSyncHint( + scope = scope.scope, + date = scope.date, + routeId = scope.routeId, + version = scope.version, + checksum = scope.checksum, + ), + ) } } @@ -1097,13 +1145,58 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently() DriverSyncRepository.SCOPE_DISPATCH_SHEET -> refreshRoutesSilently() DriverSyncRepository.SCOPE_LEAVE_REQUESTS -> { + if (leaveMutationMutex.isLocked) { + val pending = pendingLeaveHint + if (pending == null || hint.version > pending.version) pendingLeaveHint = hint + return + } if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) { - refreshLeaveRequests() + if (loadLeaveRequests(showLoading = false, navigateToList = false)) { + syncRepository.saveSyncScope(hint.asScope()) + } + } else { + syncRepository.saveSyncScope(hint.asScope()) + } + } + DriverSyncRepository.SCOPE_LEAVE_CALENDAR -> { + if (snapshot.screen == DriverScreen.LeaveCalendar) { + refreshLoadedLeaveCalendar(hint) + } else { + syncRepository.saveSyncScope(hint.asScope()) } } } } + private suspend fun consumePendingLeaveHint() { + val hint = pendingLeaveHint ?: return + pendingLeaveHint = null + if (loadLeaveRequests(showLoading = false, navigateToList = false)) { + syncRepository.saveSyncScope(hint.asScope()) + } + } + + private suspend fun refreshLoadedLeaveCalendar(hint: DriverSyncHint) { + val generation = ++leaveCalendarRequestGeneration + val snapshot = _state.value + val from = LocalDate.now() + val loadedUntil = snapshot.leaveCalendarLoadedUntil + ?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + ?.takeUnless { it.isBefore(from) } + ?: from.plusMonths(6).minusDays(1) + + runCatching { repository.leaveCalendar(from.toString(), loadedUntil.toString()) } + .onSuccess { entries -> + if (generation != leaveCalendarRequestGeneration) return@onSuccess + _state.update { it.copy(leaveCalendarEntries = entries, leaveCalendarLoadedUntil = loadedUntil.toString(), leaveCalendarLoading = false) } + syncRepository.saveSyncScope(hint.asScope()) + } + .onFailure { throwable -> + if (generation != leaveCalendarRequestGeneration) return@onFailure + _state.update { it.withApiError(throwable).copy(leaveCalendarLoading = false) } + } + } + private fun registerPushTokenIfAvailable(driverId: String) { if (pushTokenRegisteredForDriverId == driverId) return diff --git a/app/src/test/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClientTest.kt b/app/src/test/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClientTest.kt index 447c33d..ed2943a 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClientTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/sync/DriverLiveSyncClientTest.kt @@ -107,6 +107,21 @@ class DriverLiveSyncClientTest { assertTrue(factory.sockets.single().sent.any { it.contains("pusher:pong") }) } + @Test + fun subscribesToOwnUpdatesAndSharedLeaveCalendarUpdates() = runTest { + val factory = FakeWebSocketFactory() + val client = liveClient(factory = factory, scope = this) + + client.start("driver-1", config) + factory.sockets.single().message(connectionEstablished("socket-1")) + runCurrent() + + val sent = factory.sockets.single().sent + assertTrue(sent.any { it.contains("private-driver-mobile.driver-1") }) + assertTrue(sent.any { it.contains("private-driver-mobile.0") }) + client.stop() + } + @Test fun closesAndReconnectsWhenConnectedSocketStopsReceivingMessages() = runTest { val factory = FakeWebSocketFactory() diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiStateTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiStateTest.kt index d61060a..5e18ff0 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiStateTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiStateTest.kt @@ -55,6 +55,23 @@ class DriverUiStateTest { assertEquals(1, refreshed.leaveRequests.size) } + @Test + fun staleLeaveRequestRefreshDoesNotOverwriteNewerMutationResult() { + val state = DriverUiState( + screen = DriverScreen.LeaveRequestDetail, + leaveRequests = listOf(leaveRequest(id = "leave-1", status = "cancel_requested", version = 3)), + selectedLeaveRequest = leaveRequest(id = "leave-1", status = "cancel_requested", version = 3), + ) + + val refreshed = state.withLoadedLeaveRequests( + requests = listOf(leaveRequest(id = "leave-1", status = "approved", version = 2)), + navigateToList = false, + ) + + assertEquals("cancel_requested", refreshed.leaveRequests.single().status) + assertEquals(3L, refreshed.selectedLeaveRequest?.version) + } + @Test fun displayRoutesAndSelectedRouteUseLocalRouteActionsProjection() { val route = route(status = "ZAPLANOWANA") @@ -97,13 +114,14 @@ class DriverUiStateTest { assertEquals(listOf("above-first", "above-second", "span"), state.displayRoutes.map { it.id }) } - private fun leaveRequest(id: String, status: String): DriverLeaveRequestDto = + private fun leaveRequest(id: String, status: String, version: Long = 1): DriverLeaveRequestDto = DriverLeaveRequestDto( id = id, dateFrom = "2026-07-10", dateTo = "2026-07-12", type = "URLOP", status = status, + version = version, ) private fun route(