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