update client compatible 0.4.5

EBO <eric@ebii.fr> committé le 2026-09-25 14:24

f7990a49f53e67b8bbea573a6bebb0dd8ea31772

1 parent(s)

24 fichiers modifiés +984 -24
M android/app/build.gradle.kts
+1 -1
@@ -48,7 +48,7 @@ android {
48 48 minSdk = 30
49 49 targetSdk = 35
50 50 versionCode = 3
51 - versionName = "0.4.4"
51 + versionName = "0.4.5"
52 52 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
53 53 ndk {
54 54 abiFilters += listOf("arm64-v8a")
M android/app/src/main/java/fr/ebii/card2vcf/data/CrmDatabase.kt
+28 -2
@@ -29,7 +29,7 @@ import fr.ebii.card2vcf.sync.SyncOpEntity
29 29 IndisponibiliteEntity::class,
30 30 NoteProjetEntity::class,
31 31 ],
32 - version = 7,
32 + version = 8,
33 33 exportSchema = false,
34 34 )
35 35 @TypeConverters(Converters::class)
@@ -96,6 +96,26 @@ abstract class CrmDatabase : RoomDatabase() {
96 96 }
97 97
98 98 /**
99 + * v7→v8 : planification des tâches (début, durée en jours ouvrés,
100 + * étiquette, regroupement, dépendances, sous-tâches) et réglages de
101 + * planification du projet. Le serveur envoyait déjà ces champs dans le
102 + * pull ; le client les ignorait.
103 + */
104 + val MIGRATION_7_8 = object : Migration(7, 8) {
105 + override fun migrate(db: SupportSQLiteDatabase) {
106 + db.execSQL("ALTER TABLE taches ADD COLUMN debut TEXT")
107 + db.execSQL("ALTER TABLE taches ADD COLUMN dureeJours INTEGER")
108 + db.execSQL("ALTER TABLE taches ADD COLUMN etiquette TEXT")
109 + db.execSQL("ALTER TABLE taches ADD COLUMN parentId TEXT")
110 + db.execSQL("ALTER TABLE taches ADD COLUMN dependDeJson TEXT NOT NULL DEFAULT '[]'")
111 + db.execSQL("ALTER TABLE taches ADD COLUMN sousTachesJson TEXT NOT NULL DEFAULT '[]'")
112 + db.execSQL("ALTER TABLE projets ADD COLUMN planification INTEGER NOT NULL DEFAULT 0")
113 + db.execSQL("ALTER TABLE projets ADD COLUMN echeance TEXT")
114 + db.execSQL("ALTER TABLE projets ADD COLUMN joursOuvres INTEGER NOT NULL DEFAULT 7")
115 + }
116 + }
117 +
118 + /**
99 119 * v1/v2/v3 sync schema : pas de migration incrémentale — reset local à l'upgrade (acceptable v1-v3).
100 120 * À partir de v3→v4 les migrations sont additives ; le fallback destructif reste en filet.
101 121 * Les tests utilisent Room.inMemoryDatabaseBuilder sans fallback destructif.
@@ -107,7 +127,13 @@ abstract class CrmDatabase : RoomDatabase() {
107 127 CrmDatabase::class.java,
108 128 "card2vcf-crm.db",
109 129 )
110 - .addMigrations(MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7)
130 + .addMigrations(
131 + MIGRATION_3_4,
132 + MIGRATION_4_5,
133 + MIGRATION_5_6,
134 + MIGRATION_6_7,
135 + MIGRATION_7_8,
136 + )
111 137 .fallbackToDestructiveMigration()
112 138 .build()
113 139 .also { instance = it }
M android/app/src/main/java/fr/ebii/card2vcf/data/ProjetEntity.kt
+6 -0
@@ -12,6 +12,12 @@ data class ProjetEntity(
12 12 val workflowServerId: String = "",
13 13 val membresJson: String = "[]",
14 14 val creePar: String = "",
15 + /** Le projet utilise-t-il dates, durées et diagramme de Gantt ? */
16 + val planification: Boolean = false,
17 + /** Date cible du projet, format ISO `AAAA-MM-JJ`. */
18 + val echeance: String? = null,
19 + /** Rythme de travail : 5 = lun-ven, 6 = lun-sam, 7 = tous les jours. */
20 + val joursOuvres: Int = 7,
15 21 val createdAt: Long = 0L,
16 22 val updatedAt: Long = 0L,
17 23 )
M android/app/src/main/java/fr/ebii/card2vcf/data/TacheEntity.kt
+12 -0
@@ -14,6 +14,18 @@ data class TacheEntity(
14 14 val colonneId: String = "",
15 15 val ordre: Int = 0,
16 16 val assigneA: String? = null,
17 + /** Premier jour planifié, format ISO `AAAA-MM-JJ` (miroir Rust `debut`). */
18 + val debut: String? = null,
19 + /** Durée en jours **ouvrés** selon le rythme du projet. */
20 + val dureeJours: Int? = null,
21 + /** Étiquette normalisée ; la couleur s'en déduit (cf. `CouleurEtiquette`). */
22 + val etiquette: String? = null,
23 + /** Tâche parente (regroupement). Affichée, non éditable sur mobile. */
24 + val parentId: String? = null,
25 + /** Identifiants des tâches dont celle-ci dépend, en JSON. */
26 + val dependDeJson: String = "[]",
27 + /** Sous-tâches `{id, titre, fait}` en JSON (même forme que côté serveur). */
28 + val sousTachesJson: String = "[]",
17 29 val auteur: String = "",
18 30 val createdAt: Long = 0L,
19 31 val updatedAt: Long = 0L,
A android/app/src/main/java/fr/ebii/card2vcf/planning/ApercuTache.kt
+54 -0
@@ -0,0 +1,54 @@
1 +package fr.ebii.card2vcf.planning
2 +
3 +import fr.ebii.card2vcf.data.TacheEntity
4 +import fr.ebii.card2vcf.sync.SousTacheDto
5 +import fr.ebii.card2vcf.sync.syncJson
6 +import java.time.LocalDate
7 +
8 +/**
9 + * Ce qu'une carte kanban affiche d'une tâche planifiée. Extrait du Compose pour
10 + * être testable, sur le modèle de `KanbanFilter`.
11 + *
12 + * Toutes les entrées viennent du serveur et peuvent être malformées après une
13 + * synchronisation partielle : chaque fonction rend `null` plutôt que de lever.
14 + */
15 +object ApercuTache {
16 + /** « 09/01 → 13/01 » selon le rythme du projet ; `null` si la tâche n'est pas datée. */
17 + fun periode(tache: TacheEntity, joursOuvres: Int): String? {
18 + val debut = dateOuNull(tache.debut) ?: return null
19 + return CalendrierOuvre(joursOuvres).periodeAffichee(debut, tache.dureeJours ?: 1)
20 + }
21 +
22 + /** `(faites, total)` des sous-tâches ; `null` s'il n'y en a aucune. */
23 + fun avancement(tache: TacheEntity): Pair<Int, Int>? {
24 + val toutes = etats(tache)
25 + if (toutes.isEmpty()) return null
26 + return toutes.count { it } to toutes.size
27 + }
28 +
29 + /** La tâche finit-elle après l'échéance du projet ? */
30 + fun enRetard(tache: TacheEntity, joursOuvres: Int, echeance: String?): Boolean {
31 + val cible = dateOuNull(echeance) ?: return false
32 + val debut = dateOuNull(tache.debut) ?: return false
33 + return CalendrierOuvre(joursOuvres).fin(debut, tache.dureeJours ?: 1).isAfter(cible)
34 + }
35 +
36 + /** Sous-tâches décodées, dans l'ordre ; liste vide si absentes ou illisibles. */
37 + fun sousTaches(tache: TacheEntity?): List<SousTacheDto> = runCatching {
38 + syncJson.decodeFromString<List<SousTacheDto>>(tache?.sousTachesJson ?: "[]")
39 + }.getOrDefault(emptyList())
40 +
41 + /** Teinte de l'étiquette, identique à celle du web. */
42 + fun couleur(tache: TacheEntity): String? = CouleurEtiquette.pour(tache.etiquette)
43 +
44 + /**
45 + * États `fait` des sous-tâches ; liste vide si illisible.
46 + *
47 + * Décodé par kotlinx-serialization, pas par `org.json` : ce dernier n'est
48 + * qu'un stub dans les tests unitaires JVM.
49 + */
50 + private fun etats(tache: TacheEntity): List<Boolean> = sousTaches(tache).map { it.fait }
51 +
52 + private fun dateOuNull(iso: String?): LocalDate? =
53 + iso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
54 +}
A android/app/src/main/java/fr/ebii/card2vcf/planning/CalendrierOuvre.kt
+72 -0
@@ -0,0 +1,72 @@
1 +package fr.ebii.card2vcf.planning
2 +
3 +import java.time.DayOfWeek
4 +import java.time.LocalDate
5 +import java.time.format.DateTimeFormatter
6 +
7 +/**
8 + * Calendrier ouvré d'un projet — port de `Server/src/services/calendrier.rs`.
9 + *
10 + * Les durées de tâches se comptent en jours **ouvrés** selon le rythme du
11 + * projet (`joursOuvres` : 5 = lun-ven, 6 = lun-sam, 7 = tous les jours). Une
12 + * tâche ne peut donc pas calculer sa fin seule : il lui faut le calendrier de
13 + * son projet. Sans ce port, le téléphone afficherait une fin différente de
14 + * celle du diagramme web.
15 + *
16 + * Les deux implémentations sont tenues par les mêmes cas de test :
17 + * `Server/tests/calendrier.rs` et `CalendrierOuvreTest`.
18 + */
19 +class CalendrierOuvre(joursOuvres: Int) {
20 + /** Un rythme hors de 5-7 (donnée abîmée) retombe sur 7, comportement historique. */
21 + private val rythme: Int = when (joursOuvres) {
22 + 5 -> 5
23 + 6 -> 6
24 + else -> 7
25 + }
26 +
27 + fun estOuvre(jour: LocalDate): Boolean = when (jour.dayOfWeek) {
28 + DayOfWeek.SATURDAY -> rythme >= 6
29 + DayOfWeek.SUNDAY -> rythme >= 7
30 + else -> true
31 + }
32 +
33 + /** Premier jour ouvré à partir de [jour] inclus. */
34 + fun caler(jour: LocalDate): LocalDate {
35 + var j = jour
36 + while (!estOuvre(j)) j = j.plusDays(1)
37 + return j
38 + }
39 +
40 + /** Avance de [n] jours ouvrés depuis [jour], calé au préalable. */
41 + fun avancer(jour: LocalDate, n: Int): LocalDate {
42 + var j = caler(jour)
43 + repeat(maxOf(n, 0)) { j = caler(j.plusDays(1)) }
44 + return j
45 + }
46 +
47 + /** Nombre de jours ouvrés dans `[debut, fin]` inclus ; 0 si l'intervalle est inversé. */
48 + fun compter(debut: LocalDate, fin: LocalDate): Int {
49 + var n = 0
50 + var j = debut
51 + while (!j.isAfter(fin)) {
52 + if (estOuvre(j)) n++
53 + j = j.plusDays(1)
54 + }
55 + return n
56 + }
57 +
58 + /** Dernier jour inclus. Une durée nulle vaut un jour : jamais de période vide. */
59 + fun fin(debut: LocalDate, dureeJours: Int): LocalDate =
60 + avancer(debut, maxOf(dureeJours, 1) - 1)
61 +
62 + /** « 09/01 → 13/01 », ou le jour seul si la tâche tient sur une journée. */
63 + fun periodeAffichee(debut: LocalDate, dureeJours: Int): String {
64 + val f = fin(debut, dureeJours)
65 + val d = caler(debut)
66 + return if (d == f) d.format(FORMAT) else "${d.format(FORMAT)} → ${f.format(FORMAT)}"
67 + }
68 +
69 + private companion object {
70 + val FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("dd/MM")
71 + }
72 +}
A android/app/src/main/java/fr/ebii/card2vcf/planning/CouleurEtiquette.kt
+43 -0
@@ -0,0 +1,43 @@
1 +package fr.ebii.card2vcf.planning
2 +
3 +/**
4 + * Teinte d'une étiquette de tâche — port de `COULEURS_ETIQUETTE` et
5 + * `empreinte_fnv1a` de `Server/src/modeles.rs`.
6 + *
7 + * Deux tâches portant la même étiquette prennent la même couleur, sur le web
8 + * comme sur le téléphone. Le FNV-1a a été écrit explicitement côté serveur
9 + * plutôt que d'utiliser un hachage de bibliothèque, précisément pour être
10 + * reproductible ici. Les teintes de référence sont figées des deux côtés
11 + * (`teintes_de_reference_partagees_avec_le_mobile` / `teintesDeReference`).
12 + */
13 +object CouleurEtiquette {
14 + val PALETTE = listOf(
15 + "#3772CF", // bleu
16 + "#3FB985", // vert
17 + "#E89642", // ambre
18 + "#E5484D", // rouge
19 + "#8B5CF6", // violet
20 + "#0EA5E9", // cyan
21 + "#EC4899", // rose
22 + "#6B7280", // ardoise
23 + )
24 +
25 + /** `null` si la tâche n'a pas d'étiquette. */
26 + fun pour(etiquette: String?): String? {
27 + val e = etiquette?.trim().orEmpty()
28 + if (e.isEmpty()) return null
29 + // Le modulo doit se faire sur la valeur **non signée** : un Int négatif
30 + // donnerait un index négatif.
31 + val index = (empreinteFnv1a(e).toLong() and 0xFFFFFFFFL) % PALETTE.size
32 + return PALETTE[index.toInt()]
33 + }
34 +
35 + private fun empreinteFnv1a(texte: String): Int {
36 + var h = -0x7ee3623b // 0x811c9dc5
37 + for (octet in texte.toByteArray(Charsets.UTF_8)) {
38 + h = h xor (octet.toInt() and 0xFF)
39 + h *= 0x01000193
40 + }
41 + return h
42 + }
43 +}
A android/app/src/main/java/fr/ebii/card2vcf/planning/ReglagesPlanification.kt
+22 -0
@@ -0,0 +1,22 @@
1 +package fr.ebii.card2vcf.planning
2 +
3 +import fr.ebii.card2vcf.data.ProjetEntity
4 +
5 +/**
6 + * Réglages de planification d'un projet, nécessaires pour dater ses tâches.
7 + *
8 + * Le rythme conditionne le calcul de la fin (jours ouvrés) et l'échéance sert
9 + * de repère de retard. Par défaut la planification est éteinte : un projet qui
10 + * ne l'active pas n'affiche aucun repère temporel, comme sur le web.
11 + */
12 +data class ReglagesPlanification(
13 + val active: Boolean = false,
14 + val joursOuvres: Int = 7,
15 + val echeance: String? = null,
16 +) {
17 + companion object {
18 + fun de(projet: ProjetEntity?): ReglagesPlanification = projet?.let {
19 + ReglagesPlanification(it.planification, it.joursOuvres, it.echeance)
20 + } ?: ReglagesPlanification()
21 + }
22 +}
M android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApi.kt
+10 -0
@@ -34,6 +34,16 @@ interface AilianceApi {
34 34
35 35 fun moveTache(projetId: String, tacheId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
36 36
37 + /**
38 + * Coche/décoche une sous-tâche. Route dédiée : le `PUT` général ne
39 + * transporte que des titres et ne peut donc pas basculer l'état `fait`.
40 + */
41 + fun toggleSousTache(
42 + projetId: String,
43 + tacheId: String,
44 + sousTacheId: String,
45 + ): AilianceApiClient.ApiResult<String>
46 +
37 47 fun createInteraction(contactId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
38 48
39 49 fun deleteInteraction(iid: String): AilianceApiClient.ApiResult<Unit>
M android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApiClient.kt
+7 -0
@@ -81,6 +81,13 @@ class AilianceApiClient(
81 81 override fun moveTache(projetId: String, tacheId: String, jsonBody: String): ApiResult<String> =
82 82 post("/api/projets/${encodePath(projetId)}/taches/${encodePath(tacheId)}/deplacer", jsonBody) { it }
83 83
84 + override fun toggleSousTache(projetId: String, tacheId: String, sousTacheId: String): ApiResult<String> =
85 + put(
86 + "/api/projets/${encodePath(projetId)}/taches/${encodePath(tacheId)}" +
87 + "/sous-taches/${encodePath(sousTacheId)}",
88 + "",
89 + ) { it }
90 +
84 91 override fun createInteraction(contactId: String, jsonBody: String): ApiResult<String> =
85 92 post("/api/contacts/${encodePath(contactId)}/interactions", jsonBody) { it }
86 93
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncEngine.kt
+24 -0
@@ -363,6 +363,10 @@ class SyncEngine(
363 363 "update" -> op.serverId?.let { api.updateTache(projetId, it, op.payloadJson).toOutcome() } ?: PushOutcome(false)
364 364 "delete" -> op.serverId?.let { api.deleteTache(projetId, it).toOutcome() } ?: PushOutcome(false)
365 365 "move" -> op.serverId?.let { api.moveTache(projetId, it, op.payloadJson).toOutcome() } ?: PushOutcome(false)
366 + // `payloadJson` porte l'identifiant de la sous-tâche à basculer.
367 + "soustache" -> op.serverId?.let {
368 + api.toggleSousTache(projetId, it, op.payloadJson).toOutcome()
369 + } ?: PushOutcome(false)
366 370 else -> PushOutcome(true)
367 371 }
368 372 }
@@ -733,6 +737,9 @@ class SyncEngine(
733 737 workflowServerId = dto.workflowId,
734 738 membresJson = membresJson,
735 739 creePar = dto.creePar,
740 + planification = dto.planification,
741 + echeance = dto.echeance,
742 + joursOuvres = dto.joursOuvres,
736 743 createdAt = parseIsoToEpochMs(dto.creeLe),
737 744 updatedAt = remoteTs,
738 745 ),
@@ -745,6 +752,9 @@ class SyncEngine(
745 752 workflowServerId = dto.workflowId,
746 753 membresJson = membresJson,
747 754 creePar = dto.creePar,
755 + planification = dto.planification,
756 + echeance = dto.echeance,
757 + joursOuvres = dto.joursOuvres,
748 758 updatedAt = remoteTs,
749 759 )
750 760 val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs)
@@ -753,6 +763,8 @@ class SyncEngine(
753 763
754 764 private suspend fun applyTache(dto: TacheSyncDto) {
755 765 val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
766 + val dependDeJson = syncJson.encodeToString(dto.dependDe)
767 + val sousTachesJson = syncJson.encodeToString(dto.sousTaches)
756 768 val local = db.tacheDao().getByServerId(dto.id)
757 769 val projetLocalId = db.projetDao().getByServerId(dto.projetId)?.localId
758 770 if (local == null) {
@@ -766,6 +778,12 @@ class SyncEngine(
766 778 colonneId = dto.colonneId,
767 779 ordre = dto.ordre,
768 780 assigneA = dto.assigneA,
781 + debut = dto.debut,
782 + dureeJours = dto.dureeJours,
783 + etiquette = dto.etiquette,
784 + parentId = dto.parentId,
785 + dependDeJson = dependDeJson,
786 + sousTachesJson = sousTachesJson,
769 787 auteur = dto.auteur,
770 788 createdAt = parseIsoToEpochMs(dto.creeLe),
771 789 updatedAt = remoteTs,
@@ -781,6 +799,12 @@ class SyncEngine(
781 799 colonneId = dto.colonneId,
782 800 ordre = dto.ordre,
783 801 assigneA = dto.assigneA,
802 + debut = dto.debut,
803 + dureeJours = dto.dureeJours,
804 + etiquette = dto.etiquette,
805 + parentId = dto.parentId,
806 + dependDeJson = dependDeJson,
807 + sousTachesJson = sousTachesJson,
784 808 auteur = dto.auteur,
785 809 updatedAt = remoteTs,
786 810 )
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncModels.kt
+41 -2
@@ -127,9 +127,22 @@ data class ProjetDto(
127 127 val creePar: String = "",
128 128 val creeLe: String,
129 129 val membres: List<MembreProjetDto> = emptyList(),
130 + /** Le projet utilise-t-il la planification ? Défaut faux = serveur antérieur. */
131 + val planification: Boolean = false,
132 + val echeance: String? = null,
133 + /** 5 = lun-ven, 6 = lun-sam, 7 = tous les jours. */
134 + val joursOuvres: Int = 7,
130 135 val misAJourLe: String? = null,
131 136 )
132 137
138 +/** Élément de checklist d'une tâche (miroir Rust `SousTache`). */
139 +@Serializable
140 +data class SousTacheDto(
141 + val id: String,
142 + val titre: String,
143 + val fait: Boolean = false,
144 +)
145 +
133 146 /** Tâche aplatie : `projet_id` + champs Tache au même niveau (miroir Rust `TacheSync`). */
134 147 @Serializable
135 148 data class TacheSyncDto(
@@ -142,6 +155,15 @@ data class TacheSyncDto(
142 155 val auteur: String = "",
143 156 val creeLe: String,
144 157 val assigneA: String? = null,
158 + /** Premier jour planifié, `AAAA-MM-JJ`. */
159 + val debut: String? = null,
160 + /** Durée en jours **ouvrés** selon le rythme du projet. */
161 + val dureeJours: Int? = null,
162 + val etiquette: String? = null,
163 + /** Transportés et stockés pour ne pas les perdre à l'écriture, non éditables. */
164 + val dependDe: List<String> = emptyList(),
165 + val parentId: String? = null,
166 + val sousTaches: List<SousTacheDto> = emptyList(),
145 167 val misAJourLe: String? = null,
146 168 )
147 169
@@ -274,14 +296,31 @@ data class CreateTacheRequest(
274 296 val description: String = "",
275 297 val colonneId: String = "",
276 298 val assigneA: String? = null,
277 -)
278 -
299 + val debut: String? = null,
300 + val dureeJours: Int? = null,
301 + val etiquette: String? = null,
302 +)
303 +
304 +/**
305 + * Mise à jour d'une tâche.
306 + *
307 + * Le serveur applique une sémantique partielle : un champ **absent** du JSON
308 + * est laissé tel quel, `null` l'efface. Cette classe ne déclare donc que ce que
309 + * le téléphone édite — sous-tâches, dépendances et regroupement en sont
310 + * volontairement exclus pour ne pas être écrasés.
311 + *
312 + * `syncJson` encode les valeurs par défaut, y compris les nuls : vider le champ
313 + * date dans le dialogue efface bien la date côté serveur.
314 + */
279 315 @Serializable
280 316 data class UpdateTacheRequest(
281 317 val titre: String,
282 318 val description: String = "",
283 319 val assigneA: String? = null,
284 320 @SerialName("assigne_a_pose") val assigneAPose: Boolean = true,
321 + val debut: String? = null,
322 + val dureeJours: Int? = null,
323 + val etiquette: String? = null,
285 324 )
286 325
287 326 @Serializable
M android/app/src/main/java/fr/ebii/card2vcf/ui/kanban/KanbanBoard.kt
+155 -7
@@ -1,6 +1,7 @@
1 1 package fr.ebii.card2vcf.ui.kanban
2 2
3 3 import androidx.compose.foundation.BorderStroke
4 +import androidx.compose.foundation.background
4 5 import androidx.compose.foundation.border
5 6 import androidx.compose.foundation.clickable
6 7 import androidx.compose.foundation.layout.Arrangement
@@ -10,15 +11,18 @@ import androidx.compose.foundation.layout.Row
10 11 import androidx.compose.foundation.layout.fillMaxWidth
11 12 import androidx.compose.foundation.layout.height
12 13 import androidx.compose.foundation.layout.padding
14 +import androidx.compose.foundation.layout.size
13 15 import androidx.compose.foundation.layout.width
14 16 import androidx.compose.foundation.lazy.LazyRow
15 17 import androidx.compose.foundation.lazy.itemsIndexed
16 18 import androidx.compose.foundation.rememberScrollState
19 +import androidx.compose.foundation.shape.CircleShape
17 20 import androidx.compose.foundation.shape.RoundedCornerShape
18 21 import androidx.compose.foundation.verticalScroll
19 22 import androidx.compose.material.icons.Icons
20 23 import androidx.compose.material.icons.filled.ArrowDropDown
21 24 import androidx.compose.material3.AlertDialog
25 +import androidx.compose.material3.Checkbox
22 26 import androidx.compose.material3.DropdownMenu
23 27 import androidx.compose.material3.Icon
24 28 import androidx.compose.material3.DropdownMenuItem
@@ -32,13 +36,17 @@ import androidx.compose.runtime.getValue
32 36 import androidx.compose.runtime.mutableStateOf
33 37 import androidx.compose.runtime.remember
34 38 import androidx.compose.runtime.setValue
39 +import androidx.compose.ui.Alignment
35 40 import androidx.compose.ui.Modifier
36 41 import androidx.compose.ui.draw.clip
42 +import androidx.compose.ui.graphics.Color
37 43 import androidx.compose.ui.res.stringResource
38 44 import androidx.compose.ui.unit.dp
39 45 import fr.ebii.card2vcf.R
40 46 import fr.ebii.card2vcf.data.TacheEntity
41 47 import fr.ebii.card2vcf.kanban.KanbanFilter
48 +import fr.ebii.card2vcf.planning.ApercuTache
49 +import fr.ebii.card2vcf.planning.ReglagesPlanification
42 50 import fr.ebii.card2vcf.sync.ColonneDto
43 51 import fr.ebii.card2vcf.ui.theme.Bordure
44 52 import fr.ebii.card2vcf.ui.theme.Fond
@@ -55,14 +63,20 @@ fun KanbanBoard(
55 63 filterMode: KanbanFilter.Mode,
56 64 onFilterModeChange: (KanbanFilter.Mode) -> Unit,
57 65 assignableUsers: List<String>,
58 - onCreateTache: (titre: String, assigneA: String?) -> Unit,
59 - onEditTache: (TacheEntity, titre: String, assigneA: String?) -> Unit,
66 + planification: ReglagesPlanification = ReglagesPlanification(),
67 + onCreateTache: (titre: String, assigneA: String?, debut: String?, dureeJours: Int?, etiquette: String?) -> Unit,
68 + onEditTache: (TacheEntity, titre: String, assigneA: String?, debut: String?, dureeJours: Int?, etiquette: String?) -> Unit,
69 + onToggleSousTache: (TacheEntity, sousTacheId: String) -> Unit,
60 70 onDeleteTache: (TacheEntity) -> Unit,
61 71 onMoveTache: (TacheEntity, direction: Int) -> Unit,
62 72 modifier: Modifier = Modifier,
63 73 ) {
64 74 var dialogTache by remember { mutableStateOf<TacheEntity?>(null) }
65 75 var showCreateDialog by remember { mutableStateOf(false) }
76 + // Étiquettes déjà posées dans le projet, proposées en réutilisation.
77 + val etiquettesConnues = remember(taches) {
78 + taches.mapNotNull { it.etiquette }.distinct().sorted()
79 + }
66 80
67 81 Column(modifier.fillMaxWidth()) {
68 82 Row(
@@ -102,6 +116,7 @@ fun KanbanBoard(
102 116 KanbanColumn(
103 117 colonne = colonne,
104 118 taches = taches.filter { it.colonneId == colonne.id }.sortedBy { it.ordre },
119 + planification = planification,
105 120 canMoveLeft = index > 0,
106 121 canMoveRight = index < colonnes.lastIndex,
107 122 onEdit = { dialogTache = it },
@@ -118,16 +133,24 @@ fun KanbanBoard(
118 133 TacheDialog(
119 134 tache = null,
120 135 assignableUsers = assignableUsers,
136 + planification = planification,
137 + etiquettesConnues = etiquettesConnues,
138 + onToggleSousTache = {},
121 139 onDismiss = { showCreateDialog = false },
122 - onConfirm = { titre, assigneA -> onCreateTache(titre, assigneA) },
140 + onConfirm = onCreateTache,
123 141 )
124 142 }
125 143 dialogTache?.let { tache ->
126 144 TacheDialog(
127 145 tache = tache,
128 146 assignableUsers = assignableUsers,
147 + planification = planification,
148 + etiquettesConnues = etiquettesConnues,
149 + onToggleSousTache = { onToggleSousTache(tache, it) },
129 150 onDismiss = { dialogTache = null },
130 - onConfirm = { titre, assigneA -> onEditTache(tache, titre, assigneA) },
151 + onConfirm = { titre, assigneA, debut, duree, etiquette ->
152 + onEditTache(tache, titre, assigneA, debut, duree, etiquette)
153 + },
131 154 )
132 155 }
133 156 }
@@ -146,6 +169,7 @@ private fun FilterChipButton(label: String, selected: Boolean, onClick: () -> Un
146 169 private fun KanbanColumn(
147 170 colonne: ColonneDto,
148 171 taches: List<TacheEntity>,
172 + planification: ReglagesPlanification,
149 173 canMoveLeft: Boolean,
150 174 canMoveRight: Boolean,
151 175 onEdit: (TacheEntity) -> Unit,
@@ -164,6 +188,7 @@ private fun KanbanColumn(
164 188 taches.forEach { tache ->
165 189 TacheCard(
166 190 tache = tache,
191 + planification = planification,
167 192 canMoveLeft = canMoveLeft,
168 193 canMoveRight = canMoveRight,
169 194 onEdit = { onEdit(tache) },
@@ -179,6 +204,7 @@ private fun KanbanColumn(
179 204 @Composable
180 205 private fun TacheCard(
181 206 tache: TacheEntity,
207 + planification: ReglagesPlanification,
182 208 canMoveLeft: Boolean,
183 209 canMoveRight: Boolean,
184 210 onEdit: () -> Unit,
@@ -195,13 +221,58 @@ private fun TacheCard(
195 221 .clickable(onClick = onEdit)
196 222 .padding(10.dp),
197 223 ) {
198 - Text(tache.titre.ifBlank { "—" }, style = MaterialTheme.typography.bodyMedium, color = Ink)
224 + Row(verticalAlignment = Alignment.CenterVertically) {
225 + ApercuTache.couleur(tache)?.let { teinte ->
226 + Box(
227 + Modifier
228 + .padding(end = 6.dp)
229 + .size(8.dp)
230 + .clip(CircleShape)
231 + .background(Color(android.graphics.Color.parseColor(teinte))),
232 + )
233 + }
234 + Text(
235 + tache.titre.ifBlank { "—" },
236 + style = MaterialTheme.typography.bodyMedium,
237 + color = Ink,
238 + modifier = Modifier.weight(1f, fill = false),
239 + )
240 + }
199 241 Text(
200 242 text = tache.assigneA?.let { stringResource(R.string.kanban_assigne_a, it) }
201 243 ?: stringResource(R.string.kanban_non_assigne),
202 244 style = MaterialTheme.typography.bodySmall,
203 245 color = TexteFaible,
204 246 )
247 + // Repères de planification : rien si le projet ne planifie pas, comme sur le web.
248 + if (planification.active) {
249 + ApercuTache.periode(tache, planification.joursOuvres)?.let { periode ->
250 + val retard = ApercuTache.enRetard(tache, planification.joursOuvres, planification.echeance)
251 + Text(
252 + text = if (retard) {
253 + stringResource(R.string.kanban_periode_en_retard, periode)
254 + } else {
255 + stringResource(R.string.kanban_periode, periode)
256 + },
257 + style = MaterialTheme.typography.bodySmall,
258 + color = if (retard) MaterialTheme.colorScheme.error else TexteFaible,
259 + )
260 + }
261 + ApercuTache.avancement(tache)?.let { (faites, total) ->
262 + Text(
263 + text = stringResource(R.string.kanban_sous_taches, faites, total),
264 + style = MaterialTheme.typography.bodySmall,
265 + color = TexteFaible,
266 + )
267 + }
268 + tache.parentId?.let {
269 + Text(
270 + text = stringResource(R.string.kanban_regroupee),
271 + style = MaterialTheme.typography.bodySmall,
272 + color = TexteFaible,
273 + )
274 + }
275 + }
205 276 Row(
206 277 Modifier.fillMaxWidth().padding(top = 4.dp),
207 278 horizontalArrangement = Arrangement.SpaceBetween,
@@ -221,12 +292,21 @@ private fun TacheCard(
221 292 private fun TacheDialog(
222 293 tache: TacheEntity?,
223 294 assignableUsers: List<String>,
295 + planification: ReglagesPlanification,
296 + etiquettesConnues: List<String>,
297 + onToggleSousTache: (String) -> Unit,
224 298 onDismiss: () -> Unit,
225 - onConfirm: (titre: String, assigneA: String?) -> Unit,
299 + onConfirm: (titre: String, assigneA: String?, debut: String?, dureeJours: Int?, etiquette: String?) -> Unit,
226 300 ) {
227 301 var titre by remember(tache) { mutableStateOf(tache?.titre.orEmpty()) }
228 302 var assigneA by remember(tache) { mutableStateOf(tache?.assigneA) }
229 303 var assigneMenuOpen by remember { mutableStateOf(false) }
304 + var debut by remember(tache) { mutableStateOf(tache?.debut.orEmpty()) }
305 + var duree by remember(tache) { mutableStateOf(tache?.dureeJours?.toString().orEmpty()) }
306 + var etiquette by remember(tache) { mutableStateOf(tache?.etiquette.orEmpty()) }
307 + var etiquetteMenuOpen by remember { mutableStateOf(false) }
308 + // Une date malformée bloque la validation plutôt que d'être envoyée telle quelle.
309 + val debutValide = debut.isBlank() || runCatching { java.time.LocalDate.parse(debut) }.isSuccess
230 310 val fieldColors = OutlinedTextFieldDefaults.colors(
231 311 focusedBorderColor = MaterialTheme.colorScheme.secondary,
232 312 unfocusedBorderColor = MaterialTheme.colorScheme.outline,
@@ -275,12 +355,80 @@ private fun TacheDialog(
275 355 }
276 356 }
277 357 }
358 + if (planification.active) {
359 + OutlinedTextField(
360 + value = debut,
361 + onValueChange = { debut = it },
362 + modifier = Modifier.fillMaxWidth(),
363 + singleLine = true,
364 + isError = !debutValide,
365 + label = { Text(stringResource(R.string.kanban_champ_debut), color = TexteFaible) },
366 + placeholder = { Text("2026-07-22", color = TexteFaible) },
367 + colors = fieldColors,
368 + )
369 + OutlinedTextField(
370 + value = duree,
371 + onValueChange = { saisie -> duree = saisie.filter { it.isDigit() } },
372 + modifier = Modifier.fillMaxWidth(),
373 + singleLine = true,
374 + label = { Text(stringResource(R.string.kanban_champ_duree), color = TexteFaible) },
375 + colors = fieldColors,
376 + )
377 + val sousTaches = remember(tache) { ApercuTache.sousTaches(tache) }
378 + sousTaches.forEach { st ->
379 + Row(
380 + Modifier.fillMaxWidth(),
381 + verticalAlignment = Alignment.CenterVertically,
382 + ) {
383 + Checkbox(
384 + checked = st.fait,
385 + onCheckedChange = { onToggleSousTache(st.id) },
386 + )
387 + Text(st.titre, color = Ink)
388 + }
389 + }
390 + Box {
391 + OutlinedTextField(
392 + value = etiquette,
393 + onValueChange = { etiquette = it },
394 + modifier = Modifier.fillMaxWidth(),
395 + singleLine = true,
396 + label = { Text(stringResource(R.string.kanban_champ_etiquette), color = TexteFaible) },
397 + trailingIcon = {
398 + if (etiquettesConnues.isNotEmpty()) {
399 + TextButton(onClick = { etiquetteMenuOpen = true }) {
400 + Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = TexteFaible)
401 + }
402 + }
403 + },
404 + colors = fieldColors,
405 + )
406 + DropdownMenu(
407 + expanded = etiquetteMenuOpen,
408 + onDismissRequest = { etiquetteMenuOpen = false },
409 + ) {
410 + etiquettesConnues.forEach { connue ->
411 + DropdownMenuItem(
412 + text = { Text(connue) },
413 + onClick = { etiquette = connue; etiquetteMenuOpen = false },
414 + )
415 + }
416 + }
417 + }
418 + }
278 419 }
279 420 },
280 421 confirmButton = {
281 422 TextButton(
423 + enabled = debutValide,
282 424 onClick = {
283 - onConfirm(titre, assigneA)
425 + onConfirm(
426 + titre,
427 + assigneA,
428 + debut.ifBlank { null },
429 + duree.toIntOrNull(),
430 + etiquette.ifBlank { null },
431 + )
284 432 onDismiss()
285 433 },
286 434 ) {
M android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetDetailScreen.kt
+3 -0
@@ -45,6 +45,7 @@ import fr.ebii.card2vcf.R
45 45 import fr.ebii.card2vcf.data.CrmContactEntity
46 46 import fr.ebii.card2vcf.data.InteractionEntity
47 47 import fr.ebii.card2vcf.data.NoteProjetEntity
48 +import fr.ebii.card2vcf.planning.ReglagesPlanification
48 49 import fr.ebii.card2vcf.ui.audio.ConfirmationNote
49 50 import fr.ebii.card2vcf.ui.audio.EnregistrementNoteSheet
50 51 import fr.ebii.card2vcf.ui.audio.LecteurAudio
@@ -141,10 +142,12 @@ fun ProjetDetailScreen(
141 142 filterMode = filterMode,
142 143 onFilterModeChange = viewModel::setFilterMode,
143 144 assignableUsers = assignableUsers,
145 + planification = ReglagesPlanification.de(projet),
144 146 onCreateTache = viewModel::createTache,
145 147 onEditTache = viewModel::editTache,
146 148 onDeleteTache = viewModel::deleteTache,
147 149 onMoveTache = viewModel::moveTache,
150 + onToggleSousTache = viewModel::toggleSousTache,
148 151 )
149 152
150 153 HorizontalDivider(color = Bordure, thickness = 1.dp, modifier = Modifier.padding(vertical = 12.dp))
M android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetDetailViewModel.kt
+90 -6
@@ -18,6 +18,7 @@ import fr.ebii.card2vcf.sync.CreateNoteProjetRequest
18 18 import fr.ebii.card2vcf.sync.CreateTacheRequest
19 19 import fr.ebii.card2vcf.sync.MembreProjetDto
20 20 import fr.ebii.card2vcf.sync.MoveTacheRequest
21 +import fr.ebii.card2vcf.sync.SousTacheDto
21 22 import fr.ebii.card2vcf.sync.SyncCredentialsStore
22 23 import fr.ebii.card2vcf.sync.SyncEngine
23 24 import fr.ebii.card2vcf.sync.SyncOpDao
@@ -178,7 +179,13 @@ class ProjetDetailViewModel(
178 179 }
179 180 }
180 181
181 - fun createTache(titre: String, assigneA: String?) {
182 + fun createTache(
183 + titre: String,
184 + assigneA: String?,
185 + debut: String? = null,
186 + dureeJours: Int? = null,
187 + etiquette: String? = null,
188 + ) {
182 189 val titreTrim = titre.trim()
183 190 if (titreTrim.isEmpty()) return
184 191 val colonneId = colonnes.value.firstOrNull()?.id ?: return
@@ -191,6 +198,9 @@ class ProjetDetailViewModel(
191 198 titre = titreTrim,
192 199 colonneId = colonneId,
193 200 assigneA = assigneA,
201 + debut = debut,
202 + dureeJours = dureeJours,
203 + etiquette = etiquette,
194 204 auteur = userName,
195 205 createdAt = now,
196 206 updatedAt = now,
@@ -201,7 +211,14 @@ class ProjetDetailViewModel(
201 211 entityType = "tache",
202 212 op = "create",
203 213 payloadJson = syncJson.encodeToString(
204 - CreateTacheRequest(titre = titreTrim, colonneId = colonneId, assigneA = assigneA),
214 + CreateTacheRequest(
215 + titre = titreTrim,
216 + colonneId = colonneId,
217 + assigneA = assigneA,
218 + debut = debut,
219 + dureeJours = dureeJours,
220 + etiquette = etiquette,
221 + ),
205 222 ),
206 223 localId = localId,
207 224 createdAt = now,
@@ -210,19 +227,86 @@ class ProjetDetailViewModel(
210 227 }
211 228 }
212 229
213 - fun editTache(tache: TacheEntity, titre: String, assigneA: String?) {
230 + fun editTache(
231 + tache: TacheEntity,
232 + titre: String,
233 + assigneA: String?,
234 + debut: String? = tache.debut,
235 + dureeJours: Int? = tache.dureeJours,
236 + etiquette: String? = tache.etiquette,
237 + ) {
214 238 val titreTrim = titre.trim()
215 239 if (titreTrim.isEmpty()) return
216 240 viewModelScope.launch {
217 241 val now = System.currentTimeMillis()
218 - database.tacheDao().upsert(tache.copy(titre = titreTrim, assigneA = assigneA, updatedAt = now))
219 - val request = UpdateTacheRequest(titre = titreTrim, description = tache.description, assigneA = assigneA)
242 + database.tacheDao().upsert(
243 + tache.copy(
244 + titre = titreTrim,
245 + assigneA = assigneA,
246 + debut = debut,
247 + dureeJours = dureeJours,
248 + etiquette = etiquette,
249 + updatedAt = now,
250 + ),
251 + )
252 + // Sous-tâches, dépendances et regroupement ne figurent pas dans la
253 + // requête : le serveur les laisse donc intacts.
254 + val request = UpdateTacheRequest(
255 + titre = titreTrim,
256 + description = tache.description,
257 + assigneA = assigneA,
258 + debut = debut,
259 + dureeJours = dureeJours,
260 + etiquette = etiquette,
261 + )
220 262 enqueueTacheMutation(tache, "update", syncJson.encodeToString(request)) {
221 - CreateTacheRequest(titre = titreTrim, description = tache.description, colonneId = tache.colonneId, assigneA = assigneA)
263 + CreateTacheRequest(
264 + titre = titreTrim,
265 + description = tache.description,
266 + colonneId = tache.colonneId,
267 + assigneA = assigneA,
268 + debut = debut,
269 + dureeJours = dureeJours,
270 + etiquette = etiquette,
271 + )
222 272 }
223 273 }
224 274 }
225 275
276 + /**
277 + * Coche ou décoche une sous-tâche : bascule locale immédiate, puis
278 + * opération poussée à la synchronisation.
279 + *
280 + * Passe par une route dédiée plutôt que par la mise à jour générale, qui ne
281 + * transporte que des titres et ne saurait pas porter l'état `fait`.
282 + */
283 + fun toggleSousTache(tache: TacheEntity, sousTacheId: String) {
284 + val serverId = tache.serverId ?: return
285 + viewModelScope.launch {
286 + val sous = runCatching {
287 + syncJson.decodeFromString<List<SousTacheDto>>(tache.sousTachesJson)
288 + }.getOrNull() ?: return@launch
289 + val basculees = sous.map {
290 + if (it.id == sousTacheId) it.copy(fait = !it.fait) else it
291 + }
292 + database.tacheDao().upsert(
293 + tache.copy(
294 + sousTachesJson = syncJson.encodeToString(basculees),
295 + updatedAt = System.currentTimeMillis(),
296 + ),
297 + )
298 + database.syncOpDao().insert(
299 + SyncOpEntity(
300 + entityType = "tache",
301 + op = "soustache",
302 + serverId = serverId,
303 + payloadJson = sousTacheId,
304 + createdAt = System.currentTimeMillis(),
305 + ),
306 + )
307 + }
308 + }
309 +
226 310 fun deleteTache(tache: TacheEntity) {
227 311 viewModelScope.launch {
228 312 val serverId = tache.serverId
M android/app/src/main/res/values/strings.xml
+7 -0
@@ -219,4 +219,11 @@
219 219 <string name="settings_transcription_local">Sur l\'appareil</string>
220 220 <string name="settings_transcription_serveur">Sur le serveur</string>
221 221 <string name="settings_transcription_vosk_indisponible">Modèle local absent — transcription serveur forcée</string>
222 + <string name="kanban_periode">📅 %1$s</string>
223 + <string name="kanban_periode_en_retard">📅 %1$s · en retard</string>
224 + <string name="kanban_sous_taches">%1$d/%2$d fait</string>
225 + <string name="kanban_regroupee">↳ regroupée</string>
226 + <string name="kanban_champ_debut">Début (AAAA-MM-JJ)</string>
227 + <string name="kanban_champ_duree">Durée (jours ouvrés)</string>
228 + <string name="kanban_champ_etiquette">Étiquette</string>
222 229 </resources>
A android/app/src/test/java/fr/ebii/card2vcf/planning/ApercuTacheTest.kt
+67 -0
@@ -0,0 +1,67 @@
1 +package fr.ebii.card2vcf.planning
2 +
3 +import fr.ebii.card2vcf.data.TacheEntity
4 +import kotlin.test.Test
5 +import kotlin.test.assertEquals
6 +import kotlin.test.assertFalse
7 +import kotlin.test.assertNull
8 +import kotlin.test.assertTrue
9 +
10 +/**
11 + * Logique d'affichage de la carte kanban, extraite du Compose pour être
12 + * testable — même principe que `KanbanFilter`.
13 + */
14 +class ApercuTacheTest {
15 + private fun tache(
16 + debut: String? = null,
17 + duree: Int? = null,
18 + etiquette: String? = null,
19 + sousTaches: String = "[]",
20 + ) = TacheEntity(
21 + localId = 1,
22 + titre = "T",
23 + debut = debut,
24 + dureeJours = duree,
25 + etiquette = etiquette,
26 + sousTachesJson = sousTaches,
27 + )
28 +
29 + @Test
30 + fun periodeEnJoursOuvresDuProjet() {
31 + // Vendredi 2026-01-09 + 3 jours ouvrés.
32 + val t = tache(debut = "2026-01-09", duree = 3)
33 + assertEquals("09/01 → 13/01", ApercuTache.periode(t, joursOuvres = 5))
34 + assertEquals("09/01 → 11/01", ApercuTache.periode(t, joursOuvres = 7))
35 + }
36 +
37 + @Test
38 + fun pasDePeriodeSansDate() {
39 + assertNull(ApercuTache.periode(tache(duree = 3), joursOuvres = 5))
40 + }
41 +
42 + @Test
43 + fun uneDateIllisibleNeFaitPasTomberLEcran() {
44 + assertNull(ApercuTache.periode(tache(debut = "pas-une-date"), joursOuvres = 5))
45 + }
46 +
47 + @Test
48 + fun avancementDesSousTaches() {
49 + val json = """[{"id":"a","titre":"A","fait":true},{"id":"b","titre":"B","fait":false}]"""
50 + assertEquals(1 to 2, ApercuTache.avancement(tache(sousTaches = json)))
51 + assertNull(ApercuTache.avancement(tache()), "aucune sous-tâche = rien à afficher")
52 + }
53 +
54 + @Test
55 + fun sousTachesIllisiblesIgnorees() {
56 + assertNull(ApercuTache.avancement(tache(sousTaches = "{pas du json")))
57 + }
58 +
59 + @Test
60 + fun retardMesureContreLEcheance() {
61 + val t = tache(debut = "2026-01-09", duree = 3) // finit le 13 en rythme 5
62 + assertTrue(ApercuTache.enRetard(t, joursOuvres = 5, echeance = "2026-01-12"))
63 + assertFalse(ApercuTache.enRetard(t, joursOuvres = 5, echeance = "2026-01-13"))
64 + assertFalse(ApercuTache.enRetard(t, joursOuvres = 5, echeance = null))
65 + assertFalse(ApercuTache.enRetard(tache(), joursOuvres = 5, echeance = "2026-01-01"))
66 + }
67 +}
A android/app/src/test/java/fr/ebii/card2vcf/planning/CalendrierOuvreTest.kt
+100 -0
@@ -0,0 +1,100 @@
1 +package fr.ebii.card2vcf.planning
2 +
3 +import java.time.LocalDate
4 +import kotlin.test.Test
5 +import kotlin.test.assertEquals
6 +import kotlin.test.assertFalse
7 +import kotlin.test.assertTrue
8 +
9 +/**
10 + * Port Kotlin de `Server/tests/calendrier.rs` : **les mêmes cas, les mêmes
11 + * valeurs attendues**. Si les deux suites divergent, le téléphone affichera une
12 + * date de fin différente de celle du Gantt web.
13 + *
14 + * Repères : 2026-01-05 est un lundi, 2026-01-09 un vendredi, 2026-01-10 un
15 + * samedi et 2026-01-11 un dimanche.
16 + */
17 +class CalendrierOuvreTest {
18 + private fun j(s: String) = LocalDate.parse(s)
19 +
20 + @Test
21 + fun estOuvreDependDuRythme() {
22 + val samedi = j("2026-01-10")
23 + val dimanche = j("2026-01-11")
24 + val lundi = j("2026-01-05")
25 +
26 + for (rythme in listOf(5, 6, 7)) {
27 + assertTrue(CalendrierOuvre(rythme).estOuvre(lundi))
28 + }
29 + assertFalse(CalendrierOuvre(5).estOuvre(samedi))
30 + assertTrue(CalendrierOuvre(6).estOuvre(samedi))
31 + assertTrue(CalendrierOuvre(7).estOuvre(samedi))
32 + assertFalse(CalendrierOuvre(5).estOuvre(dimanche))
33 + assertFalse(CalendrierOuvre(6).estOuvre(dimanche))
34 + assertTrue(CalendrierOuvre(7).estOuvre(dimanche))
35 + }
36 +
37 + @Test
38 + fun calerAvanceAuProchainJourOuvre() {
39 + val samedi = j("2026-01-10")
40 + assertEquals(j("2026-01-12"), CalendrierOuvre(5).caler(samedi))
41 + assertEquals(samedi, CalendrierOuvre(6).caler(samedi))
42 + assertEquals(samedi, CalendrierOuvre(7).caler(samedi))
43 + val mardi = j("2026-01-06")
44 + assertEquals(mardi, CalendrierOuvre(5).caler(mardi), "jour déjà ouvré")
45 + }
46 +
47 + @Test
48 + fun finCompteDesJoursOuvres() {
49 + val vendredi = j("2026-01-09")
50 + assertEquals(j("2026-01-13"), CalendrierOuvre(5).fin(vendredi, 3))
51 + assertEquals(j("2026-01-12"), CalendrierOuvre(6).fin(vendredi, 3))
52 + assertEquals(j("2026-01-11"), CalendrierOuvre(7).fin(vendredi, 3))
53 + }
54 +
55 + @Test
56 + fun finDUneTacheDUnJourEstSonDebut() {
57 + val vendredi = j("2026-01-09")
58 + assertEquals(vendredi, CalendrierOuvre(5).fin(vendredi, 1))
59 + assertEquals(vendredi, CalendrierOuvre(5).fin(vendredi, 0), "durée nulle = un jour")
60 + }
61 +
62 + @Test
63 + fun finCaleUnDebutTombantUnJourChome() {
64 + val samedi = j("2026-01-10")
65 + assertEquals(j("2026-01-13"), CalendrierOuvre(5).fin(samedi, 2))
66 + }
67 +
68 + @Test
69 + fun avancerSauteLesJoursChomes() {
70 + val vendredi = j("2026-01-09")
71 + assertEquals(vendredi, CalendrierOuvre(5).avancer(vendredi, 0))
72 + assertEquals(j("2026-01-12"), CalendrierOuvre(5).avancer(vendredi, 1))
73 + assertEquals(j("2026-01-10"), CalendrierOuvre(6).avancer(vendredi, 1))
74 + assertEquals(j("2026-01-10"), CalendrierOuvre(7).avancer(vendredi, 1))
75 + }
76 +
77 + @Test
78 + fun compterLesJoursOuvresDUnIntervalle() {
79 + val lundi = j("2026-01-05")
80 + val dimanche = j("2026-01-11")
81 + assertEquals(5, CalendrierOuvre(5).compter(lundi, dimanche))
82 + assertEquals(6, CalendrierOuvre(6).compter(lundi, dimanche))
83 + assertEquals(7, CalendrierOuvre(7).compter(lundi, dimanche))
84 + assertEquals(10, CalendrierOuvre(5).compter(lundi, j("2026-01-18")))
85 + assertEquals(1, CalendrierOuvre(5).compter(lundi, lundi))
86 + assertEquals(0, CalendrierOuvre(5).compter(dimanche, lundi), "intervalle inversé")
87 + }
88 +
89 + @Test
90 + fun unRythmeAberrantRetombeSurSeptJours() {
91 + assertTrue(CalendrierOuvre(0).estOuvre(j("2026-01-11")))
92 + assertTrue(CalendrierOuvre(99).estOuvre(j("2026-01-11")))
93 + }
94 +
95 + @Test
96 + fun periodeAffichee() {
97 + assertEquals("09/01 → 13/01", CalendrierOuvre(5).periodeAffichee(j("2026-01-09"), 3))
98 + assertEquals("09/01", CalendrierOuvre(5).periodeAffichee(j("2026-01-09"), 1))
99 + }
100 +}
A android/app/src/test/java/fr/ebii/card2vcf/planning/CouleurEtiquetteTest.kt
+44 -0
@@ -0,0 +1,44 @@
1 +package fr.ebii.card2vcf.planning
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertEquals
5 +import kotlin.test.assertNull
6 +import kotlin.test.assertTrue
7 +
8 +/**
9 + * Port du hachage d'étiquette de `Server/src/modeles.rs`. Le FNV-1a y a été
10 + * écrit explicitement pour être reproductible : une même étiquette doit donner
11 + * la même teinte sur le web et sur le téléphone.
12 + */
13 +class CouleurEtiquetteTest {
14 + @Test
15 + fun memeEtiquetteMemeTeinte() {
16 + val a = CouleurEtiquette.pour("câblage")
17 + assertEquals(a, CouleurEtiquette.pour("câblage"))
18 + assertTrue(CouleurEtiquette.PALETTE.contains(a))
19 + }
20 +
21 + @Test
22 + fun sansEtiquettePasDeTeinte() {
23 + assertNull(CouleurEtiquette.pour(null))
24 + assertNull(CouleurEtiquette.pour(" "))
25 + }
26 +
27 + /** Valeurs figées, à comparer avec le test Rust homologue. */
28 + @Test
29 + fun teintesDeReference() {
30 + assertEquals("#8B5CF6", CouleurEtiquette.pour("câblage"))
31 + assertEquals("#3FB985", CouleurEtiquette.pour("étude"))
32 + assertEquals("#0EA5E9", CouleurEtiquette.pour("chantier"))
33 + }
34 +
35 + @Test
36 + fun laPaletteEstReellementRepartie() {
37 + val libelles = listOf(
38 + "câblage", "étude", "chantier", "livraison", "essais", "montage",
39 + "devis", "recette", "soudure", "peinture", "controle", "reprise",
40 + )
41 + val distinctes = libelles.mapNotNull { CouleurEtiquette.pour(it) }.toSet()
42 + assertTrue(distinctes.size >= 5, "12 libellés ne donnent que ${distinctes.size} teintes")
43 + }
44 +}
M android/app/src/test/java/fr/ebii/card2vcf/sync/ContratSyncFixturesTest.kt
+37 -0
@@ -44,6 +44,43 @@ class ContratSyncFixturesTest {
44 44 assertNotNull("creeLe contact ne doit pas être null", contact.creeLe)
45 45 }
46 46
47 + /**
48 + * Contrat de planification : si le serveur renomme un de ces champs, le
49 + * téléphone cesserait silencieusement d'afficher dates, étiquettes ou
50 + * sous-tâches. Ce test rend la rupture immédiate.
51 + */
52 + @Test
53 + fun syncPull_planificationDesTaches() {
54 + val json = fixture("sync_pull.json")
55 + val pull = syncJson.decodeFromString<SyncPullResponse>(json)
56 +
57 + val projet = pull.projets.first()
58 + assertTrue("planification doit être transportée", projet.planification)
59 + assertEquals(5, projet.joursOuvres)
60 + assertEquals("2026-09-30", projet.echeance)
61 +
62 + val tache = pull.taches.first()
63 + assertEquals("2026-09-21", tache.debut)
64 + assertEquals(3, tache.dureeJours)
65 + assertEquals("contrat", tache.etiquette)
66 + assertEquals(2, tache.sousTaches.size)
67 + assertTrue("l'état coché doit survivre au transport", tache.sousTaches.first().fait)
68 + }
69 +
70 + /** La fin affichée sur le téléphone doit être celle du Gantt web. */
71 + @Test
72 + fun syncPull_periodeCalculeeCommeLeServeur() {
73 + val json = fixture("sync_pull.json")
74 + val pull = syncJson.decodeFromString<SyncPullResponse>(json)
75 + val projet = pull.projets.first()
76 + val tache = pull.taches.first()
77 +
78 + // Lundi 2026-09-21 + 3 jours ouvrés en rythme 5 : mercredi 23.
79 + val periode = fr.ebii.card2vcf.planning.CalendrierOuvre(projet.joursOuvres)
80 + .periodeAffichee(java.time.LocalDate.parse(tache.debut!!), tache.dureeJours!!)
81 + assertEquals("21/09 → 23/09", periode)
82 + }
83 +
47 84 // ---- interaction_note_vocale.json ----
48 85
49 86 @Test
M android/app/src/test/java/fr/ebii/card2vcf/sync/FakeAilianceApi.kt
+13 -0
@@ -71,6 +71,19 @@ class FakeAilianceApi : AilianceApi {
71 71 override fun updateTache(projetId: String, tacheId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
72 72 override fun deleteTache(projetId: String, tacheId: String) = AilianceApiClient.ApiResult.Ok(Unit)
73 73 override fun moveTache(projetId: String, tacheId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
74 +
75 + /** Bascules reçues : `(projetId, tacheId, sousTacheId)`. */
76 + val toggleSousTacheCalls = mutableListOf<Triple<String, String, String>>()
77 +
78 + override fun toggleSousTache(
79 + projetId: String,
80 + tacheId: String,
81 + sousTacheId: String,
82 + ): AilianceApiClient.ApiResult<String> {
83 + toggleSousTacheCalls += Triple(projetId, tacheId, sousTacheId)
84 + return AilianceApiClient.ApiResult.Ok("{}")
85 + }
86 +
74 87 var createInteractionResult: AilianceApiClient.ApiResult<String> =
75 88 AilianceApiClient.ApiResult.Ok("""{"id":"interaction-generated"}""")
76 89 val createInteractionCalls = mutableListOf<Pair<String, String>>() // contactId, jsonBody
A android/app/src/test/java/fr/ebii/card2vcf/sync/UpdateTacheRequestTest.kt
+72 -0
@@ -0,0 +1,72 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import kotlinx.serialization.encodeToString
4 +import kotlin.test.Test
5 +import kotlin.test.assertEquals
6 +import kotlin.test.assertFalse
7 +import kotlin.test.assertTrue
8 +
9 +/**
10 + * Forme du payload d'édition de tâche.
11 + *
12 + * Le serveur applique une sémantique partielle : champ **absent** = laisser tel
13 + * quel, `null` = effacer. Ce test fige les deux conséquences qui comptent —
14 + * ce que le téléphone envoie, et surtout ce qu'il n'envoie **pas**.
15 + */
16 +class UpdateTacheRequestTest {
17 +
18 + @Test
19 + fun nEnvoieJamaisLesChampsQuIlNEditePas() {
20 + val json = syncJson.encodeToString(
21 + UpdateTacheRequest(titre = "Câblage", assigneA = "alice"),
22 + )
23 + // Ces champs existent côté serveur mais ne sont pas édités sur mobile :
24 + // les omettre est ce qui les préserve.
25 + assertFalse(json.contains("sous_taches"), "les sous-tâches seraient écrasées : $json")
26 + assertFalse(json.contains("depend_de"), "les dépendances seraient écrasées : $json")
27 + assertFalse(json.contains("parent_id"), "le regroupement serait écrasé : $json")
28 + }
29 +
30 + @Test
31 + fun transporteLaPlanificationEditee() {
32 + val json = syncJson.encodeToString(
33 + UpdateTacheRequest(
34 + titre = "Câblage",
35 + assigneA = "alice",
36 + debut = "2026-07-22",
37 + dureeJours = 3,
38 + etiquette = "câblage",
39 + ),
40 + )
41 + assertTrue(json.contains("\"debut\":\"2026-07-22\""), json)
42 + assertTrue(json.contains("\"duree_jours\":3"), json)
43 + assertTrue(json.contains("\"etiquette\":\"câblage\""), json)
44 + assertTrue(json.contains("\"assigne_a_pose\":true"), json)
45 + }
46 +
47 + /** Vider un champ dans le dialogue doit effacer la valeur, pas la conserver. */
48 + @Test
49 + fun unChampVideEstEnvoyeANullPourEffacer() {
50 + val json = syncJson.encodeToString(
51 + UpdateTacheRequest(titre = "Câblage", debut = null, dureeJours = null),
52 + )
53 + assertTrue(json.contains("\"debut\":null"), json)
54 + assertTrue(json.contains("\"duree_jours\":null"), json)
55 + }
56 +
57 + @Test
58 + fun laCreationTransporteAussiLaPlanification() {
59 + val json = syncJson.encodeToString(
60 + CreateTacheRequest(
61 + titre = "Câblage",
62 + colonneId = "cadrer",
63 + debut = "2026-07-22",
64 + dureeJours = 2,
65 + etiquette = "essais",
66 + ),
67 + )
68 + assertTrue(json.contains("\"colonne_id\":\"cadrer\""), json)
69 + assertTrue(json.contains("\"debut\":\"2026-07-22\""), json)
70 + assertEquals(true, json.contains("\"duree_jours\":2"), json)
71 + }
72 +}
M android/app/src/test/resources/contrats/sync_pull.json
+38 -3
@@ -1,5 +1,5 @@
1 1 {
2 - "server_time": "2026-09-18T08:42:51.508932169Z",
2 + "server_time": "2026-09-23T17:01:24.484138317Z",
3 3 "contacts": [
4 4 {
5 5 "id": "contact-contrat-1",
@@ -56,13 +56,47 @@
56 56 "fichiers": [],
57 57 "apercu_liens_riche": true,
58 58 "gestion_contacts": false,
59 + "planification": true,
60 + "echeance": "2026-09-30",
61 + "mode_planification": "au_plus_tot",
62 + "jours_ouvres": 5,
59 63 "contacts_lies": [],
60 64 "entreprises_liees": [],
61 65 "membres": [],
62 66 "mis_a_jour_le": null
63 67 }
64 68 ],
65 - "taches": [],
69 + "taches": [
70 + {
71 + "projet_id": "projet-contrat-1",
72 + "id": "tache-contrat-1",
73 + "titre": "Tâche de contrat",
74 + "description": "Porte les champs de planification",
75 + "colonne_id": "explorer",
76 + "ordre": 0,
77 + "auteur": "alice",
78 + "cree_le": "2026-09-01T10:00:00Z",
79 + "sous_taches": [
80 + {
81 + "id": "st-1",
82 + "titre": "Première étape",
83 + "fait": true
84 + },
85 + {
86 + "id": "st-2",
87 + "titre": "Seconde étape",
88 + "fait": false
89 + }
90 + ],
91 + "debut": "2026-09-21",
92 + "duree_jours": 3,
93 + "depend_de": [],
94 + "assigne_a": "alice",
95 + "etiquette": "contrat",
96 + "parent_id": null,
97 + "mis_a_jour_le": null
98 + }
99 + ],
66 100 "interactions": [
67 101 {
68 102 "id": "interaction-vocale-contrat-1",
@@ -133,8 +167,9 @@
133 167 ],
134 168 "personnalise": false,
135 169 "gestion_contacts": false,
170 + "planification": false,
136 171 "cree_par": "systeme",
137 - "cree_le": "2026-09-18T08:42:51.507116089Z"
172 + "cree_le": "2026-09-23T17:01:24.478700818Z"
138 173 }
139 174 ]
140 175 }
140 175 = \ No newline at end of file
M ios/Card2vcfTests/Fixtures/sync_pull.json
+38 -3
@@ -1,5 +1,5 @@
1 1 {
2 - "server_time": "2026-09-18T08:42:51.508932169Z",
2 + "server_time": "2026-09-23T17:01:24.484138317Z",
3 3 "contacts": [
4 4 {
5 5 "id": "contact-contrat-1",
@@ -56,13 +56,47 @@
56 56 "fichiers": [],
57 57 "apercu_liens_riche": true,
58 58 "gestion_contacts": false,
59 + "planification": true,
60 + "echeance": "2026-09-30",
61 + "mode_planification": "au_plus_tot",
62 + "jours_ouvres": 5,
59 63 "contacts_lies": [],
60 64 "entreprises_liees": [],
61 65 "membres": [],
62 66 "mis_a_jour_le": null
63 67 }
64 68 ],
65 - "taches": [],
69 + "taches": [
70 + {
71 + "projet_id": "projet-contrat-1",
72 + "id": "tache-contrat-1",
73 + "titre": "Tâche de contrat",
74 + "description": "Porte les champs de planification",
75 + "colonne_id": "explorer",
76 + "ordre": 0,
77 + "auteur": "alice",
78 + "cree_le": "2026-09-01T10:00:00Z",
79 + "sous_taches": [
80 + {
81 + "id": "st-1",
82 + "titre": "Première étape",
83 + "fait": true
84 + },
85 + {
86 + "id": "st-2",
87 + "titre": "Seconde étape",
88 + "fait": false
89 + }
90 + ],
91 + "debut": "2026-09-21",
92 + "duree_jours": 3,
93 + "depend_de": [],
94 + "assigne_a": "alice",
95 + "etiquette": "contrat",
96 + "parent_id": null,
97 + "mis_a_jour_le": null
98 + }
99 + ],
66 100 "interactions": [
67 101 {
68 102 "id": "interaction-vocale-contrat-1",
@@ -133,8 +167,9 @@
133 167 ],
134 168 "personnalise": false,
135 169 "gestion_contacts": false,
170 + "planification": false,
136 171 "cree_par": "systeme",
137 - "cree_le": "2026-09-18T08:42:51.507116089Z"
172 + "cree_le": "2026-09-23T17:01:24.478700818Z"
138 173 }
139 174 ]
140 175 }
140 175 = \ No newline at end of file