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 |
GitRust