download des modeles teseract + ios

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

a9ab2ac9d88478d2981410a57a1d33c0920389dc

1 parent(s)

59 fichiers modifiés +3012 -134
M .gitignore
+1 -0
@@ -37,6 +37,7 @@ android/.kotlin/
37 37 android/build/
38 38 android/app/build/
39 39 android/tessdata-cache/
40 +android/vosk-cache/
40 41 # Signature release (à sauvegarder hors git : la même clé est requise pour toute mise à jour)
41 42 android/keystore/
42 43 android/keystore.properties
M README.md
+48 -22
@@ -85,20 +85,37 @@ Spec détaillée : [`docs/superpowers/specs/2026-07-22-sync-agenda-rdv-ressource
85 85 | `ita` | Italien |
86 86 | `pol` | Polonais |
87 87
88 -### Choix fast / full à la compilation
88 +### Sélection des langues dans l’app
89 89
90 -| Variante | Propriété Gradle | Source | Taille approx. |
91 -|----------|------------------|--------|----------------|
92 -| **fast** (défaut) | `-Ptessdata=fast` | [tessdata_fast](https://github.com/tesseract-ocr/tessdata_fast) | ~18 Mo |
93 -| **full** | `-Ptessdata=full` | [tessdata](https://github.com/tesseract-ocr/tessdata) | ~80 Mo+ |
90 +Les langues effectivement chargées par le moteur sont celles que l’utilisateur coche dans
91 +**Paramètres → Modèles hors-ligne**, parmi celles réellement installées. Par défaut : `fra` et `eng`.
92 +Une langue cochée mais non installée est simplement ignorée ; si **aucune** langue n’est
93 +disponible, le scan n’échoue pas en erreur : il propose le téléchargement, ou la saisie
94 +manuelle sur la photo de carte (voir « Saveurs » ci-dessous).
95 +
96 +### Saveurs de distribution : `fdroid` et `direct`
97 +
98 +| Saveur | Modèles dans le APK | Taille release | Usage |
99 +|--------|---------------------|----------------|-------|
100 +| **`fdroid`** | aucun — téléchargés depuis l’app | ~94 Mo | catalogue F-Droid |
101 +| **`direct`** | tessdata + Vosk embarqués | ~182 Mo (`full`) | bêta-testeurs, distribution directe |
94 102
95 103 ```bash
96 104 cd android
97 -./gradlew :app:assembleDebug # fast
98 -./gradlew :app:assembleDebug -Ptessdata=full # full
105 +./gradlew :app:assembleFdroidDebug # sans modèle, aucun téléchargement au build
106 +./gradlew :app:assembleDirectDebug # modèles embarqués, variante fast
107 +./gradlew :app:assembleDirectDebug -Ptessdata=full # modèles embarqués, variante full
99 108 ```
100 109
101 -Premier build d’une variante : téléchargement dans `android/tessdata-cache/{fast,full}/` (gitignored), puis injection dans les assets générés. Réseau requis **uniquement** si le cache est vide.
110 +Les propriétés `-Ptessdata=fast|full` et `-Pvosk=standard|none` ne concernent que la saveur
111 +`direct` : la saveur `fdroid` n’embarque rien et ne déclenche donc aucun téléchargement au build.
112 +
113 +| Variante `direct` | Propriété Gradle | Source | Taille approx. |
114 +|----------|------------------|--------|----------------|
115 +| **fast** (défaut) | `-Ptessdata=fast` | [tessdata_fast](https://github.com/tesseract-ocr/tessdata_fast) | ~18 Mo |
116 +| **full** | `-Ptessdata=full` | [tessdata](https://github.com/tesseract-ocr/tessdata) | ~80 Mo+ |
117 +
118 +Premier build d’une variante `direct` : téléchargement dans `android/tessdata-cache/{fast,full}/` (gitignored), puis injection dans les assets générés. Réseau requis **uniquement** si le cache est vide.
102 119
103 120 ### Suivi des modèles (le modèle a-t-il changé ?)
104 121
@@ -112,12 +129,14 @@ cd android
112 129
113 130 Pour chaque langue : état vs upstream GitHub (`à jour` / `DIFFÉRENT` / `cache absent`), taille et SHA-256. Le build embarque aussi `model-info.txt` (taille + SHA-256 par langue) dans les assets, à côté de `variant.txt`. Pour forcer un re-téléchargement : supprimer `android/tessdata-cache/` puis relancer un build.
114 131
115 -### Chargement ultérieur (sans rebuild / sans Internet dans l’APK)
132 +### Chargement ultérieur (sans rebuild)
116 133
117 -Oui. Au runtime, Tesseract lit `filesDir/tesseract/tessdata/` :
134 +Au runtime, Tesseract lit `filesDir/tesseract/tessdata/` :
118 135
119 -1. Première exécution (ou changement fast↔full du APK) → copie depuis les assets embarqués.
120 -2. Ensuite → réutilisation des fichiers locaux.
136 +1. Saveur `direct`, première exécution (ou changement fast↔full du APK) → copie depuis les assets embarqués.
137 +2. Saveur `fdroid` → rien dans les assets : les modèles arrivent par **Paramètres → Modèles hors-ligne**,
138 + téléchargés depuis `tessdata_fast` (ou un miroir interne configurable) et vérifiés par SHA-256 épinglé.
139 +3. Ensuite → réutilisation des fichiers locaux.
121 140 3. Sideload : déposer/remplacer les `*.traineddata` dans ce dossier (ex. `adb push`), puis éventuellement écrire le stamp `external` pour empêcher l’écrasement au prochain changement de variante APK :
122 141
123 142 ```text
@@ -127,7 +146,9 @@ Oui. Au runtime, Tesseract lit `filesDir/tesseract/tessdata/` :
127 146 .card2vcf-tessdata-variant # "fast" | "full" | "external"
128 147 ```
129 148
130 -Pas de téléchargement réseau dans l’app pour les modèles OCR (hors sync optionnelle PicLead).
149 +Le téléchargement des modèles est **explicitement déclenché par l’utilisateur** (bouton dédié) :
150 +rien n’est récupéré en arrière-plan, conformément à la politique d’inclusion F-Droid. Les modèles
151 +`tessdata_fast` et `vosk-model-small-fr` sont sous licence Apache 2.0.
131 152
132 153 APK debug filtré `arm64-v8a`. Pour émulateur x86, retirez temporairement `ndk.abiFilters` dans `app/build.gradle.kts`.
133 154
@@ -153,30 +174,35 @@ cd android
153 174 # 1. Incrémenter versionCode (obligatoire pour qu'Android accepte la mise à jour)
154 175 # et versionName dans app/build.gradle.kts
155 176 # 2. Builder (mêmes variantes tessdata que le debug)
156 -./gradlew :app:assembleRelease # fast (défaut)
157 -./gradlew :app:assembleRelease -Ptessdata=full # full
158 -# 3. Récupérer l'APK nommé d'après l'application
159 -ls app/build/outputs/apk/release/PicLead-*-release.apk
177 +./gradlew :app:assembleDirectRelease # bêta-testeurs, fast (défaut)
178 +./gradlew :app:assembleDirectRelease -Ptessdata=full # bêta-testeurs, full
179 +./gradlew :app:assembleFdroidRelease # sans modèle
180 +# 3. Récupérer l'APK nommé d'après l'application et sa saveur
181 +ls app/build/outputs/apk/*/release/PicLead-*-release.apk
160 182 ```
161 183
162 184 Vérification optionnelle de la signature avant envoi :
163 185
164 186 ```bash
165 187 "$HOME"/Android/Sdk/build-tools/*/apksigner verify --print-certs \
166 - app/build/outputs/apk/release/PicLead-*-release.apk
188 + app/build/outputs/apk/direct/release/PicLead-*-release.apk
167 189 ```
168 190
169 -Distribution : envoyez le fichier `PicLead-<version>-release.apk` tel quel (mail, lien) ; à l'installation, Android demandera d'autoriser l'installation depuis une source inconnue.
191 +Distribution : envoyez le fichier `PicLead-<version>-direct-release.apk` tel quel (mail, lien) ; à l'installation, Android demandera d'autoriser l'installation depuis une source inconnue.
170 192
171 193 ## Build
172 194
173 195 ```bash
174 196 cd android
175 -./gradlew :app:assembleDebug
176 -./gradlew :app:testDebugUnitTest
197 +./gradlew :app:assembleFdroidDebug
198 +./gradlew :app:testFdroidDebugUnitTest
199 +./gradlew :app:testDirectDebugUnitTest
177 200 ```
178 201
179 -APK debug : `android/app/build/outputs/apk/debug/app-debug.apk`
202 +APK debug : `android/app/build/outputs/apk/fdroid/debug/PicLead-<version>-fdroid-debug.apk`
203 +
204 +Les tests d’absence de modèle vivent dans la source set `app/src/testFdroid/` : la saveur
205 +`direct` embarque ses traineddata, l’absence n’y est pas reproductible.
180 206
181 207 ##Install
182 208 adb install -r android/app/build/outputs/apk/debug/app-debug.apk
M android/app/build.gradle.kts
+31 -10
@@ -47,8 +47,8 @@ android {
47 47 applicationId = "fr.ebii.piclead"
48 48 minSdk = 30
49 49 targetSdk = 35
50 - versionCode = 3
51 - versionName = "0.4.5"
50 + versionCode = 4
51 + versionName = "0.5.0"
52 52 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
53 53 ndk {
54 54 abiFilters += listOf("arm64-v8a")
@@ -67,8 +67,15 @@ android {
67 67 unitTests.isReturnDefaultValues = true
68 68 unitTests.isIncludeAndroidResources = true
69 69 }
70 - sourceSets.getByName("main").assets.srcDir(tessdataGeneratedAssets)
71 - sourceSets.getByName("main").assets.srcDir(voskGeneratedAssets)
70 + /** `fdroid` : aucun modèle embarqué, l'utilisateur les télécharge depuis l'app.
71 + * `direct` : modèles dans le APK, comme historiquement (bêta-testeurs). */
72 + flavorDimensions += "distribution"
73 + productFlavors {
74 + create("fdroid")
75 + create("direct")
76 + }
77 + sourceSets.getByName("direct").assets.srcDir(tessdataGeneratedAssets)
78 + sourceSets.getByName("direct").assets.srcDir(voskGeneratedAssets)
72 79 signingConfigs {
73 80 if (keystoreProperties.getProperty("storeFile") != null) {
74 81 create("release") {
@@ -85,12 +92,12 @@ android {
85 92 signingConfig = signingConfigs.findByName("release")
86 93 }
87 94 }
88 - // Sorties nommées d'après l'application : PicLead-<version>-<type>.apk
95 + // Sorties nommées d'après l'application : PicLead-<version>-<saveur>-<type>.apk
89 96 applicationVariants.all {
90 97 val variant = this
91 98 outputs.all {
92 99 (this as com.android.build.gradle.internal.api.BaseVariantOutputImpl).outputFileName =
93 - "PicLead-${variant.versionName}-${variant.buildType.name}.apk"
100 + "PicLead-${variant.versionName}-${variant.flavorName}-${variant.buildType.name}.apk"
94 101 }
95 102 }
96 103 }
@@ -148,12 +155,20 @@ val prepareTessdata by tasks.registering {
148 155 }
149 156 }
150 157
158 +// Rattaché aux seules variantes `direct` : la saveur `fdroid` ne doit pas payer
159 +// le téléchargement de 172 Mo de modèles qu'elle n'embarquera pas.
160 +tasks.matching {
161 + it.name.startsWith("merge") && it.name.endsWith("Assets") && it.name.contains("Direct")
162 +}.configureEach {
163 + dependsOn(prepareTessdata)
164 +}
165 +// `pre<Saveur><Type>Build` ordonne aussi les tâches lint, qui lisent les assets générés :
166 +// sans cela Gradle refuse le build pour dépendance implicite non déclarée.
151 167 tasks.matching {
152 - it.name.startsWith("merge") && it.name.endsWith("Assets")
168 + it.name.startsWith("pre") && it.name.endsWith("Build") && it.name.contains("Direct")
153 169 }.configureEach {
154 170 dependsOn(prepareTessdata)
155 171 }
156 -tasks.named("preBuild").configure { dependsOn(prepareTessdata) }
157 172
158 173 /** Compare le cache local des traineddata avec l'upstream GitHub (lecture seule).
159 174 * Usage : ./gradlew tessdataStatus [-Ptessdata=full] */
@@ -236,11 +251,17 @@ val prepareVoskModel by tasks.registering {
236 251 }
237 252
238 253 tasks.matching {
239 - it.name.startsWith("merge") && it.name.endsWith("Assets")
254 + it.name.startsWith("merge") && it.name.endsWith("Assets") && it.name.contains("Direct")
255 +}.configureEach {
256 + dependsOn(prepareVoskModel)
257 +}
258 +// `pre<Saveur><Type>Build` ordonne aussi les tâches lint, qui lisent les assets générés :
259 +// sans cela Gradle refuse le build pour dépendance implicite non déclarée.
260 +tasks.matching {
261 + it.name.startsWith("pre") && it.name.endsWith("Build") && it.name.contains("Direct")
240 262 }.configureEach {
241 263 dependsOn(prepareVoskModel)
242 264 }
243 -tasks.named("preBuild").configure { dependsOn(prepareVoskModel) }
244 265
245 266 dependencies {
246 267 implementation(platform("androidx.compose:compose-bom:2024.09.02"))
M android/app/src/main/java/fr/ebii/card2vcf/audio/TranscripteurVosk.kt
+9 -3
@@ -15,7 +15,8 @@ import java.io.File
15 15 * Sur le premier appel à [accepterEchantillons], le modèle est extrait des assets
16 16 * vers [Context.filesDir] si ce n'est pas déjà fait.
17 17 *
18 - * Si le modèle est absent des assets (build `-Pvosk=none`), [etat] vaut
18 + * Si le modèle n'est ni dans les assets ni dans `filesDir` (saveur `fdroid` sans
19 + * téléchargement), [etat] vaut
19 20 * [TranscripteurLocal.Etat.INDISPONIBLE] et toutes les méthodes de transcription
20 21 * renvoient des valeurs vides/null sans planter.
21 22 */
@@ -55,11 +56,16 @@ class TranscripteurVosk(private val context: Context) : TranscripteurLocal {
55 56 private var reconnaisseur: Recognizer? = null
56 57
57 58 private fun detecterEtat(): TranscripteurLocal.Etat {
58 - val disponible = runCatching {
59 + val dansAssets = runCatching {
59 60 context.assets.list("")?.contains(NOM_MODELE) == true
60 61 }.getOrDefault(false)
62 + // Saveur `fdroid` : rien dans les assets, mais le modèle a pu être téléchargé
63 + // depuis Paramètres → Modèles et décompressé ici par DepotModeles.
64 + val dansFilesDir = File(context.filesDir, NOM_MODELE)
65 + .let { it.isDirectory && !it.listFiles().isNullOrEmpty() }
66 + val disponible = dansAssets || dansFilesDir
61 67 if (!disponible) {
62 - Log.w(TAG, "Modèle Vosk absent des assets — mode serveur requis (build -Pvosk=none ?)")
68 + Log.w(TAG, "Modèle Vosk absent — transcription serveur requise tant qu'il n'est pas téléchargé")
63 69 }
64 70 return if (disponible) TranscripteurLocal.Etat.DISPONIBLE else TranscripteurLocal.Etat.INDISPONIBLE
65 71 }
A android/app/src/main/java/fr/ebii/card2vcf/modeles/CatalogueModeles.kt
+54 -0
@@ -0,0 +1,54 @@
1 +package fr.ebii.card2vcf.modeles
2 +
3 +/**
4 + * Table des modèles téléchargeables, avec taille et empreinte épinglées.
5 + *
6 + * Les traineddata viennent de `tessdata_fast` (variante rapide : 17,6 Mo pour les 7 langues,
7 + * contre 107 Mo en `tessdata` complet). Ces dépôts amont sont stables depuis des années,
8 + * ce qui rend l'épinglage tenable ; `./gradlew tessdataStatus` compare le cache local à l'amont.
9 + */
10 +object CatalogueModeles {
11 +
12 + const val BASE_TESSDATA_DEFAUT = "https://github.com/tesseract-ocr/tessdata_fast/raw/main"
13 + const val BASE_VOSK_DEFAUT = "https://alphacephei.com/vosk/models"
14 +
15 + /** Langues cochées d'office : celles que le moteur charge historiquement. */
16 + val LANGUES_PAR_DEFAUT = setOf("fra", "eng")
17 +
18 + private fun tessdata(langue: String, taille: Long, sha256: String) =
19 + ModeleLocal(langue, TypeModele.TESSDATA, taille, sha256)
20 +
21 + val tessdata: List<ModeleLocal> = listOf(
22 + tessdata("fra", 1_130_365, "ced037562e8c80c13122dece28dd477d399af80911a28791a66a63ac1e3445ca"),
23 + tessdata("eng", 4_113_088, "7d4322bd2a7749724879683fc3912cb542f19906c83bcc1a52132556427170b2"),
24 + tessdata("deu", 1_525_436, "19d219bbb6672c869d20a9636c6816a81eb9a71796cb93ebe0cb1530e2cdb22d"),
25 + tessdata("spa", 2_294_433, "6f2e04d02774a18f01bed44b1111f2cd7f3ba7ac9dc4373cd3f898a40ea6b464"),
26 + tessdata("por", 1_982_756, "c4932b937207a9514b7514d518b931a99938c02a28a5a5a553f8599ed58b7deb"),
27 + tessdata("ita", 2_701_314, "b8f89e1e785118dac4d51ae042c029a64edb5c3ee42ef73027a6d412748d8827"),
28 + tessdata("pol", 4_765_518, "c4476cdbc0e33d898d32345122b7be1cbf85ace15f920f06c7714756e1ef79b2"),
29 + )
30 +
31 + val vosk: ModeleLocal = ModeleLocal(
32 + id = "vosk-model-small-fr-0.22",
33 + type = TypeModele.VOSK,
34 + tailleOctets = 42_233_323,
35 + sha256 = "cabf6180e177eb9b3a9a9d43a437bd5e549f3a7d09525e5d69a3fed787be12ad",
36 + )
37 +
38 + val tous: List<ModeleLocal> = tessdata + vosk
39 +
40 + fun parId(id: String): ModeleLocal? = tous.firstOrNull { it.id == id }
41 +
42 + /**
43 + * URL de téléchargement. Un [miroir] non vide remplace les deux bases amont ;
44 + * il est alors supposé exposer les fichiers à plat (`fra.traineddata`, `…-0.22.zip`).
45 + */
46 + fun url(modele: ModeleLocal, miroir: String?): String {
47 + val base = miroir?.trim()?.takeIf { it.isNotEmpty() }?.trimEnd('/')
48 + ?: when (modele.type) {
49 + TypeModele.TESSDATA -> BASE_TESSDATA_DEFAUT
50 + TypeModele.VOSK -> BASE_VOSK_DEFAUT
51 + }
52 + return "$base/${modele.nomFichier}"
53 + }
54 +}
A android/app/src/main/java/fr/ebii/card2vcf/modeles/DepotModeles.kt
+212 -0
@@ -0,0 +1,212 @@
1 +package fr.ebii.card2vcf.modeles
2 +
3 +import android.content.Context
4 +import android.util.Log
5 +import kotlinx.coroutines.Dispatchers
6 +import kotlinx.coroutines.flow.MutableStateFlow
7 +import kotlinx.coroutines.flow.StateFlow
8 +import kotlinx.coroutines.flow.asStateFlow
9 +import kotlinx.coroutines.withContext
10 +import okhttp3.OkHttpClient
11 +import okhttp3.Request
12 +import java.io.File
13 +import java.io.IOException
14 +import java.security.MessageDigest
15 +import java.util.zip.ZipInputStream
16 +
17 +/**
18 + * Téléchargement, vérification et suppression des modèles hors-ligne.
19 + *
20 + * Les traineddata atterrissent dans `filesDir/tesseract/tessdata/`, là où
21 + * `TesseractOcrEngine` les cherche déjà ; le modèle Vosk est décompressé à la racine
22 + * de `filesDir`, là où `TranscripteurVosk` l'attend.
23 + *
24 + * Un téléchargement passe par un fichier `.part` et n'est promu qu'après vérification
25 + * du SHA-256 épinglé au [CatalogueModeles] : un octet de travers et rien n'est installé.
26 + */
27 +class DepotModeles(
28 + context: Context,
29 + private val prefs: PreferencesModeles,
30 + private val client: OkHttpClient = OkHttpClient(),
31 +) {
32 + private val filesDir: File = context.applicationContext.filesDir
33 +
34 + private val _etats = MutableStateFlow<Map<String, EtatModele>>(emptyMap())
35 + val etats: StateFlow<Map<String, EtatModele>> = _etats.asStateFlow()
36 +
37 + /** Relit le disque pour tout le catalogue — à appeler à l'ouverture de l'écran Modèles. */
38 + fun rafraichir() {
39 + _etats.value = CatalogueModeles.tous.associate { it.id to etatDisque(it) }
40 + }
41 +
42 + /** État courant : ce qui est en mémoire (en cours, échec) prime sur le disque. */
43 + fun etat(modele: ModeleLocal): EtatModele =
44 + _etats.value[modele.id] ?: etatDisque(modele)
45 +
46 + fun languesPretes(): List<String> {
47 + val selectionnees = prefs.languesSelectionnees()
48 + return CatalogueModeles.tessdata
49 + .filter { it.id in selectionnees && etatDisque(it) is EtatModele.Present }
50 + .map { it.id }
51 + }
52 +
53 + /** Ne lève jamais : un échec se lit dans [etat]. */
54 + suspend fun telecharger(modele: ModeleLocal) {
55 + publier(modele, EtatModele.EnCours(0))
56 + val resultat = withContext(Dispatchers.IO) { runCatching { installer(modele) } }
57 + resultat
58 + .onSuccess { publier(modele, etatDisque(modele)) }
59 + .onFailure { e ->
60 + Log.w(TAG, "téléchargement ${modele.id}", e)
61 + publier(modele, EtatModele.Echec(e.message ?: "Téléchargement impossible"))
62 + }
63 + }
64 +
65 + fun supprimer(modele: ModeleLocal) {
66 + when (modele.type) {
67 + TypeModele.TESSDATA -> cibleTessdata(modele).delete()
68 + TypeModele.VOSK -> racineVosk(modele).deleteRecursively()
69 + }
70 + publier(modele, EtatModele.Absent)
71 + }
72 +
73 + private fun publier(modele: ModeleLocal, etat: EtatModele) {
74 + _etats.value = _etats.value + (modele.id to etat)
75 + }
76 +
77 + private fun etatDisque(modele: ModeleLocal): EtatModele = when (modele.type) {
78 + // Présence, pas égalité de taille : un traineddata peut venir des assets de la
79 + // saveur « direct » en variante `full`, ou d'un dépôt manuel par adb — dans les
80 + // deux cas il est plus lourd que l'entrée `fast` du catalogue et reste valide.
81 + // L'intégrité de ce que l'app télécharge, elle, est garantie par le SHA-256.
82 + TypeModele.TESSDATA -> cibleTessdata(modele)
83 + .takeIf { it.isFile && it.length() >= TAILLE_MINIMALE }
84 + ?.let { EtatModele.Present(it.length()) }
85 + ?: EtatModele.Absent
86 +
87 + TypeModele.VOSK -> racineVosk(modele)
88 + .takeIf { it.isDirectory && !it.listFiles().isNullOrEmpty() }
89 + ?.let { EtatModele.Present(modele.tailleOctets) }
90 + ?: EtatModele.Absent
91 + }
92 +
93 + private fun dossierTessdata() = File(filesDir, "tesseract/tessdata")
94 + private fun cibleTessdata(modele: ModeleLocal) = File(dossierTessdata(), modele.nomFichier)
95 + private fun racineVosk(modele: ModeleLocal) = File(filesDir, modele.id)
96 +
97 + /** Télécharge, vérifie, puis met en place. Toute erreur laisse le disque propre. */
98 + private fun installer(modele: ModeleLocal) {
99 + // Le fichier partiel vit à côté de sa destination : un échec ne laisse rien
100 + // d'exploitable là où les moteurs vont chercher leurs modèles.
101 + val part = when (modele.type) {
102 + TypeModele.TESSDATA -> File(dossierTessdata(), "${modele.nomFichier}.part")
103 + TypeModele.VOSK -> File(filesDir, "${modele.nomFichier}.part")
104 + }
105 + part.parentFile?.mkdirs()
106 + try {
107 + telechargerVers(modele, part)
108 + val empreinte = sha256(part)
109 + if (!empreinte.equals(modele.sha256, ignoreCase = true)) {
110 + throw IOException(
111 + "Empreinte inattendue pour ${modele.nomFichier} — téléchargement rejeté",
112 + )
113 + }
114 + when (modele.type) {
115 + TypeModele.TESSDATA -> installerTessdata(modele, part)
116 + TypeModele.VOSK -> installerVosk(modele, part)
117 + }
118 + } finally {
119 + part.delete()
120 + }
121 + }
122 +
123 + private fun telechargerVers(modele: ModeleLocal, destination: File) {
124 + val url = CatalogueModeles.url(modele, prefs.miroir())
125 + val requete = Request.Builder().url(url).build()
126 + client.newCall(requete).execute().use { reponse ->
127 + if (!reponse.isSuccessful) {
128 + throw IOException("HTTP ${reponse.code} sur $url")
129 + }
130 + val corps = reponse.body ?: throw IOException("Réponse vide sur $url")
131 + val attendu = corps.contentLength().takeIf { it > 0 } ?: modele.tailleOctets
132 + corps.byteStream().use { entree ->
133 + destination.outputStream().use { sortie ->
134 + val tampon = ByteArray(64 * 1024)
135 + var cumul = 0L
136 + var dernierPourcentage = 0
137 + while (true) {
138 + val lus = entree.read(tampon)
139 + if (lus < 0) break
140 + sortie.write(tampon, 0, lus)
141 + cumul += lus
142 + val pourcentage = ((cumul * 100) / attendu).toInt().coerceIn(0, 100)
143 + if (pourcentage >= dernierPourcentage + 5) {
144 + dernierPourcentage = pourcentage
145 + publier(modele, EtatModele.EnCours(pourcentage))
146 + }
147 + }
148 + }
149 + }
150 + }
151 + }
152 +
153 + private fun installerTessdata(modele: ModeleLocal, part: File) {
154 + val dossier = dossierTessdata().apply { mkdirs() }
155 + val cible = File(dossier, modele.nomFichier)
156 + cible.delete()
157 + if (!part.renameTo(cible)) {
158 + part.copyTo(cible, overwrite = true)
159 + }
160 + // Marque les modèles comme fournis par l'utilisateur : la copie depuis les
161 + // assets (saveur « direct ») ne les écrasera pas au prochain lancement.
162 + File(dossier, STAMP_TESSDATA).writeText(STAMP_EXTERNAL)
163 + }
164 +
165 + private fun installerVosk(modele: ModeleLocal, archive: File) {
166 + val racine = racineVosk(modele)
167 + racine.deleteRecursively()
168 + val canonique = filesDir.canonicalPath
169 + ZipInputStream(archive.inputStream().buffered()).use { zis ->
170 + var entree = zis.nextEntry
171 + while (entree != null) {
172 + val cible = File(filesDir, entree.name)
173 + if (!cible.canonicalPath.startsWith(canonique + File.separator)) {
174 + throw IOException("Entrée d'archive hors du dossier applicatif : ${entree.name}")
175 + }
176 + if (entree.isDirectory) {
177 + cible.mkdirs()
178 + } else {
179 + cible.parentFile?.mkdirs()
180 + cible.outputStream().use { zis.copyTo(it) }
181 + }
182 + zis.closeEntry()
183 + entree = zis.nextEntry
184 + }
185 + }
186 + if (!racine.isDirectory) {
187 + throw IOException("L'archive ne contient pas le dossier ${modele.id}")
188 + }
189 + }
190 +
191 + private fun sha256(fichier: File): String {
192 + val digest = MessageDigest.getInstance("SHA-256")
193 + fichier.inputStream().use { entree ->
194 + val tampon = ByteArray(64 * 1024)
195 + while (true) {
196 + val lus = entree.read(tampon)
197 + if (lus < 0) break
198 + digest.update(tampon, 0, lus)
199 + }
200 + }
201 + return digest.digest().joinToString("") { "%02x".format(it) }
202 + }
203 +
204 + private companion object {
205 + const val TAG = "DepotModeles"
206 + const val STAMP_TESSDATA = ".card2vcf-tessdata-variant"
207 + const val STAMP_EXTERNAL = "external"
208 +
209 + /** En deçà, le fichier est un reliquat ou une page d'erreur, pas un modèle. */
210 + const val TAILLE_MINIMALE = 10_000L
211 + }
212 +}
A android/app/src/main/java/fr/ebii/card2vcf/modeles/ModeleLocal.kt
+37 -0
@@ -0,0 +1,37 @@
1 +package fr.ebii.card2vcf.modeles
2 +
3 +enum class TypeModele {
4 + /** Fichier `.traineddata` Tesseract, déposé tel quel. */
5 + TESSDATA,
6 +
7 + /** Archive zip Vosk, décompressée après téléchargement. */
8 + VOSK,
9 +}
10 +
11 +/**
12 + * Un modèle téléchargeable après installation.
13 + *
14 + * [tailleOctets] et [sha256] sont épinglés au catalogue : ils servent à afficher le poids
15 + * avant téléchargement et à vérifier l'intégrité de ce qui a été reçu.
16 + */
17 +data class ModeleLocal(
18 + val id: String,
19 + val type: TypeModele,
20 + val tailleOctets: Long,
21 + val sha256: String,
22 +) {
23 + /** Nom du fichier tel qu'il est publié en amont (et attendu sur un miroir). */
24 + val nomFichier: String
25 + get() = when (type) {
26 + TypeModele.TESSDATA -> "$id.traineddata"
27 + TypeModele.VOSK -> "$id.zip"
28 + }
29 +}
30 +
31 +/** État d'un modèle sur l'appareil. */
32 +sealed interface EtatModele {
33 + data object Absent : EtatModele
34 + data class EnCours(val pourcentage: Int) : EtatModele
35 + data class Present(val tailleOctets: Long) : EtatModele
36 + data class Echec(val message: String) : EtatModele
37 +}
A android/app/src/main/java/fr/ebii/card2vcf/modeles/PreferencesModeles.kt
+33 -0
@@ -0,0 +1,33 @@
1 +package fr.ebii.card2vcf.modeles
2 +
3 +import android.content.Context
4 +import android.content.SharedPreferences
5 +
6 +/** Langues OCR retenues par l'utilisateur et miroir éventuel pour le téléchargement. */
7 +class PreferencesModeles internal constructor(
8 + private val prefs: SharedPreferences,
9 +) {
10 + constructor(context: Context) : this(
11 + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE),
12 + )
13 +
14 + fun languesSelectionnees(): Set<String> =
15 + prefs.getStringSet(KEY_LANGUES, null) ?: CatalogueModeles.LANGUES_PAR_DEFAUT
16 +
17 + fun setLanguesSelectionnees(langues: Set<String>) {
18 + prefs.edit().putStringSet(KEY_LANGUES, langues).apply()
19 + }
20 +
21 + /** null quand aucun miroir n'est configuré : les bases amont s'appliquent. */
22 + fun miroir(): String? = prefs.getString(KEY_MIROIR, null)?.takeIf { it.isNotBlank() }
23 +
24 + fun setMiroir(url: String?) {
25 + prefs.edit().putString(KEY_MIROIR, url?.trim().orEmpty()).apply()
26 + }
27 +
28 + private companion object {
29 + const val PREFS_NAME = "modeles_locaux"
30 + const val KEY_LANGUES = "langues_selectionnees"
31 + const val KEY_MIROIR = "miroir"
32 + }
33 +}
M android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrEngine.kt
+11 -1
@@ -6,4 +6,14 @@ interface OcrEngine {
6 6 fun recognize(bitmap: Bitmap): OcrResult
7 7 }
8 8
9 -class OcrException(message: String, cause: Throwable? = null) : Exception(message, cause)
9 +open class OcrException(message: String, cause: Throwable? = null) : Exception(message, cause)
10 +
11 +/**
12 + * Aucun `.traineddata` installé pour les langues retenues : la carte peut être
13 + * photographiée et redressée, mais aucun champ du contact ne sera rempli.
14 + *
15 + * L'UI doit proposer le téléchargement des modèles plutôt qu'un simple message d'erreur.
16 + */
17 +class ModelesManquantsException(val langues: List<String>) : OcrException(
18 + "Aucun modèle de reconnaissance de texte installé (${langues.joinToString(", ")})",
19 +)
M android/app/src/main/java/fr/ebii/card2vcf/ocr/TesseractOcrEngine.kt
+56 -30
@@ -5,19 +5,23 @@ import android.graphics.Bitmap
5 5 import android.os.SystemClock
6 6 import android.util.Log
7 7 import com.googlecode.tesseract.android.TessBaseAPI
8 +import fr.ebii.card2vcf.modeles.CatalogueModeles
9 +import fr.ebii.card2vcf.modeles.PreferencesModeles
8 10 import java.io.File
9 11 import java.io.FileOutputStream
10 12
11 13 class TesseractOcrEngine(
12 14 private val context: Context,
13 - private val languages: String = "fra+eng",
15 + private val prefs: PreferencesModeles = PreferencesModeles(context),
14 16 /** PSM_AUTO par défaut : dernière configuration validée sur appareil.
15 17 * PSM_SPARSE_TEXT reste comparable via le test instrumenté dédié. */
16 18 private val pageSegMode: Int = TessBaseAPI.PageSegMode.PSM_AUTO,
17 19 ) : OcrEngine {
18 20
19 21 override fun recognize(bitmap: Bitmap): OcrResult {
20 - val dataPath = ensureTessData()
22 + val langues = languesActives()
23 + val languages = langues.joinToString("+")
24 + val dataPath = File(context.filesDir, "tesseract").absolutePath
21 25 val t0 = SystemClock.elapsedRealtime()
22 26 // Une seule init par scan (coûteuse : charge les traineddata) ; les passes
23 27 // et orientations réutilisent l'instance via setImage.
@@ -41,7 +45,7 @@ class TesseractOcrEngine(
41 45 val onOriginal = runCatching {
42 46 OcrOrientation.recognizeBest(bitmap) { recognizeOnce(api, it) }
43 47 }.getOrNull()
44 - logDuree(t0, tInit)
48 + logDuree(t0, tInit, languages)
45 49 return listOfNotNull(onPrepared, onOriginal).maxByOrNull { OcrOrientation.score(it) }
46 50 ?: throw OcrException("OCR vide — reprenez la photo")
47 51 } finally {
@@ -77,28 +81,52 @@ class TesseractOcrEngine(
77 81 lines.toList()
78 82 }.getOrDefault(emptyList())
79 83
80 - private fun logDuree(t0: Long, tInit: Long) {
84 + private fun logDuree(t0: Long, tInit: Long, languages: String) {
81 85 val now = SystemClock.elapsedRealtime()
82 86 Log.d(TAG, "scan OCR : init=${tInit - t0} ms, reco=${now - tInit} ms, total=${now - t0} ms ($languages)")
83 87 }
84 88
85 89 /**
90 + * Langues réellement chargeables : sélection de l'utilisateur ∩ modèles présents,
91 + * dans l'ordre du [CatalogueModeles].
92 + *
86 93 * Les `.traineddata` vivent dans `filesDir/tesseract/tessdata/` :
87 - * - 1ʳᵉ fois / changement `-Ptessdata=` → copie depuis les assets du APK
88 - * - ensuite → réutilisation locale (sideload / remplacement possible sans rebuild)
89 - * - stamp `external` → ne jamais écraser (chargement ultérieur manuel)
94 + * - 1ʳᵉ fois / changement `-Ptessdata=` → copie depuis les assets du APK (saveur `direct`)
95 + * - ensuite → réutilisation locale (téléchargement, sideload, remplacement sans rebuild)
96 + * - stamp `external` → ne jamais écraser : c'est le cas des modèles téléchargés par
97 + * [fr.ebii.card2vcf.modeles.DepotModeles], comme du dépôt manuel par adb
90 98 *
91 99 * La variante attendue est lue dans l'asset `tesseract/tessdata/variant.txt`
92 - * (écrit par la tâche Gradle `prepareTessdata`), pas via BuildConfig.
100 + * (écrit par la tâche Gradle `prepareTessdata`), pas via BuildConfig — la saveur
101 + * `fdroid` n'embarque aucun asset et retombe donc sur le seul contenu de filesDir.
102 + *
103 + * @throws ModelesManquantsException si aucune langue retenue n'est installée.
93 104 */
94 - private fun ensureTessData(): String {
95 - val tessRoot = File(context.filesDir, "tesseract").apply { mkdirs() }
96 - val tessData = File(tessRoot, "tessdata").apply { mkdirs() }
105 + internal fun languesActives(): List<String> {
106 + val tessData = File(context.filesDir, "tesseract/tessdata").apply { mkdirs() }
107 + val selection = selectionOrdonnee()
108 + syncDepuisAssets(tessData, selection)
109 +
110 + val presentes = selection.filter { estInstallee(tessData, it) }
111 + if (presentes.isEmpty()) throw ModelesManquantsException(selection)
112 + return presentes
113 + }
114 +
115 + /** Sélection utilisateur remise dans l'ordre du catalogue, pour un `fra+eng` déterministe. */
116 + private fun selectionOrdonnee(): List<String> {
117 + val choisies = prefs.languesSelectionnees()
118 + .ifEmpty { CatalogueModeles.LANGUES_PAR_DEFAUT }
119 + return CatalogueModeles.tessdata.map { it.id }.filter { it in choisies }
120 + }
121 +
122 + private fun estInstallee(tessData: File, langue: String): Boolean =
123 + File(tessData, "$langue.traineddata").let { it.isFile && it.length() >= TAILLE_MINIMALE }
124 +
125 + private fun syncDepuisAssets(tessData: File, langs: List<String>) {
97 126 val stamp = File(tessData, VARIANT_STAMP)
98 - val langs = languages.split('+')
99 127 val expectedVariant = packagedVariant()
100 128 val stampValue = stamp.takeIf { it.isFile }?.readText()?.trim()
101 - val allPresent = langs.all { File(tessData, "$it.traineddata").isFile }
129 + val allPresent = langs.isNotEmpty() && langs.all { estInstallee(tessData, it) }
102 130
103 131 val syncFromAssets = when {
104 132 !allPresent -> true
@@ -107,26 +135,21 @@ class TesseractOcrEngine(
107 135 stampValue == expectedVariant -> false
108 136 else -> true // ex. fast → full après réinstall
109 137 }
138 + if (!syncFromAssets) return
110 139
111 - if (syncFromAssets) {
112 - for (lang in langs) {
113 - val assetPath = "tesseract/tessdata/$lang.traineddata"
114 - if (assetExists(assetPath)) {
115 - copyAsset(assetPath, File(tessData, "$lang.traineddata"))
116 - }
140 + // Recopie systématique : un passage fast → full doit bien remplacer les fichiers
141 + // en place. Les modèles téléchargés sont protégés en amont par le stamp `external`.
142 + var copie = false
143 + for (lang in langs) {
144 + val assetPath = "tesseract/tessdata/$lang.traineddata"
145 + if (assetExists(assetPath)) {
146 + copyAsset(assetPath, File(tessData, "$lang.traineddata"))
147 + copie = true
117 148 }
118 - stamp.writeText(expectedVariant)
119 149 }
120 -
121 - val missing = langs.filterNot { File(tessData, "$it.traineddata").isFile }
122 - if (missing.isNotEmpty()) {
123 - throw OcrException(
124 - "Modèle(s) OCR manquant(s) : ${missing.joinToString { "$it.traineddata" }}. " +
125 - "Recompilez avec -Ptessdata=fast|full, ou déposez les fichiers dans " +
126 - tessData.absolutePath
127 - )
128 - }
129 - return tessRoot.absolutePath
150 + // Saveur `fdroid` : aucun asset, donc rien à marquer — le stamp `external`
151 + // éventuellement posé par un téléchargement doit rester intact.
152 + if (copie) stamp.writeText(expectedVariant)
130 153 }
131 154
132 155 private fun packagedVariant(): String =
@@ -148,5 +171,8 @@ class TesseractOcrEngine(
148 171 private const val VARIANT_STAMP = ".card2vcf-tessdata-variant"
149 172 private const val VARIANT_ASSET = "tesseract/tessdata/variant.txt"
150 173 private const val STAMP_EXTERNAL = "external"
174 +
175 + /** En deçà, le fichier est un reliquat, pas un modèle exploitable. */
176 + private const val TAILLE_MINIMALE = 10_000L
151 177 }
152 178 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/ScanCarteScreen.kt
+33 -0
@@ -57,6 +57,7 @@ fun ScanCarteScreen(
57 57 onCreateContact: (fr.ebii.card2vcf.contact.ContactCard) -> Unit,
58 58 onExportVcf: (fr.ebii.card2vcf.contact.ContactCard) -> Unit,
59 59 onContactUpdated: (contactId: Long) -> Unit,
60 + onOuvrirModeles: () -> Unit,
60 61 modifier: Modifier = Modifier,
61 62 ) {
62 63 val state by viewModel.state.collectAsState()
@@ -80,6 +81,11 @@ fun ScanCarteScreen(
80 81 ScanUiState.Scanning -> ScanProgress(R.string.scan_en_cours)
81 82 ScanUiState.OcrRunning -> ScanProgress(R.string.scan_ocr_en_cours)
82 83 ScanUiState.Structuring -> ScanProgress(R.string.scan_structuration)
84 + is ScanUiState.ModelesManquants -> ScanModelesManquantsScreen(
85 + onTelecharger = onOuvrirModeles,
86 + onSaisirAlaMain = viewModel::saisirAlaMain,
87 + onBack = onBack,
88 + )
83 89 is ScanUiState.Error -> ScanErrorScreen(s.message, onRetry = viewModel::reset, onBack = onBack)
84 90 ScanUiState.Capturing, ScanUiState.Idle -> ScanCaptureScreen(viewModel, onBack, modifier)
85 91 }
@@ -174,6 +180,33 @@ private fun ScanCaptureScreen(
174 180 }
175 181 }
176 182
183 +/**
184 + * La capture a réussi, seul l'OCR manque : le message porte sur la conséquence
185 + * (les champs du contact resteront vides) et non sur la panne technique.
186 + */
187 +@Composable
188 +private fun ScanModelesManquantsScreen(
189 + onTelecharger: () -> Unit,
190 + onSaisirAlaMain: () -> Unit,
191 + onBack: () -> Unit,
192 +) {
193 + Column(
194 + Modifier.fillMaxSize().background(Fond).padding(18.dp),
195 + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
196 + horizontalAlignment = Alignment.CenterHorizontally,
197 + ) {
198 + Text(
199 + stringResource(R.string.scan_modeles_titre),
200 + style = MaterialTheme.typography.titleMedium,
201 + color = Encre,
202 + )
203 + Text(stringResource(R.string.scan_modeles_message), color = TexteFaible)
204 + Button(onClick = onTelecharger) { Text(stringResource(R.string.scan_modeles_telecharger)) }
205 + OutlinedButton(onClick = onSaisirAlaMain) { Text(stringResource(R.string.scan_modeles_saisir)) }
206 + OutlinedButton(onClick = onBack) { Text(stringResource(R.string.scan_retour)) }
207 + }
208 +}
209 +
177 210 @Composable
178 211 private fun ScanErrorScreen(message: String, onRetry: () -> Unit, onBack: () -> Unit) {
179 212 Column(
M android/app/src/main/java/fr/ebii/card2vcf/ui/ScanCarteViewModel.kt
+42 -3
@@ -7,10 +7,12 @@ import androidx.lifecycle.viewModelScope
7 7 import fr.ebii.card2vcf.contact.ContactCard
8 8 import fr.ebii.card2vcf.contact.ContactDraftMerge
9 9 import fr.ebii.card2vcf.data.ContactRepository
10 +import fr.ebii.card2vcf.ocr.ModelesManquantsException
10 11 import fr.ebii.card2vcf.ocr.OcrEngine
11 12 import fr.ebii.card2vcf.ocr.OcrException
12 13 import fr.ebii.card2vcf.scan.ScanEngine
13 14 import fr.ebii.card2vcf.scan.ScanException
15 +import kotlinx.coroutines.CoroutineDispatcher
14 16 import kotlinx.coroutines.Dispatchers
15 17 import kotlinx.coroutines.flow.MutableStateFlow
16 18 import kotlinx.coroutines.flow.StateFlow
@@ -25,6 +27,17 @@ sealed interface ScanUiState {
25 27 data object OcrRunning : ScanUiState
26 28 data object Structuring : ScanUiState
27 29 data class DraftReady(val draft: ContactDraftUi) : ScanUiState
30 +
31 + /**
32 + * Aucun modèle OCR installé. La carte est bien photographiée et redressée
33 + * ([preview]) : seul le remplissage automatique des champs est impossible.
34 + * L'écran propose le téléchargement, ou la saisie manuelle sur cette image.
35 + */
36 + data class ModelesManquants(
37 + val langues: List<String>,
38 + val preview: Bitmap?,
39 + ) : ScanUiState
40 +
28 41 data class Error(val message: String) : ScanUiState
29 42 }
30 43
@@ -38,6 +51,8 @@ class ScanCarteViewModel(
38 51 private val scanEngine: ScanEngine,
39 52 private val ocrEngine: OcrEngine,
40 53 private val repository: ContactRepository,
54 + /** Dispatcher des étapes de calcul (redressement, OCR, structuration) ; injecté par les tests. */
55 + private val calcul: CoroutineDispatcher = Dispatchers.Default,
41 56 ) : ViewModel() {
42 57
43 58 /** Non-null when rescanning an existing contact (update path). */
@@ -56,15 +71,19 @@ class ScanCarteViewModel(
56 71 _state.value = ScanUiState.Capturing
57 72 }
58 73
74 + /** Dernière capture redressée, conservée pour la saisie manuelle sans OCR. */
75 + private var redressee: Bitmap? = null
76 +
59 77 fun processCapture(bitmap: Bitmap) {
60 78 viewModelScope.launch {
61 79 try {
62 80 _state.value = ScanUiState.Scanning
63 - val scanned = withContext(Dispatchers.Default) { scanEngine.scan(bitmap) }
81 + val scanned = withContext(calcul) { scanEngine.scan(bitmap) }
82 + redressee = scanned.bitmap
64 83 _state.value = ScanUiState.OcrRunning
65 - val ocr = withContext(Dispatchers.Default) { ocrEngine.recognize(scanned.bitmap) }
84 + val ocr = withContext(calcul) { ocrEngine.recognize(scanned.bitmap) }
66 85 _state.value = ScanUiState.Structuring
67 - var merged = withContext(Dispatchers.Default) {
86 + var merged = withContext(calcul) {
68 87 ContactDraftMerge.merge(null, ocr)
69 88 }
70 89 val editId = editingContactId
@@ -81,6 +100,10 @@ class ScanCarteViewModel(
81 100 preview = scanned.bitmap,
82 101 )
83 102 )
103 + } catch (e: ModelesManquantsException) {
104 + // Pas une panne : la capture est bonne, il manque juste les modèles.
105 + Log.i(TAG, "modèles OCR absents (${e.langues.joinToString()})")
106 + _state.value = ScanUiState.ModelesManquants(e.langues, redressee)
84 107 } catch (e: ScanException) {
85 108 Log.e(TAG, "scan", e)
86 109 _state.value = ScanUiState.Error(e.message ?: "scan_erreur")
@@ -94,6 +117,21 @@ class ScanCarteViewModel(
94 117 }
95 118 }
96 119
120 + /**
121 + * Passe au brouillon sans OCR : tous les champs vides, mais la photo de carte
122 + * conservée — elle est enregistrée avec le contact et synchronisée comme d'habitude.
123 + */
124 + fun saisirAlaMain() {
125 + val etat = _state.value as? ScanUiState.ModelesManquants ?: return
126 + _state.value = ScanUiState.DraftReady(
127 + ContactDraftUi(
128 + card = ContactCard(),
129 + rawOcrText = "",
130 + preview = etat.preview,
131 + )
132 + )
133 + }
134 +
97 135 fun updateDraft(card: ContactCard) {
98 136 val current = _state.value
99 137 if (current is ScanUiState.DraftReady) {
@@ -142,6 +180,7 @@ class ScanCarteViewModel(
142 180 }
143 181
144 182 fun reset() {
183 + redressee = null
145 184 _state.value = ScanUiState.Idle
146 185 }
147 186
A android/app/src/main/java/fr/ebii/card2vcf/ui/modeles/ModelesSection.kt
+154 -0
@@ -0,0 +1,154 @@
1 +package fr.ebii.card2vcf.ui.modeles
2 +
3 +import androidx.compose.foundation.layout.Arrangement
4 +import androidx.compose.foundation.layout.Column
5 +import androidx.compose.foundation.layout.Row
6 +import androidx.compose.foundation.layout.fillMaxWidth
7 +import androidx.compose.foundation.layout.padding
8 +import androidx.compose.material3.Checkbox
9 +import androidx.compose.material3.HorizontalDivider
10 +import androidx.compose.material3.LinearProgressIndicator
11 +import androidx.compose.material3.MaterialTheme
12 +import androidx.compose.material3.Text
13 +import androidx.compose.material3.TextButton
14 +import androidx.compose.runtime.Composable
15 +import androidx.compose.runtime.collectAsState
16 +import androidx.compose.runtime.getValue
17 +import androidx.compose.ui.Alignment
18 +import androidx.compose.ui.Modifier
19 +import androidx.compose.ui.res.stringResource
20 +import androidx.compose.ui.unit.dp
21 +import fr.ebii.card2vcf.R
22 +import fr.ebii.card2vcf.modeles.EtatModele
23 +import fr.ebii.card2vcf.ui.composants.ChampTexte
24 +import fr.ebii.card2vcf.ui.theme.Bordure
25 +import fr.ebii.card2vcf.ui.theme.Ink
26 +import fr.ebii.card2vcf.ui.theme.TexteFaible
27 +
28 +/**
29 + * Section « Modèles hors-ligne » : téléchargement des traineddata OCR par langue et
30 + * du modèle de transcription vocale.
31 + *
32 + * Affichée quel que soit l'état de connexion — la saveur F-Droid s'installe sans
33 + * modèle, et rien n'impose d'avoir un serveur PicLead pour les récupérer.
34 + */
35 +@Composable
36 +fun ModelesSection(viewModel: ModelesViewModel, modifier: Modifier = Modifier) {
37 + val state by viewModel.state.collectAsState()
38 +
39 + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
40 + Text(
41 + stringResource(R.string.settings_modeles_titre),
42 + style = MaterialTheme.typography.titleSmall,
43 + color = Ink,
44 + )
45 + Text(
46 + stringResource(R.string.settings_modeles_ocr_titre),
47 + style = MaterialTheme.typography.labelMedium,
48 + color = TexteFaible,
49 + )
50 + Text(
51 + stringResource(R.string.settings_modeles_ocr_aide),
52 + style = MaterialTheme.typography.bodySmall,
53 + color = TexteFaible,
54 + )
55 + for (ligne in state.langues) {
56 + LigneTelechargement(
57 + libelle = ligne.modele.id,
58 + ligne = ligne,
59 + onCoche = { viewModel.basculerLangue(ligne.modele.id, it) },
60 + onTelecharger = { viewModel.telecharger(ligne.modele) },
61 + onSupprimer = { viewModel.supprimer(ligne.modele) },
62 + )
63 + }
64 +
65 + HorizontalDivider(color = Bordure, thickness = 1.dp)
66 +
67 + Text(
68 + stringResource(R.string.settings_modeles_vocal_titre),
69 + style = MaterialTheme.typography.labelMedium,
70 + color = TexteFaible,
71 + )
72 + state.vocal?.let { vocal ->
73 + LigneTelechargement(
74 + libelle = stringResource(R.string.settings_modeles_vocal_langue),
75 + ligne = vocal,
76 + onCoche = null,
77 + onTelecharger = { viewModel.telecharger(vocal.modele) },
78 + onSupprimer = { viewModel.supprimer(vocal.modele) },
79 + )
80 + }
81 +
82 + HorizontalDivider(color = Bordure, thickness = 1.dp)
83 +
84 + ChampTexte(
85 + value = state.miroir,
86 + onValueChange = viewModel::setMiroir,
87 + label = stringResource(R.string.settings_modeles_miroir),
88 + )
89 + Text(
90 + stringResource(R.string.settings_modeles_miroir_aide),
91 + style = MaterialTheme.typography.bodySmall,
92 + color = TexteFaible,
93 + )
94 + }
95 +}
96 +
97 +/** Une case à cocher (langues OCR uniquement), le poids, l'état, et l'action qui va avec. */
98 +@Composable
99 +private fun LigneTelechargement(
100 + libelle: String,
101 + ligne: LigneModele,
102 + onCoche: ((Boolean) -> Unit)?,
103 + onTelecharger: () -> Unit,
104 + onSupprimer: () -> Unit,
105 +) {
106 + val etat = ligne.etat
107 + Column(Modifier.fillMaxWidth()) {
108 + Row(verticalAlignment = Alignment.CenterVertically) {
109 + if (onCoche != null) {
110 + Checkbox(checked = ligne.selectionnee, onCheckedChange = onCoche)
111 + }
112 + Column(Modifier.weight(1f).padding(start = if (onCoche == null) 12.dp else 0.dp)) {
113 + Text(libelle, color = Ink)
114 + Text(
115 + stringResource(R.string.settings_modeles_poids, enMegaoctets(ligne.modele.tailleOctets)),
116 + style = MaterialTheme.typography.bodySmall,
117 + color = TexteFaible,
118 + )
119 + }
120 + when (etat) {
121 + is EtatModele.Present -> TextButton(onClick = onSupprimer) {
122 + Text(stringResource(R.string.settings_modeles_supprimer), color = TexteFaible)
123 + }
124 + is EtatModele.EnCours -> Text(
125 + stringResource(R.string.settings_modeles_progression, etat.pourcentage),
126 + color = TexteFaible,
127 + )
128 + else -> TextButton(onClick = onTelecharger) {
129 + Text(stringResource(R.string.settings_modeles_telecharger), color = Ink)
130 + }
131 + }
132 + }
133 + when (etat) {
134 + is EtatModele.EnCours -> LinearProgressIndicator(
135 + progress = { etat.pourcentage / 100f },
136 + modifier = Modifier.fillMaxWidth(),
137 + )
138 + is EtatModele.Present -> Text(
139 + stringResource(R.string.settings_modeles_installe),
140 + style = MaterialTheme.typography.bodySmall,
141 + color = TexteFaible,
142 + )
143 + is EtatModele.Echec -> Text(
144 + stringResource(R.string.settings_modeles_echec, etat.message),
145 + style = MaterialTheme.typography.bodySmall,
146 + color = MaterialTheme.colorScheme.error,
147 + )
148 + EtatModele.Absent -> Unit
149 + }
150 + }
151 +}
152 +
153 +private fun enMegaoctets(octets: Long): String =
154 + String.format("%.1f", octets / 1_048_576.0)
A android/app/src/main/java/fr/ebii/card2vcf/ui/modeles/ModelesViewModel.kt
+82 -0
@@ -0,0 +1,82 @@
1 +package fr.ebii.card2vcf.ui.modeles
2 +
3 +import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.modeles.CatalogueModeles
6 +import fr.ebii.card2vcf.modeles.DepotModeles
7 +import fr.ebii.card2vcf.modeles.EtatModele
8 +import fr.ebii.card2vcf.modeles.ModeleLocal
9 +import fr.ebii.card2vcf.modeles.PreferencesModeles
10 +import kotlinx.coroutines.flow.MutableStateFlow
11 +import kotlinx.coroutines.flow.StateFlow
12 +import kotlinx.coroutines.flow.asStateFlow
13 +import kotlinx.coroutines.launch
14 +
15 +/** Une ligne de la section Modèles : le modèle, son état sur l'appareil, et s'il est retenu. */
16 +data class LigneModele(
17 + val modele: ModeleLocal,
18 + val etat: EtatModele,
19 + val selectionnee: Boolean,
20 +)
21 +
22 +data class ModelesUiState(
23 + val langues: List<LigneModele> = emptyList(),
24 + val vocal: LigneModele? = null,
25 + val miroir: String = "",
26 +)
27 +
28 +/**
29 + * Section « Modèles hors-ligne » des Paramètres.
30 + *
31 + * Volontairement séparé de `SettingsViewModel` : la gestion des modèles ne dépend
32 + * d'aucun serveur configuré — un utilisateur F-Droid sans instance PicLead doit
33 + * pouvoir télécharger ses modèles.
34 + */
35 +class ModelesViewModel(
36 + private val depot: DepotModeles,
37 + private val prefs: PreferencesModeles,
38 +) : ViewModel() {
39 +
40 + private val _state = MutableStateFlow(ModelesUiState())
41 + val state: StateFlow<ModelesUiState> = _state.asStateFlow()
42 +
43 + init {
44 + rafraichir()
45 + viewModelScope.launch {
46 + depot.etats.collect { rafraichir() }
47 + }
48 + }
49 +
50 + fun rafraichir() {
51 + val selection = prefs.languesSelectionnees()
52 + _state.value = ModelesUiState(
53 + langues = CatalogueModeles.tessdata.map {
54 + LigneModele(it, depot.etat(it), it.id in selection)
55 + },
56 + vocal = CatalogueModeles.vosk.let {
57 + LigneModele(it, depot.etat(it), selectionnee = true)
58 + },
59 + miroir = prefs.miroir().orEmpty(),
60 + )
61 + }
62 +
63 + fun basculerLangue(code: String, cochee: Boolean) {
64 + val selection = prefs.languesSelectionnees()
65 + prefs.setLanguesSelectionnees(if (cochee) selection + code else selection - code)
66 + rafraichir()
67 + }
68 +
69 + fun telecharger(modele: ModeleLocal) {
70 + viewModelScope.launch { depot.telecharger(modele) }
71 + }
72 +
73 + fun supprimer(modele: ModeleLocal) {
74 + depot.supprimer(modele)
75 + rafraichir()
76 + }
77 +
78 + fun setMiroir(url: String) {
79 + prefs.setMiroir(url)
80 + rafraichir()
81 + }
82 +}
M android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+16 -2
@@ -29,6 +29,11 @@ import fr.ebii.card2vcf.sync.CalendarBridge
29 29 import fr.ebii.card2vcf.sync.SyncCredentialsStore
30 30 import fr.ebii.card2vcf.sync.SyncEngine
31 31 import fr.ebii.card2vcf.sync.creerSyncEngine
32 +import fr.ebii.card2vcf.audio.TranscripteurLocal
33 +import fr.ebii.card2vcf.modeles.DepotModeles
34 +import fr.ebii.card2vcf.modeles.PreferencesModeles
35 +import fr.ebii.card2vcf.ui.modeles.ModelesViewModel
36 +import fr.ebii.card2vcf.audio.TranscripteurVosk
32 37 import fr.ebii.card2vcf.ui.ScanCarteScreen
33 38 import fr.ebii.card2vcf.ui.ScanCarteViewModel
34 39 import fr.ebii.card2vcf.ui.carnet.CarnetScreen
@@ -90,10 +95,11 @@ fun Card2vcfNavHost(
90 95 ) {
91 96 val carnetVm = remember(repository) { CarnetViewModel(repository, database.syncOpDao()) }
92 97 val context = LocalContext.current
98 + val preferencesModeles = remember { PreferencesModeles(context.applicationContext) }
93 99 val scanVm = remember(repository) {
94 100 ScanCarteViewModel(
95 101 scanEngine = OpenCvScanEngine(),
96 - ocrEngine = TesseractOcrEngine(context.applicationContext),
102 + ocrEngine = TesseractOcrEngine(context.applicationContext, preferencesModeles),
97 103 repository = repository,
98 104 )
99 105 }
@@ -102,10 +108,16 @@ fun Card2vcfNavHost(
102 108 val calendarBindingsStore = remember { CalendarBindingsStore(context.applicationContext) }
103 109 val calendarBridge = remember { CalendarBridge(context.applicationContext.contentResolver) }
104 110 val audioStore = remember { AudioNoteStore(context.applicationContext) }
105 - val voskDisponible = remember { context.assets.list("")?.contains("vosk-model-small-fr-0.22") == true }
111 + val voskDisponible = remember {
112 + TranscripteurVosk(context.applicationContext).etat == TranscripteurLocal.Etat.DISPONIBLE
113 + }
106 114 val settingsVm = remember(credentialsStore, calendarBindingsStore, calendarBridge, voskDisponible) {
107 115 SettingsViewModel(credentialsStore, calendarBindingsStore, calendarBridge, voskDisponible)
108 116 }
117 + val depotModeles = remember(preferencesModeles) {
118 + DepotModeles(context.applicationContext, preferencesModeles)
119 + }
120 + val modelesVm = remember(depotModeles) { ModelesViewModel(depotModeles, preferencesModeles) }
109 121 val projetsVm = remember(database) { ProjetsViewModel(database) }
110 122 val syncChromeVm = remember(credentialsStore, calendarBindingsStore, calendarBridge, database) {
111 123 SyncChromeViewModel(
@@ -280,6 +292,7 @@ fun Card2vcfNavHost(
280 292 }
281 293 },
282 294 onBack = { navController.popBackStack() },
295 + onOuvrirModeles = { navController.navigate(Routes.Settings) },
283 296 )
284 297 }
285 298 composable(Routes.Manual) {
@@ -308,6 +321,7 @@ fun Card2vcfNavHost(
308 321 composable(Routes.Settings) {
309 322 SettingsScreen(
310 323 viewModel = settingsVm,
324 + modelesViewModel = modelesVm,
311 325 onBack = { navController.popBackStack() },
312 326 onAPropos = { navController.navigate(Routes.APropos) },
313 327 )
M android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsScreen.kt
+18 -1
@@ -45,6 +45,8 @@ import androidx.compose.ui.Modifier
45 45 import androidx.compose.ui.platform.LocalContext
46 46 import androidx.compose.ui.text.input.KeyboardType
47 47 import androidx.compose.ui.res.stringResource
48 +import fr.ebii.card2vcf.ui.modeles.ModelesSection
49 +import fr.ebii.card2vcf.ui.modeles.ModelesViewModel
48 50 import androidx.compose.ui.text.input.PasswordVisualTransformation
49 51 import androidx.compose.ui.unit.dp
50 52 import androidx.core.content.ContextCompat
@@ -65,6 +67,7 @@ private fun hasCalendarPermissions(context: Context): Boolean =
65 67 @Composable
66 68 fun SettingsScreen(
67 69 viewModel: SettingsViewModel,
70 + modelesViewModel: ModelesViewModel,
68 71 onBack: () -> Unit,
69 72 onAPropos: () -> Unit,
70 73 modifier: Modifier = Modifier,
@@ -104,6 +107,7 @@ fun SettingsScreen(
104 107 is SettingsUiState.LoggedIn -> LoggedInContent(
105 108 state = state as SettingsUiState.LoggedIn,
106 109 viewModel = viewModel,
110 + modelesViewModel = modelesViewModel,
107 111 hasCalendarPermission = hasCalendarPermission,
108 112 onRequestCalendarPermission = { calendarPermissionLauncher.launch(CalendarPermissions) },
109 113 onAPropos = onAPropos,
@@ -111,6 +115,7 @@ fun SettingsScreen(
111 115 is SettingsUiState.LoggedOut -> LoggedOutContent(
112 116 state = state as SettingsUiState.LoggedOut,
113 117 viewModel = viewModel,
118 + modelesViewModel = modelesViewModel,
114 119 onAPropos = onAPropos,
115 120 )
116 121 }
@@ -121,6 +126,7 @@ fun SettingsScreen(
121 126 private fun LoggedInContent(
122 127 state: SettingsUiState.LoggedIn,
123 128 viewModel: SettingsViewModel,
129 + modelesViewModel: ModelesViewModel,
124 130 hasCalendarPermission: Boolean,
125 131 onRequestCalendarPermission: () -> Unit,
126 132 onAPropos: () -> Unit,
@@ -156,6 +162,10 @@ private fun LoggedInContent(
156 162
157 163 HorizontalDivider(color = Bordure, thickness = 1.dp)
158 164
165 + ModelesSection(modelesViewModel)
166 +
167 + HorizontalDivider(color = Bordure, thickness = 1.dp)
168 +
159 169 TextButton(onClick = onAPropos, modifier = Modifier.fillMaxWidth()) {
160 170 Text(
161 171 stringResource(R.string.apropos_titre),
@@ -323,10 +333,11 @@ private fun TranscriptionSection(
323 333 private fun LoggedOutContent(
324 334 state: SettingsUiState.LoggedOut,
325 335 viewModel: SettingsViewModel,
336 + modelesViewModel: ModelesViewModel,
326 337 onAPropos: () -> Unit,
327 338 ) {
328 339 Column(
329 - Modifier.fillMaxSize().padding(18.dp),
340 + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(18.dp),
330 341 verticalArrangement = Arrangement.spacedBy(12.dp),
331 342 ) {
332 343 // Labels persistants : le nom du champ reste visible une fois rempli.
@@ -372,6 +383,12 @@ private fun LoggedOutContent(
372 383
373 384 HorizontalDivider(color = Bordure, thickness = 1.dp)
374 385
386 + // Accessible sans serveur configuré : une installation F-Droid neuve n'a
387 + // aucun modèle, et n'a pas forcément d'instance PicLead en face.
388 + ModelesSection(modelesViewModel)
389 +
390 + HorizontalDivider(color = Bordure, thickness = 1.dp)
391 +
375 392 TextButton(onClick = onAPropos, modifier = Modifier.fillMaxWidth()) {
376 393 Text(
377 394 stringResource(R.string.apropos_titre),
M android/app/src/main/res/values-en/strings.xml
+21 -0
@@ -207,4 +207,25 @@
207 207 <string name="scan_champ_a_verifier">To verify — uncertain reading</string>
208 208 <string name="projet_notes_titre">Notes</string>
209 209 <string name="projet_notes_vide">No notes for this project</string>
210 +
211 + <!-- Scan — missing OCR models -->
212 + <string name="scan_modeles_titre">On-device OCR unavailable</string>
213 + <string name="scan_modeles_message">No text recognition model is installed. The card photo is still saved, but the contact fields — name, company, phone, email — will not be filled in automatically. You will have to type them yourself.</string>
214 + <string name="scan_modeles_telecharger">Download models</string>
215 + <string name="scan_modeles_saisir">Enter manually</string>
216 +
217 + <!-- Settings — offline models -->
218 + <string name="settings_modeles_titre">Offline models</string>
219 + <string name="settings_modeles_ocr_titre">Text recognition (OCR)</string>
220 + <string name="settings_modeles_ocr_aide">Without an installed model, scanning photographs the card but fills in no contact field.</string>
221 + <string name="settings_modeles_vocal_titre">Voice transcription</string>
222 + <string name="settings_modeles_vocal_langue">French</string>
223 + <string name="settings_modeles_telecharger">Download</string>
224 + <string name="settings_modeles_supprimer">Delete</string>
225 + <string name="settings_modeles_installe">Installed</string>
226 + <string name="settings_modeles_progression">%1$d %%</string>
227 + <string name="settings_modeles_echec">Failed — %1$s</string>
228 + <string name="settings_modeles_poids">%1$s MB</string>
229 + <string name="settings_modeles_miroir">Model mirror (optional)</string>
230 + <string name="settings_modeles_miroir_aide">Empty: download from the official Tesseract and Vosk repositories. Set a URL to serve models from an internal server.</string>
210 231 </resources>
M android/app/src/main/res/values/strings.xml
+21 -0
@@ -226,4 +226,25 @@
226 226 <string name="kanban_champ_debut">Début (AAAA-MM-JJ)</string>
227 227 <string name="kanban_champ_duree">Durée (jours ouvrés)</string>
228 228 <string name="kanban_champ_etiquette">Étiquette</string>
229 +
230 + <!-- Scan — modèles OCR absents -->
231 + <string name="scan_modeles_titre">OCR local indisponible</string>
232 + <string name="scan_modeles_message">Aucun modèle de reconnaissance de texte n\'est installé. La photo de la carte est bien enregistrée, mais les champs du contact — nom, société, téléphone, e-mail — ne seront pas remplis automatiquement. Il faudra les saisir à la main.</string>
233 + <string name="scan_modeles_telecharger">Télécharger les modèles</string>
234 + <string name="scan_modeles_saisir">Saisir à la main</string>
235 +
236 + <!-- Paramètres — modèles hors-ligne -->
237 + <string name="settings_modeles_titre">Modèles hors-ligne</string>
238 + <string name="settings_modeles_ocr_titre">Reconnaissance de texte (OCR)</string>
239 + <string name="settings_modeles_ocr_aide">Sans modèle installé, le scan photographie la carte mais ne remplit aucun champ du contact.</string>
240 + <string name="settings_modeles_vocal_titre">Transcription vocale</string>
241 + <string name="settings_modeles_vocal_langue">Français</string>
242 + <string name="settings_modeles_telecharger">Télécharger</string>
243 + <string name="settings_modeles_supprimer">Supprimer</string>
244 + <string name="settings_modeles_installe">Installé</string>
245 + <string name="settings_modeles_progression">%1$d %%</string>
246 + <string name="settings_modeles_echec">Échec — %1$s</string>
247 + <string name="settings_modeles_poids">%1$s Mo</string>
248 + <string name="settings_modeles_miroir">Miroir des modèles (optionnel)</string>
249 + <string name="settings_modeles_miroir_aide">Vide : téléchargement depuis les dépôts officiels Tesseract et Vosk. Renseignez une URL pour servir les modèles depuis un serveur interne.</string>
229 250 </resources>
A android/app/src/test/java/fr/ebii/card2vcf/modeles/CatalogueModelesTest.kt
+89 -0
@@ -0,0 +1,89 @@
1 +package fr.ebii.card2vcf.modeles
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertEquals
5 +import kotlin.test.assertNotNull
6 +import kotlin.test.assertNull
7 +import kotlin.test.assertTrue
8 +
9 +class CatalogueModelesTest {
10 +
11 + @Test
12 + fun `les sept langues OCR sont au catalogue`() {
13 + val codes = CatalogueModeles.tessdata.map { it.id }
14 + assertEquals(listOf("fra", "eng", "deu", "spa", "por", "ita", "pol"), codes)
15 + }
16 +
17 + @Test
18 + fun `fra et eng sont pre-selectionnes`() {
19 + assertEquals(setOf("fra", "eng"), CatalogueModeles.LANGUES_PAR_DEFAUT)
20 + }
21 +
22 + @Test
23 + fun `url par defaut d'un traineddata pointe sur tessdata_fast`() {
24 + val fra = CatalogueModeles.parId("fra")!!
25 + assertEquals(
26 + "https://github.com/tesseract-ocr/tessdata_fast/raw/main/fra.traineddata",
27 + CatalogueModeles.url(fra, miroir = null),
28 + )
29 + }
30 +
31 + @Test
32 + fun `url par defaut du modele vocal pointe sur alphacephei`() {
33 + assertEquals(
34 + "https://alphacephei.com/vosk/models/vosk-model-small-fr-0.22.zip",
35 + CatalogueModeles.url(CatalogueModeles.vosk, miroir = null),
36 + )
37 + }
38 +
39 + @Test
40 + fun `un miroir configure remplace les deux bases`() {
41 + val miroir = "https://interne.example/modeles"
42 + assertEquals(
43 + "https://interne.example/modeles/fra.traineddata",
44 + CatalogueModeles.url(CatalogueModeles.parId("fra")!!, miroir),
45 + )
46 + assertEquals(
47 + "https://interne.example/modeles/vosk-model-small-fr-0.22.zip",
48 + CatalogueModeles.url(CatalogueModeles.vosk, miroir),
49 + )
50 + }
51 +
52 + @Test
53 + fun `un miroir vide ou blanc retombe sur les bases par defaut`() {
54 + val fra = CatalogueModeles.parId("fra")!!
55 + val attendu = "https://github.com/tesseract-ocr/tessdata_fast/raw/main/fra.traineddata"
56 + assertEquals(attendu, CatalogueModeles.url(fra, ""))
57 + assertEquals(attendu, CatalogueModeles.url(fra, " "))
58 + }
59 +
60 + @Test
61 + fun `un miroir avec slash final ne produit pas de double slash`() {
62 + assertEquals(
63 + "https://interne.example/modeles/fra.traineddata",
64 + CatalogueModeles.url(CatalogueModeles.parId("fra")!!, "https://interne.example/modeles/"),
65 + )
66 + }
67 +
68 + @Test
69 + fun `parId retrouve le modele vocal et ignore un id inconnu`() {
70 + assertNotNull(CatalogueModeles.parId("vosk-model-small-fr-0.22"))
71 + assertNull(CatalogueModeles.parId("klingon"))
72 + }
73 +
74 + @Test
75 + fun `chaque entree porte une taille et une empreinte epinglees`() {
76 + for (modele in CatalogueModeles.tous) {
77 + assertTrue(modele.tailleOctets > 10_000, "taille manquante pour ${modele.id}")
78 + assertEquals(64, modele.sha256.length, "sha256 invalide pour ${modele.id}")
79 + }
80 + }
81 +
82 + @Test
83 + fun `le poids des langues pre-selectionnees reste sous six megaoctets`() {
84 + val poids = CatalogueModeles.tessdata
85 + .filter { it.id in CatalogueModeles.LANGUES_PAR_DEFAUT }
86 + .sumOf { it.tailleOctets }
87 + assertTrue(poids < 6L * 1024 * 1024, "fra+eng pèsent $poids octets")
88 + }
89 +}
A android/app/src/test/java/fr/ebii/card2vcf/modeles/DepotModelesTest.kt
+198 -0
@@ -0,0 +1,198 @@
1 +package fr.ebii.card2vcf.modeles
2 +
3 +import android.content.Context
4 +import androidx.test.core.app.ApplicationProvider
5 +import kotlinx.coroutines.test.runTest
6 +import okhttp3.mockwebserver.MockResponse
7 +import okhttp3.mockwebserver.MockWebServer
8 +import okio.Buffer
9 +import org.junit.After
10 +import org.junit.Before
11 +import org.junit.runner.RunWith
12 +import org.robolectric.RobolectricTestRunner
13 +import org.robolectric.annotation.Config
14 +import java.io.ByteArrayOutputStream
15 +import java.io.File
16 +import java.security.MessageDigest
17 +import java.util.zip.ZipEntry
18 +import java.util.zip.ZipOutputStream
19 +import kotlin.test.Test
20 +import kotlin.test.assertEquals
21 +import kotlin.test.assertFalse
22 +import kotlin.test.assertIs
23 +import kotlin.test.assertTrue
24 +
25 +@RunWith(RobolectricTestRunner::class)
26 +@Config(sdk = [31])
27 +class DepotModelesTest {
28 +
29 + private lateinit var serveur: MockWebServer
30 + private lateinit var context: Context
31 + private lateinit var prefs: PreferencesModeles
32 + private lateinit var depot: DepotModeles
33 +
34 + private val contenuFra = "traineddata factice pour les tests".repeat(400).toByteArray()
35 +
36 + private fun sha256(octets: ByteArray): String =
37 + MessageDigest.getInstance("SHA-256").digest(octets).joinToString("") { "%02x".format(it) }
38 +
39 + private fun modeleFra() = ModeleLocal(
40 + id = "fra",
41 + type = TypeModele.TESSDATA,
42 + tailleOctets = contenuFra.size.toLong(),
43 + sha256 = sha256(contenuFra),
44 + )
45 +
46 + private fun corpsBinaire(octets: ByteArray) = MockResponse().setBody(Buffer().write(octets))
47 +
48 + @Before
49 + fun setUp() {
50 + serveur = MockWebServer().apply { start() }
51 + context = ApplicationProvider.getApplicationContext()
52 + context.filesDir.deleteRecursively()
53 + prefs = PreferencesModeles(context).apply {
54 + setMiroir(serveur.url("/").toString().trimEnd('/'))
55 + }
56 + depot = DepotModeles(context, prefs)
57 + }
58 +
59 + @After
60 + fun tearDown() {
61 + serveur.shutdown()
62 + }
63 +
64 + private fun fichierTessdata(langue: String) =
65 + File(context.filesDir, "tesseract/tessdata/$langue.traineddata")
66 +
67 + @Test
68 + fun `un modele absent est signale absent`() {
69 + assertIs<EtatModele.Absent>(depot.etat(modeleFra()))
70 + }
71 +
72 + @Test
73 + fun `telechargement nominal ecrit le traineddata et passe a present`() = runTest {
74 + serveur.enqueue(corpsBinaire(contenuFra))
75 +
76 + depot.telecharger(modeleFra())
77 +
78 + val cible = fichierTessdata("fra")
79 + assertTrue(cible.isFile, "le traineddata doit être écrit")
80 + assertEquals(contenuFra.size.toLong(), cible.length())
81 + val etat = depot.etat(modeleFra())
82 + assertIs<EtatModele.Present>(etat)
83 + assertEquals(contenuFra.size.toLong(), etat.tailleOctets)
84 + }
85 +
86 + @Test
87 + fun `telechargement pose le stamp external pour proteger du recouvrement par les assets`() = runTest {
88 + serveur.enqueue(corpsBinaire(contenuFra))
89 +
90 + depot.telecharger(modeleFra())
91 +
92 + val stamp = File(context.filesDir, "tesseract/tessdata/.card2vcf-tessdata-variant")
93 + assertTrue(stamp.isFile, "le stamp doit exister")
94 + assertEquals("external", stamp.readText().trim())
95 + }
96 +
97 + @Test
98 + fun `le miroir configure est bien celui qui est interroge`() = runTest {
99 + serveur.enqueue(corpsBinaire(contenuFra))
100 +
101 + depot.telecharger(modeleFra())
102 +
103 + assertEquals("/fra.traineddata", serveur.takeRequest().path)
104 + }
105 +
106 + @Test
107 + fun `une empreinte invalide laisse le modele absent et ne laisse aucun fichier partiel`() = runTest {
108 + serveur.enqueue(corpsBinaire("contenu corrompu".toByteArray()))
109 +
110 + depot.telecharger(modeleFra())
111 +
112 + val etat = depot.etat(modeleFra())
113 + assertIs<EtatModele.Echec>(etat)
114 + assertFalse(fichierTessdata("fra").exists(), "aucun traineddata ne doit subsister")
115 + assertFalse(
116 + File(context.filesDir, "tesseract/tessdata/fra.traineddata.part").exists(),
117 + "le fichier partiel doit être nettoyé",
118 + )
119 + }
120 +
121 + @Test
122 + fun `une erreur reseau est rapportee en echec sans fichier residuel`() = runTest {
123 + serveur.enqueue(MockResponse().setResponseCode(404))
124 +
125 + depot.telecharger(modeleFra())
126 +
127 + assertIs<EtatModele.Echec>(depot.etat(modeleFra()))
128 + assertFalse(fichierTessdata("fra").exists())
129 + assertFalse(File(context.filesDir, "tesseract/tessdata/fra.traineddata.part").exists())
130 + }
131 +
132 + @Test
133 + fun `un echec n'empeche pas une relance reussie`() = runTest {
134 + serveur.enqueue(MockResponse().setResponseCode(500))
135 + depot.telecharger(modeleFra())
136 + assertIs<EtatModele.Echec>(depot.etat(modeleFra()))
137 +
138 + serveur.enqueue(corpsBinaire(contenuFra))
139 + depot.telecharger(modeleFra())
140 +
141 + assertIs<EtatModele.Present>(depot.etat(modeleFra()))
142 + }
143 +
144 + @Test
145 + fun `supprimer un modele le ramene a absent`() = runTest {
146 + serveur.enqueue(corpsBinaire(contenuFra))
147 + depot.telecharger(modeleFra())
148 + assertIs<EtatModele.Present>(depot.etat(modeleFra()))
149 +
150 + depot.supprimer(modeleFra())
151 +
152 + assertIs<EtatModele.Absent>(depot.etat(modeleFra()))
153 + assertFalse(fichierTessdata("fra").exists())
154 + }
155 +
156 + @Test
157 + fun `languesPretes ne retient que les langues selectionnees et presentes`() = runTest {
158 + serveur.enqueue(corpsBinaire(contenuFra))
159 + depot.telecharger(modeleFra())
160 + prefs.setLanguesSelectionnees(setOf("fra", "deu"))
161 +
162 + assertEquals(listOf("fra"), depot.languesPretes())
163 + }
164 +
165 + @Test
166 + fun `le modele vocal est decompresse dans filesDir`() = runTest {
167 + val zip = zipDeTest()
168 + val modele = ModeleLocal(
169 + id = "vosk-model-small-fr-0.22",
170 + type = TypeModele.VOSK,
171 + tailleOctets = zip.size.toLong(),
172 + sha256 = sha256(zip),
173 + )
174 + serveur.enqueue(corpsBinaire(zip))
175 +
176 + depot.telecharger(modele)
177 +
178 + val racine = File(context.filesDir, "vosk-model-small-fr-0.22")
179 + assertTrue(racine.isDirectory, "le modèle doit être décompressé")
180 + assertEquals("bonjour", File(racine, "am/final.mdl").readText())
181 + assertIs<EtatModele.Present>(depot.etat(modele))
182 + assertFalse(
183 + File(context.filesDir, "vosk-model-small-fr-0.22.zip.part").exists(),
184 + "l'archive téléchargée doit être nettoyée",
185 + )
186 + }
187 +
188 + /** Archive minimale imitant la structure d'un modèle Vosk. */
189 + private fun zipDeTest(): ByteArray {
190 + val sortie = ByteArrayOutputStream()
191 + ZipOutputStream(sortie).use { zos ->
192 + zos.putNextEntry(ZipEntry("vosk-model-small-fr-0.22/am/final.mdl"))
193 + zos.write("bonjour".toByteArray())
194 + zos.closeEntry()
195 + }
196 + return sortie.toByteArray()
197 + }
198 +}
A android/app/src/test/java/fr/ebii/card2vcf/ocr/TesseractModelesTest.kt
+66 -0
@@ -0,0 +1,66 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import android.content.Context
4 +import androidx.test.core.app.ApplicationProvider
5 +import fr.ebii.card2vcf.modeles.PreferencesModeles
6 +import org.junit.Before
7 +import org.junit.runner.RunWith
8 +import org.robolectric.RobolectricTestRunner
9 +import org.robolectric.annotation.Config
10 +import java.io.File
11 +import kotlin.test.Test
12 +import kotlin.test.assertEquals
13 +
14 +/**
15 + * Résolution des modèles OCR : quelles langues sont réellement chargeables.
16 + *
17 + * Le cas « aucun modèle installé » est couvert par TesseractModelesAbsentsTest,
18 + * dans la source set `testFdroid` — la saveur `direct` embarque ses traineddata.
19 + */
20 +@RunWith(RobolectricTestRunner::class)
21 +@Config(sdk = [31])
22 +class TesseractModelesTest {
23 +
24 + private lateinit var context: Context
25 + private lateinit var prefs: PreferencesModeles
26 +
27 + @Before
28 + fun setUp() {
29 + context = ApplicationProvider.getApplicationContext()
30 + context.filesDir.deleteRecursively()
31 + prefs = PreferencesModeles(context)
32 + }
33 +
34 + private fun poserModele(langue: String) {
35 + val dossier = File(context.filesDir, "tesseract/tessdata").apply { mkdirs() }
36 + File(dossier, "$langue.traineddata").writeBytes(ByteArray(20_000))
37 + }
38 +
39 + private fun moteur() = TesseractOcrEngine(context, prefs)
40 +
41 + @Test
42 + fun `les deux langues par defaut sont chargees quand elles sont la`() {
43 + poserModele("fra")
44 + poserModele("eng")
45 +
46 + assertEquals(listOf("fra", "eng"), moteur().languesActives())
47 + }
48 +
49 + @Test
50 + fun `la selection de l'utilisateur pilote les langues chargees`() {
51 + poserModele("fra")
52 + poserModele("deu")
53 + prefs.setLanguesSelectionnees(setOf("deu"))
54 +
55 + assertEquals(listOf("deu"), moteur().languesActives())
56 + }
57 +
58 + @Test
59 + fun `l'ordre suit le catalogue et non l'ordre de selection`() {
60 + poserModele("pol")
61 + poserModele("fra")
62 + prefs.setLanguesSelectionnees(setOf("pol", "fra"))
63 +
64 + assertEquals(listOf("fra", "pol"), moteur().languesActives())
65 + }
66 +}
A android/app/src/test/java/fr/ebii/card2vcf/ui/ScanCarteModelesManquantsTest.kt
+143 -0
@@ -0,0 +1,143 @@
1 +package fr.ebii.card2vcf.ui
2 +
3 +import android.content.Context
4 +import android.graphics.Bitmap
5 +import androidx.room.Room
6 +import androidx.test.core.app.ApplicationProvider
7 +import fr.ebii.card2vcf.data.ContactImageStore
8 +import fr.ebii.card2vcf.data.ContactRepository
9 +import fr.ebii.card2vcf.data.CrmDatabase
10 +import fr.ebii.card2vcf.ocr.ModelesManquantsException
11 +import fr.ebii.card2vcf.ocr.OcrEngine
12 +import fr.ebii.card2vcf.ocr.OcrException
13 +import fr.ebii.card2vcf.ocr.OcrResult
14 +import fr.ebii.card2vcf.scan.ScanEngine
15 +import fr.ebii.card2vcf.scan.ScanResult
16 +import kotlinx.coroutines.Dispatchers
17 +import kotlinx.coroutines.ExperimentalCoroutinesApi
18 +import kotlinx.coroutines.test.StandardTestDispatcher
19 +import kotlinx.coroutines.test.resetMain
20 +import kotlinx.coroutines.test.runTest
21 +import kotlinx.coroutines.test.setMain
22 +import org.junit.After
23 +import org.junit.Before
24 +import org.junit.runner.RunWith
25 +import org.robolectric.RobolectricTestRunner
26 +import org.robolectric.annotation.Config
27 +import kotlin.test.Test
28 +import kotlin.test.assertEquals
29 +import kotlin.test.assertIs
30 +import kotlin.test.assertSame
31 +import kotlin.test.assertTrue
32 +
33 +/**
34 + * Sans modèle OCR, la capture aboutit quand même : la carte est photographiée et
35 + * redressée, seul le remplissage des champs manque. Le scan ne doit donc pas finir
36 + * en erreur générique mais proposer le téléchargement — ou la saisie manuelle,
37 + * en conservant l'image redressée.
38 + */
39 +@OptIn(ExperimentalCoroutinesApi::class)
40 +@RunWith(RobolectricTestRunner::class)
41 +@Config(sdk = [31])
42 +class ScanCarteModelesManquantsTest {
43 +
44 + private lateinit var db: CrmDatabase
45 + private lateinit var repo: ContactRepository
46 + private lateinit var source: Bitmap
47 + private lateinit var redressee: Bitmap
48 +
49 + private val dispatcher = StandardTestDispatcher()
50 +
51 + private class ScanFactice(private val resultat: Bitmap) : ScanEngine {
52 + override fun scan(source: Bitmap, manualCorners: fr.ebii.card2vcf.scan.ScanCorners?) =
53 + ScanResult(bitmap = resultat)
54 + }
55 +
56 + private class OcrQuiEchoue(private val erreur: OcrException) : OcrEngine {
57 + override fun recognize(bitmap: Bitmap): OcrResult = throw erreur
58 + }
59 +
60 + @Before
61 + fun setUp() {
62 + Dispatchers.setMain(dispatcher)
63 + val ctx = ApplicationProvider.getApplicationContext<Context>()
64 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
65 + .allowMainThreadQueries()
66 + .build()
67 + repo = ContactRepository(db.crmContactDao(), ContactImageStore(ctx))
68 + source = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888)
69 + redressee = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888)
70 + }
71 +
72 + @After
73 + fun tearDown() {
74 + db.close()
75 + Dispatchers.resetMain()
76 + }
77 +
78 + private fun viewModel(erreur: OcrException) = ScanCarteViewModel(
79 + scanEngine = ScanFactice(redressee),
80 + ocrEngine = OcrQuiEchoue(erreur),
81 + repository = repo,
82 + calcul = dispatcher,
83 + )
84 +
85 + @Test
86 + fun `des modeles manquants donnent un etat dedie et non une erreur generique`() = runTest(dispatcher) {
87 + val vm = viewModel(ModelesManquantsException(listOf("fra", "eng")))
88 +
89 + vm.processCapture(source)
90 + testScheduler.advanceUntilIdle()
91 +
92 + val etat = vm.state.value
93 + assertIs<ScanUiState.ModelesManquants>(etat)
94 + assertEquals(listOf("fra", "eng"), etat.langues)
95 + }
96 +
97 + @Test
98 + fun `l'image redressee est conservee pour la saisie manuelle`() = runTest(dispatcher) {
99 + val vm = viewModel(ModelesManquantsException(listOf("fra")))
100 +
101 + vm.processCapture(source)
102 + testScheduler.advanceUntilIdle()
103 +
104 + val etat = assertIs<ScanUiState.ModelesManquants>(vm.state.value)
105 + assertSame(redressee, etat.preview, "la capture redressée ne doit pas être jetée")
106 + }
107 +
108 + @Test
109 + fun `saisir a la main ouvre un brouillon vide portant la photo de carte`() = runTest(dispatcher) {
110 + val vm = viewModel(ModelesManquantsException(listOf("fra")))
111 + vm.processCapture(source)
112 + testScheduler.advanceUntilIdle()
113 +
114 + vm.saisirAlaMain()
115 +
116 + val etat = assertIs<ScanUiState.DraftReady>(vm.state.value)
117 + assertSame(redressee, etat.draft.preview)
118 + assertEquals("", etat.draft.rawOcrText)
119 + assertTrue(etat.draft.card.fullName.isNullOrEmpty(), "aucun champ ne doit être pré-rempli")
120 + assertTrue(etat.draft.card.emails.isEmpty())
121 + assertTrue(etat.draft.card.phones.isEmpty())
122 + }
123 +
124 + @Test
125 + fun `saisir a la main est sans effet hors de l'etat modeles manquants`() = runTest(dispatcher) {
126 + val vm = viewModel(ModelesManquantsException(listOf("fra")))
127 +
128 + vm.saisirAlaMain()
129 +
130 + assertIs<ScanUiState.Idle>(vm.state.value)
131 + }
132 +
133 + @Test
134 + fun `une autre erreur OCR reste une erreur generique`() = runTest(dispatcher) {
135 + val vm = viewModel(OcrException("OCR vide — reprenez la photo"))
136 +
137 + vm.processCapture(source)
138 + testScheduler.advanceUntilIdle()
139 +
140 + val etat = assertIs<ScanUiState.Error>(vm.state.value)
141 + assertEquals("OCR vide — reprenez la photo", etat.message)
142 + }
143 +}
A android/app/src/test/java/fr/ebii/card2vcf/ui/modeles/ModelesViewModelTest.kt
+132 -0
@@ -0,0 +1,132 @@
1 +package fr.ebii.card2vcf.ui.modeles
2 +
3 +import android.content.Context
4 +import androidx.test.core.app.ApplicationProvider
5 +import fr.ebii.card2vcf.modeles.CatalogueModeles
6 +import fr.ebii.card2vcf.modeles.DepotModeles
7 +import fr.ebii.card2vcf.modeles.EtatModele
8 +import fr.ebii.card2vcf.modeles.PreferencesModeles
9 +import kotlinx.coroutines.Dispatchers
10 +import kotlinx.coroutines.ExperimentalCoroutinesApi
11 +import kotlinx.coroutines.test.StandardTestDispatcher
12 +import kotlinx.coroutines.test.resetMain
13 +import kotlinx.coroutines.test.runTest
14 +import kotlinx.coroutines.test.setMain
15 +import org.junit.After
16 +import org.junit.Before
17 +import org.junit.runner.RunWith
18 +import org.robolectric.RobolectricTestRunner
19 +import org.robolectric.annotation.Config
20 +import java.io.File
21 +import kotlin.test.Test
22 +import kotlin.test.assertEquals
23 +import kotlin.test.assertFalse
24 +import kotlin.test.assertIs
25 +import kotlin.test.assertNotNull
26 +import kotlin.test.assertTrue
27 +
28 +@OptIn(ExperimentalCoroutinesApi::class)
29 +@RunWith(RobolectricTestRunner::class)
30 +@Config(sdk = [31])
31 +class ModelesViewModelTest {
32 +
33 + private val dispatcher = StandardTestDispatcher()
34 + private lateinit var context: Context
35 + private lateinit var prefs: PreferencesModeles
36 + private lateinit var depot: DepotModeles
37 +
38 + @Before
39 + fun setUp() {
40 + Dispatchers.setMain(dispatcher)
41 + context = ApplicationProvider.getApplicationContext()
42 + context.filesDir.deleteRecursively()
43 + prefs = PreferencesModeles(context)
44 + depot = DepotModeles(context, prefs)
45 + }
46 +
47 + @After
48 + fun tearDown() = Dispatchers.resetMain()
49 +
50 + private fun vm() = ModelesViewModel(depot, prefs)
51 +
52 + private fun poserModele(langue: String) {
53 + val dossier = File(context.filesDir, "tesseract/tessdata").apply { mkdirs() }
54 + File(dossier, "$langue.traineddata").writeBytes(ByteArray(20_000))
55 + }
56 +
57 + @Test
58 + fun `les sept langues sont listees avec fra et eng coches`() = runTest(dispatcher) {
59 + val etat = vm().state.value
60 +
61 + assertEquals(7, etat.langues.size)
62 + val cochees = etat.langues.filter { it.selectionnee }.map { it.modele.id }
63 + assertEquals(listOf("fra", "eng"), cochees)
64 + }
65 +
66 + @Test
67 + fun `le modele vocal figure a part`() = runTest(dispatcher) {
68 + val vocal = vm().state.value.vocal
69 +
70 + assertNotNull(vocal)
71 + assertEquals(CatalogueModeles.vosk.id, vocal.modele.id)
72 + }
73 +
74 + @Test
75 + fun `une langue installee est signalee presente`() = runTest(dispatcher) {
76 + poserModele("fra")
77 +
78 + val ligne = vm().state.value.langues.first { it.modele.id == "fra" }
79 +
80 + assertIs<EtatModele.Present>(ligne.etat)
81 + }
82 +
83 + @Test
84 + fun `cocher une langue la persiste et se reflete dans l'etat`() = runTest(dispatcher) {
85 + val modele = vm()
86 +
87 + modele.basculerLangue("deu", true)
88 +
89 + assertTrue(prefs.languesSelectionnees().contains("deu"))
90 + assertTrue(modele.state.value.langues.first { it.modele.id == "deu" }.selectionnee)
91 + }
92 +
93 + @Test
94 + fun `decocher une langue la retire de la selection`() = runTest(dispatcher) {
95 + val modele = vm()
96 +
97 + modele.basculerLangue("eng", false)
98 +
99 + assertFalse(prefs.languesSelectionnees().contains("eng"))
100 + assertFalse(modele.state.value.langues.first { it.modele.id == "eng" }.selectionnee)
101 + }
102 +
103 + @Test
104 + fun `supprimer une langue la ramene a absent`() = runTest(dispatcher) {
105 + poserModele("fra")
106 + val modele = vm()
107 +
108 + modele.supprimer(CatalogueModeles.parId("fra")!!)
109 +
110 + assertIs<EtatModele.Absent>(modele.state.value.langues.first { it.modele.id == "fra" }.etat)
111 + }
112 +
113 + @Test
114 + fun `le miroir saisi est persiste`() = runTest(dispatcher) {
115 + val modele = vm()
116 +
117 + modele.setMiroir("https://interne.example/modeles")
118 +
119 + assertEquals("https://interne.example/modeles", prefs.miroir())
120 + assertEquals("https://interne.example/modeles", modele.state.value.miroir)
121 + }
122 +
123 + @Test
124 + fun `un miroir efface revient aux depots officiels`() = runTest(dispatcher) {
125 + val modele = vm()
126 + modele.setMiroir("https://interne.example/modeles")
127 +
128 + modele.setMiroir("")
129 +
130 + assertEquals(null, prefs.miroir())
131 + }
132 +}
A android/app/src/testDirect/java/fr/ebii/card2vcf/ocr/TesseractAssetsEmbarquesTest.kt
+57 -0
@@ -0,0 +1,57 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import android.content.Context
4 +import androidx.test.core.app.ApplicationProvider
5 +import fr.ebii.card2vcf.modeles.PreferencesModeles
6 +import org.junit.Before
7 +import org.junit.runner.RunWith
8 +import org.robolectric.RobolectricTestRunner
9 +import org.robolectric.annotation.Config
10 +import java.io.File
11 +import kotlin.test.Test
12 +import kotlin.test.assertEquals
13 +import kotlin.test.assertTrue
14 +
15 +/**
16 + * La saveur `direct` doit rester autonome : sur un `filesDir` vierge, les traineddata
17 + * embarqués sont recopiés et l'OCR fonctionne sans le moindre téléchargement.
18 + */
19 +@RunWith(RobolectricTestRunner::class)
20 +@Config(sdk = [31])
21 +class TesseractAssetsEmbarquesTest {
22 +
23 + private lateinit var context: Context
24 +
25 + @Before
26 + fun setUp() {
27 + context = ApplicationProvider.getApplicationContext()
28 + context.filesDir.deleteRecursively()
29 + }
30 +
31 + @Test
32 + fun `la saveur direct embarque bien les traineddata`() {
33 + val assets = context.assets.list("tesseract/tessdata").orEmpty()
34 + assertTrue(
35 + assets.any { it.endsWith(".traineddata") },
36 + "aucun traineddata embarqué : ${assets.joinToString()}",
37 + )
38 + }
39 +
40 + @Test
41 + fun `sur un filesDir vierge les modeles sont recopies depuis les assets`() {
42 + val langues = TesseractOcrEngine(context, PreferencesModeles(context)).languesActives()
43 +
44 + assertEquals(listOf("fra", "eng"), langues)
45 + val dossier = File(context.filesDir, "tesseract/tessdata")
46 + assertTrue(File(dossier, "fra.traineddata").isFile)
47 + assertTrue(File(dossier, "eng.traineddata").isFile)
48 + }
49 +
50 + @Test
51 + fun `la recopie depuis les assets marque la variante et non external`() {
52 + TesseractOcrEngine(context, PreferencesModeles(context)).languesActives()
53 +
54 + val stamp = File(context.filesDir, "tesseract/tessdata/.card2vcf-tessdata-variant")
55 + assertEquals("fast", stamp.readText().trim())
56 + }
57 +}
A android/app/src/testFdroid/java/fr/ebii/card2vcf/audio/TranscripteurVoskDisponibiliteTest.kt
+50 -0
@@ -0,0 +1,50 @@
1 +package fr.ebii.card2vcf.audio
2 +
3 +import android.content.Context
4 +import androidx.test.core.app.ApplicationProvider
5 +import org.junit.Before
6 +import org.junit.runner.RunWith
7 +import org.robolectric.RobolectricTestRunner
8 +import org.robolectric.annotation.Config
9 +import java.io.File
10 +import kotlin.test.Test
11 +import kotlin.test.assertEquals
12 +
13 +/**
14 + * Détection de disponibilité du modèle vocal dans la saveur `fdroid`, qui n'embarque
15 + * aucun asset : seul un modèle téléchargé dans `filesDir` peut rendre Vosk disponible.
16 + *
17 + * Seul [TranscripteurVosk.etat] est sollicité — il ne touche pas la lib native.
18 + */
19 +@RunWith(RobolectricTestRunner::class)
20 +@Config(sdk = [31])
21 +class TranscripteurVoskDisponibiliteTest {
22 +
23 + private lateinit var context: Context
24 +
25 + @Before
26 + fun setUp() {
27 + context = ApplicationProvider.getApplicationContext()
28 + context.filesDir.deleteRecursively()
29 + }
30 +
31 + @Test
32 + fun `sans modele telecharge le moteur vocal est indisponible`() {
33 + assertEquals(TranscripteurLocal.Etat.INDISPONIBLE, TranscripteurVosk(context).etat)
34 + }
35 +
36 + @Test
37 + fun `un modele decompresse dans filesDir rend le moteur disponible`() {
38 + File(context.filesDir, "vosk-model-small-fr-0.22/am").mkdirs()
39 + File(context.filesDir, "vosk-model-small-fr-0.22/am/final.mdl").writeText("modèle")
40 +
41 + assertEquals(TranscripteurLocal.Etat.DISPONIBLE, TranscripteurVosk(context).etat)
42 + }
43 +
44 + @Test
45 + fun `un dossier de modele vide ne suffit pas`() {
46 + File(context.filesDir, "vosk-model-small-fr-0.22").mkdirs()
47 +
48 + assertEquals(TranscripteurLocal.Etat.INDISPONIBLE, TranscripteurVosk(context).etat)
49 + }
50 +}
A android/app/src/testFdroid/java/fr/ebii/card2vcf/ocr/TesseractModelesAbsentsTest.kt
+87 -0
@@ -0,0 +1,87 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import android.content.Context
4 +import androidx.test.core.app.ApplicationProvider
5 +import fr.ebii.card2vcf.modeles.PreferencesModeles
6 +import org.junit.Before
7 +import org.junit.runner.RunWith
8 +import org.robolectric.RobolectricTestRunner
9 +import org.robolectric.annotation.Config
10 +import java.io.File
11 +import kotlin.test.Test
12 +import kotlin.test.assertEquals
13 +import kotlin.test.assertFailsWith
14 +import kotlin.test.assertTrue
15 +
16 +/**
17 + * Comportement quand aucun modèle n'est installé — le cas nominal d'une installation
18 + * F-Droid neuve. Réservé à la saveur `fdroid` : la saveur `direct` embarque les
19 + * traineddata dans ses assets, l'absence y est donc impossible à reproduire.
20 + */
21 +@RunWith(RobolectricTestRunner::class)
22 +@Config(sdk = [31])
23 +class TesseractModelesAbsentsTest {
24 +
25 + private lateinit var context: Context
26 + private lateinit var prefs: PreferencesModeles
27 +
28 + @Before
29 + fun setUp() {
30 + context = ApplicationProvider.getApplicationContext()
31 + context.filesDir.deleteRecursively()
32 + prefs = PreferencesModeles(context)
33 + }
34 +
35 + private fun moteur() = TesseractOcrEngine(context, prefs)
36 +
37 + private fun poserModele(langue: String) {
38 + val dossier = File(context.filesDir, "tesseract/tessdata").apply { mkdirs() }
39 + File(dossier, "$langue.traineddata").writeBytes(ByteArray(20_000))
40 + }
41 +
42 + @Test
43 + fun `la saveur fdroid n'embarque aucun traineddata`() {
44 + val assets = context.assets.list("tesseract/tessdata").orEmpty()
45 + assertTrue(
46 + assets.none { it.endsWith(".traineddata") },
47 + "assets inattendus dans la saveur fdroid : ${assets.joinToString()}",
48 + )
49 + }
50 +
51 + @Test
52 + fun `sans aucun modele l'exception nomme les langues attendues`() {
53 + val erreur = assertFailsWith<ModelesManquantsException> { moteur().languesActives() }
54 +
55 + assertEquals(listOf("fra", "eng"), erreur.langues)
56 + }
57 +
58 + @Test
59 + fun `l'exception des modeles manquants reste une OcrException`() {
60 + // ScanCarteViewModel attrape OcrException : la sous-classe doit y entrer.
61 + val erreur = assertFailsWith<ModelesManquantsException> { moteur().languesActives() }
62 + assertTrue(erreur is OcrException)
63 + }
64 +
65 + @Test
66 + fun `seules les langues presentes sont chargees`() {
67 + poserModele("fra")
68 +
69 + assertEquals(listOf("fra"), moteur().languesActives())
70 + }
71 +
72 + @Test
73 + fun `une langue selectionnee mais absente n'empeche pas les autres`() {
74 + poserModele("fra")
75 + prefs.setLanguesSelectionnees(setOf("fra", "pol"))
76 +
77 + assertEquals(listOf("fra"), moteur().languesActives())
78 + }
79 +
80 + @Test
81 + fun `un fichier tronque ne compte pas comme un modele`() {
82 + val dossier = File(context.filesDir, "tesseract/tessdata").apply { mkdirs() }
83 + File(dossier, "fra.traineddata").writeBytes(ByteArray(12))
84 +
85 + assertFailsWith<ModelesManquantsException> { moteur().languesActives() }
86 + }
87 +}
A android/fastlane/metadata/android/en-US/changelogs/4.txt
+6 -0
@@ -0,0 +1,6 @@
1 +Recognition models are now downloaded after installation from Settings → Offline
2 +models: pick OCR languages and the voice transcription model, with SHA-256 integrity
3 +checks and a configurable mirror.
4 +
5 +With no model installed, scanning photographs the card and offers manual entry instead
6 +of failing.
A android/fastlane/metadata/android/en-US/full_description.txt
+23 -0
@@ -0,0 +1,23 @@
1 +PicLead photographs a business card, reads its text on the device and turns it into an
2 +editable contact draft, stored in a local address book.
3 +
4 +Everything runs offline: no Google services, no remote processing, no language model.
5 +Text recognition uses Tesseract, voice note transcription uses Vosk — both on device.
6 +
7 +Main features
8 +* Card scanning: framing, deskewing, text recognition, editable draft
9 +* Local SQLite address book with full-text search and duplicate handling
10 +* vCard import and export (reads 3.0 and 4.0, writes 3.0)
11 +* Android contact creation
12 +* Voice notes with on-device transcription
13 +* Optional sync with a self-hosted PicLead server
14 +
15 +Models to download
16 +This build ships without recognition models. On the first scan the app offers to download
17 +them from Settings → Offline models: seven OCR languages to choose from (1 to 5 MB each)
18 +and a French voice transcription model. Until a model is installed, scanning still
19 +photographs the card but fills in no contact field — you type them yourself.
20 +
21 +Privacy
22 +With no server configured, no data leaves the device. The Internet permission is used for
23 +the model downloads you trigger yourself, and for optional sync with your own server.
A android/fastlane/metadata/android/en-US/short_description.txt
+1 -0
@@ -0,0 +1 @@
1 +Offline business card scanner and contact book
A android/fastlane/metadata/android/en-US/title.txt
+1 -0
@@ -0,0 +1 @@
1 +PicLead
A android/fastlane/metadata/android/fr-FR/changelogs/4.txt
+6 -0
@@ -0,0 +1,6 @@
1 +Modèles de reconnaissance téléchargeables après installation, depuis Paramètres →
2 +Modèles hors-ligne : choix des langues OCR et du modèle de transcription vocale,
3 +vérification d'intégrité SHA-256, miroir configurable.
4 +
5 +Sans modèle installé, le scan photographie la carte et propose la saisie manuelle
6 +plutôt que d'échouer.
A android/fastlane/metadata/android/fr-FR/full_description.txt
+26 -0
@@ -0,0 +1,26 @@
1 +PicLead photographie une carte de visite, en lit le texte sur l'appareil et en tire un
2 +brouillon de contact éditable, enregistré dans un carnet local.
3 +
4 +Tout fonctionne hors-ligne : aucun service Google, aucune analyse à distance, aucun modèle
5 +de langage. La reconnaissance de texte s'appuie sur Tesseract, la transcription des notes
6 +vocales sur Vosk — les deux tournent sur l'appareil.
7 +
8 +Fonctions principales
9 +* Scan d'une carte : cadrage, redressement, reconnaissance de texte, brouillon éditable
10 +* Carnet local en SQLite, recherche plein texte, gestion des doublons
11 +* Import et export vCard (lecture 3.0 et 4.0, écriture 3.0)
12 +* Création de contacts Android
13 +* Notes vocales avec transcription sur l'appareil
14 +* Synchronisation optionnelle avec un serveur PicLead auto-hébergé
15 +
16 +Modèles à télécharger
17 +Cette version ne contient aucun modèle de reconnaissance. Au premier scan, l'application
18 +propose de les télécharger depuis Paramètres → Modèles hors-ligne : sept langues au choix
19 +pour l'OCR (1 à 5 Mo chacune) et un modèle de transcription vocale française. Tant
20 +qu'aucun modèle n'est installé, le scan photographie bien la carte mais ne remplit aucun
21 +champ du contact : la saisie se fait alors à la main.
22 +
23 +Confidentialité
24 +Sans serveur configuré, aucune donnée ne quitte l'appareil. La permission Internet sert
25 +au téléchargement des modèles, que vous déclenchez vous-même, et à la synchronisation
26 +optionnelle avec votre propre serveur.
A android/fastlane/metadata/android/fr-FR/short_description.txt
+1 -0
@@ -0,0 +1 @@
1 +Scan de cartes de visite hors-ligne et carnet de contacts
A android/fastlane/metadata/android/fr-FR/title.txt
+1 -0
@@ -0,0 +1 @@
1 +PicLead
M ios/Card2vcf/Data/ProjetEntity.swift
+6 -0
@@ -9,6 +9,12 @@ struct ProjetEntity: Equatable {
9 9 var workflowServerId: String = ""
10 10 var membresJson: String = "[]"
11 11 var creePar: String = ""
12 + /// Le projet utilise-t-il dates, durées et diagramme de Gantt ?
13 + var planification: Bool = false
14 + /// Date cible du projet, format ISO `AAAA-MM-JJ`.
15 + var echeance: String? = nil
16 + /// Rythme de travail : 5 = lun-ven, 6 = lun-sam, 7 = tous les jours.
17 + var joursOuvres: Int = 7
12 18 var createdAt: Int64 = 0
13 19 var updatedAt: Int64 = 0
14 20 }
M ios/Card2vcf/Data/Sqlite/SqliteDatabase.swift
+50 -4
@@ -19,7 +19,7 @@ struct DatabaseError: Error, CustomStringConvertible {
19 19 /// Owner of the sqlite3 connection. Mirrors the Room schema of the Android
20 20 /// `CrmDatabase` (version 3) with destructive migration semantics.
21 21 actor SqliteDatabase {
22 - static let schemaVersion: Int32 = 6
22 + static let schemaVersion: Int32 = 7
23 23 static let fileName = "card2vcf.sqlite"
24 24
25 25 private let inMemory: Bool
@@ -169,13 +169,27 @@ actor SqliteDatabase {
169 169 // v5→v6: audio + transcription columns on `interactions`.
170 170 // Guard on table existence: some legacy test databases (v4→v5 path) may lack `interactions`.
171 171 if version == 5 {
172 - let checkStmt = try SqliteStatement(db: db,
173 - sql: "SELECT name FROM sqlite_master WHERE type='table' AND name='interactions'")
174 - if try checkStmt.step() {
172 + if try tableExists(db, "interactions") {
175 173 try exec(db, "ALTER TABLE interactions ADD COLUMN audioPath TEXT;")
176 174 try exec(db, "ALTER TABLE interactions ADD COLUMN transcriptionStatut TEXT;")
177 175 try exec(db, "ALTER TABLE interactions ADD COLUMN transcriptionErreur TEXT;")
178 176 }
177 + version = 6
178 + }
179 + // v6→v7: task planning (start, working-day duration, label, grouping,
180 + // dependencies, subtasks) and project planning settings. Mirror of Room
181 + // `MIGRATION_7_8`; the server already sent these fields in the pull.
182 + // Same table-existence guard as above: partial test databases exist.
183 + if version == 6 {
184 + if try tableExists(db, "taches") {
185 + try exec(db, tachesPlanificationAlterSql)
186 + }
187 + if try tableExists(db, "projets") {
188 + try exec(db, projetsPlanificationAlterSql)
189 + }
190 + version = 7
191 + }
192 + if version == schemaVersion {
179 193 try exec(db, "PRAGMA user_version = \(schemaVersion);")
180 194 return
181 195 }
@@ -184,6 +198,29 @@ actor SqliteDatabase {
184 198 try exec(db, "PRAGMA user_version = \(schemaVersion);")
185 199 }
186 200
201 + private static func tableExists(_ db: OpaquePointer, _ name: String) throws -> Bool {
202 + let stmt = try SqliteStatement(
203 + db: db,
204 + sql: "SELECT name FROM sqlite_master WHERE type='table' AND name='\(name)'"
205 + )
206 + return try stmt.step()
207 + }
208 +
209 + private static let tachesPlanificationAlterSql = """
210 + ALTER TABLE taches ADD COLUMN debut TEXT;
211 + ALTER TABLE taches ADD COLUMN dureeJours INTEGER;
212 + ALTER TABLE taches ADD COLUMN etiquette TEXT;
213 + ALTER TABLE taches ADD COLUMN parentId TEXT;
214 + ALTER TABLE taches ADD COLUMN dependDeJson TEXT NOT NULL DEFAULT '[]';
215 + ALTER TABLE taches ADD COLUMN sousTachesJson TEXT NOT NULL DEFAULT '[]';
216 + """
217 +
218 + private static let projetsPlanificationAlterSql = """
219 + ALTER TABLE projets ADD COLUMN planification INTEGER NOT NULL DEFAULT 0;
220 + ALTER TABLE projets ADD COLUMN echeance TEXT;
221 + ALTER TABLE projets ADD COLUMN joursOuvres INTEGER NOT NULL DEFAULT 7;
222 + """
223 +
187 224 private static let notesProjetCreateSql = """
188 225 CREATE TABLE notes_projet (
189 226 localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
@@ -313,6 +350,9 @@ actor SqliteDatabase {
313 350 workflowServerId TEXT NOT NULL,
314 351 membresJson TEXT NOT NULL,
315 352 creePar TEXT NOT NULL,
353 + planification INTEGER NOT NULL DEFAULT 0,
354 + echeance TEXT,
355 + joursOuvres INTEGER NOT NULL DEFAULT 7,
316 356 createdAt INTEGER NOT NULL,
317 357 updatedAt INTEGER NOT NULL
318 358 );
@@ -327,6 +367,12 @@ actor SqliteDatabase {
327 367 colonneId TEXT NOT NULL,
328 368 ordre INTEGER NOT NULL,
329 369 assigneA TEXT,
370 + debut TEXT,
371 + dureeJours INTEGER,
372 + etiquette TEXT,
373 + parentId TEXT,
374 + dependDeJson TEXT NOT NULL DEFAULT '[]',
375 + sousTachesJson TEXT NOT NULL DEFAULT '[]',
330 376 auteur TEXT NOT NULL,
331 377 createdAt INTEGER NOT NULL,
332 378 updatedAt INTEGER NOT NULL
M ios/Card2vcf/Data/Sqlite/SqliteProjetDao.swift
+11 -4
@@ -4,7 +4,8 @@ struct SqliteProjetDao: ProjetDao {
4 4 let db: SqliteDatabase
5 5
6 6 private static let columns = """
7 - localId, serverId, nom, description, workflowServerId, membresJson, creePar, createdAt, updatedAt
7 + localId, serverId, nom, description, workflowServerId, membresJson, creePar, \
8 + planification, echeance, joursOuvres, createdAt, updatedAt
8 9 """
9 10
10 11 private static func map(_ row: SqliteRow) -> ProjetEntity {
@@ -16,15 +17,18 @@ struct SqliteProjetDao: ProjetDao {
16 17 e.workflowServerId = row.text(4)
17 18 e.membresJson = row.text(5)
18 19 e.creePar = row.text(6)
19 - e.createdAt = row.int64(7)
20 - e.updatedAt = row.int64(8)
20 + e.planification = row.bool(7)
21 + e.echeance = row.textOrNil(8)
22 + e.joursOuvres = row.int(9)
23 + e.createdAt = row.int64(10)
24 + e.updatedAt = row.int64(11)
21 25 return e
22 26 }
23 27
24 28 @discardableResult
25 29 func upsert(_ entity: ProjetEntity) async throws -> Int64 {
26 30 try await db.insert(
27 - "INSERT OR REPLACE INTO projets (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
31 + "INSERT OR REPLACE INTO projets (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
28 32 [
29 33 .rowId(entity.localId),
30 34 .optText(entity.serverId),
@@ -33,6 +37,9 @@ struct SqliteProjetDao: ProjetDao {
33 37 .text(entity.workflowServerId),
34 38 .text(entity.membresJson),
35 39 .text(entity.creePar),
40 + .bool(entity.planification),
41 + .optText(entity.echeance),
42 + .int(Int64(entity.joursOuvres)),
36 43 .int(entity.createdAt),
37 44 .int(entity.updatedAt),
38 45 ]
M ios/Card2vcf/Data/Sqlite/SqliteTacheDao.swift
+18 -5
@@ -5,7 +5,8 @@ struct SqliteTacheDao: TacheDao {
5 5
6 6 private static let columns = """
7 7 localId, serverId, projetLocalId, projetServerId, titre, description, colonneId, \
8 - ordre, assigneA, auteur, createdAt, updatedAt
8 + ordre, assigneA, debut, dureeJours, etiquette, parentId, dependDeJson, sousTachesJson, \
9 + auteur, createdAt, updatedAt
9 10 """
10 11
11 12 private static func map(_ row: SqliteRow) -> TacheEntity {
@@ -19,16 +20,22 @@ struct SqliteTacheDao: TacheDao {
19 20 e.colonneId = row.text(6)
20 21 e.ordre = row.int(7)
21 22 e.assigneA = row.textOrNil(8)
22 - e.auteur = row.text(9)
23 - e.createdAt = row.int64(10)
24 - e.updatedAt = row.int64(11)
23 + e.debut = row.textOrNil(9)
24 + e.dureeJours = row.int64OrNil(10).map(Int.init)
25 + e.etiquette = row.textOrNil(11)
26 + e.parentId = row.textOrNil(12)
27 + e.dependDeJson = row.text(13)
28 + e.sousTachesJson = row.text(14)
29 + e.auteur = row.text(15)
30 + e.createdAt = row.int64(16)
31 + e.updatedAt = row.int64(17)
25 32 return e
26 33 }
27 34
28 35 @discardableResult
29 36 func upsert(_ entity: TacheEntity) async throws -> Int64 {
30 37 try await db.insert(
31 - "INSERT OR REPLACE INTO taches (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
38 + "INSERT OR REPLACE INTO taches (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
32 39 [
33 40 .rowId(entity.localId),
34 41 .optText(entity.serverId),
@@ -39,6 +46,12 @@ struct SqliteTacheDao: TacheDao {
39 46 .text(entity.colonneId),
40 47 .int(Int64(entity.ordre)),
41 48 .optText(entity.assigneA),
49 + .optText(entity.debut),
50 + .optInt(entity.dureeJours.map(Int64.init)),
51 + .optText(entity.etiquette),
52 + .optText(entity.parentId),
53 + .text(entity.dependDeJson),
54 + .text(entity.sousTachesJson),
42 55 .text(entity.auteur),
43 56 .int(entity.createdAt),
44 57 .int(entity.updatedAt),
M ios/Card2vcf/Data/TacheEntity.swift
+12 -0
@@ -11,6 +11,18 @@ struct TacheEntity: Equatable {
11 11 var colonneId: String = ""
12 12 var ordre: Int = 0
13 13 var assigneA: String? = nil
14 + /// Premier jour planifié, format ISO `AAAA-MM-JJ` (miroir Rust `debut`).
15 + var debut: String? = nil
16 + /// Durée en jours **ouvrés** selon le rythme du projet.
17 + var dureeJours: Int? = nil
18 + /// Étiquette normalisée ; la couleur s'en déduit (cf. `CouleurEtiquette`).
19 + var etiquette: String? = nil
20 + /// Tâche parente (regroupement). Affichée, non éditable sur mobile.
21 + var parentId: String? = nil
22 + /// Identifiants des tâches dont celle-ci dépend, en JSON.
23 + var dependDeJson: String = "[]"
24 + /// Sous-tâches `{id, titre, fait}` en JSON (même forme que côté serveur).
25 + var sousTachesJson: String = "[]"
14 26 var auteur: String = ""
15 27 var createdAt: Int64 = 0
16 28 var updatedAt: Int64 = 0
A ios/Card2vcf/Planning/ApercuTache.swift
+46 -0
@@ -0,0 +1,46 @@
1 +import Foundation
2 +
3 +/// Ce qu'une carte kanban affiche d'une tâche planifiée. Extrait de la vue pour
4 +/// être testable, sur le modèle de `KanbanFilter`.
5 +///
6 +/// Toutes les entrées viennent du serveur et peuvent être malformées après une
7 +/// synchronisation partielle : chaque fonction rend `nil` plutôt que de lever.
8 +///
9 +/// Port de `planning/ApercuTache.kt`.
10 +enum ApercuTache {
11 + /// « 09/01 → 13/01 » selon le rythme du projet ; `nil` si la tâche n'est pas datée.
12 + static func periode(_ tache: TacheEntity, joursOuvres: Int) -> String? {
13 + guard let debut = dateOuNil(tache.debut) else { return nil }
14 + return CalendrierOuvre(joursOuvres: joursOuvres)
15 + .periodeAffichee(debut, tache.dureeJours ?? 1)
16 + }
17 +
18 + /// `(faites, total)` des sous-tâches ; `nil` s'il n'y en a aucune.
19 + static func avancement(_ tache: TacheEntity) -> (faites: Int, total: Int)? {
20 + let toutes = sousTaches(tache)
21 + if toutes.isEmpty { return nil }
22 + return (toutes.filter(\.fait).count, toutes.count)
23 + }
24 +
25 + /// La tâche finit-elle après l'échéance du projet ?
26 + static func enRetard(_ tache: TacheEntity, joursOuvres: Int, echeance: String?) -> Bool {
27 + guard let cible = dateOuNil(echeance), let debut = dateOuNil(tache.debut) else { return false }
28 + return CalendrierOuvre(joursOuvres: joursOuvres).fin(debut, tache.dureeJours ?? 1) > cible
29 + }
30 +
31 + /// Sous-tâches décodées, dans l'ordre ; liste vide si absentes ou illisibles.
32 + static func sousTaches(_ tache: TacheEntity?) -> [SousTacheDto] {
33 + let json = tache?.sousTachesJson ?? "[]"
34 + return (try? SyncJson.decode([SousTacheDto].self, from: json)) ?? []
35 + }
36 +
37 + /// Teinte de l'étiquette, identique à celle du web.
38 + static func couleur(_ tache: TacheEntity) -> String? {
39 + CouleurEtiquette.pour(tache.etiquette)
40 + }
41 +
42 + private static func dateOuNil(_ iso: String?) -> DateCivile? {
43 + guard let iso else { return nil }
44 + return DateCivile(iso: iso)
45 + }
46 +}
A ios/Card2vcf/Planning/CalendrierOuvre.swift
+70 -0
@@ -0,0 +1,70 @@
1 +import Foundation
2 +
3 +/// Calendrier ouvré d'un projet — port de `Server/src/services/calendrier.rs`,
4 +/// décalqué du Kotlin `planning/CalendrierOuvre.kt`.
5 +///
6 +/// Les durées de tâches se comptent en jours **ouvrés** selon le rythme du
7 +/// projet (`joursOuvres` : 5 = lun-ven, 6 = lun-sam, 7 = tous les jours). Une
8 +/// tâche ne peut donc pas calculer sa fin seule : il lui faut le calendrier de
9 +/// son projet. Sans ce port, le téléphone afficherait une fin différente de
10 +/// celle du diagramme web.
11 +///
12 +/// Les trois implémentations sont tenues par les mêmes cas de test :
13 +/// `Server/tests/calendrier.rs`, `CalendrierOuvreTest.kt` et `CalendrierOuvreTest.swift`.
14 +struct CalendrierOuvre {
15 + /// Un rythme hors de 5-7 (donnée abîmée) retombe sur 7, comportement historique.
16 + private let rythme: Int
17 +
18 + init(joursOuvres: Int) {
19 + switch joursOuvres {
20 + case 5: rythme = 5
21 + case 6: rythme = 6
22 + default: rythme = 7
23 + }
24 + }
25 +
26 + func estOuvre(_ jour: DateCivile) -> Bool {
27 + switch jour.jourDeSemaine {
28 + case 6: return rythme >= 6 // samedi
29 + case 7: return rythme >= 7 // dimanche
30 + default: return true
31 + }
32 + }
33 +
34 + /// Premier jour ouvré à partir de `jour` inclus.
35 + func caler(_ jour: DateCivile) -> DateCivile {
36 + var j = jour
37 + while !estOuvre(j) { j = j.plusJours(1) }
38 + return j
39 + }
40 +
41 + /// Avance de `n` jours ouvrés depuis `jour`, calé au préalable.
42 + func avancer(_ jour: DateCivile, _ n: Int) -> DateCivile {
43 + var j = caler(jour)
44 + for _ in 0..<max(n, 0) { j = caler(j.plusJours(1)) }
45 + return j
46 + }
47 +
48 + /// Nombre de jours ouvrés dans `[debut, fin]` inclus ; 0 si l'intervalle est inversé.
49 + func compter(_ debut: DateCivile, _ fin: DateCivile) -> Int {
50 + var n = 0
51 + var j = debut
52 + while j <= fin {
53 + if estOuvre(j) { n += 1 }
54 + j = j.plusJours(1)
55 + }
56 + return n
57 + }
58 +
59 + /// Dernier jour inclus. Une durée nulle vaut un jour : jamais de période vide.
60 + func fin(_ debut: DateCivile, _ dureeJours: Int) -> DateCivile {
61 + avancer(debut, max(dureeJours, 1) - 1)
62 + }
63 +
64 + /// « 09/01 → 13/01 », ou le jour seul si la tâche tient sur une journée.
65 + func periodeAffichee(_ debut: DateCivile, _ dureeJours: Int) -> String {
66 + let f = fin(debut, dureeJours)
67 + let d = caler(debut)
68 + return d == f ? d.jourMois : "\(d.jourMois) → \(f.jourMois)"
69 + }
70 +}
A ios/Card2vcf/Planning/CouleurEtiquette.swift
+42 -0
@@ -0,0 +1,42 @@
1 +import Foundation
2 +
3 +/// Teinte d'une étiquette de tâche — port de `COULEURS_ETIQUETTE` et
4 +/// `empreinte_fnv1a` de `Server/src/modeles.rs`, décalqué du Kotlin
5 +/// `planning/CouleurEtiquette.kt`.
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 trois côtés
11 +/// (`teintes_de_reference_partagees_avec_le_mobile` / `teintesDeReference`).
12 +enum CouleurEtiquette {
13 + static let palette = [
14 + "#3772CF", // bleu
15 + "#3FB985", // vert
16 + "#E89642", // ambre
17 + "#E5484D", // rouge
18 + "#8B5CF6", // violet
19 + "#0EA5E9", // cyan
20 + "#EC4899", // rose
21 + "#6B7280", // ardoise
22 + ]
23 +
24 + /// `nil` si la tâche n'a pas d'étiquette.
25 + static func pour(_ etiquette: String?) -> String? {
26 + let e = etiquette?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
27 + if e.isEmpty { return nil }
28 + // Le hachage est **non signé** sur 32 bits, comme en Rust : passer par
29 + // `Int` (64 bits, débordement fatal en Swift) donnerait un autre index.
30 + let index = Int(empreinteFnv1a(e) % UInt32(palette.count))
31 + return palette[index]
32 + }
33 +
34 + private static func empreinteFnv1a(_ texte: String) -> UInt32 {
35 + var h: UInt32 = 0x811c_9dc5
36 + for octet in Array(texte.utf8) {
37 + h ^= UInt32(octet)
38 + h = h &* 0x0100_0193
39 + }
40 + return h
41 + }
42 +}
A ios/Card2vcf/Planning/DateCivile.swift
+86 -0
@@ -0,0 +1,86 @@
1 +import Foundation
2 +
3 +/// Date sans heure ni fuseau — équivalent Swift de `java.time.LocalDate`, que
4 +/// Foundation n'offre pas.
5 +///
6 +/// Le calendrier de planification raisonne en jours, jamais en instants : un
7 +/// `Date` (qui est un instant UTC) donnerait des résultats dépendants du fuseau
8 +/// de l'appareil. Toute l'arithmétique passe donc par un calendrier grégorien
9 +/// figé sur UTC.
10 +struct DateCivile: Equatable, Comparable, CustomStringConvertible {
11 + let annee: Int
12 + let mois: Int
13 + let jour: Int
14 +
15 + /// Calendrier grégorien en UTC : même résultat quel que soit le réglage de l'appareil.
16 + private static let calendrier: Calendar = {
17 + var c = Calendar(identifier: .gregorian)
18 + c.timeZone = TimeZone(secondsFromGMT: 0)!
19 + return c
20 + }()
21 +
22 + /// `nil` si la chaîne n'est pas un `AAAA-MM-JJ` valide (donnée abîmée par une
23 + /// synchronisation partielle).
24 + init?(iso: String) {
25 + let parts = iso.split(separator: "-", omittingEmptySubsequences: false)
26 + guard parts.count == 3,
27 + parts[0].count == 4, parts[1].count == 2, parts[2].count == 2,
28 + let a = Int(parts[0]), let m = Int(parts[1]), let j = Int(parts[2])
29 + else { return nil }
30 + // Rejette les dates inexistantes (31/02) : le calendrier les normaliserait en silence.
31 + let composantes = DateComponents(year: a, month: m, day: j)
32 + guard composantes.isValidDate(in: Self.calendrier) else { return nil }
33 + annee = a
34 + mois = m
35 + jour = j
36 + }
37 +
38 + private init(annee: Int, mois: Int, jour: Int) {
39 + self.annee = annee
40 + self.mois = mois
41 + self.jour = jour
42 + }
43 +
44 + private var instantUtc: Date {
45 + Self.calendrier.date(from: DateComponents(year: annee, month: mois, day: jour))!
46 + }
47 +
48 + private init(instantUtc: Date) {
49 + let c = Self.calendrier.dateComponents([.year, .month, .day], from: instantUtc)
50 + self.init(annee: c.year!, mois: c.month!, jour: c.day!)
51 + }
52 +
53 + /// 1 = lundi … 7 = dimanche (numérotation `java.time.DayOfWeek`).
54 + var jourDeSemaine: Int {
55 + // `Calendar.weekday` est 1 = dimanche … 7 = samedi.
56 + let weekday = Self.calendrier.component(.weekday, from: instantUtc)
57 + return weekday == 1 ? 7 : weekday - 1
58 + }
59 +
60 + func plusJours(_ n: Int) -> DateCivile {
61 + DateCivile(instantUtc: Self.calendrier.date(byAdding: .day, value: n, to: instantUtc)!)
62 + }
63 +
64 + /// « 09/01 » — format court des cartes kanban, identique au web.
65 + var jourMois: String {
66 + "\(Self.pad(jour, 2))/\(Self.pad(mois, 2))"
67 + }
68 +
69 + var iso: String {
70 + "\(Self.pad(annee, 4))-\(Self.pad(mois, 2))-\(Self.pad(jour, 2))"
71 + }
72 +
73 + /// Zéros de tête à la main : `String(format: "%02d", …)` mélange `Int`
74 + /// 64 bits et `%d` 32 bits dans les varargs, et décale les arguments suivants.
75 + private static func pad(_ valeur: Int, _ largeur: Int) -> String {
76 + let chiffres = String(valeur)
77 + guard chiffres.count < largeur else { return chiffres }
78 + return String(repeating: "0", count: largeur - chiffres.count) + chiffres
79 + }
80 +
81 + var description: String { iso }
82 +
83 + static func < (lhs: DateCivile, rhs: DateCivile) -> Bool {
84 + (lhs.annee, lhs.mois, lhs.jour) < (rhs.annee, rhs.mois, rhs.jour)
85 + }
86 +}
A ios/Card2vcf/Planning/ReglagesPlanification.swift
+23 -0
@@ -0,0 +1,23 @@
1 +import Foundation
2 +
3 +/// Réglages de planification d'un projet, nécessaires pour dater ses tâches.
4 +///
5 +/// Le rythme conditionne le calcul de la fin (jours ouvrés) et l'échéance sert
6 +/// de repère de retard. Par défaut la planification est éteinte : un projet qui
7 +/// ne l'active pas n'affiche aucun repère temporel, comme sur le web.
8 +///
9 +/// Port de `planning/ReglagesPlanification.kt`.
10 +struct ReglagesPlanification: Equatable {
11 + var active: Bool = false
12 + var joursOuvres: Int = 7
13 + var echeance: String? = nil
14 +
15 + static func de(_ projet: ProjetEntity?) -> ReglagesPlanification {
16 + guard let projet else { return ReglagesPlanification() }
17 + return ReglagesPlanification(
18 + active: projet.planification,
19 + joursOuvres: projet.joursOuvres,
20 + echeance: projet.echeance
21 + )
22 + }
23 +}
M ios/Card2vcf/Sync/AilianceApi.swift
+8 -0
@@ -34,6 +34,14 @@ protocol AilianceApi {
34 34
35 35 func moveTache(projetId: String, tacheId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String>
36 36
37 + /// Coche/décoche une sous-tâche. Route dédiée : le `PUT` général ne
38 + /// transporte que des titres et ne peut donc pas basculer l'état `fait`.
39 + func toggleSousTache(
40 + projetId: String,
41 + tacheId: String,
42 + sousTacheId: String
43 + ) async -> AilianceApiClient.ApiResult<String>
44 +
37 45 func createInteraction(contactId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String>
38 46
39 47 func listRdv() async -> AilianceApiClient.ApiResult<[RendezVousDto]>
M ios/Card2vcf/Sync/AilianceApiClient.swift
+13 -0
@@ -116,6 +116,19 @@ final class AilianceApiClient: AilianceApi {
116 116 )
117 117 }
118 118
119 + func toggleSousTache(
120 + projetId: String,
121 + tacheId: String,
122 + sousTacheId: String
123 + ) async -> ApiResult<String> {
124 + await put(
125 + "/api/projets/\(Self.encodePath(projetId))/taches/\(Self.encodePath(tacheId))"
126 + + "/sous-taches/\(Self.encodePath(sousTacheId))",
127 + "",
128 + parse: Self.text
129 + )
130 + }
131 +
119 132 func createInteraction(contactId: String, jsonBody: String) async -> ApiResult<String> {
120 133 await post("/api/contacts/\(Self.encodePath(contactId))/interactions", jsonBody, parse: Self.text)
121 134 }
M ios/Card2vcf/Sync/SyncEngine.swift
+30 -0
@@ -461,6 +461,16 @@ final class SyncEngine {
461 461 case "move":
462 462 guard let serverId = op.serverId else { return PushOutcome(ok: false) }
463 463 return Self.outcome(await api.moveTache(projetId: projetId, tacheId: serverId, jsonBody: op.payloadJson))
464 + case "soustache":
465 + // `payloadJson` porte l'identifiant de la sous-tâche à basculer.
466 + guard let serverId = op.serverId else { return PushOutcome(ok: false) }
467 + return Self.outcome(
468 + await api.toggleSousTache(
469 + projetId: projetId,
470 + tacheId: serverId,
471 + sousTacheId: op.payloadJson
472 + )
473 + )
464 474 default:
465 475 return PushOutcome(ok: true)
466 476 }
@@ -845,6 +855,9 @@ final class SyncEngine {
845 855 entity.workflowServerId = dto.workflowId
846 856 entity.membresJson = membresJson
847 857 entity.creePar = dto.creePar
858 + entity.planification = dto.planification
859 + entity.echeance = dto.echeance
860 + entity.joursOuvres = dto.joursOuvres
848 861 entity.createdAt = parseIsoToEpochMs(dto.creeLe)
849 862 entity.updatedAt = remoteTs
850 863 try await db.projetDao.upsert(entity)
@@ -856,6 +869,9 @@ final class SyncEngine {
856 869 remoteEntity.workflowServerId = dto.workflowId
857 870 remoteEntity.membresJson = membresJson
858 871 remoteEntity.creePar = dto.creePar
872 + remoteEntity.planification = dto.planification
873 + remoteEntity.echeance = dto.echeance
874 + remoteEntity.joursOuvres = dto.joursOuvres
859 875 remoteEntity.updatedAt = remoteTs
860 876 let merged = LwwMerger.pickLww(local: local, localTs: local.updatedAt, remote: remoteEntity, remoteTs: remoteTs)
861 877 if merged != local {
@@ -867,6 +883,8 @@ final class SyncEngine {
867 883 let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe)
868 884 let local = try await db.tacheDao.getByServerId(dto.id)
869 885 let projetLocalId = try await db.projetDao.getByServerId(dto.projetId)?.localId
886 + let dependDeJson = SyncJson.encodeToString(dto.dependDe)
887 + let sousTachesJson = SyncJson.encodeToString(dto.sousTaches)
870 888 guard let local else {
871 889 var entity = TacheEntity()
872 890 entity.serverId = dto.id
@@ -877,6 +895,12 @@ final class SyncEngine {
877 895 entity.colonneId = dto.colonneId
878 896 entity.ordre = dto.ordre
879 897 entity.assigneA = dto.assigneA
898 + entity.debut = dto.debut
899 + entity.dureeJours = dto.dureeJours
900 + entity.etiquette = dto.etiquette
901 + entity.parentId = dto.parentId
902 + entity.dependDeJson = dependDeJson
903 + entity.sousTachesJson = sousTachesJson
880 904 entity.auteur = dto.auteur
881 905 entity.createdAt = parseIsoToEpochMs(dto.creeLe)
882 906 entity.updatedAt = remoteTs
@@ -891,6 +915,12 @@ final class SyncEngine {
891 915 remoteEntity.colonneId = dto.colonneId
892 916 remoteEntity.ordre = dto.ordre
893 917 remoteEntity.assigneA = dto.assigneA
918 + remoteEntity.debut = dto.debut
919 + remoteEntity.dureeJours = dto.dureeJours
920 + remoteEntity.etiquette = dto.etiquette
921 + remoteEntity.parentId = dto.parentId
922 + remoteEntity.dependDeJson = dependDeJson
923 + remoteEntity.sousTachesJson = sousTachesJson
894 924 remoteEntity.auteur = dto.auteur
895 925 remoteEntity.updatedAt = remoteTs
896 926 let merged = LwwMerger.pickLww(local: local, localTs: local.updatedAt, remote: remoteEntity, remoteTs: remoteTs)
M ios/Card2vcf/Sync/SyncModels.swift
+39 -0
@@ -223,6 +223,11 @@ struct ProjetDto: Codable, Equatable {
223 223 var creePar: String = ""
224 224 var creeLe: String
225 225 var membres: [MembreProjetDto] = []
226 + /// Le projet utilise-t-il la planification ? Défaut faux = serveur antérieur.
227 + var planification: Bool = false
228 + var echeance: String? = nil
229 + /// 5 = lun-ven, 6 = lun-sam, 7 = tous les jours.
230 + var joursOuvres: Int = 7
226 231 var misAJourLe: String? = nil
227 232 }
228 233
@@ -236,10 +241,29 @@ extension ProjetDto {
236 241 creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
237 242 creeLe = try c.decode(String.self, forKey: .creeLe)
238 243 membres = try c.decodeIfPresent([MembreProjetDto].self, forKey: .membres) ?? []
244 + planification = try c.decodeIfPresent(Bool.self, forKey: .planification) ?? false
245 + echeance = try c.decodeIfPresent(String.self, forKey: .echeance)
246 + joursOuvres = try c.decodeIfPresent(Int.self, forKey: .joursOuvres) ?? 7
239 247 misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
240 248 }
241 249 }
242 250
251 +/// Élément de checklist d'une tâche (miroir Rust `SousTache`).
252 +struct SousTacheDto: Codable, Equatable {
253 + var id: String
254 + var titre: String
255 + var fait: Bool = false
256 +}
257 +
258 +extension SousTacheDto {
259 + init(from decoder: Decoder) throws {
260 + let c = try decoder.container(keyedBy: CodingKeys.self)
261 + id = try c.decode(String.self, forKey: .id)
262 + titre = try c.decode(String.self, forKey: .titre)
263 + fait = try c.decodeIfPresent(Bool.self, forKey: .fait) ?? false
264 + }
265 +}
266 +
243 267 /// Flattened task: `projet_id` + Tache fields at the same level (mirror of Rust `TacheSync`).
244 268 struct TacheSyncDto: Codable, Equatable {
245 269 var projetId: String
@@ -251,6 +275,15 @@ struct TacheSyncDto: Codable, Equatable {
251 275 var auteur: String = ""
252 276 var creeLe: String
253 277 var assigneA: String? = nil
278 + /// Premier jour planifié, `AAAA-MM-JJ`.
279 + var debut: String? = nil
280 + /// Durée en jours **ouvrés** selon le rythme du projet.
281 + var dureeJours: Int? = nil
282 + var etiquette: String? = nil
283 + /// Transportés et stockés pour ne pas les perdre à l'écriture, non éditables.
284 + var dependDe: [String] = []
285 + var parentId: String? = nil
286 + var sousTaches: [SousTacheDto] = []
254 287 var misAJourLe: String? = nil
255 288 }
256 289
@@ -266,6 +299,12 @@ extension TacheSyncDto {
266 299 auteur = try c.decodeIfPresent(String.self, forKey: .auteur) ?? ""
267 300 creeLe = try c.decode(String.self, forKey: .creeLe)
268 301 assigneA = try c.decodeIfPresent(String.self, forKey: .assigneA)
302 + debut = try c.decodeIfPresent(String.self, forKey: .debut)
303 + dureeJours = try c.decodeIfPresent(Int.self, forKey: .dureeJours)
304 + etiquette = try c.decodeIfPresent(String.self, forKey: .etiquette)
305 + dependDe = try c.decodeIfPresent([String].self, forKey: .dependDe) ?? []
306 + parentId = try c.decodeIfPresent(String.self, forKey: .parentId)
307 + sousTaches = try c.decodeIfPresent([SousTacheDto].self, forKey: .sousTaches) ?? []
269 308 misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
270 309 }
271 310 }
M ios/Card2vcf/UI/Kanban/KanbanBoardView.swift
+170 -33
@@ -12,13 +12,20 @@ struct KanbanBoardView: View {
12 12 let filterMode: KanbanFilter.Mode
13 13 let onFilterModeChange: (KanbanFilter.Mode) -> Void
14 14 let assignableUsers: [String]
15 - let onCreateTache: (String, String?) -> Void
16 - let onEditTache: (TacheEntity, String, String?) -> Void
15 + let planification: ReglagesPlanification
16 + let onCreateTache: (String, String?, String?, Int?, String?) -> Void
17 + let onEditTache: (TacheEntity, String, String?, String?, Int?, String?) -> Void
17 18 let onDeleteTache: (TacheEntity) -> Void
18 19 let onMoveTache: (TacheEntity, Int) -> Void
20 + let onToggleSousTache: (TacheEntity, String) -> Void
19 21
20 22 @State private var dialogTarget: TacheDialogTarget?
21 23
24 + /// Étiquettes déjà posées dans le projet, proposées en réutilisation.
25 + private var etiquettesConnues: [String] {
26 + Array(Set(taches.compactMap(\.etiquette))).sorted()
27 + }
28 +
22 29 var body: some View {
23 30 VStack(alignment: .leading, spacing: 0) {
24 31 HStack {
@@ -53,6 +60,7 @@ struct KanbanBoardView: View {
53 60 taches: taches
54 61 .filter { $0.colonneId == colonne.id }
55 62 .sorted { $0.ordre < $1.ordre },
63 + planification: planification,
56 64 canMoveLeft: index > 0,
57 65 canMoveRight: index < colonnes.count - 1,
58 66 onEdit: { dialogTarget = TacheDialogTarget(tache: $0) },
@@ -69,12 +77,18 @@ struct KanbanBoardView: View {
69 77 TacheDialog(
70 78 tache: target.tache,
71 79 assignableUsers: assignableUsers,
80 + planification: planification,
81 + etiquettesConnues: etiquettesConnues,
82 + onToggleSousTache: { sousTacheId in
83 + guard let tache = target.tache else { return }
84 + onToggleSousTache(tache, sousTacheId)
85 + },
72 86 onDismiss: { dialogTarget = nil },
73 - onConfirm: { titre, assigneA in
87 + onConfirm: { titre, assigneA, debut, dureeJours, etiquette in
74 88 if let tache = target.tache {
75 - onEditTache(tache, titre, assigneA)
89 + onEditTache(tache, titre, assigneA, debut, dureeJours, etiquette)
76 90 } else {
77 - onCreateTache(titre, assigneA)
91 + onCreateTache(titre, assigneA, debut, dureeJours, etiquette)
78 92 }
79 93 dialogTarget = nil
80 94 }
@@ -111,6 +125,7 @@ private struct FilterChipButton: View {
111 125 private struct KanbanColumnView: View {
112 126 let colonne: KanbanColonne
113 127 let taches: [TacheEntity]
128 + let planification: ReglagesPlanification
114 129 let canMoveLeft: Bool
115 130 let canMoveRight: Bool
116 131 let onEdit: (TacheEntity) -> Void
@@ -129,6 +144,7 @@ private struct KanbanColumnView: View {
129 144 ForEach(taches, id: \.localId) { tache in
130 145 TacheCard(
131 146 tache: tache,
147 + planification: planification,
132 148 canMoveLeft: canMoveLeft,
133 149 canMoveRight: canMoveRight,
134 150 onEdit: { onEdit(tache) },
@@ -146,6 +162,7 @@ private struct KanbanColumnView: View {
146 162
147 163 private struct TacheCard: View {
148 164 let tache: TacheEntity
165 + let planification: ReglagesPlanification
149 166 let canMoveLeft: Bool
150 167 let canMoveRight: Bool
151 168 let onEdit: () -> Void
@@ -153,18 +170,51 @@ private struct TacheCard: View {
153 170 let onMoveLeft: () -> Void
154 171 let onMoveRight: () -> Void
155 172
173 + private var enRetard: Bool {
174 + ApercuTache.enRetard(
175 + tache,
176 + joursOuvres: planification.joursOuvres,
177 + echeance: planification.echeance
178 + )
179 + }
180 +
156 181 var body: some View {
157 182 VStack(alignment: .leading, spacing: 4) {
158 183 Button(action: onEdit) {
159 184 VStack(alignment: .leading, spacing: 2) {
160 - Text(tache.titre.nilIfBlank ?? "—")
161 - .font(C2VFont.bodyMedium)
162 - .foregroundColor(C2VColor.ink)
185 + HStack(spacing: 6) {
186 + if let teinte = ApercuTache.couleur(tache) {
187 + Circle()
188 + .fill(Color(teinteEtiquette: teinte))
189 + .frame(width: 8, height: 8)
190 + }
191 + Text(tache.titre.nilIfBlank ?? "—")
192 + .font(C2VFont.bodyMedium)
193 + .foregroundColor(C2VColor.ink)
194 + }
163 195 Text(
164 196 tache.assigneA.map { String(format: "Assigné : %@", $0) } ?? "Non assigné"
165 197 )
166 198 .font(C2VFont.bodySmall)
167 199 .foregroundColor(C2VColor.texteFaible)
200 + // Repères de planification : rien si le projet ne planifie pas, comme sur le web.
201 + if planification.active {
202 + if let periode = ApercuTache.periode(tache, joursOuvres: planification.joursOuvres) {
203 + Text(enRetard ? "📅 \(periode) · en retard" : "📅 \(periode)")
204 + .font(C2VFont.bodySmall)
205 + .foregroundColor(enRetard ? C2VColor.brandError : C2VColor.texteFaible)
206 + }
207 + if let avancement = ApercuTache.avancement(tache) {
208 + Text("\(avancement.faites)/\(avancement.total) fait")
209 + .font(C2VFont.bodySmall)
210 + .foregroundColor(C2VColor.texteFaible)
211 + }
212 + if tache.parentId != nil {
213 + Text("↳ regroupée")
214 + .font(C2VFont.bodySmall)
215 + .foregroundColor(C2VColor.texteFaible)
216 + }
217 + }
168 218 }
169 219 .frame(maxWidth: .infinity, alignment: .leading)
170 220 }
@@ -201,54 +251,141 @@ private struct TacheCard: View {
201 251 private struct TacheDialog: View {
202 252 let tache: TacheEntity?
203 253 let assignableUsers: [String]
254 + let planification: ReglagesPlanification
255 + let etiquettesConnues: [String]
256 + let onToggleSousTache: (String) -> Void
204 257 let onDismiss: () -> Void
205 - let onConfirm: (String, String?) -> Void
258 + let onConfirm: (String, String?, String?, Int?, String?) -> Void
206 259
207 260 @State private var titre: String
208 261 @State private var assigneA: String?
262 + @State private var debut: String
263 + @State private var duree: String
264 + @State private var etiquette: String
209 265
210 266 init(
211 267 tache: TacheEntity?,
212 268 assignableUsers: [String],
269 + planification: ReglagesPlanification,
270 + etiquettesConnues: [String],
271 + onToggleSousTache: @escaping (String) -> Void,
213 272 onDismiss: @escaping () -> Void,
214 - onConfirm: @escaping (String, String?) -> Void
273 + onConfirm: @escaping (String, String?, String?, Int?, String?) -> Void
215 274 ) {
216 275 self.tache = tache
217 276 self.assignableUsers = assignableUsers
277 + self.planification = planification
278 + self.etiquettesConnues = etiquettesConnues
279 + self.onToggleSousTache = onToggleSousTache
218 280 self.onDismiss = onDismiss
219 281 self.onConfirm = onConfirm
220 282 _titre = State(initialValue: tache?.titre ?? "")
221 283 _assigneA = State(initialValue: tache?.assigneA)
284 + _debut = State(initialValue: tache?.debut ?? "")
285 + _duree = State(initialValue: tache?.dureeJours.map(String.init) ?? "")
286 + _etiquette = State(initialValue: tache?.etiquette ?? "")
287 + }
288 +
289 + /// Une date malformée bloque la validation plutôt que d'être envoyée telle quelle.
290 + private var debutValide: Bool {
291 + debut.isEmpty || DateCivile(iso: debut) != nil
222 292 }
223 293
224 294 var body: some View {
225 - VStack(alignment: .leading, spacing: 14) {
226 - Text(tache == nil ? "Nouvelle tâche" : "Modifier la tâche")
227 - .font(C2VFont.titleLarge)
228 - .foregroundColor(C2VColor.ink)
229 - C2VTextField(label: "", placeholder: "Titre", text: $titre)
230 - Menu {
231 - Button("Non assigné") { assigneA = nil }
232 - ForEach(assignableUsers.distinctPreservingOrder(), id: \.self) { user in
233 - Button(user) { assigneA = user }
234 - }
235 - } label: {
236 - Text(assigneA ?? "Non assigné")
237 - .font(C2VFont.labelMedium)
295 + ScrollView {
296 + VStack(alignment: .leading, spacing: 14) {
297 + Text(tache == nil ? "Nouvelle tâche" : "Modifier la tâche")
298 + .font(C2VFont.titleLarge)
238 299 .foregroundColor(C2VColor.ink)
239 - .padding(.vertical, 8)
240 - }
241 - Spacer()
242 - HStack {
243 - Spacer()
244 - Button("Annuler", action: onDismiss)
245 - .buttonStyle(C2VTextButtonStyle())
246 - Button("Valider") { onConfirm(titre, assigneA) }
300 + C2VTextField(label: "", placeholder: "Titre", text: $titre)
301 + Menu {
302 + Button("Non assigné") { assigneA = nil }
303 + ForEach(assignableUsers.distinctPreservingOrder(), id: \.self) { user in
304 + Button(user) { assigneA = user }
305 + }
306 + } label: {
307 + Text(assigneA ?? "Non assigné")
308 + .font(C2VFont.labelMedium)
309 + .foregroundColor(C2VColor.ink)
310 + .padding(.vertical, 8)
311 + }
312 + if planification.active {
313 + C2VTextField(
314 + label: "Début",
315 + placeholder: "2026-07-22",
316 + text: $debut,
317 + supportingText: debutValide ? nil : "Date attendue au format AAAA-MM-JJ",
318 + isError: !debutValide
319 + )
320 + C2VTextField(label: "Durée (jours ouvrés)", placeholder: "3", text: $duree)
321 + .keyboardType(.numberPad)
322 + .onChange(of: duree) { saisie in
323 + let chiffres = saisie.filter(\.isNumber)
324 + if chiffres != saisie { duree = chiffres }
325 + }
326 + ForEach(ApercuTache.sousTaches(tache), id: \.id) { st in
327 + Button {
328 + onToggleSousTache(st.id)
329 + } label: {
330 + HStack(spacing: 8) {
331 + Image(systemName: st.fait ? "checkmark.square" : "square")
332 + .foregroundColor(C2VColor.ink)
333 + Text(st.titre)
334 + .font(C2VFont.bodyMedium)
335 + .foregroundColor(C2VColor.ink)
336 + Spacer()
337 + }
338 + }
339 + .buttonStyle(.plain)
340 + }
341 + C2VTextField(label: "Étiquette", placeholder: "câblage", text: $etiquette)
342 + if !etiquettesConnues.isEmpty {
343 + Menu {
344 + ForEach(etiquettesConnues, id: \.self) { connue in
345 + Button(connue) { etiquette = connue }
346 + }
347 + } label: {
348 + Text("Étiquettes du projet")
349 + .font(C2VFont.labelMedium)
350 + .foregroundColor(C2VColor.texteFaible)
351 + }
352 + }
353 + }
354 + HStack {
355 + Spacer()
356 + Button("Annuler", action: onDismiss)
357 + .buttonStyle(C2VTextButtonStyle())
358 + Button("Valider") {
359 + onConfirm(
360 + titre,
361 + assigneA,
362 + debut.nilIfBlank,
363 + Int(duree),
364 + etiquette.nilIfBlank
365 + )
366 + }
247 367 .buttonStyle(C2VTextButtonStyle())
368 + .disabled(!debutValide)
369 + }
248 370 }
371 + .padding(18)
249 372 }
250 - .padding(18)
251 373 .background(C2VColor.fond)
252 - .presentationDetents([.medium])
374 + .presentationDetents([.medium, .large])
375 + }
376 +}
377 +
378 +private extension Color {
379 + /// « #RRGGBB » de `CouleurEtiquette` → `Color`, gris ardoise si illisible.
380 + init(teinteEtiquette: String) {
381 + let hex = teinteEtiquette.hasPrefix("#")
382 + ? String(teinteEtiquette.dropFirst())
383 + : teinteEtiquette
384 + let rgb = UInt32(hex, radix: 16) ?? 0x6B_7280
385 + self.init(
386 + red: Double((rgb >> 16) & 0xFF) / 255.0,
387 + green: Double((rgb >> 8) & 0xFF) / 255.0,
388 + blue: Double(rgb & 0xFF) / 255.0
389 + )
253 390 }
254 391 }
M ios/Card2vcf/UI/Projets/ProjetDetailScreen.swift
+21 -4
@@ -45,15 +45,32 @@ struct ProjetDetailScreen: View {
45 45 filterMode: viewModel.filterMode,
46 46 onFilterModeChange: { viewModel.setFilterMode($0) },
47 47 assignableUsers: viewModel.assignableUsers,
48 - onCreateTache: { titre, assigneA in
49 - viewModel.createTache(titre: titre, assigneA: assigneA)
48 + planification: ReglagesPlanification.de(viewModel.projet),
49 + onCreateTache: { titre, assigneA, debut, dureeJours, etiquette in
50 + viewModel.createTache(
51 + titre: titre,
52 + assigneA: assigneA,
53 + debut: debut,
54 + dureeJours: dureeJours,
55 + etiquette: etiquette
56 + )
50 57 },
51 - onEditTache: { tache, titre, assigneA in
52 - viewModel.editTache(tache, titre: titre, assigneA: assigneA)
58 + onEditTache: { tache, titre, assigneA, debut, dureeJours, etiquette in
59 + viewModel.editTache(
60 + tache,
61 + titre: titre,
62 + assigneA: assigneA,
63 + debut: debut,
64 + dureeJours: dureeJours,
65 + etiquette: etiquette
66 + )
53 67 },
54 68 onDeleteTache: { viewModel.deleteTache($0) },
55 69 onMoveTache: { tache, direction in
56 70 viewModel.moveTache(tache, direction: direction)
71 + },
72 + onToggleSousTache: { tache, sousTacheId in
73 + viewModel.toggleSousTache(tache, sousTacheId: sousTacheId)
57 74 }
58 75 )
59 76
M ios/Card2vcf/UI/Projets/ProjetDetailViewModel.swift
+104 -12
@@ -152,7 +152,13 @@ final class ProjetDetailViewModel: ObservableObject {
152 152 }
153 153 }
154 154
155 - func createTache(titre: String, assigneA: String?) {
155 + func createTache(
156 + titre: String,
157 + assigneA: String?,
158 + debut: String? = nil,
159 + dureeJours: Int? = nil,
160 + etiquette: String? = nil
161 + ) {
156 162 let titreTrim = titre.trimmingCharacters(in: .whitespacesAndNewlines)
157 163 guard !titreTrim.isEmpty else { return }
158 164 guard let colonneId = colonnes.first?.id else { return }
@@ -165,6 +171,9 @@ final class ProjetDetailViewModel: ObservableObject {
165 171 titre: titreTrim,
166 172 colonneId: colonneId,
167 173 assigneA: assigneA,
174 + debut: debut,
175 + dureeJours: dureeJours,
176 + etiquette: etiquette,
168 177 auteur: userName,
169 178 createdAt: now,
170 179 updatedAt: now
@@ -178,7 +187,10 @@ final class ProjetDetailViewModel: ObservableObject {
178 187 titre: titreTrim,
179 188 description: "",
180 189 colonneId: colonneId,
181 - assigneA: assigneA
190 + assigneA: assigneA,
191 + debut: debut,
192 + dureeJours: dureeJours,
193 + etiquette: etiquette
182 194 ),
183 195 localId: localId,
184 196 createdAt: now
@@ -188,7 +200,17 @@ final class ProjetDetailViewModel: ObservableObject {
188 200 }
189 201 }
190 202
191 - func editTache(_ tache: TacheEntity, titre: String, assigneA: String?) {
203 + /// Le dialogue initialise toujours les champs de planification depuis la
204 + /// tâche et les renvoie tels quels : renommer ne peut donc pas effacer la
205 + /// date qu'on vient de lire (miroir des valeurs par défaut du Kotlin).
206 + func editTache(
207 + _ tache: TacheEntity,
208 + titre: String,
209 + assigneA: String?,
210 + debut: String?,
211 + dureeJours: Int?,
212 + etiquette: String?
213 + ) {
192 214 let titreTrim = titre.trimmingCharacters(in: .whitespacesAndNewlines)
193 215 guard !titreTrim.isEmpty else { return }
194 216 Task {
@@ -196,26 +218,68 @@ final class ProjetDetailViewModel: ObservableObject {
196 218 var updated = tache
197 219 updated.titre = titreTrim
198 220 updated.assigneA = assigneA
221 + updated.debut = debut
222 + updated.dureeJours = dureeJours
223 + updated.etiquette = etiquette
199 224 updated.updatedAt = now
200 225 _ = try? await database.tacheDao.upsert(updated)
201 - let payload = Self.encodeJson([
202 - "titre": titreTrim,
203 - "description": tache.description,
204 - "assigne_a": assigneA as Any,
205 - "assigne_a_pose": true
206 - ])
226 + let payload = Self.updateTachePayload(
227 + titre: titreTrim,
228 + description: tache.description,
229 + assigneA: assigneA,
230 + debut: debut,
231 + dureeJours: dureeJours,
232 + etiquette: etiquette
233 + )
207 234 await enqueueTacheMutation(tache, op: "update", payloadJson: payload) {
208 235 Self.createTachePayload(
209 236 titre: titreTrim,
210 237 description: tache.description,
211 238 colonneId: tache.colonneId,
212 - assigneA: assigneA
239 + assigneA: assigneA,
240 + debut: debut,
241 + dureeJours: dureeJours,
242 + etiquette: etiquette
213 243 )
214 244 }
215 245 await refresh()
216 246 }
217 247 }
218 248
249 + /// Coche ou décoche une sous-tâche : bascule locale immédiate, puis
250 + /// opération poussée à la synchronisation.
251 + ///
252 + /// Passe par une route dédiée plutôt que par la mise à jour générale, qui ne
253 + /// transporte que des titres et ne saurait pas porter l'état `fait`.
254 + func toggleSousTache(_ tache: TacheEntity, sousTacheId: String) {
255 + guard let serverId = tache.serverId else { return }
256 + Task {
257 + let sous = ApercuTache.sousTaches(tache)
258 + guard !sous.isEmpty else { return }
259 + let basculees = sous.map { st -> SousTacheDto in
260 + guard st.id == sousTacheId else { return st }
261 + var copie = st
262 + copie.fait = !st.fait
263 + return copie
264 + }
265 + let now = ContactRepository.currentMillis()
266 + var updated = tache
267 + updated.sousTachesJson = SyncJson.encodeToString(basculees)
268 + updated.updatedAt = now
269 + _ = try? await database.tacheDao.upsert(updated)
270 + _ = try? await database.syncOpDao.insert(
271 + SyncOpEntity(
272 + entityType: "tache",
273 + op: "soustache",
274 + payloadJson: sousTacheId,
275 + serverId: serverId,
276 + createdAt: now
277 + )
278 + )
279 + await refresh()
280 + }
281 + }
282 +
219 283 func deleteTache(_ tache: TacheEntity) {
220 284 Task {
221 285 if let serverId = tache.serverId {
@@ -303,13 +367,41 @@ final class ProjetDetailViewModel: ObservableObject {
303 367 titre: String,
304 368 description: String,
305 369 colonneId: String,
306 - assigneA: String?
370 + assigneA: String?,
371 + debut: String? = nil,
372 + dureeJours: Int? = nil,
373 + etiquette: String? = nil
307 374 ) -> String {
308 375 encodeJson([
309 376 "titre": titre,
310 377 "description": description,
311 378 "colonne_id": colonneId,
312 - "assigne_a": assigneA as Any
379 + "assigne_a": assigneA as Any,
380 + "debut": debut as Any,
381 + "duree_jours": dureeJours as Any,
382 + "etiquette": etiquette as Any
383 + ])
384 + }
385 +
386 + /// Miroir de `UpdateTacheRequest` (Kotlin). **Ne déclare que ce que le
387 + /// téléphone édite** : sous-tâches, dépendances et regroupement sont absents,
388 + /// et c'est leur absence qui les préserve côté serveur.
389 + static func updateTachePayload(
390 + titre: String,
391 + description: String,
392 + assigneA: String?,
393 + debut: String?,
394 + dureeJours: Int?,
395 + etiquette: String?
396 + ) -> String {
397 + encodeJson([
398 + "titre": titre,
399 + "description": description,
400 + "assigne_a": assigneA as Any,
401 + "assigne_a_pose": true,
402 + "debut": debut as Any,
403 + "duree_jours": dureeJours as Any,
404 + "etiquette": etiquette as Any
313 405 ])
314 406 }
315 407
A ios/Card2vcfTests/ApercuTacheTest.swift
+74 -0
@@ -0,0 +1,74 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Logique d'affichage de la carte kanban, extraite de la vue pour être
5 +/// testable — même principe que `KanbanFilter`. Miroir de `ApercuTacheTest.kt`.
6 +final class ApercuTacheTest: XCTestCase {
7 + private func tache(
8 + debut: String? = nil,
9 + duree: Int? = nil,
10 + etiquette: String? = nil,
11 + sousTaches: String = "[]"
12 + ) -> TacheEntity {
13 + TacheEntity(
14 + localId: 1,
15 + titre: "T",
16 + debut: debut,
17 + dureeJours: duree,
18 + etiquette: etiquette,
19 + sousTachesJson: sousTaches
20 + )
21 + }
22 +
23 + func testPeriodeEnJoursOuvresDuProjet() {
24 + // Vendredi 2026-01-09 + 3 jours ouvrés.
25 + let t = tache(debut: "2026-01-09", duree: 3)
26 + XCTAssertEqual("09/01 → 13/01", ApercuTache.periode(t, joursOuvres: 5))
27 + XCTAssertEqual("09/01 → 11/01", ApercuTache.periode(t, joursOuvres: 7))
28 + }
29 +
30 + func testPasDePeriodeSansDate() {
31 + XCTAssertNil(ApercuTache.periode(tache(duree: 3), joursOuvres: 5))
32 + }
33 +
34 + func testUneDateIllisibleNeFaitPasTomberLEcran() {
35 + XCTAssertNil(ApercuTache.periode(tache(debut: "pas-une-date"), joursOuvres: 5))
36 + }
37 +
38 + func testAvancementDesSousTaches() {
39 + let json = #"[{"id":"a","titre":"A","fait":true},{"id":"b","titre":"B","fait":false}]"#
40 + let avancement = ApercuTache.avancement(tache(sousTaches: json))
41 + XCTAssertEqual(1, avancement?.faites)
42 + XCTAssertEqual(2, avancement?.total)
43 + XCTAssertNil(ApercuTache.avancement(tache()), "aucune sous-tâche = rien à afficher")
44 + }
45 +
46 + func testSousTachesIllisiblesIgnorees() {
47 + XCTAssertNil(ApercuTache.avancement(tache(sousTaches: "{pas du json")))
48 + }
49 +
50 + func testRetardMesureContreLEcheance() {
51 + let t = tache(debut: "2026-01-09", duree: 3) // finit le 13 en rythme 5
52 + XCTAssertTrue(ApercuTache.enRetard(t, joursOuvres: 5, echeance: "2026-01-12"))
53 + XCTAssertFalse(ApercuTache.enRetard(t, joursOuvres: 5, echeance: "2026-01-13"))
54 + XCTAssertFalse(ApercuTache.enRetard(t, joursOuvres: 5, echeance: nil))
55 + XCTAssertFalse(ApercuTache.enRetard(tache(), joursOuvres: 5, echeance: "2026-01-01"))
56 + }
57 +
58 + func testCouleurSuitLEtiquette() {
59 + XCTAssertEqual("#8B5CF6", ApercuTache.couleur(tache(etiquette: "câblage")))
60 + XCTAssertNil(ApercuTache.couleur(tache()))
61 + }
62 +
63 + func testReglagesDUnProjetSansPlanification() {
64 + XCTAssertEqual(ReglagesPlanification(), ReglagesPlanification.de(nil))
65 + var projet = ProjetEntity()
66 + projet.planification = true
67 + projet.joursOuvres = 5
68 + projet.echeance = "2026-09-30"
69 + XCTAssertEqual(
70 + ReglagesPlanification(active: true, joursOuvres: 5, echeance: "2026-09-30"),
71 + ReglagesPlanification.de(projet)
72 + )
73 + }
74 +}
A ios/Card2vcfTests/CalendrierOuvreTest.swift
+117 -0
@@ -0,0 +1,117 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Port Swift de `Server/tests/calendrier.rs` et de `CalendrierOuvreTest.kt` :
5 +/// **les mêmes cas, les mêmes valeurs attendues**. Si les trois suites
6 +/// divergent, le téléphone affichera une date de fin différente de celle du
7 +/// Gantt web.
8 +///
9 +/// Repères : 2026-01-05 est un lundi, 2026-01-09 un vendredi, 2026-01-10 un
10 +/// samedi et 2026-01-11 un dimanche.
11 +final class CalendrierOuvreTest: XCTestCase {
12 + private func j(_ s: String) -> DateCivile {
13 + guard let d = DateCivile(iso: s) else {
14 + XCTFail("date de test invalide : \(s)")
15 + return DateCivile(iso: "1970-01-01")!
16 + }
17 + return d
18 + }
19 +
20 + func testEstOuvreDependDuRythme() {
21 + let samedi = j("2026-01-10")
22 + let dimanche = j("2026-01-11")
23 + let lundi = j("2026-01-05")
24 +
25 + for rythme in [5, 6, 7] {
26 + XCTAssertTrue(CalendrierOuvre(joursOuvres: rythme).estOuvre(lundi))
27 + }
28 + XCTAssertFalse(CalendrierOuvre(joursOuvres: 5).estOuvre(samedi))
29 + XCTAssertTrue(CalendrierOuvre(joursOuvres: 6).estOuvre(samedi))
30 + XCTAssertTrue(CalendrierOuvre(joursOuvres: 7).estOuvre(samedi))
31 + XCTAssertFalse(CalendrierOuvre(joursOuvres: 5).estOuvre(dimanche))
32 + XCTAssertFalse(CalendrierOuvre(joursOuvres: 6).estOuvre(dimanche))
33 + XCTAssertTrue(CalendrierOuvre(joursOuvres: 7).estOuvre(dimanche))
34 + }
35 +
36 + func testCalerAvanceAuProchainJourOuvre() {
37 + let samedi = j("2026-01-10")
38 + XCTAssertEqual(j("2026-01-12"), CalendrierOuvre(joursOuvres: 5).caler(samedi))
39 + XCTAssertEqual(samedi, CalendrierOuvre(joursOuvres: 6).caler(samedi))
40 + XCTAssertEqual(samedi, CalendrierOuvre(joursOuvres: 7).caler(samedi))
41 + let mardi = j("2026-01-06")
42 + XCTAssertEqual(mardi, CalendrierOuvre(joursOuvres: 5).caler(mardi), "jour déjà ouvré")
43 + }
44 +
45 + func testFinCompteDesJoursOuvres() {
46 + let vendredi = j("2026-01-09")
47 + XCTAssertEqual(j("2026-01-13"), CalendrierOuvre(joursOuvres: 5).fin(vendredi, 3))
48 + XCTAssertEqual(j("2026-01-12"), CalendrierOuvre(joursOuvres: 6).fin(vendredi, 3))
49 + XCTAssertEqual(j("2026-01-11"), CalendrierOuvre(joursOuvres: 7).fin(vendredi, 3))
50 + }
51 +
52 + func testFinDUneTacheDUnJourEstSonDebut() {
53 + let vendredi = j("2026-01-09")
54 + XCTAssertEqual(vendredi, CalendrierOuvre(joursOuvres: 5).fin(vendredi, 1))
55 + XCTAssertEqual(vendredi, CalendrierOuvre(joursOuvres: 5).fin(vendredi, 0), "durée nulle = un jour")
56 + }
57 +
58 + func testFinCaleUnDebutTombantUnJourChome() {
59 + let samedi = j("2026-01-10")
60 + XCTAssertEqual(j("2026-01-13"), CalendrierOuvre(joursOuvres: 5).fin(samedi, 2))
61 + }
62 +
63 + func testAvancerSauteLesJoursChomes() {
64 + let vendredi = j("2026-01-09")
65 + XCTAssertEqual(vendredi, CalendrierOuvre(joursOuvres: 5).avancer(vendredi, 0))
66 + XCTAssertEqual(j("2026-01-12"), CalendrierOuvre(joursOuvres: 5).avancer(vendredi, 1))
67 + XCTAssertEqual(j("2026-01-10"), CalendrierOuvre(joursOuvres: 6).avancer(vendredi, 1))
68 + XCTAssertEqual(j("2026-01-10"), CalendrierOuvre(joursOuvres: 7).avancer(vendredi, 1))
69 + }
70 +
71 + func testCompterLesJoursOuvresDUnIntervalle() {
72 + let lundi = j("2026-01-05")
73 + let dimanche = j("2026-01-11")
74 + XCTAssertEqual(5, CalendrierOuvre(joursOuvres: 5).compter(lundi, dimanche))
75 + XCTAssertEqual(6, CalendrierOuvre(joursOuvres: 6).compter(lundi, dimanche))
76 + XCTAssertEqual(7, CalendrierOuvre(joursOuvres: 7).compter(lundi, dimanche))
77 + XCTAssertEqual(10, CalendrierOuvre(joursOuvres: 5).compter(lundi, j("2026-01-18")))
78 + XCTAssertEqual(1, CalendrierOuvre(joursOuvres: 5).compter(lundi, lundi))
79 + XCTAssertEqual(0, CalendrierOuvre(joursOuvres: 5).compter(dimanche, lundi), "intervalle inversé")
80 + }
81 +
82 + func testUnRythmeAberrantRetombeSurSeptJours() {
83 + XCTAssertTrue(CalendrierOuvre(joursOuvres: 0).estOuvre(j("2026-01-11")))
84 + XCTAssertTrue(CalendrierOuvre(joursOuvres: 99).estOuvre(j("2026-01-11")))
85 + }
86 +
87 + func testPeriodeAffichee() {
88 + XCTAssertEqual("09/01 → 13/01", CalendrierOuvre(joursOuvres: 5).periodeAffichee(j("2026-01-09"), 3))
89 + XCTAssertEqual("09/01", CalendrierOuvre(joursOuvres: 5).periodeAffichee(j("2026-01-09"), 1))
90 + }
91 +
92 + // MARK: - `DateCivile`, sans équivalent Kotlin (`LocalDate` y est fourni)
93 +
94 + func testDateCivileRejetteLesChainesInvalides() {
95 + XCTAssertNil(DateCivile(iso: "2026-02-31"), "jour inexistant")
96 + XCTAssertNil(DateCivile(iso: "2026-13-01"), "mois inexistant")
97 + XCTAssertNil(DateCivile(iso: "26-01-05"), "année sur deux chiffres")
98 + XCTAssertNil(DateCivile(iso: "2026-1-5"), "champs non remplis à deux chiffres")
99 + XCTAssertNil(DateCivile(iso: ""))
100 + XCTAssertNil(DateCivile(iso: "pas une date"))
101 + }
102 +
103 + func testDateCivileEstIndependanteDuFuseau() {
104 + // Un `Date` interprété dans un fuseau à l'ouest basculerait la veille.
105 + let lundi = j("2026-01-05")
106 + XCTAssertEqual(1, lundi.jourDeSemaine, "2026-01-05 est un lundi")
107 + XCTAssertEqual(7, j("2026-01-11").jourDeSemaine, "2026-01-11 est un dimanche")
108 + XCTAssertEqual("2026-01-05", lundi.iso)
109 + XCTAssertEqual("05/01", lundi.jourMois)
110 + }
111 +
112 + func testDateCivileFranchitLesBornesDeMoisEtDAnnee() {
113 + XCTAssertEqual(j("2026-03-01"), j("2026-02-28").plusJours(1))
114 + XCTAssertEqual(j("2027-01-01"), j("2026-12-31").plusJours(1))
115 + XCTAssertEqual(j("2024-02-29"), j("2024-02-28").plusJours(1), "2024 est bissextile")
116 + }
117 +}
M ios/Card2vcfTests/ContratSyncFixturesTest.swift
+42 -0
@@ -38,6 +38,48 @@ final class ContratSyncFixturesTest: XCTestCase {
38 38 XCTAssertFalse(contact.creeLe.isEmpty, "creeLe contact ne doit pas être vide")
39 39 }
40 40
41 + /// Contrat de planification : si le serveur renomme un de ces champs, le
42 + /// téléphone cesserait silencieusement d'afficher dates, étiquettes ou
43 + /// sous-tâches. Ce test rend la rupture immédiate.
44 + ///
45 + /// Preuve de rougeur (vérifiée) : en renommant `duree_jours` → `duree` dans
46 + /// `sync_pull.json`, ce test **et** `testSyncPull_periodeCalculeeCommeLeServeur`
47 + /// échouent —
48 + /// XCTAssertEqual failed: ("Optional(3)") is not equal to ("nil")
49 + func testSyncPull_planificationDesTaches() throws {
50 + let data = try fixture("sync_pull.json")
51 + let pull = try SyncJson.decoder().decode(SyncPullResponse.self, from: data)
52 +
53 + let projet = try XCTUnwrap(pull.projets.first)
54 + XCTAssertTrue(projet.planification, "planification doit être transportée")
55 + XCTAssertEqual(5, projet.joursOuvres)
56 + XCTAssertEqual("2026-09-30", projet.echeance)
57 +
58 + let tache = try XCTUnwrap(pull.taches.first)
59 + XCTAssertEqual("2026-09-21", tache.debut)
60 + XCTAssertEqual(3, tache.dureeJours)
61 + XCTAssertEqual("contrat", tache.etiquette)
62 + XCTAssertEqual(2, tache.sousTaches.count)
63 + let premiere = try XCTUnwrap(tache.sousTaches.first)
64 + XCTAssertTrue(premiere.fait, "l'état coché doit survivre au transport")
65 + }
66 +
67 + /// La fin affichée sur le téléphone doit être celle du Gantt web.
68 + func testSyncPull_periodeCalculeeCommeLeServeur() throws {
69 + let data = try fixture("sync_pull.json")
70 + let pull = try SyncJson.decoder().decode(SyncPullResponse.self, from: data)
71 + let projet = try XCTUnwrap(pull.projets.first)
72 + let tache = try XCTUnwrap(pull.taches.first)
73 +
74 + // Lundi 2026-09-21 + 3 jours ouvrés en rythme 5 : mercredi 23.
75 + let debutIso = try XCTUnwrap(tache.debut)
76 + let debut = try XCTUnwrap(DateCivile(iso: debutIso))
77 + let duree = try XCTUnwrap(tache.dureeJours)
78 + let periode = CalendrierOuvre(joursOuvres: projet.joursOuvres)
79 + .periodeAffichee(debut, duree)
80 + XCTAssertEqual("21/09 → 23/09", periode)
81 + }
82 +
41 83 // ---- interaction_note_vocale.json ----
42 84
43 85 func testInteraction_decodeInteractionDto() throws {
A ios/Card2vcfTests/CouleurEtiquetteTest.swift
+42 -0
@@ -0,0 +1,42 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Port du hachage d'étiquette de `Server/src/modeles.rs`, miroir de
5 +/// `CouleurEtiquetteTest.kt`. Le FNV-1a y a été écrit explicitement pour être
6 +/// reproductible : une même étiquette doit donner la même teinte sur le web,
7 +/// sur Android et sur iOS.
8 +final class CouleurEtiquetteTest: XCTestCase {
9 + func testMemeEtiquetteMemeTeinte() {
10 + let a = CouleurEtiquette.pour("câblage")
11 + XCTAssertEqual(a, CouleurEtiquette.pour("câblage"))
12 + XCTAssertTrue(CouleurEtiquette.palette.contains(a ?? ""))
13 + }
14 +
15 + func testSansEtiquettePasDeTeinte() {
16 + XCTAssertNil(CouleurEtiquette.pour(nil))
17 + XCTAssertNil(CouleurEtiquette.pour(" "))
18 + }
19 +
20 + /// Valeurs figées, à comparer avec les tests Rust et Kotlin homologues.
21 + func testTeintesDeReference() {
22 + XCTAssertEqual("#8B5CF6", CouleurEtiquette.pour("câblage"))
23 + XCTAssertEqual("#3FB985", CouleurEtiquette.pour("étude"))
24 + XCTAssertEqual("#0EA5E9", CouleurEtiquette.pour("chantier"))
25 + }
26 +
27 + func testLEspaceAutourNeChangePasLaTeinte() {
28 + XCTAssertEqual(CouleurEtiquette.pour("câblage"), CouleurEtiquette.pour(" câblage "))
29 + }
30 +
31 + func testLaPaletteEstReellementRepartie() {
32 + let libelles = [
33 + "câblage", "étude", "chantier", "livraison", "essais", "montage",
34 + "devis", "recette", "soudure", "peinture", "controle", "reprise",
35 + ]
36 + let distinctes = Set(libelles.compactMap { CouleurEtiquette.pour($0) })
37 + XCTAssertGreaterThanOrEqual(
38 + distinctes.count, 5,
39 + "12 libellés ne donnent que \(distinctes.count) teintes"
40 + )
41 + }
42 +}
M ios/Card2vcfTests/FakeAilianceApi.swift
+13 -0
@@ -22,6 +22,9 @@ final class FakeAilianceApi: AilianceApi {
22 22 var createProjetResult: AilianceApiClient.ApiResult<String> = .ok(#"{"id":"projet-generated"}"#)
23 23 var createProjetCalls: [String] = []
24 24
25 + /// `(projetId, tacheId, sousTacheId)` de chaque bascule de sous-tâche poussée.
26 + var toggleSousTacheCalls: [(String, String, String)] = []
27 +
25 28 var statusQueries: [String?] = []
26 29 var pullQueries: [String?] = []
27 30 var pullSince: [String] = []
@@ -70,6 +73,16 @@ final class FakeAilianceApi: AilianceApi {
70 73 func updateTache(projetId: String, tacheId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
71 74 func deleteTache(projetId: String, tacheId: String) async -> AilianceApiClient.ApiResult<Void> { .ok(()) }
72 75 func moveTache(projetId: String, tacheId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
76 +
77 + func toggleSousTache(
78 + projetId: String,
79 + tacheId: String,
80 + sousTacheId: String
81 + ) async -> AilianceApiClient.ApiResult<String> {
82 + toggleSousTacheCalls.append((projetId, tacheId, sousTacheId))
83 + return .ok("{}")
84 + }
85 +
73 86 func createInteraction(contactId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> { .ok("{}") }
74 87 func listRdv() async -> AilianceApiClient.ApiResult<[RendezVousDto]> { .ok([]) }
75 88
A ios/Card2vcfTests/PlanificationMigrationTest.swift
+128 -0
@@ -0,0 +1,128 @@
1 +import XCTest
2 +import SQLite3
3 +@testable import Card2vcf
4 +
5 +/// Vérifie la migration additive v6 → v7 (miroir de Room `MIGRATION_7_8`) :
6 +/// - les colonnes de planification apparaissent sur `taches` et `projets` ;
7 +/// - les lignes existantes sont **préservées**, avec les défauts attendus.
8 +///
9 +/// Sans ce filet, une montée de version viderait le carnet de projets de
10 +/// l'utilisateur au lieu d'ajouter trois colonnes.
11 +final class PlanificationMigrationTest: XCTestCase {
12 +
13 + private var dbPath: String!
14 +
15 + override func setUp() {
16 + super.setUp()
17 + dbPath = FileManager.default.temporaryDirectory
18 + .appendingPathComponent("migration_v6v7_\(UUID().uuidString).sqlite")
19 + .path
20 + }
21 +
22 + override func tearDown() {
23 + try? FileManager.default.removeItem(atPath: dbPath)
24 + super.tearDown()
25 + }
26 +
27 + /// Schéma v6 réduit aux deux tables que la migration touche.
28 + private static let v6Sql = """
29 + CREATE TABLE projets (
30 + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
31 + serverId TEXT,
32 + nom TEXT NOT NULL,
33 + description TEXT NOT NULL,
34 + workflowServerId TEXT NOT NULL,
35 + membresJson TEXT NOT NULL,
36 + creePar TEXT NOT NULL,
37 + createdAt INTEGER NOT NULL,
38 + updatedAt INTEGER NOT NULL
39 + );
40 + CREATE TABLE taches (
41 + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
42 + serverId TEXT,
43 + projetLocalId INTEGER,
44 + projetServerId TEXT NOT NULL,
45 + titre TEXT NOT NULL,
46 + description TEXT NOT NULL,
47 + colonneId TEXT NOT NULL,
48 + ordre INTEGER NOT NULL,
49 + assigneA TEXT,
50 + auteur TEXT NOT NULL,
51 + createdAt INTEGER NOT NULL,
52 + updatedAt INTEGER NOT NULL
53 + );
54 + INSERT INTO projets
55 + (serverId, nom, description, workflowServerId, membresJson, creePar, createdAt, updatedAt)
56 + VALUES ('srv-p1', 'Salon', '', 'wf-1', '[]', 'alice', 1000, 1000);
57 + INSERT INTO taches
58 + (serverId, projetLocalId, projetServerId, titre, description, colonneId, ordre,
59 + assigneA, auteur, createdAt, updatedAt)
60 + VALUES ('srv-t1', 1, 'srv-p1', 'Câblage', '', 'explorer', 0, 'alice', 'alice', 1000, 1000);
61 + PRAGMA user_version = 6;
62 + """
63 +
64 + private func creerBaseV6() throws {
65 + var rawHandle: OpaquePointer?
66 + guard sqlite3_open(dbPath, &rawHandle) == SQLITE_OK, let rawHandle else {
67 + XCTFail("Cannot open test database at \(dbPath!)")
68 + return
69 + }
70 + defer { sqlite3_close_v2(rawHandle) }
71 + var errMsg: UnsafeMutablePointer<CChar>?
72 + guard sqlite3_exec(rawHandle, Self.v6Sql, nil, nil, &errMsg) == SQLITE_OK else {
73 + let msg = errMsg.map { String(cString: $0) } ?? "unknown"
74 + sqlite3_free(errMsg)
75 + XCTFail("v6 setup failed: \(msg)")
76 + return
77 + }
78 + }
79 +
80 + func testMigrationV6ToV7PreserveLesDonneesEtAjouteLesColonnes() async throws {
81 + try creerBaseV6()
82 +
83 + // Ouvrir avec SqliteDatabase déclenche la migration v6 → v7.
84 + let db = SqliteDatabase(filePath: dbPath)
85 + let projets = try await SqliteProjetDao(db: db).listAll()
86 + let taches = try await SqliteTacheDao(db: db).listAll()
87 +
88 + let projet = try XCTUnwrap(projets.first, "le projet doit survivre à la migration")
89 + XCTAssertEqual("Salon", projet.nom)
90 + XCTAssertFalse(projet.planification, "défaut : planification éteinte")
91 + XCTAssertNil(projet.echeance)
92 + XCTAssertEqual(7, projet.joursOuvres, "défaut : tous les jours")
93 +
94 + let tache = try XCTUnwrap(taches.first, "la tâche doit survivre à la migration")
95 + XCTAssertEqual("Câblage", tache.titre)
96 + XCTAssertEqual("alice", tache.assigneA)
97 + XCTAssertNil(tache.debut)
98 + XCTAssertNil(tache.dureeJours)
99 + XCTAssertNil(tache.etiquette)
100 + XCTAssertNil(tache.parentId)
101 + XCTAssertEqual("[]", tache.dependDeJson)
102 + XCTAssertEqual("[]", tache.sousTachesJson)
103 + }
104 +
105 + func testLesChampsDePlanificationFontUnAllerRetourEnBase() async throws {
106 + try creerBaseV6()
107 + let db = SqliteDatabase(filePath: dbPath)
108 + let tacheDao = SqliteTacheDao(db: db)
109 +
110 + // `XCTUnwrap` prend une autoclosure non asynchrone : l'appel `await`
111 + // doit être évalué avant.
112 + let existante = try await tacheDao.getByServerId("srv-t1")
113 + var tache = try XCTUnwrap(existante)
114 + tache.debut = "2026-09-21"
115 + tache.dureeJours = 3
116 + tache.etiquette = "contrat"
117 + tache.parentId = "srv-t0"
118 + tache.dependDeJson = #"["srv-t0"]"#
119 + tache.sousTachesJson = #"[{"id":"st-1","titre":"Première étape","fait":true}]"#
120 + _ = try await tacheDao.upsert(tache)
121 +
122 + let rechargee = try await tacheDao.getByServerId("srv-t1")
123 + let relue = try XCTUnwrap(rechargee)
124 + XCTAssertEqual(tache, relue)
125 + XCTAssertEqual("21/09 → 23/09", ApercuTache.periode(relue, joursOuvres: 5))
126 + XCTAssertEqual(1, ApercuTache.avancement(relue)?.total)
127 + }
128 +}
A ios/Card2vcfTests/TachePayloadPlanificationTest.swift
+66 -0
@@ -0,0 +1,66 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Miroir de `UpdateTacheRequestTest.kt`.
5 +///
6 +/// Le serveur applique « absent = ne pas toucher, `null` = effacer ». Ce test
7 +/// fige les deux faces de ce contrat : les champs que le mobile n'édite pas
8 +/// doivent rester **hors** du JSON, et un champ vidé doit partir à `null`.
9 +///
10 +/// `ProjetDetailViewModel` est `@MainActor` : ses membres statiques le sont aussi.
11 +@MainActor
12 +final class TachePayloadPlanificationTest: XCTestCase {
13 +
14 + private func payloadMaj(
15 + debut: String? = nil,
16 + dureeJours: Int? = nil,
17 + etiquette: String? = nil
18 + ) -> String {
19 + ProjetDetailViewModel.updateTachePayload(
20 + titre: "Câblage",
21 + description: "",
22 + assigneA: "alice",
23 + debut: debut,
24 + dureeJours: dureeJours,
25 + etiquette: etiquette
26 + )
27 + }
28 +
29 + func testNEnvoieJamaisLesChampsQuIlNEditePas() {
30 + let json = payloadMaj()
31 + // Ces champs existent côté serveur mais ne sont pas édités sur mobile :
32 + // les omettre est ce qui les préserve.
33 + XCTAssertFalse(json.contains("sous_taches"), "les sous-tâches seraient écrasées : \(json)")
34 + XCTAssertFalse(json.contains("depend_de"), "les dépendances seraient écrasées : \(json)")
35 + XCTAssertFalse(json.contains("parent_id"), "le regroupement serait écrasé : \(json)")
36 + }
37 +
38 + func testPorteLaPlanificationQuandElleEstRenseignee() {
39 + let json = payloadMaj(debut: "2026-07-22", dureeJours: 3, etiquette: "câblage")
40 + XCTAssertTrue(json.contains(#""debut":"2026-07-22""#), json)
41 + XCTAssertTrue(json.contains(#""duree_jours":3"#), json)
42 + XCTAssertTrue(json.contains(#""etiquette":"câblage""#), json)
43 + XCTAssertTrue(json.contains(#""assigne_a_pose":true"#), json)
44 + }
45 +
46 + func testUnChampVideEstEnvoyeANullPourEffacer() {
47 + let json = payloadMaj()
48 + XCTAssertTrue(json.contains(#""debut":null"#), json)
49 + XCTAssertTrue(json.contains(#""etiquette":null"#), json)
50 + }
51 +
52 + func testLaCreationPorteAussiLaPlanification() {
53 + let json = ProjetDetailViewModel.createTachePayload(
54 + titre: "Câblage",
55 + description: "",
56 + colonneId: "explorer",
57 + assigneA: nil,
58 + debut: "2026-07-22",
59 + dureeJours: 3,
60 + etiquette: "câblage"
61 + )
62 + XCTAssertTrue(json.contains(#""colonne_id":"explorer""#), json)
63 + XCTAssertTrue(json.contains(#""debut":"2026-07-22""#), json)
64 + XCTAssertTrue(json.contains(#""duree_jours":3"#), json)
65 + }
66 +}
M ios/README.md
+15 -0
@@ -50,6 +50,7 @@ versionné) ; la vérité est `project.yml`.
50 50 | Stockage clé API chiffrée | EncryptedSharedPreferences + Argon2id/XChaCha20 | Keychain + port Swift byte-compatible (Argon2id RFC 9106 + XChaCha20-Poly1305 via CryptoKit) |
51 51 | Client sync | OkHttp + kotlinx-serialization | URLSession + Codable (mêmes endpoints, mêmes JSON) |
52 52 | Pont Agenda | CalendarProvider (calendriers locaux) | EventKit (calendriers `EKSource` locaux) |
53 +| Planification des tâches (jours ouvrés, teintes d'étiquette) | `planning/` (Kotlin, `java.time.LocalDate`) | `Planning/` (port Swift 1:1 + `DateCivile`, Foundation n'offrant pas de date sans fuseau) |
53 54 | UI | Jetpack Compose | SwiftUI (mêmes écrans, chaîne graphique `design.md`) |
54 55
55 56 ## Structure
@@ -62,6 +63,7 @@ ios/
62 63 │ ├── Contact/ # ContactCard, parseur heuristique, vCard
63 64 │ ├── CRM/ # normalisation, doublons, fusion, tri, abcédaire
64 65 │ ├── OCR/ # OcrResult, post-traitement, moteur Vision
66 +│ ├── Planning/ # calendrier ouvré, teintes d'étiquette, aperçu de tâche
65 67 │ ├── Scan/ # caméra document, recadrage, pipeline scan→brouillon
66 68 │ ├── Data/ # entités, protocoles DAO, dépôt, images
67 69 │ │ └── Sqlite/ # implémentation SQLite3 + FTS5 (schéma Room v3)
@@ -73,6 +75,19 @@ ios/
73 75
74 76 ## Écarts connus vs Android
75 77
78 +- **Modèles hors-ligne téléchargeables** (Android `modeles/`, saveurs
79 + `fdroid`/`direct`) : **sans objet sur iOS**, et c'est volontaire. L'OCR passe
80 + par Vision et la transcription par `SFSpeechRecognizer` : les modèles sont
81 + fournis et mis à jour par le système, il n'y a rien à télécharger, donc ni
82 + catalogue, ni dépôt de modèles, ni section « Modèles hors-ligne » dans les
83 + Réglages. Le chantier F-Droid répondait à une contrainte de taille d'APK
84 + (Tesseract + Vosk embarqués) qui n'existe pas ici.
85 +- **Planification des tâches** : portée (dates, durée en jours ouvrés,
86 + étiquettes colorées, sous-tâches, repère de retard, édition depuis la carte
87 + kanban). Restent hors périmètre des **deux** plateformes : diagramme de Gantt
88 + sur téléphone, édition des dépendances et du regroupement, réglage de
89 + l'échéance et du rythme du projet — tous réservés au web. Comme sur Android,
90 + la date de début se saisit en texte ISO, pas par un sélecteur graphique.
76 91 - **Langues OCR** : Vision ne propose pas de correction linguistique
77 92 `pol`/`nld` sur iOS 16 ; le texte latin reste reconnu (recognizer
78 93 latin), sans correction spécifique à ces deux langues.