mini_crm_card2vcf_ff17681c.plan.md 40105 octets

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) or superpowers:executing-plans task-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) contient VERSION: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: MainActivity obtient repo via (application as Card2vcfApp).database + ContactImageStore ; setContent { Card2vcfNavHost(...) } startDestination = carnet.

  • Step 2: Stub CarnetScreen texte « Carnet » pour smoke assembleDebug.

  • 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.kt
  • res/values/strings.xml (+ values-en si présent)

Wireframe (rappel) :

CARD2VCF          [tri]
[recherche................]
sections + rows     | A-Z
                    | #
                 [+]
  • Step 1: ViewModelsort: ContactSort, query: String, contacts: StateFlow depuis observeAll ou search(query) ; expose sections: List<Pair<Char, List<CrmContactEntity>>> ; letterOffsets.

  • Step 2: AlphabetRail — colonne A..Z + # ; lettres absentes en Body gris (Ink soft / 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) ; HorizontalPager index initial = contactId.

  • Step 2: GestesdetectTapGestures sur zone contenu (pas top bar) :

    • onDoubleTapIntent(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.jpg si 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.kteditingContactId: Long?

  • Modify: draft flow

  • Step 1: Edit — champs = ContactDraftFields réutilisés ; notes ; bouton photo profil (TakePicture / GetContent) ; Rescanner la cartenavigate("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 fusionrepo.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) :

    1. Enregistrer / Mettre à jour (primary noir)
    2. Créer contact Android (outline)
    3. Exporter VCF (outline)
    4. 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() mime text/x-vcard + text/vcard + */* fallback.

  • Step 2: Lire URI → string → VCardParser.parserepo.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.xml si besoin paths files/

  • 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énarioTasks
Enregistrer scan → SQLite + card.jpgT3, T11
Recherche notesT2, T7
Import anti-doublonsT3, T5, T12
Import 4.0 / export 3.0T5, T12
Puce + fusionT4, T10
Contacts différentsT4, T10
Rescan conserve notesT3, T9
Abcédiaire entrepriseT4, T7
Swipe + gestesT8

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.