chore: worktrees ignorés et plan mini-CRM versionné

EBO <eric.bouhana@softalys.com> committé le 2026-07-22 15:17

337f98e09b18bbcd34c73bc6940431d60de28abd

1 parent(s)

2 fichiers modifiés +1315 -0
M .gitignore
+1 -0
@@ -39,3 +39,4 @@ android/app/src/main/assets/tesseract/tessdata/*.traineddata
39 39 android/local.properties
40 40 android/.idea/
41 41 *.iml
42 +.worktrees/
A docs/plans/mini_crm_card2vcf_ff17681c.plan.md
+1314 -0
@@ -0,0 +1,1314 @@
1 +---
2 +name: Mini CRM Card2vcf
3 +overview: "Plan d’implémentation détaillé TDD : mini-CRM Room (carnet, FTS, doublons, import VCF 3+4 / export 3, rescan) — hors-ligne, sans GMS."
4 +todos:
5 + - id: crm-gradle
6 + content: "T0: Room KSP Navigation deps + Application singleton DB"
7 + status: pending
8 + - id: crm-room
9 + content: "T1–T3: Entity FTS exclusions ImageStore Repository + tests"
10 + status: pending
11 + - id: crm-dupes-domain
12 + content: "T4: DuplicateDetector ContactMerge Normalizers + tests"
13 + status: pending
14 + - id: crm-vcard
15 + content: "T5: VCardParser 3.0+4.0 + fixtures + tests"
16 + status: pending
17 + - id: crm-nav-carnet
18 + content: "T6–T7: NavHost home carnet tri rail recherche FAB"
19 + status: pending
20 + - id: crm-fiche
21 + content: "T8–T9: Pager gestes édition rescan"
22 + status: pending
23 + - id: crm-dupes-ui
24 + content: "T10: Puce + DuplicateReview + fusion UI"
25 + status: pending
26 + - id: crm-draft-import
27 + content: "T11–T12: Draft save/update + import VCF anti-doublons"
28 + status: pending
29 + - id: crm-docs-tests
30 + content: "T13: README strings empty states assemble + test suite"
31 + status: pending
32 +isProject: true
33 +---
34 +
35 +# Mini-CRM Card2vcf — Implementation Plan
36 +
37 +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
38 +
39 +**Goal:** Transformer Card2vcf en mini-CRM local offline : carnet SQLite home, images carte/profil, abcédaire, recherche FTS (notes incluses), fiche swipe + gestes mail/appel, notes/édition/rescan, import VCF (lire 3.0+4.0, écrire 3.0) avec anti-doublons et gestion doublons (puce / fusion / exclusion).
40 +
41 +**Architecture:** Room (entity + FTS4 + exclusions) + `ContactRepository` + `DuplicateDetector` ; UI Navigation Compose (home = carnet) ; pipeline OCR existant branché en insert ou update (`editingContactId`) ; images dans `filesDir/contacts/{id}/`.
42 +
43 +**Tech Stack:** Kotlin 2.0, Compose BOM 2024.09, Room 2.6.1 + KSP, Navigation Compose 2.8.x, CameraX/OpenCV/Tesseract existants, JUnit + Robolectric, minSdk 31, package `fr.ebii.card2vcf`, design [`design.md`](design.md).
44 +
45 +**Repo racine :** `/home/monsieurb/Documents/devel/EBII_mobileVCF`
46 +**Tests :** `cd android && ./gradlew :app:testDebugUnitTest`
47 +**Build :** `cd android && ./gradlew :app:assembleDebug`
48 +
49 +---
50 +
51 +## Décisions produit (non négociables)
52 +
53 +- Home = **carnet** ; `+` = Scanner | Saisie manuelle | Import VCF
54 +- Save local **systématique** ; Contacts Android + export VCF **optionnels**
55 +- Tri : Nom | Prénom | Entreprise (groupé + rail A–Z) | Date (sans rail)
56 +- Recherche FTS inclut **notes** ; rail masqué si query non vide
57 +- Doublons : email OU téléphone normalisé ; exclusions persistantes ; puce `N doublon(s)`
58 +- Import : skip si match ; résumé imported / skippedDuplicates / errors
59 +- vCard : **lire 3.0+4.0**, **écrire 3.0** seulement ; pas d’import `PHOTO`
60 +- Rescan : remplace `card.jpg` + champs ; **notes conservées** par défaut
61 +
62 +**Hors v1 :** sync cloud, tags CRM, matching flou sans email/tél, écriture vCard 4.0, `PHOTO` VCF.
63 +
64 +---
65 +
66 +## Carte des fichiers
67 +
68 +```text
69 +android/app/src/main/java/fr/ebii/card2vcf/
70 +├── Card2vcfApp.kt # NEW Application + DB singleton
71 +├── MainActivity.kt # MOD NavHost
72 +├── contact/
73 +│ ├── ContactCard.kt # EXIST
74 +│ ├── VCardSerializer.kt # EXIST écriture 3.0
75 +│ └── VCardParser.kt # NEW lecture 3+4
76 +├── data/
77 +│ ├── Converters.kt
78 +│ ├── CrmContactEntity.kt
79 +│ ├── CrmContactFts.kt
80 +│ ├── DuplicateExclusionEntity.kt
81 +│ ├── CrmContactDao.kt
82 +│ ├── CrmDatabase.kt
83 +│ ├── ContactImageStore.kt
84 +│ └── ContactRepository.kt
85 +├── crm/
86 +│ ├── ContactSort.kt
87 +│ ├── ContactNormalizer.kt
88 +│ ├── AlphabetIndex.kt
89 +│ ├── DuplicateDetector.kt
90 +│ └── ContactMerge.kt
91 +└── ui/
92 + ├── nav/Card2vcfNavHost.kt
93 + ├── carnet/{CarnetScreen,AlphabetRail,CarnetViewModel}.kt
94 + ├── contact/{ContactPagerScreen,ContactEditScreen,DuplicateReviewScreen,*ViewModel}.kt
95 + ├── import/{VcfImportScreen,VcfImportViewModel}.kt
96 + ├── ContactDraftScreen.kt # MOD
97 + └── ScanCarteViewModel.kt # MOD editingContactId
98 +
99 +android/app/src/test/java/fr/ebii/card2vcf/
100 +├── data/ContactRepositoryTest.kt
101 +├── crm/{ContactNormalizer,DuplicateDetector,ContactMerge,AlphabetIndex}Test.kt
102 +└── contact/VCardParserTest.kt (+ resources/vcf/*.vcf)
103 +```
104 +
105 +---
106 +
107 +### Task 0: Gradle Room + KSP + Navigation + Application
108 +
109 +**Files:**
110 +- Modify: `android/build.gradle.kts`
111 +- Modify: `android/app/build.gradle.kts`
112 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/Card2vcfApp.kt`
113 +- Modify: `android/app/src/main/AndroidManifest.xml`
114 +
115 +- [ ] **Step 1: Ajouter le plugin KSP racine**
116 +
117 +Dans `android/build.gradle.kts` :
118 +
119 +```kotlin
120 +plugins {
121 + id("com.android.application") version "8.6.0" apply false
122 + id("org.jetbrains.kotlin.android") version "2.0.20" apply false
123 + id("org.jetbrains.kotlin.plugin.compose") version "2.0.20" apply false
124 + id("com.google.devtools.ksp") version "2.0.20-1.0.25" apply false
125 +}
126 +```
127 +
128 +- [ ] **Step 2: Dépendances app**
129 +
130 +Dans `android/app/build.gradle.kts` plugins :
131 +
132 +```kotlin
133 +plugins {
134 + id("com.android.application")
135 + id("org.jetbrains.kotlin.android")
136 + id("org.jetbrains.kotlin.plugin.compose")
137 + id("com.google.devtools.ksp")
138 +}
139 +```
140 +
141 +Dans `dependencies` ajouter :
142 +
143 +```kotlin
144 +val room = "2.6.1"
145 +implementation("androidx.room:room-runtime:$room")
146 +implementation("androidx.room:room-ktx:$room")
147 +ksp("androidx.room:room-compiler:$room")
148 +implementation("androidx.navigation:navigation-compose:2.8.2")
149 +testImplementation("androidx.room:room-testing:$room")
150 +```
151 +
152 +- [ ] **Step 3: Application + Manifest**
153 +
154 +```kotlin
155 +package fr.ebii.card2vcf
156 +
157 +import android.app.Application
158 +import fr.ebii.card2vcf.data.CrmDatabase
159 +
160 +class Card2vcfApp : Application() {
161 + val database: CrmDatabase by lazy { CrmDatabase.get(this) }
162 +}
163 +```
164 +
165 +Manifest : `android:name=".Card2vcfApp"` sur `<application>`.
166 +
167 +- [ ] **Step 4: Vérifier resolve**
168 +
169 +Run: `cd android && ./gradlew :app:dependencies --configuration debugCompileClasspath | head -n 5`
170 +Expected: exit 0 (après Task 1 pour classes Room, `assembleDebug` peut encore fail tant que DB absente — OK après T1).
171 +
172 +- [ ] **Step 5: Commit**
173 +
174 +```bash
175 +git add android/build.gradle.kts android/app/build.gradle.kts \
176 + android/app/src/main/java/fr/ebii/card2vcf/Card2vcfApp.kt \
177 + android/app/src/main/AndroidManifest.xml
178 +git commit -m "$(cat <<'EOF'
179 +build: Room KSP Navigation et Application Card2vcf
180 +
181 +EOF
182 +)"
183 +```
184 +
185 +---
186 +
187 +### Task 1: Modèle Room + Converters (TDD)
188 +
189 +**Files:**
190 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/Converters.kt`
191 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/CrmContactEntity.kt`
192 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/CrmContactFts.kt`
193 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/DuplicateExclusionEntity.kt`
194 +- Create: `android/app/src/test/java/fr/ebii/card2vcf/data/ConvertersTest.kt`
195 +
196 +- [ ] **Step 1: Test Converters (rouge)**
197 +
198 +```kotlin
199 +package fr.ebii.card2vcf.data
200 +
201 +import org.junit.Assert.assertEquals
202 +import org.junit.Test
203 +
204 +class ConvertersTest {
205 + private val c = Converters()
206 +
207 + @Test
208 + fun roundTripStringList() {
209 + val list = listOf("a@b.c", "x@y.z")
210 + assertEquals(list, c.toStringList(c.fromStringList(list)))
211 + }
212 +
213 + @Test
214 + fun nullOrEmpty() {
215 + assertEquals(emptyList<String>(), c.toStringList(null))
216 + assertEquals("[]", c.fromStringList(emptyList()))
217 + }
218 +}
219 +```
220 +
221 +- [ ] **Step 2: Run — expect FAIL**
222 +
223 +Run: `cd android && ./gradlew :app:testDebugUnitTest --tests fr.ebii.card2vcf.data.ConvertersTest`
224 +Expected: FAIL compilation / class missing.
225 +
226 +- [ ] **Step 3: Implémenter Converters + entities**
227 +
228 +```kotlin
229 +package fr.ebii.card2vcf.data
230 +
231 +import androidx.room.TypeConverter
232 +import org.json.JSONArray
233 +
234 +class Converters {
235 + @TypeConverter
236 + fun fromStringList(value: List<String>?): String {
237 + val arr = JSONArray()
238 + (value ?: emptyList()).forEach { arr.put(it) }
239 + return arr.toString()
240 + }
241 +
242 + @TypeConverter
243 + fun toStringList(value: String?): List<String> {
244 + if (value.isNullOrBlank()) return emptyList()
245 + val arr = JSONArray(value)
246 + return buildList {
247 + for (i in 0 until arr.length()) add(arr.getString(i))
248 + }
249 + }
250 +}
251 +```
252 +
253 +```kotlin
254 +package fr.ebii.card2vcf.data
255 +
256 +import androidx.room.Entity
257 +import androidx.room.PrimaryKey
258 +
259 +@Entity(tableName = "crm_contacts")
260 +data class CrmContactEntity(
261 + @PrimaryKey(autoGenerate = true) val id: Long = 0,
262 + val fullName: String? = null,
263 + val firstName: String? = null,
264 + val lastName: String? = null,
265 + val company: String? = null,
266 + val jobTitle: String? = null,
267 + val phones: List<String> = emptyList(),
268 + val emails: List<String> = emptyList(),
269 + val website: String? = null,
270 + val address: String? = null,
271 + val notes: String? = null,
272 + val cardImagePath: String? = null,
273 + val profileImagePath: String? = null,
274 + val createdAt: Long = 0L,
275 + val updatedAt: Long = 0L,
276 +)
277 +```
278 +
279 +```kotlin
280 +package fr.ebii.card2vcf.data
281 +
282 +import androidx.room.Entity
283 +import androidx.room.Fts4
284 +
285 +@Fts4(contentEntity = CrmContactEntity::class)
286 +@Entity(tableName = "crm_contacts_fts")
287 +data class CrmContactFts(
288 + val fullName: String?,
289 + val firstName: String?,
290 + val lastName: String?,
291 + val company: String?,
292 + val jobTitle: String?,
293 + val phones: String?,
294 + val emails: String?,
295 + val website: String?,
296 + val address: String?,
297 + val notes: String?,
298 +)
299 +```
300 +
301 +Note Room FTS external content : les colonnes FTS doivent correspondre à des colonnes texte de l’entity. **`phones`/`emails` dans l’entity sont `List<String>`** — pour FTS, ajouter sur l’entity des colonnes miroir dénormalisées **ou** stocker phones/emails uniquement en String JSON déjà présent et indexer ces strings. **Choix figé :** garder `phones`/`emails` comme `List<String>` via converter ; **ne pas** les mettre dans `@Fts4 contentEntity` colonnes List. Utiliser plutôt :
302 +
303 +```kotlin
304 +@Fts4(contentEntity = CrmContactEntity::class)
305 +@Entity(tableName = "crm_contacts_fts")
306 +data class CrmContactFts(
307 + val fullName: String?,
308 + val firstName: String?,
309 + val lastName: String?,
310 + val company: String?,
311 + val jobTitle: String?,
312 + val website: String?,
313 + val address: String?,
314 + val notes: String?,
315 +)
316 +```
317 +
318 +Et pour chercher dans phones/emails : colonnes supplémentaires `phonesText` / `emailsText` (String, join `" "`) mises à jour dans le repository à chaque insert/update, **incluses** dans l’entity + FTS.
319 +
320 +Mettre à jour `CrmContactEntity` :
321 +
322 +```kotlin
323 +val phonesText: String = "", // space-joined, pour FTS
324 +val emailsText: String = "",
325 +```
326 +
327 +- [ ] **Step 4: Exclusion entity**
328 +
329 +```kotlin
330 +package fr.ebii.card2vcf.data
331 +
332 +import androidx.room.Entity
333 +import androidx.room.Index
334 +import androidx.room.PrimaryKey
335 +
336 +@Entity(
337 + tableName = "duplicate_exclusions",
338 + indices = [Index(value = ["idA", "idB"], unique = true)],
339 +)
340 +data class DuplicateExclusionEntity(
341 + @PrimaryKey(autoGenerate = true) val id: Long = 0,
342 + val idA: Long, // always idA < idB
343 + val idB: Long,
344 +)
345 +```
346 +
347 +- [ ] **Step 5: Run tests verts**
348 +
349 +Run: `cd android && ./gradlew :app:testDebugUnitTest --tests fr.ebii.card2vcf.data.ConvertersTest`
350 +Expected: PASS
351 +
352 +- [ ] **Step 6: Commit**
353 +
354 +```bash
355 +git add android/app/src/main/java/fr/ebii/card2vcf/data/ \
356 + android/app/src/test/java/fr/ebii/card2vcf/data/ConvertersTest.kt
357 +git commit -m "$(cat <<'EOF'
358 +feat(data): entities Room CRM converters et exclusions doublons
359 +
360 +EOF
361 +)"
362 +```
363 +
364 +---
365 +
366 +### Task 2: DAO + Database
367 +
368 +**Files:**
369 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/CrmContactDao.kt`
370 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/CrmDatabase.kt`
371 +- Create: `android/app/src/test/java/fr/ebii/card2vcf/data/CrmContactDaoTest.kt`
372 +
373 +- [ ] **Step 1: Test DAO insert + FTS notes (rouge)**
374 +
375 +```kotlin
376 +package fr.ebii.card2vcf.data
377 +
378 +import android.content.Context
379 +import androidx.room.Room
380 +import androidx.test.core.app.ApplicationProvider
381 +import kotlinx.coroutines.runBlocking
382 +import org.junit.After
383 +import org.junit.Assert.assertEquals
384 +import org.junit.Before
385 +import org.junit.Test
386 +import org.junit.runner.RunWith
387 +import org.robolectric.RobolectricTestRunner
388 +
389 +@RunWith(RobolectricTestRunner::class)
390 +class CrmContactDaoTest {
391 + private lateinit var db: CrmDatabase
392 + private lateinit var dao: CrmContactDao
393 +
394 + @Before
395 + fun setUp() {
396 + val ctx = ApplicationProvider.getApplicationContext<Context>()
397 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
398 + .allowMainThreadQueries()
399 + .build()
400 + dao = db.crmContactDao()
401 + }
402 +
403 + @After fun tearDown() = db.close()
404 +
405 + @Test
406 + fun insertAndSearchNotes() = runBlocking {
407 + val id = dao.insert(
408 + CrmContactEntity(
409 + fullName = "Ada",
410 + notes = "salon Lyon 2026",
411 + phonesText = "",
412 + emailsText = "",
413 + createdAt = 1L,
414 + updatedAt = 1L,
415 + ),
416 + )
417 + val hits = dao.searchIds("Lyon")
418 + assertEquals(listOf(id), hits)
419 + }
420 +}
421 +```
422 +
423 +- [ ] **Step 2: Run — expect FAIL**
424 +
425 +- [ ] **Step 3: DAO + Database**
426 +
427 +```kotlin
428 +package fr.ebii.card2vcf.data
429 +
430 +import androidx.room.Dao
431 +import androidx.room.Insert
432 +import androidx.room.OnConflictStrategy
433 +import androidx.room.Query
434 +import androidx.room.Update
435 +import kotlinx.coroutines.flow.Flow
436 +
437 +@Dao
438 +interface CrmContactDao {
439 + @Insert(onConflict = OnConflictStrategy.REPLACE)
440 + suspend fun insert(entity: CrmContactEntity): Long
441 +
442 + @Update
443 + suspend fun update(entity: CrmContactEntity)
444 +
445 + @Query("DELETE FROM crm_contacts WHERE id = :id")
446 + suspend fun deleteById(id: Long)
447 +
448 + @Query("SELECT * FROM crm_contacts WHERE id = :id")
449 + suspend fun getById(id: Long): CrmContactEntity?
450 +
451 + @Query("SELECT * FROM crm_contacts ORDER BY createdAt DESC")
452 + fun observeAll(): Flow<List<CrmContactEntity>>
453 +
454 + @Query("SELECT * FROM crm_contacts ORDER BY createdAt DESC")
455 + suspend fun getAll(): List<CrmContactEntity>
456 +
457 + @Query(
458 + """
459 + SELECT rowid FROM crm_contacts_fts
460 + WHERE crm_contacts_fts MATCH :query
461 + """,
462 + )
463 + suspend fun searchIds(query: String): List<Long>
464 +
465 + @Insert(onConflict = OnConflictStrategy.IGNORE)
466 + suspend fun insertExclusion(ex: DuplicateExclusionEntity): Long
467 +
468 + @Query("SELECT * FROM duplicate_exclusions")
469 + suspend fun getExclusions(): List<DuplicateExclusionEntity>
470 +
471 + @Query("DELETE FROM duplicate_exclusions WHERE idA = :id OR idB = :id")
472 + suspend fun deleteExclusionsFor(id: Long)
473 +}
474 +```
475 +
476 +```kotlin
477 +package fr.ebii.card2vcf.data
478 +
479 +import android.content.Context
480 +import androidx.room.Database
481 +import androidx.room.Room
482 +import androidx.room.RoomDatabase
483 +import androidx.room.TypeConverters
484 +
485 +@Database(
486 + entities = [
487 + CrmContactEntity::class,
488 + CrmContactFts::class,
489 + DuplicateExclusionEntity::class,
490 + ],
491 + version = 1,
492 + exportSchema = false,
493 +)
494 +@TypeConverters(Converters::class)
495 +abstract class CrmDatabase : RoomDatabase() {
496 + abstract fun crmContactDao(): CrmContactDao
497 +
498 + companion object {
499 + @Volatile private var instance: CrmDatabase? = null
500 +
501 + fun get(context: Context): CrmDatabase =
502 + instance ?: synchronized(this) {
503 + instance ?: Room.databaseBuilder(
504 + context.applicationContext,
505 + CrmDatabase::class.java,
506 + "card2vcf-crm.db",
507 + ).build().also { instance = it }
508 + }
509 + }
510 +}
511 +```
512 +
513 +**FTS MATCH :** préfixer la query utilisateur avec tokenisation simple — dans le repository, transformer `"Lyon"` en `"Lyon*"` pour préfixe. Documenter dans `ContactRepository.search`.
514 +
515 +Si `contentEntity` FTS pose problème avec converters List : fallback figé — table FTS manuelle synchronisée dans `insert`/`update` via `@Insert` sur `CrmContactFts` **sans** contentEntity (copier les champs texte). Préférer faire passer les tests ; ajuster l’annotation FTS en conséquence.
516 +
517 +- [ ] **Step 4: Run — expect PASS**
518 +
519 +- [ ] **Step 5: Commit**
520 +
521 +```bash
522 +git commit -m "$(cat <<'EOF'
523 +feat(data): DAO Room FTS et CrmDatabase
524 +
525 +EOF
526 +)"
527 +```
528 +
529 +---
530 +
531 +### Task 3: ContactImageStore + ContactRepository
532 +
533 +**Files:**
534 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/ContactImageStore.kt`
535 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/data/ContactRepository.kt`
536 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/crm/ContactNormalizer.kt` (utilisé par repo import)
537 +- Create: `android/app/src/test/java/fr/ebii/card2vcf/data/ContactRepositoryTest.kt`
538 +- Create: `android/app/src/test/java/fr/ebii/card2vcf/crm/ContactNormalizerTest.kt`
539 +
540 +- [ ] **Step 1: Tests Normalizer (rouge puis vert)**
541 +
542 +```kotlin
543 +package fr.ebii.card2vcf.crm
544 +
545 +object ContactNormalizer {
546 + fun email(raw: String): String = raw.trim().lowercase()
547 + fun phone(raw: String): String = raw.filter { it.isDigit() }
548 +}
549 +```
550 +
551 +Tests : `" A@B.C "` → `"a@b.c"` ; `"+33 6 12-34"` → `"3361234"` (digits only).
552 +
553 +- [ ] **Step 2: ImageStore**
554 +
555 +```kotlin
556 +package fr.ebii.card2vcf.data
557 +
558 +import android.content.Context
559 +import android.graphics.Bitmap
560 +import java.io.File
561 +import java.io.FileOutputStream
562 +
563 +class ContactImageStore(private val context: Context) {
564 + private fun dir(id: Long): File =
565 + File(context.filesDir, "contacts/$id").also { it.mkdirs() }
566 +
567 + fun saveJpeg(contactId: Long, name: String, bitmap: Bitmap, quality: Int = 90): String {
568 + val file = File(dir(contactId), name)
569 + FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.JPEG, quality, it) }
570 + return file.absolutePath
571 + }
572 +
573 + fun deleteAll(contactId: Long) {
574 + dir(contactId).deleteRecursively()
575 + }
576 +
577 + fun copyImage(fromPath: String?, toContactId: Long, name: String): String? {
578 + if (fromPath.isNullOrBlank()) return null
579 + val src = File(fromPath)
580 + if (!src.isFile) return null
581 + val dest = File(dir(toContactId), name)
582 + src.copyTo(dest, overwrite = true)
583 + return dest.absolutePath
584 + }
585 +}
586 +```
587 +
588 +- [ ] **Step 3: Repository API**
589 +
590 +```kotlin
591 +package fr.ebii.card2vcf.data
592 +
593 +import android.graphics.Bitmap
594 +import fr.ebii.card2vcf.contact.ContactCard
595 +import fr.ebii.card2vcf.crm.ContactNormalizer
596 +import kotlinx.coroutines.flow.Flow
597 +
598 +data class VcfImportResult(
599 + val imported: Int,
600 + val skippedDuplicates: Int,
601 + val errors: Int,
602 +)
603 +
604 +class ContactRepository(
605 + private val dao: CrmContactDao,
606 + private val images: ContactImageStore,
607 +) {
608 + fun observeAll(): Flow<List<CrmContactEntity>> = dao.observeAll()
609 +
610 + suspend fun getById(id: Long) = dao.getById(id)
611 +
612 + suspend fun search(query: String): List<CrmContactEntity> {
613 + val q = query.trim()
614 + if (q.isEmpty()) return dao.getAll()
615 + val token = q.split(Regex("\\s+")).joinToString(" ") { "$it*" }
616 + val ids = dao.searchIds(token)
617 + return ids.mapNotNull { dao.getById(it) }
618 + }
619 +
620 + suspend fun insertFromCard(
621 + card: ContactCard,
622 + cardBitmap: Bitmap?,
623 + profileBitmap: Bitmap?,
624 + notesOverride: String? = card.note,
625 + now: Long = System.currentTimeMillis(),
626 + ): Long {
627 + val entity = card.toEntity(notes = notesOverride, now = now)
628 + val id = dao.insert(entity)
629 + var updated = entity.copy(id = id)
630 + if (cardBitmap != null) {
631 + updated = updated.copy(cardImagePath = images.saveJpeg(id, "card.jpg", cardBitmap))
632 + }
633 + if (profileBitmap != null) {
634 + updated = updated.copy(profileImagePath = images.saveJpeg(id, "profile.jpg", profileBitmap))
635 + }
636 + if (updated != entity.copy(id = id)) dao.update(updated)
637 + return id
638 + }
639 +
640 + suspend fun updateFromCard(
641 + id: Long,
642 + card: ContactCard,
643 + cardBitmap: Bitmap?,
644 + profileBitmap: Bitmap?,
645 + preserveNotesIfBlankDraft: Boolean = true,
646 + now: Long = System.currentTimeMillis(),
647 + ) {
648 + val existing = dao.getById(id) ?: return
649 + val notes = when {
650 + preserveNotesIfBlankDraft && card.note.isNullOrBlank() -> existing.notes
651 + else -> card.note
652 + }
653 + var updated = card.toEntity(notes = notes, now = now).copy(
654 + id = id,
655 + createdAt = existing.createdAt,
656 + cardImagePath = existing.cardImagePath,
657 + profileImagePath = existing.profileImagePath,
658 + )
659 + if (cardBitmap != null) {
660 + updated = updated.copy(cardImagePath = images.saveJpeg(id, "card.jpg", cardBitmap))
661 + }
662 + if (profileBitmap != null) {
663 + updated = updated.copy(profileImagePath = images.saveJpeg(id, "profile.jpg", profileBitmap))
664 + }
665 + dao.update(updated)
666 + }
667 +
668 + suspend fun delete(id: Long) {
669 + dao.deleteExclusionsFor(id)
670 + dao.deleteById(id)
671 + images.deleteAll(id)
672 + }
673 +
674 + suspend fun findMatchingId(card: ContactCard, among: List<CrmContactEntity>): Long? {
675 + val emails = card.emails.map(ContactNormalizer::email).filter { it.isNotEmpty() }.toSet()
676 + val phones = card.phones.map(ContactNormalizer::phone).filter { it.isNotEmpty() }.toSet()
677 + for (e in among) {
678 + val eEmails = e.emails.map(ContactNormalizer::email).toSet()
679 + val ePhones = e.phones.map(ContactNormalizer::phone).toSet()
680 + if (emails.any { it in eEmails } || phones.any { it in ePhones }) return e.id
681 + }
682 + return null
683 + }
684 +
685 + suspend fun importCards(cards: List<ContactCard>): VcfImportResult {
686 + var imported = 0
687 + var skipped = 0
688 + var errors = 0
689 + val existing = dao.getAll().toMutableList()
690 + for (card in cards) {
691 + try {
692 + if (!card.hasAnyField()) {
693 + errors++
694 + continue
695 + }
696 + if (findMatchingId(card, existing) != null) {
697 + skipped++
698 + continue
699 + }
700 + val id = insertFromCard(card, null, null)
701 + existing.add(dao.getById(id)!!)
702 + imported++
703 + } catch (_: Exception) {
704 + errors++
705 + }
706 + }
707 + return VcfImportResult(imported, skipped, errors)
708 + }
709 +}
710 +
711 +fun ContactCard.toEntity(notes: String?, now: Long) = CrmContactEntity(
712 + fullName = fullName,
713 + firstName = firstName,
714 + lastName = lastName,
715 + company = company,
716 + jobTitle = jobTitle,
717 + phones = phones,
718 + emails = emails,
719 + website = website,
720 + address = address,
721 + notes = notes,
722 + phonesText = phones.joinToString(" "),
723 + emailsText = emails.joinToString(" "),
724 + createdAt = now,
725 + updatedAt = now,
726 +)
727 +
728 +fun CrmContactEntity.toCard() = ContactCard(
729 + fullName = fullName,
730 + firstName = firstName,
731 + lastName = lastName,
732 + company = company,
733 + jobTitle = jobTitle,
734 + phones = phones,
735 + emails = emails,
736 + website = website,
737 + address = address,
738 + note = notes,
739 +)
740 +```
741 +
742 +- [ ] **Step 4: Tests repository** — insert + search notes ; import skip duplicate email ; delete removes row.
743 +
744 +- [ ] **Step 5: Commit**
745 +
746 +```bash
747 +git commit -m "$(cat <<'EOF'
748 +feat(data): ContactRepository ImageStore et import anti-doublons
749 +
750 +EOF
751 +)"
752 +```
753 +
754 +---
755 +
756 +### Task 4: DuplicateDetector + ContactMerge + AlphabetIndex
757 +
758 +**Files:**
759 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/crm/DuplicateDetector.kt`
760 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/crm/ContactMerge.kt`
761 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/crm/ContactSort.kt`
762 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/crm/AlphabetIndex.kt`
763 +- Tests miroirs sous `android/app/src/test/java/fr/ebii/card2vcf/crm/`
764 +
765 +- [ ] **Step 1: Tests Detector**
766 +
767 +Cas : A et B même email → même cluster ; exclusion (minId,maxId) → clusters séparés ; téléphone normalisé match.
768 +
769 +```kotlin
770 +package fr.ebii.card2vcf.crm
771 +
772 +import fr.ebii.card2vcf.data.CrmContactEntity
773 +import fr.ebii.card2vcf.data.DuplicateExclusionEntity
774 +
775 +object DuplicateDetector {
776 + fun clusters(
777 + contacts: List<CrmContactEntity>,
778 + exclusions: List<DuplicateExclusionEntity>,
779 + ): Map<Long, Set<Long>> {
780 + val parent = contacts.associate { it.id to it.id }.toMutableMap()
781 + fun find(x: Long): Long {
782 + var p = x
783 + while (parent[p] != p) p = parent[p]!!
784 + return p
785 + }
786 + fun union(a: Long, b: Long) {
787 + val ra = find(a)
788 + val rb = find(b)
789 + if (ra != rb) parent[ra] = rb
790 + }
791 + val excluded = exclusions.map { it.idA to it.idB }.toSet()
792 + fun excludedPair(a: Long, b: Long): Boolean {
793 + val lo = minOf(a, b)
794 + val hi = maxOf(a, b)
795 + return (lo to hi) in excluded
796 + }
797 + val byEmail = mutableMapOf<String, MutableList<Long>>()
798 + val byPhone = mutableMapOf<String, MutableList<Long>>()
799 + for (c in contacts) {
800 + c.emails.map(ContactNormalizer::email).filter { it.isNotEmpty() }
801 + .forEach { byEmail.getOrPut(it) { mutableListOf() }.add(c.id) }
802 + c.phones.map(ContactNormalizer::phone).filter { it.isNotEmpty() }
803 + .forEach { byPhone.getOrPut(it) { mutableListOf() }.add(c.id) }
804 + }
805 + fun linkGroups(groups: Collection<List<Long>>) {
806 + for (g in groups) {
807 + for (i in 1 until g.size) {
808 + if (!excludedPair(g[0], g[i])) union(g[0], g[i])
809 + }
810 + }
811 + }
812 + linkGroups(byEmail.values)
813 + linkGroups(byPhone.values)
814 + val rootToMembers = mutableMapOf<Long, MutableSet<Long>>()
815 + for (c in contacts) {
816 + rootToMembers.getOrPut(find(c.id)) { mutableSetOf() }.add(c.id)
817 + }
818 + val idToCluster = mutableMapOf<Long, Set<Long>>()
819 + for (members in rootToMembers.values) {
820 + for (id in members) idToCluster[id] = members
821 + }
822 + return idToCluster
823 + }
824 +
825 + fun duplicateCount(contactId: Long, clusters: Map<Long, Set<Long>>): Int =
826 + ((clusters[contactId]?.size ?: 1) - 1).coerceAtLeast(0)
827 +}
828 +```
829 +
830 +- [ ] **Step 2: ContactMerge**
831 +
832 +```kotlin
833 +package fr.ebii.card2vcf.crm
834 +
835 +import fr.ebii.card2vcf.data.CrmContactEntity
836 +
837 +data class MergeFieldChoices(
838 + val fullNameFromId: Long,
839 + val firstNameFromId: Long,
840 + val lastNameFromId: Long,
841 + val companyFromId: Long,
842 + val jobTitleFromId: Long,
843 + val websiteFromId: Long,
844 + val addressFromId: Long,
845 + val notesMode: NotesMode, // KEEP_PRIMARY | CONCAT
846 + val cardImageFromId: Long?,
847 + val profileImageFromId: Long?,
848 + val keepId: Long,
849 +)
850 +
851 +enum class NotesMode { KEEP_PRIMARY, CONCAT }
852 +
853 +object ContactMerge {
854 + fun merge(
855 + sources: Map<Long, CrmContactEntity>,
856 + choices: MergeFieldChoices,
857 + now: Long = System.currentTimeMillis(),
858 + ): CrmContactEntity {
859 + fun scalar(picker: Long, sel: (CrmContactEntity) -> String?) =
860 + sources.getValue(picker).let(sel)
861 + val keep = sources.getValue(choices.keepId)
862 + val phones = sources.values.flatMap { it.phones }.distinct()
863 + val emails = sources.values.flatMap { it.emails }.distinct()
864 + val notes = when (choices.notesMode) {
865 + NotesMode.KEEP_PRIMARY -> keep.notes
866 + NotesMode.CONCAT -> sources.values.mapNotNull { it.notes?.takeIf { n -> n.isNotBlank() } }
867 + .distinct()
868 + .joinToString("\n---\n")
869 + .ifBlank { null }
870 + }
871 + return keep.copy(
872 + fullName = scalar(choices.fullNameFromId) { it.fullName },
873 + firstName = scalar(choices.firstNameFromId) { it.firstName },
874 + lastName = scalar(choices.lastNameFromId) { it.lastName },
875 + company = scalar(choices.companyFromId) { it.company },
876 + jobTitle = scalar(choices.jobTitleFromId) { it.jobTitle },
877 + website = scalar(choices.websiteFromId) { it.website },
878 + address = scalar(choices.addressFromId) { it.address },
879 + notes = notes,
880 + phones = phones,
881 + emails = emails,
882 + phonesText = phones.joinToString(" "),
883 + emailsText = emails.joinToString(" "),
884 + updatedAt = now,
885 + // image paths applied by repository after copy
886 + )
887 + }
888 +}
889 +```
890 +
891 +Repository méthodes à ajouter dans la même task ou T10 :
892 +
893 +```kotlin
894 +suspend fun markDifferent(a: Long, b: Long) {
895 + val lo = minOf(a, b); val hi = maxOf(a, b)
896 + dao.insertExclusion(DuplicateExclusionEntity(idA = lo, idB = hi))
897 +}
898 +
899 +suspend fun mergeContacts(
900 + sources: List<CrmContactEntity>,
901 + choices: MergeFieldChoices,
902 +) {
903 + val map = sources.associateBy { it.id }
904 + var merged = ContactMerge.merge(map, choices)
905 + // copy chosen images onto keepId, then delete others
906 + ...
907 +}
908 +```
909 +
910 +- [ ] **Step 3: ContactSort + AlphabetIndex**
911 +
912 +```kotlin
913 +enum class ContactSort { LAST_NAME, FIRST_NAME, COMPANY, CREATED_AT }
914 +
915 +fun sortKey(c: CrmContactEntity, sort: ContactSort): String = when (sort) {
916 + ContactSort.LAST_NAME -> c.lastName ?: c.fullName ?: ""
917 + ContactSort.FIRST_NAME -> c.firstName ?: c.fullName ?: ""
918 + ContactSort.COMPANY -> c.company ?: ""
919 + ContactSort.CREATED_AT -> c.createdAt.toString().padStart(20, '0')
920 +}
921 +
922 +fun sectionLetter(key: String): Char {
923 + val c = key.trim().firstOrNull()?.uppercaseChar()
924 + return if (c != null && c in 'A'..'Z') c else '#'
925 +}
926 +```
927 +
928 +Grouper : `LinkedHashMap<Char, List<CrmContactEntity>>` après sort alphabétique (sauf `CREATED_AT` = liste plate chrono desc, pas de sections).
929 +
930 +- [ ] **Step 4: Tests verts + commit**
931 +
932 +```bash
933 +git commit -m "$(cat <<'EOF'
934 +feat(crm): détection doublons fusion tri et abcédaire
935 +
936 +EOF
937 +)"
938 +```
939 +
940 +---
941 +
942 +### Task 5: VCardParser (lire 3.0 + 4.0)
943 +
944 +**Files:**
945 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/contact/VCardParser.kt`
946 +- Create: `android/app/src/test/resources/vcf/sample3.vcf`
947 +- Create: `android/app/src/test/resources/vcf/sample4.vcf`
948 +- Create: `android/app/src/test/java/fr/ebii/card2vcf/contact/VCardParserTest.kt`
949 +
950 +- [ ] **Step 1: Fixtures**
951 +
952 +`sample3.vcf` :
953 +
954 +```text
955 +BEGIN:VCARD
956 +VERSION:3.0
957 +FN:Alice Martin
958 +N:Martin;Alice;;;
959 +ORG:Acme
960 +EMAIL;TYPE=INTERNET:alice@acme.test
961 +TEL;TYPE=CELL:+33601020304
962 +NOTE:salon Lyon
963 +END:VCARD
964 +BEGIN:VCARD
965 +VERSION:3.0
966 +FN:Bob
967 +EMAIL:bob@unique.test
968 +END:VCARD
969 +```
970 +
971 +`sample4.vcf` :
972 +
973 +```text
974 +BEGIN:VCARD
975 +VERSION:4.0
976 +FN:Alice V4
977 +N:V4;Alice;;;
978 +EMAIL;TYPE=work:alice@acme.test
979 +TEL;TYPE=cell:+33601020304
980 +END:VCARD
981 +```
982 +
983 +- [ ] **Step 2: Tests**
984 +
985 +- `parse(sample3)` → 2 cards, FN/ORG/EMAIL/NOTE
986 +- `parse(sample4)` → 1 card, email/tel
987 +- Unfold : ligne pliée spatiale ` ` en début de ligne suivante
988 +- Ignorer `PHOTO`
989 +- Export roundtrip : `VCardSerializer.toVCard3(parserResult)` contient `VERSION:3.0`
990 +
991 +- [ ] **Step 3: Implémentation minimale**
992 +
993 +```kotlin
994 +object VCardParser {
995 + fun parse(text: String): List<ContactCard> {
996 + val unfolded = unfold(text)
997 + val cards = mutableListOf<ContactCard>()
998 + var buf = mutableListOf<String>()
999 + for (line in unfolded.lineSequence()) {
1000 + val t = line.trimEnd()
1001 + when {
1002 + t.equals("BEGIN:VCARD", true) -> buf = mutableListOf()
1003 + t.equals("END:VCARD", true) -> cards.add(parseOne(buf))
1004 + else -> buf.add(t)
1005 + }
1006 + }
1007 + return cards
1008 + }
1009 +
1010 + private fun unfold(text: String): String = buildString {
1011 + for (line in text.replace("\r\n", "\n").replace('\r', '\n').lineSequence()) {
1012 + if (line.startsWith(" ") || line.startsWith("\t")) {
1013 + if (isNotEmpty()) setLength(length - 1) // remove previous \n
1014 + append(line.drop(1))
1015 + } else {
1016 + if (isNotEmpty()) append('\n')
1017 + append(line)
1018 + }
1019 + }
1020 + }
1021 +
1022 + private fun parseOne(lines: List<String>): ContactCard {
1023 + // split name/params:value ; handle FN N ORG TITLE TEL EMAIL URL ADR NOTE
1024 + // N: Last;First;...
1025 + // ADR: ignore empty components, join street-like
1026 + ...
1027 + }
1028 +}
1029 +```
1030 +
1031 +Compléter `parseOne` avec extraction robuste `property` = avant `:` en gérant `TEL;TYPE=cell:value` et `item1.EMAIL:value`.
1032 +
1033 +- [ ] **Step 4: Commit**
1034 +
1035 +```bash
1036 +git commit -m "$(cat <<'EOF'
1037 +feat(contact): VCardParser lecture vCard 3.0 et 4.0
1038 +
1039 +EOF
1040 +)"
1041 +```
1042 +
1043 +---
1044 +
1045 +### Task 6: Navigation shell — home = carnet
1046 +
1047 +**Files:**
1048 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt`
1049 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetScreen.kt` (stub empty)
1050 +- Create: `android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetViewModel.kt`
1051 +- Modify: `MainActivity.kt`
1052 +
1053 +Routes figées :
1054 +
1055 +```kotlin
1056 +object Routes {
1057 + const val Carnet = "carnet"
1058 + const val Fiche = "fiche/{contactId}?sort={sort}"
1059 + const val Edit = "edit/{contactId}"
1060 + const val Duplicates = "duplicates/{contactId}"
1061 + const val Scan = "scan?editId={editId}"
1062 + const val Manual = "manual"
1063 + const val Draft = "draft" // ou état dans backStack scan
1064 + const val ImportVcf = "import_vcf"
1065 +}
1066 +```
1067 +
1068 +- [ ] **Step 1:** `MainActivity` obtient `repo` via `(application as Card2vcfApp).database` + `ContactImageStore` ; `setContent { Card2vcfNavHost(...) }` startDestination = `carnet`.
1069 +
1070 +- [ ] **Step 2:** Stub `CarnetScreen` texte « Carnet » pour smoke `assembleDebug`.
1071 +
1072 +- [ ] **Step 3: Commit**
1073 +
1074 +```bash
1075 +git commit -m "$(cat <<'EOF'
1076 +feat(ui): NavHost Card2vcf home carnet
1077 +
1078 +EOF
1079 +)"
1080 +```
1081 +
1082 +---
1083 +
1084 +### Task 7: Carnet UI — tri, abcédaire, recherche, FAB
1085 +
1086 +**Files:**
1087 +- `CarnetScreen.kt`, `AlphabetRail.kt`, `CarnetViewModel.kt`
1088 +- `res/values/strings.xml` (+ `values-en` si présent)
1089 +
1090 +Wireframe (rappel) :
1091 +
1092 +```text
1093 +CARD2VCF [tri]
1094 +[recherche................]
1095 +sections + rows | A-Z
1096 + | #
1097 + [+]
1098 +```
1099 +
1100 +- [ ] **Step 1: ViewModel** — `sort: ContactSort`, `query: String`, `contacts: StateFlow` depuis `observeAll` ou `search(query)` ; expose `sections: List<Pair<Char, List<CrmContactEntity>>>` ; `letterOffsets`.
1101 +
1102 +- [ ] **Step 2: AlphabetRail** — colonne `A..Z` + `#` ; lettres absentes en `Body` gris (`Ink` soft / `colors.body`) ; `onLetter(Char)` → `LazyListState.animateScrollToItem`.
1103 +
1104 +- [ ] **Step 3: FAB menu** — trois actions Scanner / Manuel / Import (Dialog ou DropdownMenu carré design.md).
1105 +
1106 +- [ ] **Step 4: Masquer rail si `query.isNotBlank()`**.
1107 +
1108 +- [ ] **Step 5: assembleDebug + commit**
1109 +
1110 +```bash
1111 +git commit -m "$(cat <<'EOF'
1112 +feat(ui): carnet tri abcédaire recherche et menu création
1113 +
1114 +EOF
1115 +)"
1116 +```
1117 +
1118 +---
1119 +
1120 +### Task 8: Fiche HorizontalPager + gestes
1121 +
1122 +**Files:**
1123 +- `ui/contact/ContactPagerScreen.kt`
1124 +- `ui/contact/ContactPagerViewModel.kt`
1125 +
1126 +- [ ] **Step 1:** Charger liste **ordonnée selon `sort`** (même ordre que carnet) ; `HorizontalPager` index initial = contactId.
1127 +
1128 +- [ ] **Step 2: Gestes** — `detectTapGestures` sur zone contenu (pas top bar) :
1129 + - `onDoubleTap` → `Intent(ACTION_SENDTO, Uri.parse("mailto:${email}"))`
1130 + - triple tap : compteur taps avec timeout 400 ms → `Intent(ACTION_DIAL, Uri.parse("tel:$phone"))`
1131 + - si email/tel absent → no-op (pas de crash)
1132 +
1133 +- [ ] **Step 3: Afficher** profil, identité, champs, `card.jpg` si path, notes lecture.
1134 +
1135 +- [ ] **Step 4: Chrome** — ← retour, Éditer, puce doublons (branche T10 si count>0).
1136 +
1137 +- [ ] **Step 5: Commit**
1138 +
1139 +```bash
1140 +git commit -m "$(cat <<'EOF'
1141 +feat(ui): fiche contact pager swipe double/triple tap
1142 +
1143 +EOF
1144 +)"
1145 +```
1146 +
1147 +---
1148 +
1149 +### Task 9: Édition + rescan
1150 +
1151 +**Files:**
1152 +- `ContactEditScreen.kt` + ViewModel
1153 +- Modify: `ScanCarteViewModel.kt` — `editingContactId: Long?`
1154 +- Modify: draft flow
1155 +
1156 +- [ ] **Step 1: Edit** — champs = `ContactDraftFields` réutilisés ; notes ; bouton photo profil (TakePicture / GetContent) ; **Rescanner la carte** → `navigate("scan?editId=$id")`.
1157 +
1158 +- [ ] **Step 2: Scan en mode update** — après OCR, draft prérempli ; notes = notes DB ; bouton primary **Mettre à jour** appelle `repo.updateFromCard`.
1159 +
1160 +- [ ] **Step 3: Manuel** — draft vide → `insertFromCard`.
1161 +
1162 +- [ ] **Step 4: Commit**
1163 +
1164 +```bash
1165 +git commit -m "$(cat <<'EOF'
1166 +feat(ui): édition contact notes profil et rescan carte
1167 +
1168 +EOF
1169 +)"
1170 +```
1171 +
1172 +---
1173 +
1174 +### Task 10: UI doublons (puce + revue + fusion)
1175 +
1176 +**Files:**
1177 +- `DuplicateReviewScreen.kt` + ViewModel
1178 +- Brancher puce dans `ContactPagerScreen`
1179 +
1180 +- [ ] **Step 1:** ViewModel calcule `DuplicateDetector.clusters(all, exclusions)[id]` ; count pour puce.
1181 +
1182 +- [ ] **Step 2: Revue** — liste des autres contacts ; actions :
1183 + - Supprimer sélection
1184 + - Contacts différents → `repo.markDifferent`
1185 + - Fusionner → sous-écran choix keepId + pickers champ + images
1186 +
1187 +- [ ] **Step 3: Après fusion** — `repo.mergeContacts` ; navigate fiche keepId.
1188 +
1189 +- [ ] **Step 4: Tests unitaires merge déjà verts ; smoke UI assembleDebug.**
1190 +
1191 +- [ ] **Step 5: Commit**
1192 +
1193 +```bash
1194 +git commit -m "$(cat <<'EOF'
1195 +feat(ui): puce doublons revue fusion et exclusions
1196 +
1197 +EOF
1198 +)"
1199 +```
1200 +
1201 +---
1202 +
1203 +### Task 11: Draft — Enregistrer local prioritaire
1204 +
1205 +**Files:**
1206 +- Modify: `ContactDraftScreen.kt`, `ScanCarteViewModel.kt`, strings
1207 +
1208 +- [ ] **Step 1:** Boutons (ordre) :
1209 + 1. **Enregistrer** / **Mettre à jour** (primary noir)
1210 + 2. Créer contact Android (outline)
1211 + 3. Exporter VCF (outline)
1212 + 4. Ajouter photo profil (optionnel)
1213 +
1214 +- [ ] **Step 2:** Après Enregistrer → `popUpTo(carnet)` inclusif false + éventuellement ouvrir fiche.
1215 +
1216 +- [ ] **Step 3: Commit**
1217 +
1218 +```bash
1219 +git commit -m "$(cat <<'EOF'
1220 +feat(ui): brouillon enregistrement carnet local prioritaire
1221 +
1222 +EOF
1223 +)"
1224 +```
1225 +
1226 +---
1227 +
1228 +### Task 12: Import VCF UI
1229 +
1230 +**Files:**
1231 +- `ui/import/VcfImportScreen.kt`, `VcfImportViewModel.kt`
1232 +
1233 +- [ ] **Step 1:** `ActivityResultContracts.OpenDocument()` mime `text/x-vcard` + `text/vcard` + `*/*` fallback.
1234 +
1235 +- [ ] **Step 2:** Lire URI → string → `VCardParser.parse` → `repo.importCards` → afficher résumé.
1236 +
1237 +- [ ] **Step 3: Commit**
1238 +
1239 +```bash
1240 +git commit -m "$(cat <<'EOF'
1241 +feat(ui): import VCF massif anti-doublons résumé
1242 +
1243 +EOF
1244 +)"
1245 +```
1246 +
1247 +---
1248 +
1249 +### Task 13: Polish, README, suite de tests
1250 +
1251 +**Files:**
1252 +- `README.md`
1253 +- strings empty state carnet
1254 +- vérifier `file_paths.xml` si besoin paths `files/`
1255 +
1256 +- [ ] **Step 1: README** — section Mini-CRM : carnet offline, recherche, import (lire 3+4 / écrire 3), doublons, rescan, pas de sync.
1257 +
1258 +- [ ] **Step 2: Empty state** — « Aucun contact — scanner une carte ou importer un VCF ».
1259 +
1260 +- [ ] **Step 3: Suite**
1261 +
1262 +Run:
1263 +
1264 +```bash
1265 +cd android && ./gradlew :app:testDebugUnitTest :app:assembleDebug
1266 +```
1267 +
1268 +Expected: BUILD SUCCESSFUL ; tous les tests data/crm/contact verts.
1269 +
1270 +- [ ] **Step 4: Checklist manuelle Gherkin** (scénarios du plan produit — scan save, FTS notes, import skip, import 4→export 3, puce fusion, contacts différents, rescan notes).
1271 +
1272 +- [ ] **Step 5: Commit**
1273 +
1274 +```bash
1275 +git commit -m "$(cat <<'EOF'
1276 +docs: README mini-CRM et polish empty states
1277 +
1278 +EOF
1279 +)"
1280 +```
1281 +
1282 +---
1283 +
1284 +## Mapping scénarios → tasks
1285 +
1286 +| Scénario | Tasks |
1287 +|----------|-------|
1288 +| Enregistrer scan → SQLite + card.jpg | T3, T11 |
1289 +| Recherche notes | T2, T7 |
1290 +| Import anti-doublons | T3, T5, T12 |
1291 +| Import 4.0 / export 3.0 | T5, T12 |
1292 +| Puce + fusion | T4, T10 |
1293 +| Contacts différents | T4, T10 |
1294 +| Rescan conserve notes | T3, T9 |
1295 +| Abcédiaire entreprise | T4, T7 |
1296 +| Swipe + gestes | T8 |
1297 +
1298 +---
1299 +
1300 +## Ordre d’exécution recommandé
1301 +
1302 +`T0 → T1 → T2 → T3 → T4 → T5 → T6 → T7 → T8 → T9 → T10 → T11 → T12 → T13`
1303 +
1304 +Chaque task se termine par tests unitaires pertinents verts (ou `assembleDebug` pour UI) + commit atomique.
1305 +
1306 +---
1307 +
1308 +## Self-review
1309 +
1310 +- Spec coverage : toutes décisions produit ont une task.
1311 +- Pas de TBD / option A vs B restants.
1312 +- Types alignés : `CrmContactEntity`, `MergeFieldChoices`, `VcfImportResult`, `ContactSort`.
1313 +- vCard lire 3+4 / écrire 3 couvert T5 + scénario.
1314 +- Doublons import + UI couverts T3/T4/T10/T12.