name: Mini CRM Card2vcf 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." todos:
- id: crm-gradle content: "T0: Room KSP Navigation deps + Application singleton DB" status: pending
- id: crm-room content: "T1–T3: Entity FTS exclusions ImageStore Repository + tests" status: pending
- id: crm-dupes-domain content: "T4: DuplicateDetector ContactMerge Normalizers + tests" status: pending
- id: crm-vcard content: "T5: VCardParser 3.0+4.0 + fixtures + tests" status: pending
- id: crm-nav-carnet content: "T6–T7: NavHost home carnet tri rail recherche FAB" status: pending
- id: crm-fiche content: "T8–T9: Pager gestes édition rescan" status: pending
- id: crm-dupes-ui content: "T10: Puce + DuplicateReview + fusion UI" status: pending
- id: crm-draft-import content: "T11–T12: Draft save/update + import VCF anti-doublons" status: pending
- id: crm-docs-tests content: "T13: README strings empty states assemble + test suite" status: pending isProject: true
Mini-CRM Card2vcf — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development(recommended) orsuperpowers:executing-planstask-by-task. Steps use checkbox (- [ ]) syntax for tracking.
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).
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}/.
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.
Repo racine : /home/monsieurb/Documents/devel/EBII_mobileVCF
Tests : cd android && ./gradlew :app:testDebugUnitTest
Build : cd android && ./gradlew :app:assembleDebug
Décisions produit (non négociables)
- Home = carnet ;
+= Scanner | Saisie manuelle | Import VCF - Save local systématique ; Contacts Android + export VCF optionnels
- Tri : Nom | Prénom | Entreprise (groupé + rail A–Z) | Date (sans rail)
- Recherche FTS inclut notes ; rail masqué si query non vide
- Doublons : email OU téléphone normalisé ; exclusions persistantes ; puce
N doublon(s) - Import : skip si match ; résumé imported / skippedDuplicates / errors
- vCard : lire 3.0+4.0, écrire 3.0 seulement ; pas d’import
PHOTO - Rescan : remplace
card.jpg+ champs ; notes conservées par défaut
Hors v1 : sync cloud, tags CRM, matching flou sans email/tél, écriture vCard 4.0, PHOTO VCF.
Carte des fichiers
android/app/src/main/java/fr/ebii/card2vcf/ ├── Card2vcfApp.kt # NEW Application + DB singleton ├── MainActivity.kt # MOD NavHost ├── contact/ │ ├── ContactCard.kt # EXIST │ ├── VCardSerializer.kt # EXIST écriture 3.0 │ └── VCardParser.kt # NEW lecture 3+4 ├── data/ │ ├── Converters.kt │ ├── CrmContactEntity.kt │ ├── CrmContactFts.kt │ ├── DuplicateExclusionEntity.kt │ ├── CrmContactDao.kt │ ├── CrmDatabase.kt │ ├── ContactImageStore.kt │ └── ContactRepository.kt ├── crm/ │ ├── ContactSort.kt │ ├── ContactNormalizer.kt │ ├── AlphabetIndex.kt │ ├── DuplicateDetector.kt │ └── ContactMerge.kt └── ui/ ├── nav/Card2vcfNavHost.kt ├── carnet/{CarnetScreen,AlphabetRail,CarnetViewModel}.kt ├── contact/{ContactPagerScreen,ContactEditScreen,DuplicateReviewScreen,*ViewModel}.kt ├── import/{VcfImportScreen,VcfImportViewModel}.kt ├── ContactDraftScreen.kt # MOD └── ScanCarteViewModel.kt # MOD editingContactId android/app/src/test/java/fr/ebii/card2vcf/ ├── data/ContactRepositoryTest.kt ├── crm/{ContactNormalizer,DuplicateDetector,ContactMerge,AlphabetIndex}Test.kt └── contact/VCardParserTest.kt (+ resources/vcf/*.vcf)
Task 0: Gradle Room + KSP + Navigation + Application
Files:
-
Modify:
android/build.gradle.kts -
Modify:
android/app/build.gradle.kts -
Create:
android/app/src/main/java/fr/ebii/card2vcf/Card2vcfApp.kt -
Modify:
android/app/src/main/AndroidManifest.xml -
Step 1: Ajouter le plugin KSP racine
Dans android/build.gradle.kts :
plugins { id("com.android.application") version "8.6.0" apply false id("org.jetbrains.kotlin.android") version "2.0.20" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.0.20" apply false id("com.google.devtools.ksp") version "2.0.20-1.0.25" apply false }
- Step 2: Dépendances app
Dans android/app/build.gradle.kts plugins :
plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") id("com.google.devtools.ksp") }
Dans dependencies ajouter :
val room = "2.6.1" implementation("androidx.room:room-runtime:$room") implementation("androidx.room:room-ktx:$room") ksp("androidx.room:room-compiler:$room") implementation("androidx.navigation:navigation-compose:2.8.2") testImplementation("androidx.room:room-testing:$room")
- Step 3: Application + Manifest
package fr.ebii.card2vcf import android.app.Application import fr.ebii.card2vcf.data.CrmDatabase class Card2vcfApp : Application() { val database: CrmDatabase by lazy { CrmDatabase.get(this) } }
Manifest : android:name=".Card2vcfApp" sur <application>.
- Step 4: Vérifier resolve
Run: cd android && ./gradlew :app:dependencies --configuration debugCompileClasspath | head -n 5
Expected: exit 0 (après Task 1 pour classes Room, assembleDebug peut encore fail tant que DB absente — OK après T1).
- Step 5: Commit
git add android/build.gradle.kts android/app/build.gradle.kts \ android/app/src/main/java/fr/ebii/card2vcf/Card2vcfApp.kt \ android/app/src/main/AndroidManifest.xml git commit -m "$(cat <<'EOF' build: Room KSP Navigation et Application Card2vcf EOF )"
Task 1: Modèle Room + Converters (TDD)
Files:
-
Create:
android/app/src/main/java/fr/ebii/card2vcf/data/Converters.kt -
Create:
android/app/src/main/java/fr/ebii/card2vcf/data/CrmContactEntity.kt -
Create:
android/app/src/main/java/fr/ebii/card2vcf/data/CrmContactFts.kt -
Create:
android/app/src/main/java/fr/ebii/card2vcf/data/DuplicateExclusionEntity.kt -
Create:
android/app/src/test/java/fr/ebii/card2vcf/data/ConvertersTest.kt -
Step 1: Test Converters (rouge)
package fr.ebii.card2vcf.data import org.junit.Assert.assertEquals import org.junit.Test class ConvertersTest { private val c = Converters() @Test fun roundTripStringList() { val list = listOf("a@b.c", "x@y.z") assertEquals(list, c.toStringList(c.fromStringList(list))) } @Test fun nullOrEmpty() { assertEquals(emptyList<String>(), c.toStringList(null)) assertEquals("[]", c.fromStringList(emptyList())) } }
- Step 2: Run — expect FAIL
Run: cd android && ./gradlew :app:testDebugUnitTest --tests fr.ebii.card2vcf.data.ConvertersTest
Expected: FAIL compilation / class missing.
- Step 3: Implémenter Converters + entities
package fr.ebii.card2vcf.data import androidx.room.TypeConverter import org.json.JSONArray class Converters { @TypeConverter fun fromStringList(value: List<String>?): String { val arr = JSONArray() (value ?: emptyList()).forEach { arr.put(it) } return arr.toString() } @TypeConverter fun toStringList(value: String?): List<String> { if (value.isNullOrBlank()) return emptyList() val arr = JSONArray(value) return buildList { for (i in 0 until arr.length()) add(arr.getString(i)) } } }
package fr.ebii.card2vcf.data import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "crm_contacts") data class CrmContactEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val fullName: String? = null, val firstName: String? = null, val lastName: String? = null, val company: String? = null, val jobTitle: String? = null, val phones: List<String> = emptyList(), val emails: List<String> = emptyList(), val website: String? = null, val address: String? = null, val notes: String? = null, val cardImagePath: String? = null, val profileImagePath: String? = null, val createdAt: Long = 0L, val updatedAt: Long = 0L, )
package fr.ebii.card2vcf.data import androidx.room.Entity import androidx.room.Fts4 @Fts4(contentEntity = CrmContactEntity::class) @Entity(tableName = "crm_contacts_fts") data class CrmContactFts( val fullName: String?, val firstName: String?, val lastName: String?, val company: String?, val jobTitle: String?, val phones: String?, val emails: String?, val website: String?, val address: String?, val notes: String?, )
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 :
@Fts4(contentEntity = CrmContactEntity::class) @Entity(tableName = "crm_contacts_fts") data class CrmContactFts( val fullName: String?, val firstName: String?, val lastName: String?, val company: String?, val jobTitle: String?, val website: String?, val address: String?, val notes: String?, )
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.
Mettre à jour CrmContactEntity :
val phonesText: String = "", // space-joined, pour FTS val emailsText: String = "",
- Step 4: Exclusion entity
package fr.ebii.card2vcf.data import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @Entity( tableName = "duplicate_exclusions", indices = [Index(value = ["idA", "idB"], unique = true)], ) data class DuplicateExclusionEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val idA: Long, // always idA < idB val idB: Long, )
- Step 5: Run tests verts
Run: cd android && ./gradlew :app:testDebugUnitTest --tests fr.ebii.card2vcf.data.ConvertersTest
Expected: PASS
- Step 6: Commit
git add android/app/src/main/java/fr/ebii/card2vcf/data/ \ android/app/src/test/java/fr/ebii/card2vcf/data/ConvertersTest.kt git commit -m "$(cat <<'EOF' feat(data): entities Room CRM converters et exclusions doublons EOF )"
Task 2: DAO + Database
Files:
-
Create:
android/app/src/main/java/fr/ebii/card2vcf/data/CrmContactDao.kt -
Create:
android/app/src/main/java/fr/ebii/card2vcf/data/CrmDatabase.kt -
Create:
android/app/src/test/java/fr/ebii/card2vcf/data/CrmContactDaoTest.kt -
Step 1: Test DAO insert + FTS notes (rouge)
package fr.ebii.card2vcf.data import android.content.Context import androidx.room.Room import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class CrmContactDaoTest { private lateinit var db: CrmDatabase private lateinit var dao: CrmContactDao @Before fun setUp() { val ctx = ApplicationProvider.getApplicationContext<Context>() db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java) .allowMainThreadQueries() .build() dao = db.crmContactDao() } @After fun tearDown() = db.close() @Test fun insertAndSearchNotes() = runBlocking { val id = dao.insert( CrmContactEntity( fullName = "Ada", notes = "salon Lyon 2026", phonesText = "", emailsText = "", createdAt = 1L, updatedAt = 1L, ), ) val hits = dao.searchIds("Lyon") assertEquals(listOf(id), hits) } }
-
Step 2: Run — expect FAIL
-
Step 3: DAO + Database
package fr.ebii.card2vcf.data import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow @Dao interface CrmContactDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entity: CrmContactEntity): Long @Update suspend fun update(entity: CrmContactEntity) @Query("DELETE FROM crm_contacts WHERE id = :id") suspend fun deleteById(id: Long) @Query("SELECT * FROM crm_contacts WHERE id = :id") suspend fun getById(id: Long): CrmContactEntity? @Query("SELECT * FROM crm_contacts ORDER BY createdAt DESC") fun observeAll(): Flow<List<CrmContactEntity>> @Query("SELECT * FROM crm_contacts ORDER BY createdAt DESC") suspend fun getAll(): List<CrmContactEntity> @Query( """ SELECT rowid FROM crm_contacts_fts WHERE crm_contacts_fts MATCH :query """, ) suspend fun searchIds(query: String): List<Long> @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertExclusion(ex: DuplicateExclusionEntity): Long @Query("SELECT * FROM duplicate_exclusions") suspend fun getExclusions(): List<DuplicateExclusionEntity> @Query("DELETE FROM duplicate_exclusions WHERE idA = :id OR idB = :id") suspend fun deleteExclusionsFor(id: Long) }
package fr.ebii.card2vcf.data import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters @Database( entities = [ CrmContactEntity::class, CrmContactFts::class, DuplicateExclusionEntity::class, ], version = 1, exportSchema = false, ) @TypeConverters(Converters::class) abstract class CrmDatabase : RoomDatabase() { abstract fun crmContactDao(): CrmContactDao companion object { @Volatile private var instance: CrmDatabase? = null fun get(context: Context): CrmDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, CrmDatabase::class.java, "card2vcf-crm.db", ).build().also { instance = it } } } }
FTS MATCH : préfixer la query utilisateur avec tokenisation simple — dans le repository, transformer "Lyon" en "Lyon*" pour préfixe. Documenter dans ContactRepository.search.
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.
-
Step 4: Run — expect PASS
-
Step 5: Commit
git commit -m "$(cat <<'EOF' feat(data): DAO Room FTS et CrmDatabase EOF )"
Task 3: ContactImageStore + ContactRepository
Files:
-
Create:
android/app/src/main/java/fr/ebii/card2vcf/data/ContactImageStore.kt -
Create:
android/app/src/main/java/fr/ebii/card2vcf/data/ContactRepository.kt -
Create:
android/app/src/main/java/fr/ebii/card2vcf/crm/ContactNormalizer.kt(utilisé par repo import) -
Create:
android/app/src/test/java/fr/ebii/card2vcf/data/ContactRepositoryTest.kt -
Create:
android/app/src/test/java/fr/ebii/card2vcf/crm/ContactNormalizerTest.kt -
Step 1: Tests Normalizer (rouge puis vert)
package fr.ebii.card2vcf.crm object ContactNormalizer { fun email(raw: String): String = raw.trim().lowercase() fun phone(raw: String): String = raw.filter { it.isDigit() } }
Tests : " A@B.C " → "a@b.c" ; "+33 6 12-34" → "3361234" (digits only).
- Step 2: ImageStore
package fr.ebii.card2vcf.data import android.content.Context import android.graphics.Bitmap import java.io.File import java.io.FileOutputStream class ContactImageStore(private val context: Context) { private fun dir(id: Long): File = File(context.filesDir, "contacts/$id").also { it.mkdirs() } fun saveJpeg(contactId: Long, name: String, bitmap: Bitmap, quality: Int = 90): String { val file = File(dir(contactId), name) FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.JPEG, quality, it) } return file.absolutePath } fun deleteAll(contactId: Long) { dir(contactId).deleteRecursively() } fun copyImage(fromPath: String?, toContactId: Long, name: String): String? { if (fromPath.isNullOrBlank()) return null val src = File(fromPath) if (!src.isFile) return null val dest = File(dir(toContactId), name) src.copyTo(dest, overwrite = true) return dest.absolutePath } }
- Step 3: Repository API
package fr.ebii.card2vcf.data import android.graphics.Bitmap import fr.ebii.card2vcf.contact.ContactCard import fr.ebii.card2vcf.crm.ContactNormalizer import kotlinx.coroutines.flow.Flow data class VcfImportResult( val imported: Int, val skippedDuplicates: Int, val errors: Int, ) class ContactRepository( private val dao: CrmContactDao, private val images: ContactImageStore, ) { fun observeAll(): Flow<List<CrmContactEntity>> = dao.observeAll() suspend fun getById(id: Long) = dao.getById(id) suspend fun search(query: String): List<CrmContactEntity> { val q = query.trim() if (q.isEmpty()) return dao.getAll() val token = q.split(Regex("\\s+")).joinToString(" ") { "$it*" } val ids = dao.searchIds(token) return ids.mapNotNull { dao.getById(it) } } suspend fun insertFromCard( card: ContactCard, cardBitmap: Bitmap?, profileBitmap: Bitmap?, notesOverride: String? = card.note, now: Long = System.currentTimeMillis(), ): Long { val entity = card.toEntity(notes = notesOverride, now = now) val id = dao.insert(entity) var updated = entity.copy(id = id) if (cardBitmap != null) { updated = updated.copy(cardImagePath = images.saveJpeg(id, "card.jpg", cardBitmap)) } if (profileBitmap != null) { updated = updated.copy(profileImagePath = images.saveJpeg(id, "profile.jpg", profileBitmap)) } if (updated != entity.copy(id = id)) dao.update(updated) return id } suspend fun updateFromCard( id: Long, card: ContactCard, cardBitmap: Bitmap?, profileBitmap: Bitmap?, preserveNotesIfBlankDraft: Boolean = true, now: Long = System.currentTimeMillis(), ) { val existing = dao.getById(id) ?: return val notes = when { preserveNotesIfBlankDraft && card.note.isNullOrBlank() -> existing.notes else -> card.note } var updated = card.toEntity(notes = notes, now = now).copy( id = id, createdAt = existing.createdAt, cardImagePath = existing.cardImagePath, profileImagePath = existing.profileImagePath, ) if (cardBitmap != null) { updated = updated.copy(cardImagePath = images.saveJpeg(id, "card.jpg", cardBitmap)) } if (profileBitmap != null) { updated = updated.copy(profileImagePath = images.saveJpeg(id, "profile.jpg", profileBitmap)) } dao.update(updated) } suspend fun delete(id: Long) { dao.deleteExclusionsFor(id) dao.deleteById(id) images.deleteAll(id) } suspend fun findMatchingId(card: ContactCard, among: List<CrmContactEntity>): Long? { val emails = card.emails.map(ContactNormalizer::email).filter { it.isNotEmpty() }.toSet() val phones = card.phones.map(ContactNormalizer::phone).filter { it.isNotEmpty() }.toSet() for (e in among) { val eEmails = e.emails.map(ContactNormalizer::email).toSet() val ePhones = e.phones.map(ContactNormalizer::phone).toSet() if (emails.any { it in eEmails } || phones.any { it in ePhones }) return e.id } return null } suspend fun importCards(cards: List<ContactCard>): VcfImportResult { var imported = 0 var skipped = 0 var errors = 0 val existing = dao.getAll().toMutableList() for (card in cards) { try { if (!card.hasAnyField()) { errors++ continue } if (findMatchingId(card, existing) != null) { skipped++ continue } val id = insertFromCard(card, null, null) existing.add(dao.getById(id)!!) imported++ } catch (_: Exception) { errors++ } } return VcfImportResult(imported, skipped, errors) } } fun ContactCard.toEntity(notes: String?, now: Long) = CrmContactEntity( fullName = fullName, firstName = firstName, lastName = lastName, company = company, jobTitle = jobTitle, phones = phones, emails = emails, website = website, address = address, notes = notes, phonesText = phones.joinToString(" "), emailsText = emails.joinToString(" "), createdAt = now, updatedAt = now, ) fun CrmContactEntity.toCard() = ContactCard( fullName = fullName, firstName = firstName, lastName = lastName, company = company, jobTitle = jobTitle, phones = phones, emails = emails, website = website, address = address, note = notes, )
-
Step 4: Tests repository — insert + search notes ; import skip duplicate email ; delete removes row.
-
Step 5: Commit
git commit -m "$(cat <<'EOF' feat(data): ContactRepository ImageStore et import anti-doublons EOF )"
Task 4: DuplicateDetector + ContactMerge + AlphabetIndex
Files:
-
Create:
android/app/src/main/java/fr/ebii/card2vcf/crm/DuplicateDetector.kt -
Create:
android/app/src/main/java/fr/ebii/card2vcf/crm/ContactMerge.kt -
Create:
android/app/src/main/java/fr/ebii/card2vcf/crm/ContactSort.kt -
Create:
android/app/src/main/java/fr/ebii/card2vcf/crm/AlphabetIndex.kt -
Tests miroirs sous
android/app/src/test/java/fr/ebii/card2vcf/crm/ -
Step 1: Tests Detector
Cas : A et B même email → même cluster ; exclusion (minId,maxId) → clusters séparés ; téléphone normalisé match.
package fr.ebii.card2vcf.crm import fr.ebii.card2vcf.data.CrmContactEntity import fr.ebii.card2vcf.data.DuplicateExclusionEntity object DuplicateDetector { fun clusters( contacts: List<CrmContactEntity>, exclusions: List<DuplicateExclusionEntity>, ): Map<Long, Set<Long>> { val parent = contacts.associate { it.id to it.id }.toMutableMap() fun find(x: Long): Long { var p = x while (parent[p] != p) p = parent[p]!! return p } fun union(a: Long, b: Long) { val ra = find(a) val rb = find(b) if (ra != rb) parent[ra] = rb } val excluded = exclusions.map { it.idA to it.idB }.toSet() fun excludedPair(a: Long, b: Long): Boolean { val lo = minOf(a, b) val hi = maxOf(a, b) return (lo to hi) in excluded } val byEmail = mutableMapOf<String, MutableList<Long>>() val byPhone = mutableMapOf<String, MutableList<Long>>() for (c in contacts) { c.emails.map(ContactNormalizer::email).filter { it.isNotEmpty() } .forEach { byEmail.getOrPut(it) { mutableListOf() }.add(c.id) } c.phones.map(ContactNormalizer::phone).filter { it.isNotEmpty() } .forEach { byPhone.getOrPut(it) { mutableListOf() }.add(c.id) } } fun linkGroups(groups: Collection<List<Long>>) { for (g in groups) { for (i in 1 until g.size) { if (!excludedPair(g[0], g[i])) union(g[0], g[i]) } } } linkGroups(byEmail.values) linkGroups(byPhone.values) val rootToMembers = mutableMapOf<Long, MutableSet<Long>>() for (c in contacts) { rootToMembers.getOrPut(find(c.id)) { mutableSetOf() }.add(c.id) } val idToCluster = mutableMapOf<Long, Set<Long>>() for (members in rootToMembers.values) { for (id in members) idToCluster[id] = members } return idToCluster } fun duplicateCount(contactId: Long, clusters: Map<Long, Set<Long>>): Int = ((clusters[contactId]?.size ?: 1) - 1).coerceAtLeast(0) }
- Step 2: ContactMerge
package fr.ebii.card2vcf.crm import fr.ebii.card2vcf.data.CrmContactEntity data class MergeFieldChoices( val fullNameFromId: Long, val firstNameFromId: Long, val lastNameFromId: Long, val companyFromId: Long, val jobTitleFromId: Long, val websiteFromId: Long, val addressFromId: Long, val notesMode: NotesMode, // KEEP_PRIMARY | CONCAT val cardImageFromId: Long?, val profileImageFromId: Long?, val keepId: Long, ) enum class NotesMode { KEEP_PRIMARY, CONCAT } object ContactMerge { fun merge( sources: Map<Long, CrmContactEntity>, choices: MergeFieldChoices, now: Long = System.currentTimeMillis(), ): CrmContactEntity { fun scalar(picker: Long, sel: (CrmContactEntity) -> String?) = sources.getValue(picker).let(sel) val keep = sources.getValue(choices.keepId) val phones = sources.values.flatMap { it.phones }.distinct() val emails = sources.values.flatMap { it.emails }.distinct() val notes = when (choices.notesMode) { NotesMode.KEEP_PRIMARY -> keep.notes NotesMode.CONCAT -> sources.values.mapNotNull { it.notes?.takeIf { n -> n.isNotBlank() } } .distinct() .joinToString("\n---\n") .ifBlank { null } } return keep.copy( fullName = scalar(choices.fullNameFromId) { it.fullName }, firstName = scalar(choices.firstNameFromId) { it.firstName }, lastName = scalar(choices.lastNameFromId) { it.lastName }, company = scalar(choices.companyFromId) { it.company }, jobTitle = scalar(choices.jobTitleFromId) { it.jobTitle }, website = scalar(choices.websiteFromId) { it.website }, address = scalar(choices.addressFromId) { it.address }, notes = notes, phones = phones, emails = emails, phonesText = phones.joinToString(" "), emailsText = emails.joinToString(" "), updatedAt = now, // image paths applied by repository after copy ) } }
Repository méthodes à ajouter dans la même task ou T10 :
suspend fun markDifferent(a: Long, b: Long) { val lo = minOf(a, b); val hi = maxOf(a, b) dao.insertExclusion(DuplicateExclusionEntity(idA = lo, idB = hi)) } suspend fun mergeContacts( sources: List<CrmContactEntity>, choices: MergeFieldChoices, ) { val map = sources.associateBy { it.id } var merged = ContactMerge.merge(map, choices) // copy chosen images onto keepId, then delete others ... }
- Step 3: ContactSort + AlphabetIndex
enum class ContactSort { LAST_NAME, FIRST_NAME, COMPANY, CREATED_AT } fun sortKey(c: CrmContactEntity, sort: ContactSort): String = when (sort) { ContactSort.LAST_NAME -> c.lastName ?: c.fullName ?: "" ContactSort.FIRST_NAME -> c.firstName ?: c.fullName ?: "" ContactSort.COMPANY -> c.company ?: "" ContactSort.CREATED_AT -> c.createdAt.toString().padStart(20, '0') } fun sectionLetter(key: String): Char { val c = key.trim().firstOrNull()?.uppercaseChar() return if (c != null && c in 'A'..'Z') c else '#' }
Grouper : LinkedHashMap<Char, List<CrmContactEntity>> après sort alphabétique (sauf CREATED_AT = liste plate chrono desc, pas de sections).
- Step 4: Tests verts + commit
git commit -m "$(cat <<'EOF' feat(crm): détection doublons fusion tri et abcédaire EOF )"
Task 5: VCardParser (lire 3.0 + 4.0)
Files:
-
Create:
android/app/src/main/java/fr/ebii/card2vcf/contact/VCardParser.kt -
Create:
android/app/src/test/resources/vcf/sample3.vcf -
Create:
android/app/src/test/resources/vcf/sample4.vcf -
Create:
android/app/src/test/java/fr/ebii/card2vcf/contact/VCardParserTest.kt -
Step 1: Fixtures
sample3.vcf :
BEGIN:VCARD VERSION:3.0 FN:Alice Martin N:Martin;Alice;;; ORG:Acme EMAIL;TYPE=INTERNET:alice@acme.test TEL;TYPE=CELL:+33601020304 NOTE:salon Lyon END:VCARD BEGIN:VCARD VERSION:3.0 FN:Bob EMAIL:bob@unique.test END:VCARD
sample4.vcf :
BEGIN:VCARD VERSION:4.0 FN:Alice V4 N:V4;Alice;;; EMAIL;TYPE=work:alice@acme.test TEL;TYPE=cell:+33601020304 END:VCARD
-
Step 2: Tests
-
parse(sample3)→ 2 cards, FN/ORG/EMAIL/NOTE -
parse(sample4)→ 1 card, email/tel -
Unfold : ligne pliée spatiale
en début de ligne suivante -
Ignorer
PHOTO -
Export roundtrip :
VCardSerializer.toVCard3(parserResult)contientVERSION:3.0 -
Step 3: Implémentation minimale
object VCardParser { fun parse(text: String): List<ContactCard> { val unfolded = unfold(text) val cards = mutableListOf<ContactCard>() var buf = mutableListOf<String>() for (line in unfolded.lineSequence()) { val t = line.trimEnd() when { t.equals("BEGIN:VCARD", true) -> buf = mutableListOf() t.equals("END:VCARD", true) -> cards.add(parseOne(buf)) else -> buf.add(t) } } return cards } private fun unfold(text: String): String = buildString { for (line in text.replace("\r\n", "\n").replace('\r', '\n').lineSequence()) { if (line.startsWith(" ") || line.startsWith("\t")) { if (isNotEmpty()) setLength(length - 1) // remove previous \n append(line.drop(1)) } else { if (isNotEmpty()) append('\n') append(line) } } } private fun parseOne(lines: List<String>): ContactCard { // split name/params:value ; handle FN N ORG TITLE TEL EMAIL URL ADR NOTE // N: Last;First;... // ADR: ignore empty components, join street-like ... } }
Compléter parseOne avec extraction robuste property = avant : en gérant TEL;TYPE=cell:value et item1.EMAIL:value.
- Step 4: Commit
git commit -m "$(cat <<'EOF' feat(contact): VCardParser lecture vCard 3.0 et 4.0 EOF )"
Task 6: Navigation shell — home = carnet
Files:
- Create:
android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt - Create:
android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetScreen.kt(stub empty) - Create:
android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetViewModel.kt - Modify:
MainActivity.kt
Routes figées :
object Routes { const val Carnet = "carnet" const val Fiche = "fiche/{contactId}?sort={sort}" const val Edit = "edit/{contactId}" const val Duplicates = "duplicates/{contactId}" const val Scan = "scan?editId={editId}" const val Manual = "manual" const val Draft = "draft" // ou état dans backStack scan const val ImportVcf = "import_vcf" }
-
Step 1:
MainActivityobtientrepovia(application as Card2vcfApp).database+ContactImageStore;setContent { Card2vcfNavHost(...) }startDestination =carnet. -
Step 2: Stub
CarnetScreentexte « Carnet » pour smokeassembleDebug. -
Step 3: Commit
git commit -m "$(cat <<'EOF' feat(ui): NavHost Card2vcf home carnet EOF )"
Task 7: Carnet UI — tri, abcédaire, recherche, FAB
Files:
CarnetScreen.kt,AlphabetRail.kt,CarnetViewModel.ktres/values/strings.xml(+values-ensi présent)
Wireframe (rappel) :
CARD2VCF [tri] [recherche................] sections + rows | A-Z | # [+]
-
Step 1: ViewModel —
sort: ContactSort,query: String,contacts: StateFlowdepuisobserveAllousearch(query); exposesections: List<Pair<Char, List<CrmContactEntity>>>;letterOffsets. -
Step 2: AlphabetRail — colonne
A..Z+#; lettres absentes enBodygris (Inksoft /colors.body) ;onLetter(Char)→LazyListState.animateScrollToItem. -
Step 3: FAB menu — trois actions Scanner / Manuel / Import (Dialog ou DropdownMenu carré design.md).
-
Step 4: Masquer rail si
query.isNotBlank(). -
Step 5: assembleDebug + commit
git commit -m "$(cat <<'EOF' feat(ui): carnet tri abcédaire recherche et menu création EOF )"
Task 8: Fiche HorizontalPager + gestes
Files:
-
ui/contact/ContactPagerScreen.kt -
ui/contact/ContactPagerViewModel.kt -
Step 1: Charger liste ordonnée selon
sort(même ordre que carnet) ;HorizontalPagerindex initial = contactId. -
Step 2: Gestes —
detectTapGesturessur zone contenu (pas top bar) :onDoubleTap→Intent(ACTION_SENDTO, Uri.parse("mailto:${email}"))- triple tap : compteur taps avec timeout 400 ms →
Intent(ACTION_DIAL, Uri.parse("tel:$phone")) - si email/tel absent → no-op (pas de crash)
-
Step 3: Afficher profil, identité, champs,
card.jpgsi path, notes lecture. -
Step 4: Chrome — ← retour, Éditer, puce doublons (branche T10 si count>0).
-
Step 5: Commit
git commit -m "$(cat <<'EOF' feat(ui): fiche contact pager swipe double/triple tap EOF )"
Task 9: Édition + rescan
Files:
-
ContactEditScreen.kt+ ViewModel -
Modify:
ScanCarteViewModel.kt—editingContactId: Long? -
Modify: draft flow
-
Step 1: Edit — champs =
ContactDraftFieldsréutilisés ; notes ; bouton photo profil (TakePicture / GetContent) ; Rescanner la carte →navigate("scan?editId=$id"). -
Step 2: Scan en mode update — après OCR, draft prérempli ; notes = notes DB ; bouton primary Mettre à jour appelle
repo.updateFromCard. -
Step 3: Manuel — draft vide →
insertFromCard. -
Step 4: Commit
git commit -m "$(cat <<'EOF' feat(ui): édition contact notes profil et rescan carte EOF )"
Task 10: UI doublons (puce + revue + fusion)
Files:
-
DuplicateReviewScreen.kt+ ViewModel -
Brancher puce dans
ContactPagerScreen -
Step 1: ViewModel calcule
DuplicateDetector.clusters(all, exclusions)[id]; count pour puce. -
Step 2: Revue — liste des autres contacts ; actions :
- Supprimer sélection
- Contacts différents →
repo.markDifferent - Fusionner → sous-écran choix keepId + pickers champ + images
-
Step 3: Après fusion —
repo.mergeContacts; navigate fiche keepId. -
Step 4: Tests unitaires merge déjà verts ; smoke UI assembleDebug.
-
Step 5: Commit
git commit -m "$(cat <<'EOF' feat(ui): puce doublons revue fusion et exclusions EOF )"
Task 11: Draft — Enregistrer local prioritaire
Files:
-
Modify:
ContactDraftScreen.kt,ScanCarteViewModel.kt, strings -
Step 1: Boutons (ordre) :
- Enregistrer / Mettre à jour (primary noir)
- Créer contact Android (outline)
- Exporter VCF (outline)
- Ajouter photo profil (optionnel)
-
Step 2: Après Enregistrer →
popUpTo(carnet)inclusif false + éventuellement ouvrir fiche. -
Step 3: Commit
git commit -m "$(cat <<'EOF' feat(ui): brouillon enregistrement carnet local prioritaire EOF )"
Task 12: Import VCF UI
Files:
-
ui/import/VcfImportScreen.kt,VcfImportViewModel.kt -
Step 1:
ActivityResultContracts.OpenDocument()mimetext/x-vcard+text/vcard+*/*fallback. -
Step 2: Lire URI → string →
VCardParser.parse→repo.importCards→ afficher résumé. -
Step 3: Commit
git commit -m "$(cat <<'EOF' feat(ui): import VCF massif anti-doublons résumé EOF )"
Task 13: Polish, README, suite de tests
Files:
-
README.md -
strings empty state carnet
-
vérifier
file_paths.xmlsi besoin pathsfiles/ -
Step 1: README — section Mini-CRM : carnet offline, recherche, import (lire 3+4 / écrire 3), doublons, rescan, pas de sync.
-
Step 2: Empty state — « Aucun contact — scanner une carte ou importer un VCF ».
-
Step 3: Suite
Run:
cd android && ./gradlew :app:testDebugUnitTest :app:assembleDebug
Expected: BUILD SUCCESSFUL ; tous les tests data/crm/contact verts.
-
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).
-
Step 5: Commit
git commit -m "$(cat <<'EOF' docs: README mini-CRM et polish empty states EOF )"
Mapping scénarios → tasks
| Scénario | Tasks |
|---|---|
| Enregistrer scan → SQLite + card.jpg | T3, T11 |
| Recherche notes | T2, T7 |
| Import anti-doublons | T3, T5, T12 |
| Import 4.0 / export 3.0 | T5, T12 |
| Puce + fusion | T4, T10 |
| Contacts différents | T4, T10 |
| Rescan conserve notes | T3, T9 |
| Abcédiaire entreprise | T4, T7 |
| Swipe + gestes | T8 |
Ordre d’exécution recommandé
T0 → T1 → T2 → T3 → T4 → T5 → T6 → T7 → T8 → T9 → T10 → T11 → T12 → T13
Chaque task se termine par tests unitaires pertinents verts (ou assembleDebug pour UI) + commit atomique.
Self-review
- Spec coverage : toutes décisions produit ont une task.
- Pas de TBD / option A vs B restants.
- Types alignés :
CrmContactEntity,MergeFieldChoices,VcfImportResult,ContactSort. - vCard lire 3+4 / écrire 3 couvert T5 + scénario.
- Doublons import + UI couverts T3/T4/T10/T12.
GitRust