update synchro agenda

EBO <eric.bouhana@softalys.com> committé le 2026-08-29 11:45

821ceeb418356255322b2bce012fc308255eea7f

1 parent(s)

17 fichiers modifiés +503 -88
M README.md
+1 -1
@@ -48,7 +48,7 @@ PicLead ne contient **aucun écran agenda** : les rendez-vous et réservations d
48 48 **Configuration dans l’app :** Paramètres → section **Calendriers** (visible une fois connecté) :
49 49 - Toggle **« Mes RDV »** → crée/lie le calendrier local `PicLead — Mes RDV` ; les événements qui y sont créés/modifiés deviennent des RDV PicLead à la sync.
50 50 - Si des identifiants sont configurés, la liste des **ressources** (salles, matériel, véhicules) du serveur s’affiche avec un toggle par ressource active ; les ressources **inactives** sont grisées (non sélectionnables). Activer une ressource crée le calendrier `PicLead — {Salle|Matériel|Véhicule} {nom}` ; un événement qui y est créé devient une **réservation Active** poussée au serveur.
51 -- Désactiver un toggle demande **confirmation** avant de retirer la liaison. Le retrait supprime uniquement la liaison locale (sync arrêtée pour ce calendrier) — l’API `CalendarBridge` n’expose pas de suppression de calendrier Android, le calendrier créé reste donc visible (vide, non synchronisé) dans l’app Agenda système.
51 +- Désactiver un toggle demande **confirmation** avant retrait. Le retrait **supprime réellement le calendrier local et ses événements de l’appareil** (`CalendarBridge.deleteCalendar`, suppression des événements en cascade par Android), puis retire la liaison : le calendrier disparaît de l’app Agenda système. Les **données serveur ne sont pas supprimées** — vos RDV et réservations restent dans PicLead. Si l’autorisation Agenda a été révoquée entre-temps, la suppression échoue proprement : la liaison est retirée malgré tout et un message le signale dans la section Calendriers.
52 52
53 53 **Sync et conflits :** le bouton **Sync** pousse aussi les événements Agenda liés (RDV + réservations) puis tire le delta serveur (RDV, réservations actives, indisponibilités en lecture). Un bandeau signale un **calendrier local en avance** (événements pas encore poussés). Si une réservation entre en conflit avec le serveur (**HTTP 409**, chevauchement), PicLead conserve l’événement local, retire le blocage serveur pour affichage, puis propose un dialog **« Annuler ma réservation ? »** :
54 54 - **Oui** → la réservation locale et son événement Agenda sont supprimés.
M android/app/src/main/java/fr/ebii/card2vcf/sync/AgendaSyncCoordinator.kt
+11 -3
@@ -103,7 +103,7 @@ class AgendaSyncCoordinator(
103 103 cibleId = cibleId,
104 104 debutMs = snap.debutMs,
105 105 finMs = snap.finMs,
106 - motif = snap.descriptionBody,
106 + motif = snap.motifReservation(),
107 107 calendarEventId = eventId,
108 108 updatedAt = System.currentTimeMillis(),
109 109 dirtyLocal = true,
@@ -117,7 +117,7 @@ class AgendaSyncCoordinator(
117 117 val updated = local.copy(
118 118 debutMs = snap.debutMs,
119 119 finMs = snap.finMs,
120 - motif = snap.descriptionBody,
120 + motif = snap.motifReservation(),
121 121 updatedAt = System.currentTimeMillis(),
122 122 dirtyLocal = true,
123 123 )
@@ -144,7 +144,15 @@ class AgendaSyncCoordinator(
144 144 }
145 145
146 146 private fun ReservationEntity.differsFrom(snap: CalendarEventSnapshot): Boolean =
147 - motif != snap.descriptionBody || debutMs != snap.debutMs || finMs != snap.finMs
147 + motif != snap.motifReservation() || debutMs != snap.debutMs || finMs != snap.finMs
148 +
149 + /**
150 + * Motif d'une réservation poussée : le TITRE de l'événement (symétrique du
151 + * sens descendant, qui affiche le motif comme titre — cf. `roomToAgenda`),
152 + * la description en secours. Le serveur refuse un motif vide.
153 + */
154 + private fun CalendarEventSnapshot.motifReservation(): String =
155 + title.ifBlank { descriptionBody }
148 156
149 157 /** Remplace toute op en attente pour cette entité locale avant d'insérer la nouvelle (évite l'accumulation). */
150 158 private suspend fun enqueueOp(entityType: String, op: String, localId: Long, serverId: String?, payloadJson: String) {
M android/app/src/main/java/fr/ebii/card2vcf/sync/CalendarBridge.kt
+21 -5
@@ -3,6 +3,7 @@ package fr.ebii.card2vcf.sync
3 3 import android.content.ContentResolver
4 4 import android.content.ContentUris
5 5 import android.content.ContentValues
6 +import android.net.Uri
6 7 import android.provider.CalendarContract
7 8 import java.util.TimeZone
8 9
@@ -52,6 +53,9 @@ object CalendarEventTitles {
52 53 interface CalendarBridgeApi {
53 54 fun ensureLocalCalendar(displayName: String): Long
54 55
56 + /** Supprime le calendrier local et, en cascade côté Android, tous ses événements. */
57 + fun deleteCalendar(calendarId: Long)
58 +
55 59 fun listEvents(calendarId: Long): List<CalendarEventSnapshot>
56 60
57 61 fun upsertEvent(calendarId: Long, snap: CalendarEventSnapshot): Long
@@ -70,11 +74,7 @@ class CalendarBridge(private val resolver: ContentResolver) : CalendarBridgeApi
70 74 override fun ensureLocalCalendar(displayName: String): Long {
71 75 findCalendarId(displayName)?.let { return it }
72 76
73 - val uri = CalendarContract.Calendars.CONTENT_URI.buildUpon()
74 - .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
75 - .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME)
76 - .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
77 - .build()
77 + val uri = asSyncAdapter(CalendarContract.Calendars.CONTENT_URI)
78 78
79 79 val values = ContentValues().apply {
80 80 put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME)
@@ -93,6 +93,15 @@ class CalendarBridge(private val resolver: ContentResolver) : CalendarBridgeApi
93 93 return ContentUris.parseId(created)
94 94 }
95 95
96 + /**
97 + * Supprime le calendrier local [calendarId] de l'appareil. Le fournisseur Agenda supprime ses
98 + * événements en cascade. Un id inconnu est sans effet (`delete` retourne 0).
99 + */
100 + override fun deleteCalendar(calendarId: Long) {
101 + val uri = asSyncAdapter(ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId))
102 + resolver.delete(uri, null, null)
103 + }
104 +
96 105 override fun listEvents(calendarId: Long): List<CalendarEventSnapshot> {
97 106 val projection = arrayOf(
98 107 CalendarContract.Events._ID,
@@ -157,6 +166,13 @@ class CalendarBridge(private val resolver: ContentResolver) : CalendarBridgeApi
157 166 resolver.delete(uri, null, null)
158 167 }
159 168
169 + /** Écriture sur `Calendars` en mode sync-adapter : mêmes compte/type qu'à la création. */
170 + private fun asSyncAdapter(uri: Uri): Uri = uri.buildUpon()
171 + .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
172 + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME)
173 + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
174 + .build()
175 +
160 176 private fun findCalendarId(displayName: String): Long? {
161 177 val projection = arrayOf(CalendarContract.Calendars._ID)
162 178 val selection = "${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " +
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncEngine.kt
+127 -60
@@ -18,11 +18,26 @@ data class SyncStatusResult(
18 18 val error: String? = null,
19 19 )
20 20
21 +/**
22 + * Op refusée par le serveur et laissée en file. [message] est le message serveur verbatim
23 + * (« Réservation refusée : période en conflit avec Entretien »), `null` si l'op n'a même pas
24 + * atteint le réseau (référence locale non résolue) ; [code] vaut -1 pour une panne réseau.
25 + */
26 +data class PushFailure(
27 + val entityType: String,
28 + val op: String,
29 + val code: Int? = null,
30 + val message: String? = null,
31 +)
32 +
21 33 data class SyncResult(
22 34 val success: Boolean,
23 35 val serverTime: String? = null,
24 36 val error: String? = null,
25 37 val conflicts: List<AgendaConflict> = emptyList(),
38 + val pushed: Int = 0,
39 + val received: Int = 0,
40 + val pushFailures: List<PushFailure> = emptyList(),
26 41 )
27 42
28 43 /**
@@ -57,17 +72,30 @@ class SyncEngine(
57 72 val agendaEnabled = bridge != null && bindings.isNotEmpty()
58 73 if (agendaEnabled) agenda.agendaToRoom(bindings, bridge!!)
59 74
60 - val conflicts = pushOps()
75 + val push = pushOps()
61 76 val ressourcesQuery = if (agendaEnabled) agenda.ressourcesQuery(bindings) else null
62 77
63 78 when (val result = api.syncPull(watermark(), ressourcesQuery)) {
64 79 is AilianceApiClient.ApiResult.Ok -> {
65 - applyPull(result.value)
80 + val received = applyPull(result.value)
66 81 if (agendaEnabled) agenda.roomToAgenda(bindings, bridge!!)
67 82 setWatermark(result.value.serverTime)
68 - SyncResult(success = true, serverTime = result.value.serverTime, conflicts = conflicts)
83 + SyncResult(
84 + success = true,
85 + serverTime = result.value.serverTime,
86 + conflicts = push.conflicts,
87 + pushed = push.succeeded,
88 + received = received,
89 + pushFailures = push.failures,
90 + )
69 91 }
70 - is AilianceApiClient.ApiResult.Err -> SyncResult(success = false, error = result.message, conflicts = conflicts)
92 + is AilianceApiClient.ApiResult.Err -> SyncResult(
93 + success = false,
94 + error = result.message,
95 + conflicts = push.conflicts,
96 + pushed = push.succeeded,
97 + pushFailures = push.failures,
98 + )
71 99 }
72 100 }
73 101
@@ -108,30 +136,56 @@ class SyncEngine(
108 136
109 137 // ---- push ----
110 138
111 - /** Résultat d'une op poussée : [conflict] non nul uniquement pour un 409 réservation ([ok] reste `false`). */
112 - private data class PushOutcome(val ok: Boolean, val conflict: AgendaConflict? = null)
113 -
114 - private suspend fun pushOps(): List<AgendaConflict> {
139 + /**
140 + * Résultat d'une op poussée. [conflict] non nul uniquement pour un 409 réservation ([ok] reste
141 + * `false`) : c'est un cas arbitré par la dialog dédiée, pas un échec générique. [code]/[message]
142 + * portent la réponse serveur pour tous les autres refus, afin de les remonter à l'utilisateur.
143 + */
144 + private data class PushOutcome(
145 + val ok: Boolean,
146 + val conflict: AgendaConflict? = null,
147 + val code: Int? = null,
148 + val message: String? = null,
149 + )
150 +
151 + /** Récapitulatif d'un cycle de push : ops tentées / dépilées, conflits 409 et échecs génériques. */
152 + private data class PushReport(
153 + val attempted: Int,
154 + val succeeded: Int,
155 + val conflicts: List<AgendaConflict>,
156 + val failures: List<PushFailure>,
157 + )
158 +
159 + private suspend fun pushOps(): PushReport {
115 160 val conflicts = mutableListOf<AgendaConflict>()
161 + val failures = mutableListOf<PushFailure>()
162 + var attempted = 0
163 + var succeeded = 0
116 164 for (op in db.syncOpDao().listAll()) {
165 + attempted++
117 166 val outcome = when (op.entityType) {
118 - "contact" -> PushOutcome(pushContactOp(op))
119 - ENTITY_CONTACT_MEDIA -> PushOutcome(pushContactMediaOp(op))
120 - "entreprise" -> PushOutcome(pushEntrepriseOp(op))
121 - "projet" -> PushOutcome(pushProjetOp(op))
122 - "tache" -> PushOutcome(pushTacheOp(op))
123 - "interaction" -> PushOutcome(pushInteractionOp(op))
124 - AgendaSyncCoordinator.KIND_RDV -> PushOutcome(pushRdvOp(op))
167 + "contact" -> pushContactOp(op)
168 + ENTITY_CONTACT_MEDIA -> pushContactMediaOp(op)
169 + "entreprise" -> pushEntrepriseOp(op)
170 + "projet" -> pushProjetOp(op)
171 + "tache" -> pushTacheOp(op)
172 + "interaction" -> pushInteractionOp(op)
173 + AgendaSyncCoordinator.KIND_RDV -> pushRdvOp(op)
125 174 AgendaSyncCoordinator.ENTITY_RESERVATION -> pushReservationOp(op)
126 175 else -> PushOutcome(true)
127 176 }
128 - if (outcome.ok) db.syncOpDao().deleteById(op.id)
177 + if (outcome.ok) {
178 + succeeded++
179 + db.syncOpDao().deleteById(op.id)
180 + } else if (outcome.conflict == null) {
181 + failures += PushFailure(op.entityType, op.op, outcome.code, outcome.message)
182 + }
129 183 outcome.conflict?.let(conflicts::add)
130 184 }
131 - return conflicts
185 + return PushReport(attempted, succeeded, conflicts, failures)
132 186 }
133 187
134 - private suspend fun pushContactOp(op: SyncOpEntity): Boolean {
188 + private suspend fun pushContactOp(op: SyncOpEntity): PushOutcome {
135 189 return when (op.op) {
136 190 "create" -> {
137 191 val result = api.createContact(op.payloadJson)
@@ -146,26 +200,27 @@ class SyncEngine(
146 200 }
147 201 }
148 202 }
149 - result is AilianceApiClient.ApiResult.Ok
203 + result.toOutcome()
150 204 }
151 205 "update" -> {
152 - val serverId = op.serverId ?: return false
153 - if (!api.updateContact(serverId, op.payloadJson).isOk()) return false
206 + val serverId = op.serverId ?: return PushOutcome(false)
207 + val result = api.updateContact(serverId, op.payloadJson)
208 + if (!result.isOk()) return result.toOutcome()
154 209 val localId = op.localId ?: db.crmContactDao().getByServerId(serverId)?.id
155 210 if (localId != null && !pushContactImages(localId, serverId)) {
156 211 enqueueContactMediaRetry(localId, serverId)
157 212 }
158 - true
213 + PushOutcome(true)
159 214 }
160 - "delete" -> op.serverId?.let { api.deleteContact(it).isOk() } ?: false
161 - else -> true
215 + "delete" -> op.serverId?.let { api.deleteContact(it).toOutcome() } ?: PushOutcome(false)
216 + else -> PushOutcome(true)
162 217 }
163 218 }
164 219
165 - private suspend fun pushContactMediaOp(op: SyncOpEntity): Boolean {
166 - val serverId = op.serverId ?: return true
167 - val localId = op.localId ?: db.crmContactDao().getByServerId(serverId)?.id ?: return true
168 - return pushContactImages(localId, serverId)
220 + private suspend fun pushContactMediaOp(op: SyncOpEntity): PushOutcome {
221 + val serverId = op.serverId ?: return PushOutcome(true)
222 + val localId = op.localId ?: db.crmContactDao().getByServerId(serverId)?.id ?: return PushOutcome(true)
223 + return PushOutcome(pushContactImages(localId, serverId))
169 224 }
170 225
171 226 /**
@@ -211,7 +266,7 @@ class SyncEngine(
211 266 )
212 267 }
213 268
214 - private suspend fun pushEntrepriseOp(op: SyncOpEntity): Boolean = when (op.op) {
269 + private suspend fun pushEntrepriseOp(op: SyncOpEntity): PushOutcome = when (op.op) {
215 270 "create" -> {
216 271 val result = api.createEntreprise(op.payloadJson)
217 272 if (result is AilianceApiClient.ApiResult.Ok) {
@@ -222,14 +277,14 @@ class SyncEngine(
222 277 }
223 278 }
224 279 }
225 - result is AilianceApiClient.ApiResult.Ok
280 + result.toOutcome()
226 281 }
227 - "update" -> op.serverId?.let { api.updateEntreprise(it, op.payloadJson).isOk() } ?: false
228 - "delete" -> op.serverId?.let { api.deleteEntreprise(it).isOk() } ?: false
229 - else -> true
282 + "update" -> op.serverId?.let { api.updateEntreprise(it, op.payloadJson).toOutcome() } ?: PushOutcome(false)
283 + "delete" -> op.serverId?.let { api.deleteEntreprise(it).toOutcome() } ?: PushOutcome(false)
284 + else -> PushOutcome(true)
230 285 }
231 286
232 - private suspend fun pushProjetOp(op: SyncOpEntity): Boolean = when (op.op) {
287 + private suspend fun pushProjetOp(op: SyncOpEntity): PushOutcome = when (op.op) {
233 288 "create" -> {
234 289 val result = api.createProjet(op.payloadJson)
235 290 if (result is AilianceApiClient.ApiResult.Ok) {
@@ -240,11 +295,11 @@ class SyncEngine(
240 295 }
241 296 }
242 297 }
243 - result is AilianceApiClient.ApiResult.Ok
298 + result.toOutcome()
244 299 }
245 - "update" -> op.serverId?.let { api.updateProjet(it, op.payloadJson).isOk() } ?: false
246 - "delete" -> op.serverId?.let { api.deleteProjet(it).isOk() } ?: false
247 - else -> true
300 + "update" -> op.serverId?.let { api.updateProjet(it, op.payloadJson).toOutcome() } ?: PushOutcome(false)
301 + "delete" -> op.serverId?.let { api.deleteProjet(it).toOutcome() } ?: PushOutcome(false)
302 + else -> PushOutcome(true)
248 303 }
249 304
250 305 /** Résout le `projetServerId` d'une tâche via [op.localId] (create) ou [op.serverId] (update/delete/move). */
@@ -254,8 +309,8 @@ class SyncEngine(
254 309 return null
255 310 }
256 311
257 - private suspend fun pushTacheOp(op: SyncOpEntity): Boolean {
258 - val projetId = resolveTacheProjetId(op) ?: return false
312 + private suspend fun pushTacheOp(op: SyncOpEntity): PushOutcome {
313 + val projetId = resolveTacheProjetId(op) ?: return PushOutcome(false)
259 314 return when (op.op) {
260 315 "create" -> {
261 316 val result = api.createTache(projetId, op.payloadJson)
@@ -267,23 +322,23 @@ class SyncEngine(
267 322 }
268 323 }
269 324 }
270 - result is AilianceApiClient.ApiResult.Ok
325 + result.toOutcome()
271 326 }
272 - "update" -> op.serverId?.let { api.updateTache(projetId, it, op.payloadJson).isOk() } ?: false
273 - "delete" -> op.serverId?.let { api.deleteTache(projetId, it).isOk() } ?: false
274 - "move" -> op.serverId?.let { api.moveTache(projetId, it, op.payloadJson).isOk() } ?: false
275 - else -> true
327 + "update" -> op.serverId?.let { api.updateTache(projetId, it, op.payloadJson).toOutcome() } ?: PushOutcome(false)
328 + "delete" -> op.serverId?.let { api.deleteTache(projetId, it).toOutcome() } ?: PushOutcome(false)
329 + "move" -> op.serverId?.let { api.moveTache(projetId, it, op.payloadJson).toOutcome() } ?: PushOutcome(false)
330 + else -> PushOutcome(true)
276 331 }
277 332 }
278 333
279 334 /** `op.serverId` porte le `serverId` du contact cible (les interactions n'ont pas d'update/delete). */
280 - private suspend fun pushInteractionOp(op: SyncOpEntity): Boolean {
281 - if (op.op != "create") return true
282 - val contactServerId = op.serverId ?: return false
283 - return api.createInteraction(contactServerId, op.payloadJson).isOk()
335 + private suspend fun pushInteractionOp(op: SyncOpEntity): PushOutcome {
336 + if (op.op != "create") return PushOutcome(true)
337 + val contactServerId = op.serverId ?: return PushOutcome(false)
338 + return api.createInteraction(contactServerId, op.payloadJson).toOutcome()
284 339 }
285 340
286 - private suspend fun pushRdvOp(op: SyncOpEntity): Boolean = when (op.op) {
341 + private suspend fun pushRdvOp(op: SyncOpEntity): PushOutcome = when (op.op) {
287 342 "create" -> {
288 343 val result = api.createRdv(op.payloadJson)
289 344 if (result is AilianceApiClient.ApiResult.Ok) {
@@ -294,17 +349,17 @@ class SyncEngine(
294 349 }
295 350 }
296 351 }
297 - result is AilianceApiClient.ApiResult.Ok
352 + result.toOutcome()
298 353 }
299 354 "update" -> op.serverId?.let { serverId ->
300 - val ok = api.updateRdv(serverId, op.payloadJson).isOk()
301 - if (ok && op.localId != null) {
355 + val result = api.updateRdv(serverId, op.payloadJson)
356 + if (result.isOk() && op.localId != null) {
302 357 db.rdvDao().getByLocalId(op.localId)?.let { db.rdvDao().upsert(it.copy(dirtyLocal = false)) }
303 358 }
304 - ok
305 - } ?: false
306 - "delete" -> op.serverId?.let { api.deleteRdv(it).isOk() } ?: false
307 - else -> true
359 + result.toOutcome()
360 + } ?: PushOutcome(false)
361 + "delete" -> op.serverId?.let { api.deleteRdv(it).toOutcome() } ?: PushOutcome(false)
362 + else -> PushOutcome(true)
308 363 }
309 364
310 365 /**
@@ -329,7 +384,7 @@ class SyncEngine(
329 384 "update" -> op.serverId?.let { serverId ->
330 385 handleReservationPushResult(api.updateReservation(cibleType, cibleId, serverId, op.payloadJson), op)
331 386 } ?: PushOutcome(false)
332 - "delete" -> op.serverId?.let { PushOutcome(api.deleteReservation(cibleType, cibleId, it).isOk()) } ?: PushOutcome(false)
387 + "delete" -> op.serverId?.let { api.deleteReservation(cibleType, cibleId, it).toOutcome() } ?: PushOutcome(false)
333 388 else -> PushOutcome(true)
334 389 }
335 390 }
@@ -354,14 +409,18 @@ class SyncEngine(
354 409 }
355 410 PushOutcome(false, AgendaConflict(localReservationId = op.localId, message = result.message))
356 411 } else {
357 - PushOutcome(false)
412 + result.toOutcome()
358 413 }
359 414 }
360 415 }
361 416
362 417 // ---- pull ----
363 418
364 - private suspend fun applyPull(pull: SyncPullResponse) {
419 + /**
420 + * Applique le pull et renvoie le nombre d'éléments reçus tel qu'un utilisateur le compte :
421 + * workflows (référentiel) et tombstones (suppressions) sont exclus.
422 + */
423 + private suspend fun applyPull(pull: SyncPullResponse): Int {
365 424 pull.contacts.forEach { applyContact(it) }
366 425 pull.entreprises.forEach { applyEntreprise(it) }
367 426 pull.projets.forEach { applyProjet(it) }
@@ -372,6 +431,8 @@ class SyncEngine(
372 431 pull.reservations.forEach { agenda.applyReservationPull(it) }
373 432 pull.indisponibilites.forEach { agenda.applyIndisponibilitePull(it) }
374 433 pull.tombstones.forEach { applyTombstone(it) }
434 + return pull.contacts.size + pull.entreprises.size + pull.projets.size + pull.taches.size +
435 + pull.interactions.size + pull.rdv.size + pull.reservations.size + pull.indisponibilites.size
375 436 }
376 437
377 438 private suspend fun applyContact(dto: ContactDto) {
@@ -607,6 +668,12 @@ class SyncEngine(
607 668
608 669 fun <T> AilianceApiClient.ApiResult<T>.isOk(): Boolean = this is AilianceApiClient.ApiResult.Ok
609 670
671 + /** Conserve `code`/`message` serveur sur refus, pour que [pushOps] puisse les remonter à l'UI. */
672 + fun <T> AilianceApiClient.ApiResult<T>.toOutcome(): PushOutcome = when (this) {
673 + is AilianceApiClient.ApiResult.Ok -> PushOutcome(true)
674 + is AilianceApiClient.ApiResult.Err -> PushOutcome(false, code = code, message = message)
675 + }
676 +
610 677 @Serializable
611 678 data class CreatedIdDto(val id: String? = null)
612 679
M android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetScreen.kt
+2 -0
@@ -97,6 +97,7 @@ fun CarnetScreen(
97 97 val syncing by syncViewModel.syncing.collectAsState()
98 98 val syncError by syncViewModel.error.collectAsState()
99 99 val syncConflict by syncViewModel.conflictPending.collectAsState()
100 + val syncSummary by syncViewModel.syncSummary.collectAsState()
100 101
101 102 LaunchedEffect(Unit) {
102 103 syncViewModel.refreshStatus()
@@ -249,6 +250,7 @@ fun CarnetScreen(
249 250 syncing = syncing,
250 251 error = syncError,
251 252 onSyncClick = { syncViewModel.syncNow() },
253 + summary = syncSummary,
252 254 )
253 255
254 256 SyncConflictDialog(
M android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetsListScreen.kt
+2 -0
@@ -57,6 +57,7 @@ fun ProjetsListScreen(
57 57 val syncing by syncViewModel.syncing.collectAsState()
58 58 val syncError by syncViewModel.error.collectAsState()
59 59 val syncConflict by syncViewModel.conflictPending.collectAsState()
60 + val syncSummary by syncViewModel.syncSummary.collectAsState()
60 61
61 62 LaunchedEffect(Unit) {
62 63 syncViewModel.refreshStatus()
@@ -97,6 +98,7 @@ fun ProjetsListScreen(
97 98 syncing = syncing,
98 99 error = syncError,
99 100 onSyncClick = { syncViewModel.syncNow() },
101 + summary = syncSummary,
100 102 )
101 103
102 104 SyncConflictDialog(
M android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsViewModel.kt
+32 -10
@@ -246,22 +246,40 @@ class SettingsViewModel(
246 246 }
247 247
248 248 /**
249 - * Confirme le retrait d'une liaison (« Oui » du dialog de confirmation). Se limite à la liaison
250 - * ([CalendarBindingsStore.remove]) : [CalendarBridgeApi] n'expose pas de suppression de calendrier
251 - * Android, le calendrier local créé reste donc visible (vide) dans l'app Agenda système.
249 + * Confirme le retrait d'une liaison (« Oui » du dialog de confirmation) : supprime le calendrier
250 + * local de l'appareil ([CalendarBridgeApi.deleteCalendar], événements en cascade) **puis** retire
251 + * la liaison ([CalendarBindingsStore.remove]). Les données serveur (RDV, réservations) ne sont pas
252 + * touchées. Si la suppression échoue (permission Agenda révoquée entre-temps), la liaison est
253 + * retirée quand même et l'utilisateur en est informé via [SettingsUiState.LoggedIn.catalogueError].
252 254 */
253 255 fun confirmRemoveBinding() {
254 256 val s = _state.value
255 257 if (s !is SettingsUiState.LoggedIn) return
256 258 val pending = s.pendingRemoval ?: return
257 - calendarBindingsStore.remove(pending.kind, pending.serverResourceId)
258 - updateLoggedIn { current ->
259 - val unlinked = if (pending.kind == AgendaSyncCoordinator.KIND_RDV) {
260 - current.copy(mesRdvLinked = false)
261 - } else {
262 - current.copy(ressources = current.ressources.map { it.withLinked(pending.kind, pending.serverResourceId, false) })
259 + val calendarId = calendarBindingsStore.list()
260 + .firstOrNull { it.kind == pending.kind && it.serverResourceId == pending.serverResourceId }
261 + ?.androidCalendarId
262 + viewModelScope.launch {
263 + val deleted = withContext(ioDispatcher) {
264 + try {
265 + if (calendarId != null) calendarBridge.deleteCalendar(calendarId)
266 + true
267 + } catch (_: Exception) {
268 + false
269 + }
270 + }
271 + calendarBindingsStore.remove(pending.kind, pending.serverResourceId)
272 + updateLoggedIn { current ->
273 + val unlinked = if (pending.kind == AgendaSyncCoordinator.KIND_RDV) {
274 + current.copy(mesRdvLinked = false)
275 + } else {
276 + current.copy(ressources = current.ressources.map { it.withLinked(pending.kind, pending.serverResourceId, false) })
277 + }
278 + unlinked.copy(
279 + pendingRemoval = null,
280 + catalogueError = if (deleted) current.catalogueError else CALENDAR_DELETE_ERROR,
281 + )
263 282 }
264 - unlinked.copy(pendingRemoval = null)
265 283 }
266 284 }
267 285
@@ -329,5 +347,9 @@ class SettingsViewModel(
329 347 const val KIND_MATERIEL = "materiel"
330 348 const val KIND_VEHICULE = "vehicule"
331 349 const val MES_RDV_DISPLAY_NAME = "PicLead — Mes RDV"
350 +
351 + /** Affiché quand la suppression du calendrier échoue (permission Agenda révoquée entre-temps). */
352 + const val CALENDAR_DELETE_ERROR =
353 + "Calendrier non supprimé : autorisation Agenda manquante. La liaison a bien été retirée."
332 354 }
333 355 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/sync/SyncBanner.kt
+21 -3
@@ -24,8 +24,9 @@ import fr.ebii.card2vcf.ui.theme.Ink
24 24 private val Square = RoundedCornerShape(0.dp)
25 25
26 26 /**
27 - * Bandeaux hairline empilés : A) changements sur le serveur (ou erreur) ; B) calendrier local en
28 - * avance ([localAhead] > 0, events/entités pas encore poussés). Masqués si rien à signaler.
27 + * Bandeaux hairline empilés : A) résumé de la dernière synchro ([summary], avec le message serveur
28 + * verbatim si des ops ont été refusées) ; B) changements sur le serveur (ou erreur) ; C) calendrier
29 + * local en avance ([localAhead] > 0, events/entités pas encore poussés). Masqués si rien à signaler.
29 30 */
30 31 @Composable
31 32 fun SyncBanner(
@@ -35,15 +36,32 @@ fun SyncBanner(
35 36 error: String?,
36 37 onSyncClick: () -> Unit,
37 38 modifier: Modifier = Modifier,
39 + summary: SyncSummary? = null,
38 40 ) {
41 + val summaryLabel = summary?.let {
42 + if (it.failures > 0) {
43 + stringResource(
44 + R.string.sync_resume_avec_echecs,
45 + it.pushed,
46 + it.received,
47 + it.failures,
48 + it.firstFailureMessage ?: stringResource(R.string.sync_resume_echec_sans_message),
49 + )
50 + } else {
51 + stringResource(R.string.sync_resume, it.pushed, it.received)
52 + }
53 + }
39 54 val remoteLabel = when {
40 55 error != null -> error
41 56 pendingChanges != null && pendingChanges > 0 -> stringResource(R.string.sync_pending, pendingChanges)
42 57 else -> null
43 58 }
44 - if (remoteLabel == null && localAhead <= 0) return
59 + if (summaryLabel == null && remoteLabel == null && localAhead <= 0) return
45 60
46 61 Column(modifier.fillMaxWidth()) {
62 + if (summaryLabel != null) {
63 + SyncBannerRow(label = summaryLabel, syncing = syncing, onSyncClick = onSyncClick)
64 + }
47 65 if (remoteLabel != null) {
48 66 SyncBannerRow(label = remoteLabel, syncing = syncing, onSyncClick = onSyncClick)
49 67 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/sync/SyncChromeViewModel.kt
+25 -1
@@ -14,9 +14,22 @@ import kotlinx.coroutines.flow.asStateFlow
14 14 import kotlinx.coroutines.launch
15 15
16 16 /**
17 + * Résumé de la dernière synchro, rendu par `SyncBanner` (les libellés vivent dans les ressources).
18 + * [firstFailureMessage] est le message serveur verbatim du premier refus, `null` si le serveur n'en
19 + * a pas fourni.
20 + */
21 +data class SyncSummary(
22 + val pushed: Int,
23 + val received: Int,
24 + val failures: Int,
25 + val firstFailureMessage: String? = null,
26 +)
27 +
28 +/**
17 29 * État bandeau sync : changements serveur + calendrier local en avance à l'ouverture, sync manuelle
18 30 * à la demande. Après [syncNow], une réservation en conflit (409) déclenche le dialog
19 - * « Annuler ma réservation ? » ([conflictPending]) — une décision à la fois si plusieurs conflits.
31 + * « Annuler ma réservation ? » ([conflictPending]) — une décision à la fois si plusieurs conflits ;
32 + * tout autre refus de push est reporté dans [syncSummary] avec le message du serveur.
20 33 */
21 34 class SyncChromeViewModel(
22 35 private val credentialsStore: SyncCredentialsStore,
@@ -40,6 +53,10 @@ class SyncChromeViewModel(
40 53 private val _error = MutableStateFlow<String?>(null)
41 54 val error: StateFlow<String?> = _error.asStateFlow()
42 55
56 + /** Résumé de la dernière synchro réussie ; remis à zéro au début de chaque [syncNow]. */
57 + private val _syncSummary = MutableStateFlow<SyncSummary?>(null)
58 + val syncSummary: StateFlow<SyncSummary?> = _syncSummary.asStateFlow()
59 +
43 60 private val pendingConflicts = ArrayDeque<AgendaConflict>()
44 61 private val _conflictPending = MutableStateFlow<AgendaConflict?>(null)
45 62 val conflictPending: StateFlow<AgendaConflict?> = _conflictPending.asStateFlow()
@@ -62,10 +79,17 @@ class SyncChromeViewModel(
62 79 viewModelScope.launch {
63 80 _syncing.value = true
64 81 _error.value = null
82 + _syncSummary.value = null
65 83 val bindings = calendarBindingsStore.list()
66 84 val result = engine.syncNow(bindings, calendarBridge)
67 85 if (result.success) {
68 86 setPendingConflicts(result.conflicts)
87 + _syncSummary.value = SyncSummary(
88 + pushed = result.pushed,
89 + received = result.received,
90 + failures = result.pushFailures.size,
91 + firstFailureMessage = result.pushFailures.firstOrNull()?.message?.takeIf { it.isNotBlank() },
92 + )
69 93 val status = engine.checkStatus(AgendaSyncCoordinator.ressourcesQuery(bindings))
70 94 _pendingRemoteChanges.value = status.pendingRemoteChanges
71 95 _error.value = status.error
M android/app/src/main/res/values-en/strings.xml
+4 -1
@@ -107,7 +107,7 @@
107 107 <string name="settings_calendrier_chargement">Loading resources…</string>
108 108 <string name="settings_calendrier_ressource_inactive">inactive</string>
109 109 <string name="settings_calendrier_retirer_titre">Remove this calendar?</string>
110 - <string name="settings_calendrier_retirer_message">"%1$s" will no longer sync. The calendar stays visible in the Calendar app.</string>
110 + <string name="settings_calendrier_retirer_message">"%1$s" and its events will be deleted from this device. Your appointments and bookings stay on the PicLead server.</string>
111 111 <string name="settings_calendrier_retirer_confirmer">Remove</string>
112 112 <string name="settings_calendrier_retirer_annuler">Cancel</string>
113 113
@@ -116,6 +116,9 @@
116 116 <string name="sync_en_cours">Syncing…</string>
117 117 <string name="sync_icone">Sync</string>
118 118 <string name="sync_local_en_avance">Local calendar is ahead of the server</string>
119 + <string name="sync_resume">Sync: %1$d sent · %2$d received</string>
120 + <string name="sync_resume_avec_echecs">Sync: %1$d sent · %2$d received · %3$d failed: %4$s</string>
121 + <string name="sync_resume_echec_sans_message">server refused without details</string>
119 122 <string name="sync_conflit_titre">Cancel my reservation?</string>
120 123 <string name="sync_conflit_message">This reservation conflicts with another one on the server. Do you want to cancel it?</string>
121 124 <string name="sync_conflit_oui">Yes, cancel</string>
M android/app/src/main/res/values/strings.xml
+4 -1
@@ -107,7 +107,7 @@
107 107 <string name="settings_calendrier_chargement">Chargement des ressources…</string>
108 108 <string name="settings_calendrier_ressource_inactive">inactive</string>
109 109 <string name="settings_calendrier_retirer_titre">Retirer ce calendrier ?</string>
110 - <string name="settings_calendrier_retirer_message">« %1$s » ne sera plus synchronisé. Le calendrier reste visible dans l\'app Agenda.</string>
110 + <string name="settings_calendrier_retirer_message">« %1$s » et ses événements seront supprimés de l\'appareil. Vos RDV et réservations restent sur le serveur PicLead.</string>
111 111 <string name="settings_calendrier_retirer_confirmer">Retirer</string>
112 112 <string name="settings_calendrier_retirer_annuler">Annuler</string>
113 113
@@ -116,6 +116,9 @@
116 116 <string name="sync_en_cours">Synchronisation…</string>
117 117 <string name="sync_icone">Synchroniser</string>
118 118 <string name="sync_local_en_avance">Calendrier local en avance sur le serveur</string>
119 + <string name="sync_resume">Synchro : %1$d envoyé(s) · %2$d reçu(s)</string>
120 + <string name="sync_resume_avec_echecs">Synchro : %1$d envoyé(s) · %2$d reçu(s) · %3$d échec(s) : %4$s</string>
121 + <string name="sync_resume_echec_sans_message">refus du serveur sans détail</string>
119 122 <string name="sync_conflit_titre">Annuler ma réservation ?</string>
120 123 <string name="sync_conflit_message">Cette réservation est en conflit avec une autre sur le serveur. Voulez-vous l\'annuler ?</string>
121 124 <string name="sync_conflit_oui">Oui, annuler</string>
M android/app/src/test/java/fr/ebii/card2vcf/sync/AgendaSyncEngineTest.kt
+26 -0
@@ -125,12 +125,38 @@ class AgendaSyncEngineTest {
125 125
126 126 assertTrue(result.conflicts.isNotEmpty())
127 127 assertEquals(localId, result.conflicts.single().localReservationId)
128 + // Le 409 réservation reste arbitré par la dialog dédiée : pas d'échec générique dans le résumé.
129 + assertTrue(result.pushFailures.isEmpty())
130 + assertEquals(0, result.pushed)
128 131 assertTrue(db.reservationDao().getByLocalId(localId)?.conflictPending == true)
129 132 assertEquals(1, db.syncOpDao().listAll().size)
130 133 assertEquals("salle", api.createReservationCalls.single().first)
131 134 assertEquals("salle-1", api.createReservationCalls.single().second)
132 135 }
133 136
137 + // ---- Motif d'une réservation créée dans l'app Agenda ----
138 +
139 + @Test
140 + fun agendaEventCree_pousseLeTitreCommeMotif() = runBlocking {
141 + val bridge = FakeCalendarBridge()
142 + val calendarId = bridge.ensureLocalCalendar("PicLead — Véhicule kangoo")
143 + bridge.seedEvent(
144 + calendarId,
145 + // Cas réel : l'app Agenda remplit le titre, pas la description.
146 + CalendarEventSnapshot(title = "Course fournisseur", debutMs = 1_000L, finMs = 2_000L),
147 + )
148 + val bindings = listOf(
149 + CalendarBinding(kind = "vehicule", serverResourceId = "v1", displayName = "Kangoo", androidCalendarId = calendarId),
150 + )
151 +
152 + engine.syncNow(bindings, bridge)
153 +
154 + val entity = db.reservationDao().listByCible("vehicule", "v1").single()
155 + assertEquals("Course fournisseur", entity.motif)
156 + val corps = api.createReservationCalls.single().third
157 + assertTrue("motif attendu dans $corps", corps.contains("\"motif\":\"Course fournisseur\""))
158 + }
159 +
134 160 // ---- Abandon ----
135 161
136 162 @Test
M android/app/src/test/java/fr/ebii/card2vcf/sync/CalendarBridgeTest.kt
+37 -2
@@ -23,7 +23,7 @@ import org.robolectric.annotation.Config
23 23
24 24 /**
25 25 * Couvre [CalendarBridge] au-delà des fonctions pures déjà testées dans
26 - * `CalendarServerIdCodecTest` : `ensureLocalCalendar`/`listEvents`/`upsertEvent`/`deleteEvent`
26 + * `CalendarServerIdCodecTest` : `ensureLocalCalendar`/`deleteCalendar`/`listEvents`/`upsertEvent`/`deleteEvent`
27 27 * via un `ContentProvider` en mémoire ([FakeCalendarProvider]), le vrai fournisseur Agenda
28 28 * n'étant pas embarqué dans `android-all` sous Robolectric.
29 29 */
@@ -118,6 +118,30 @@ class CalendarBridgeTest {
118 118
119 119 assertTrue(bridge.listEvents(calendarId).isEmpty())
120 120 }
121 +
122 + @Test
123 + fun deleteCalendarRemovesCalendarAndItsEvents() {
124 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
125 + bridge.upsertEvent(
126 + calendarId,
127 + CalendarEventSnapshot(title = "RDV client", debutMs = 1_000L, finMs = 2_000L, serverId = "rdv-42"),
128 + )
129 +
130 + bridge.deleteCalendar(calendarId)
131 +
132 + assertTrue(bridge.listEvents(calendarId).isEmpty())
133 + // Le calendrier n'existe plus : `ensureLocalCalendar` en recrée un (id différent).
134 + assertTrue(bridge.ensureLocalCalendar("Card2vcf — Mes RDV") != calendarId)
135 + }
136 +
137 + @Test
138 + fun deleteCalendarWithUnknownIdIsNoOp() {
139 + val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
140 +
141 + bridge.deleteCalendar(9_999L)
142 +
143 + assertEquals(calendarId, bridge.ensureLocalCalendar("Card2vcf — Mes RDV"))
144 + }
121 145 }
122 146
123 147 /**
@@ -174,7 +198,18 @@ class FakeCalendarProvider : ContentProvider() {
174 198
175 199 override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
176 200 val id = ContentUris.parseId(uri)
177 - return if (events.remove(id) != null) 1 else 0
201 + return when (matcher.match(uri)) {
202 + // Le vrai fournisseur Agenda supprime les events du calendrier en cascade.
203 + CALENDARS -> {
204 + if (calendars.remove(id) == null) return 0
205 + events.filterValues { it.getAsLong(CalendarContract.Events.CALENDAR_ID) == id }
206 + .keys
207 + .forEach { events.remove(it) }
208 + 1
209 + }
210 + EVENTS -> if (events.remove(id) != null) 1 else 0
211 + else -> error("Uri non supportée par FakeCalendarProvider : $uri")
212 + }
178 213 }
179 214
180 215 private fun baseUriFor(matchCode: Int): Uri = when (matchCode) {
M android/app/src/test/java/fr/ebii/card2vcf/sync/FakeCalendarBridge.kt
+11 -0
@@ -7,10 +7,21 @@ class FakeCalendarBridge : CalendarBridgeApi {
7 7 private var nextCalendarId = 1L
8 8 private var nextEventId = 1L
9 9 val deletedEventIds = mutableListOf<Long>()
10 + val deletedCalendarIds = mutableListOf<Long>()
11 +
12 + /** Simule une permission Agenda révoquée : [deleteCalendar] lève cette exception si non nulle. */
13 + var deleteCalendarFailure: Exception? = null
10 14
11 15 override fun ensureLocalCalendar(displayName: String): Long =
12 16 calendars.getOrPut(displayName) { nextCalendarId++ }
13 17
18 + override fun deleteCalendar(calendarId: Long) {
19 + deleteCalendarFailure?.let { throw it }
20 + deletedCalendarIds += calendarId
21 + calendars.entries.removeAll { it.value == calendarId }
22 + events.remove(calendarId)
23 + }
24 +
14 25 override fun listEvents(calendarId: Long): List<CalendarEventSnapshot> =
15 26 events[calendarId]?.values?.toList() ?: emptyList()
16 27
M android/app/src/test/java/fr/ebii/card2vcf/sync/SyncEngineTest.kt
+68 -0
@@ -5,6 +5,7 @@ import androidx.room.Room
5 5 import androidx.test.core.app.ApplicationProvider
6 6 import fr.ebii.card2vcf.data.CrmContactEntity
7 7 import fr.ebii.card2vcf.data.CrmDatabase
8 +import fr.ebii.card2vcf.data.RdvEntity
8 9 import kotlinx.coroutines.runBlocking
9 10 import org.junit.After
10 11 import org.junit.Assert.assertEquals
@@ -261,4 +262,71 @@ class SyncEngineTest {
261 262 assertEquals(0, api.uploadCarteCalls.size)
262 263 assertEquals(0, api.uploadPhotoCalls.size)
263 264 }
265 +
266 + // ---- Retour utilisateur : compteurs et échecs de push ----
267 +
268 + @Test
269 + fun syncNow_pushRefusedKeepsOpAndReportsServerMessage() = runBlocking {
270 + val localId = db.rdvDao().upsert(RdvEntity(titre = "Point client", dirtyLocal = true))
271 + db.syncOpDao().insert(
272 + SyncOpEntity(
273 + entityType = AgendaSyncCoordinator.KIND_RDV,
274 + op = "create",
275 + payloadJson = """{"titre":"Point client"}""",
276 + localId = localId,
277 + createdAt = 1L,
278 + ),
279 + )
280 + api.createRdvResult = AilianceApiClient.ApiResult.Err(
281 + 409,
282 + "Réservation refusée : période en conflit avec Entretien",
283 + )
284 +
285 + val result = engine.syncNow()
286 +
287 + assertTrue(result.success)
288 + assertEquals(1, db.syncOpDao().listAll().size)
289 + assertEquals(0, result.pushed)
290 + val failure = result.pushFailures.single()
291 + assertEquals(AgendaSyncCoordinator.KIND_RDV, failure.entityType)
292 + assertEquals("create", failure.op)
293 + assertEquals(409, failure.code)
294 + assertEquals("Réservation refusée : période en conflit avec Entretien", failure.message)
295 + }
296 +
297 + @Test
298 + fun syncNow_reportsPushedAndReceivedCounts() = runBlocking {
299 + val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Nouveau"))
300 + db.syncOpDao().insert(
301 + SyncOpEntity(
302 + entityType = "contact",
303 + op = "create",
304 + payloadJson = """{"prenom":"A","nom":"B"}""",
305 + localId = localId,
306 + createdAt = 1L,
307 + ),
308 + )
309 + api.pullResult = AilianceApiClient.ApiResult.Ok(
310 + SyncPullResponse(
311 + serverTime = "2026-07-22T12:05:00Z",
312 + contacts = listOf(
313 + ContactDto(id = "c1", prenom = "Jean", nom = "Dupont", creeLe = "2026-07-20T10:00:00Z"),
314 + ),
315 + interactions = listOf(
316 + InteractionDto(id = "i1", contactId = "c1", sujet = "Appel", creeLe = "2026-07-22T09:00:00Z"),
317 + ),
318 + // Exclus du décompte utilisateur : référentiel et suppressions.
319 + workflows = listOf(WorkflowDto(id = "w1", nom = "Défaut", creeLe = "2026-07-20T10:00:00Z")),
320 + tombstones = listOf(
321 + TombstoneDto(entityType = "contact", id = "c-old", supprimeLe = "2026-07-22T12:00:00Z"),
322 + ),
323 + ),
324 + )
325 +
326 + val result = engine.syncNow()
327 +
328 + assertEquals(1, result.pushed)
329 + assertEquals(2, result.received)
330 + assertTrue(result.pushFailures.isEmpty())
331 + }
264 332 }
M android/app/src/test/java/fr/ebii/card2vcf/ui/settings/SettingsViewModelTest.kt
+44 -1
@@ -275,17 +275,59 @@ class SettingsViewModelTest {
275 275 }
276 276
277 277 @Test
278 - fun confirmRemoveBinding_removesMesRdvBinding() = runTest(testDispatcher) {
278 + fun confirmRemoveBinding_deletesLocalCalendarThenRemovesMesRdvBinding() = runTest(testDispatcher) {
279 279 val vm = loggedInViewModel()
280 280 vm.setMesRdvEnabled(true)
281 281 advanceUntilIdle()
282 + val calendarId = calendarBindingsStore.list().single().androidCalendarId
282 283 vm.setMesRdvEnabled(false)
283 284
284 285 vm.confirmRemoveBinding()
286 + advanceUntilIdle()
287 +
288 + val state = vm.state.value as SettingsUiState.LoggedIn
289 + assertFalse(state.mesRdvLinked)
290 + assertNull(state.pendingRemoval)
291 + assertNull(state.catalogueError)
292 + assertEquals(listOf(calendarId), calendarBridge.deletedCalendarIds)
293 + assertTrue(calendarBindingsStore.list().isEmpty())
294 + }
295 +
296 + @Test
297 + fun confirmRemoveBinding_deletesRessourceCalendar() = runTest(testDispatcher) {
298 + val catalogue = RessourcesCatalogueDto(vehicules = listOf(RessourceItemDto(id = "v1", nom = "Kangoo", actif = true)))
299 + val vm = loggedInViewModel(catalogueResult = AilianceApiClient.ApiResult.Ok(catalogue))
300 + vm.onCalendarPermissionChanged(true)
301 + advanceUntilIdle()
302 + vm.setRessourceEnabled(kind = "vehicule", serverResourceId = "v1", enabled = true)
303 + advanceUntilIdle()
304 + val calendarId = calendarBindingsStore.list().single().androidCalendarId
305 + vm.setRessourceEnabled(kind = "vehicule", serverResourceId = "v1", enabled = false)
306 +
307 + vm.confirmRemoveBinding()
308 + advanceUntilIdle()
309 +
310 + val state = vm.state.value as SettingsUiState.LoggedIn
311 + assertFalse(state.ressources.single().linked)
312 + assertEquals(listOf(calendarId), calendarBridge.deletedCalendarIds)
313 + assertTrue(calendarBindingsStore.list().isEmpty())
314 + }
315 +
316 + @Test
317 + fun confirmRemoveBinding_calendarPermissionRevoked_stillUnlinksAndReportsError() = runTest(testDispatcher) {
318 + val vm = loggedInViewModel()
319 + vm.setMesRdvEnabled(true)
320 + advanceUntilIdle()
321 + vm.setMesRdvEnabled(false)
322 + calendarBridge.deleteCalendarFailure = SecurityException("permission WRITE_CALENDAR révoquée")
323 +
324 + vm.confirmRemoveBinding()
325 + advanceUntilIdle()
285 326
286 327 val state = vm.state.value as SettingsUiState.LoggedIn
287 328 assertFalse(state.mesRdvLinked)
288 329 assertNull(state.pendingRemoval)
330 + assertEquals(SettingsViewModel.CALENDAR_DELETE_ERROR, state.catalogueError)
289 331 assertTrue(calendarBindingsStore.list().isEmpty())
290 332 }
291 333
@@ -302,6 +344,7 @@ class SettingsViewModelTest {
302 344 assertTrue(state.mesRdvLinked)
303 345 assertNull(state.pendingRemoval)
304 346 assertEquals(1, calendarBindingsStore.list().size)
347 + assertTrue(calendarBridge.deletedCalendarIds.isEmpty())
305 348 }
306 349
307 350 @Test
M android/app/src/test/java/fr/ebii/card2vcf/ui/sync/SyncChromeViewModelTest.kt
+67 -0
@@ -3,16 +3,19 @@ package fr.ebii.card2vcf.ui.sync
3 3 import android.content.Context
4 4 import androidx.room.Room
5 5 import androidx.test.core.app.ApplicationProvider
6 +import fr.ebii.card2vcf.data.CrmContactEntity
6 7 import fr.ebii.card2vcf.data.CrmDatabase
7 8 import fr.ebii.card2vcf.data.RdvEntity
8 9 import fr.ebii.card2vcf.data.ReservationEntity
9 10 import fr.ebii.card2vcf.sync.AilianceApiClient
10 11 import fr.ebii.card2vcf.sync.CalendarBindingsStore
12 +import fr.ebii.card2vcf.sync.ContactDto
11 13 import fr.ebii.card2vcf.sync.FakeAilianceApi
12 14 import fr.ebii.card2vcf.sync.FakeCalendarBridge
13 15 import fr.ebii.card2vcf.sync.SyncCredentialsStore
14 16 import fr.ebii.card2vcf.sync.SyncEngine
15 17 import fr.ebii.card2vcf.sync.SyncOpEntity
18 +import fr.ebii.card2vcf.sync.SyncPullResponse
16 19 import kotlinx.coroutines.Dispatchers
17 20 import kotlinx.coroutines.ExperimentalCoroutinesApi
18 21 import kotlinx.coroutines.runBlocking
@@ -176,4 +179,68 @@ class SyncChromeViewModelTest {
176 179 assertNull(vm.conflictPending.value)
177 180 assertTrue(db.reservationDao().getByLocalId(localId)?.conflictPending == true)
178 181 }
182 +
183 + // ---- Résumé de synchro (bandeau) ----
184 +
185 + @Test
186 + fun syncNow_success_publishesSummaryWithPushedAndReceivedCounts() = runBlocking {
187 + val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Nouveau"))
188 + db.syncOpDao().insert(
189 + SyncOpEntity(entityType = "contact", op = "create", localId = localId, createdAt = 1L),
190 + )
191 + api.pullResult = AilianceApiClient.ApiResult.Ok(
192 + SyncPullResponse(
193 + serverTime = "2026-07-22T12:05:00Z",
194 + contacts = listOf(
195 + ContactDto(id = "c1", prenom = "Jean", nom = "Dupont", creeLe = "2026-07-20T10:00:00Z"),
196 + ),
197 + ),
198 + )
199 + val vm = newViewModel()
200 +
201 + vm.syncNow()
202 +
203 + awaitUntil { !vm.syncing.value }
204 + val summary = vm.syncSummary.value
205 + assertEquals(1, summary?.pushed)
206 + assertEquals(1, summary?.received)
207 + assertEquals(0, summary?.failures)
208 + assertNull(summary?.firstFailureMessage)
209 + }
210 +
211 + @Test
212 + fun syncNow_pushRefused_publishesSummaryWithVerbatimServerMessage() = runBlocking {
213 + val localId = db.rdvDao().upsert(RdvEntity(titre = "Point client", dirtyLocal = true))
214 + db.syncOpDao().insert(
215 + SyncOpEntity(entityType = "rdv", op = "create", localId = localId, createdAt = 1L),
216 + )
217 + api.createRdvResult = AilianceApiClient.ApiResult.Err(
218 + 409,
219 + "Réservation refusée : période en conflit avec Entretien",
220 + )
221 + val vm = newViewModel()
222 +
223 + vm.syncNow()
224 +
225 + awaitUntil { !vm.syncing.value }
226 + val summary = vm.syncSummary.value
227 + assertEquals(0, summary?.pushed)
228 + assertEquals(1, summary?.failures)
229 + assertEquals("Réservation refusée : période en conflit avec Entretien", summary?.firstFailureMessage)
230 + assertNull(vm.conflictPending.value)
231 + }
232 +
233 + @Test
234 + fun syncNow_clearsPreviousSummaryOnGlobalPullFailure() = runBlocking {
235 + val vm = newViewModel()
236 + vm.syncNow()
237 + awaitUntil { vm.syncSummary.value != null }
238 + api.pullResult = AilianceApiClient.ApiResult.Err(-1, "Réseau indisponible")
239 +
240 + vm.syncNow()
241 +
242 + awaitUntil { !vm.syncing.value }
243 + assertNull(vm.syncSummary.value)
244 + assertEquals("Réseau indisponible", vm.error.value)
245 + }
179 246 }