serveur sync etapes 1

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

95a41f8c381af0b6e5138b308c08a273de0e8c29

1 parent(s)

58 fichiers modifiés +4481 -7
M README.md
+18 -4
@@ -2,7 +2,7 @@
2 2
3 3 Application Android **hors-ligne** : scan d’une carte de visite → OCR local (Tesseract) → brouillon éditable → **carnet local** (SQLite), contact Android et export `.vcf`.
4 4
5 -Aucune dépendance Google Play Services / ML Kit, aucun LLM, aucune permission Internet, **pas de synchronisation cloud**.
5 +Aucune dépendance Google Play Services / ML Kit, aucun LLM. **OCR et carnet restent 100 % hors-ligne** ; la permission **Internet** sert uniquement à la **sync optionnelle** avec un serveur [Projectiaon](https://github.com/) (voir ci-dessous).
6 6
7 7 [![Licence: AGPL v3](https://img.shields.io/badge/Licence-AGPL%20v3-blue.svg)](LICENSE)
8 8 ![Plateforme](https://img.shields.io/badge/Plateforme-Android%2012%2B-3DDC84?logo=android&logoColor=white)
@@ -20,7 +20,21 @@ L’écran d’accueil est un **carnet offline** stocké en SQLite (Room) sur l
20 20 - Gestion des **doublons** : badge, revue, fusion ou marquage « contacts différents »
21 21 - Création : scan, saisie manuelle ou import
22 22
23 -Aucune sync, aucun compte, aucune donnée hors appareil.
23 +Sans configuration sync : aucun compte, aucune donnée CRM envoyée hors appareil (export VCF manuel possible).
24 +
25 +## Sync Projectiaon (optionnelle)
26 +
27 +Sync **offline-first** avec un serveur [Projectiaon](https://github.com/) (ailiance-brain) : carnet, projets, tâches kanban et comptes-rendus. **L’OCR et le scan ne nécessitent pas Internet.**
28 +
29 +**Prérequis serveur :** variable `AILIANCE_API_CLES` (clés API par utilisateur), endpoints `/api/auth/cle`, `/api/sync/status`, `/api/sync/pull` — voir le [plan serveur](../Projectiaon/docs/plans/sync_api_mobile_v1.md).
30 +
31 +**Configuration dans l’app :** Paramètres (engrenage) → URL HTTPS du serveur → identifiant + mot de passe → « Obtenir la clé » → retaper le mot de passe pour déchiffrer la clé API (stockée chiffrée sur l’appareil ; le mot de passe n’est jamais conservé).
32 +
33 +**Utilisation :** à l’ouverture, un bandeau signale les changements distants sans pull automatique. Le bouton **Sync** pousse les modifications locales (contacts créés/édités dans le carnet, tâches, CR…) puis tire le delta serveur. Sans sync configurée, le carnet reste local uniquement.
34 +
35 +Plan d’implémentation client : [`docs/plans/sync_card2vcf_projectiaon_v1.md`](docs/plans/sync_card2vcf_projectiaon_v1.md).
36 +
37 +> **Upgrade schéma v2 :** la migration Room peut réinitialiser les données locales (documenté dans le plan sync).
24 38
25 39 ## Fonctionnement (scan / OCR)
26 40
@@ -75,7 +89,7 @@ Oui. Au runtime, Tesseract lit `filesDir/tesseract/tessdata/` :
75 89 .card2vcf-tessdata-variant # "fast" | "full" | "external"
76 90 ```
77 91
78 -Pas de téléchargement réseau dans l’app (pas de permission `INTERNET`).
92 +Pas de téléchargement réseau dans l’app pour les modèles OCR (hors sync optionnelle Projectiaon).
79 93
80 94 APK debug filtré `arm64-v8a`. Pour émulateur x86, retirez temporairement `ndk.abiFilters` dans `app/build.gradle.kts`.
81 95
@@ -95,7 +109,7 @@ Prérequis : JDK 21, Android SDK (API 35).
95 109
96 110 - **Pas de LLM** : la qualité du brouillon dépend des heuristiques + du texte OCR. Cartes très décoratives ou manuscrites peuvent échouer.
97 111 - **Caméra obligatoire** (`android.hardware.camera` required) pour le scan ; le carnet et l’import VCF restent utilisables sans.
98 -- **Pas de sync** : le carnet est local uniquement ; perte/désinstallation = perte des données sauf export VCF.
112 +- **Pas de sync obligatoire** : sans configuration Projectiaon, le carnet est local uniquement ; perte/désinstallation = perte des données sauf export VCF ou sync préalable.
99 113 - Structuration sémantique avancée (homonymes, mises en page atypiques) hors périmètre.
100 114
101 115 ## Design
M android/app/build.gradle.kts
+7 -0
@@ -4,6 +4,7 @@ plugins {
4 4 id("com.android.application")
5 5 id("org.jetbrains.kotlin.android")
6 6 id("org.jetbrains.kotlin.plugin.compose")
7 + id("org.jetbrains.kotlin.plugin.serialization")
7 8 id("com.google.devtools.ksp")
8 9 }
9 10
@@ -120,6 +121,12 @@ dependencies {
120 121 testImplementation("androidx.test:core:1.6.1")
121 122 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
122 123
124 + implementation("com.squareup.okhttp3:okhttp:4.12.0")
125 + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
126 + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
127 + implementation("androidx.security:security-crypto:1.1.0-alpha06")
128 + implementation("org.bouncycastle:bcprov-jdk18on:1.85")
129 +
123 130 androidTestImplementation("androidx.test.ext:junit:1.2.1")
124 131 androidTestImplementation("androidx.test:runner:1.6.2")
125 132 androidTestImplementation("androidx.test:rules:1.6.1")
M android/app/src/main/AndroidManifest.xml
+1 -0
@@ -1,6 +1,7 @@
1 1 <?xml version="1.0" encoding="utf-8"?>
2 2 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3 3 <uses-permission android:name="android.permission.CAMERA" />
4 + <uses-permission android:name="android.permission.INTERNET" />
4 5 <uses-feature android:name="android.hardware.camera" android:required="true" />
5 6
6 7 <application
M android/app/src/main/java/fr/ebii/card2vcf/MainActivity.kt
+9 -1
@@ -14,6 +14,7 @@ import fr.ebii.card2vcf.contact.VcfShare
14 14 import fr.ebii.card2vcf.data.ContactImageStore
15 15 import fr.ebii.card2vcf.data.ContactRepository
16 16 import fr.ebii.card2vcf.scan.OpenCvScanEngine
17 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
17 18 import fr.ebii.card2vcf.ui.nav.Card2vcfNavHost
18 19 import fr.ebii.card2vcf.ui.theme.Card2vcfTheme
19 20 import fr.ebii.card2vcf.ui.theme.Canvas
@@ -26,7 +27,13 @@ class MainActivity : ComponentActivity() {
26 27
27 28 val app = application as Card2vcfApp
28 29 val images = ContactImageStore(this)
29 - val repository = ContactRepository(app.database.crmContactDao(), images)
30 + val credentialsStore = SyncCredentialsStore(this)
31 + val repository = ContactRepository(
32 + dao = app.database.crmContactDao(),
33 + images = images,
34 + syncOpDao = app.database.syncOpDao(),
35 + isSyncEnabled = { credentialsStore.isConfigured() },
36 + )
30 37
31 38 setContent {
32 39 Card2vcfTheme {
@@ -38,6 +45,7 @@ class MainActivity : ComponentActivity() {
38 45 ) {
39 46 Card2vcfNavHost(
40 47 repository = repository,
48 + database = app.database,
41 49 onCreateContact = { card ->
42 50 startActivity(ContactInsertIntent.build(card))
43 51 },
M android/app/src/main/java/fr/ebii/card2vcf/data/ContactRepository.kt
+70 -0
@@ -7,6 +7,9 @@ import fr.ebii.card2vcf.crm.ContactMerge
7 7 import fr.ebii.card2vcf.crm.ContactNormalizer
8 8 import fr.ebii.card2vcf.crm.MergeFieldChoices
9 9 import fr.ebii.card2vcf.scan.ImageOrientation
10 +import fr.ebii.card2vcf.sync.ContactSyncMapper
11 +import fr.ebii.card2vcf.sync.SyncOpDao
12 +import fr.ebii.card2vcf.sync.SyncOpEntity
10 13 import java.io.File
11 14 import kotlinx.coroutines.flow.Flow
12 15
@@ -19,6 +22,8 @@ data class VcfImportResult(
19 22 class ContactRepository(
20 23 private val dao: CrmContactDao,
21 24 private val images: ContactImageStore,
25 + private val syncOpDao: SyncOpDao? = null,
26 + private val isSyncEnabled: () -> Boolean = { false },
22 27 ) {
23 28 fun observeAll(): Flow<List<CrmContactEntity>> = dao.observeAll()
24 29
@@ -56,6 +61,7 @@ class ContactRepository(
56 61 updated = updated.copy(profileImagePath = images.saveJpeg(id, "profile.jpg", profileBitmap))
57 62 }
58 63 if (updated != entity.copy(id = id)) dao.update(updated)
64 + dao.getById(id)?.let { enqueueContactCreate(it) }
59 65 return id
60 66 }
61 67
@@ -85,12 +91,15 @@ class ContactRepository(
85 91 updated = updated.copy(profileImagePath = images.saveJpeg(id, "profile.jpg", profileBitmap))
86 92 }
87 93 dao.update(updated)
94 + dao.getById(id)?.let { enqueueContactUpdate(it) }
88 95 }
89 96
90 97 suspend fun delete(id: Long) {
98 + val existing = dao.getById(id)
91 99 dao.deleteExclusionsFor(id)
92 100 dao.deleteById(id)
93 101 images.deleteAll(id)
102 + if (existing != null) enqueueContactDelete(existing)
94 103 }
95 104
96 105 /** Tourne la photo carte de [degrees] (ex. -90 = 90° à gauche) et réécrit card.jpg. */
@@ -173,6 +182,67 @@ class ContactRepository(
173 182 }
174 183 return VcfImportResult(imported, skipped, errors)
175 184 }
185 +
186 + private suspend fun enqueueContactCreate(contact: CrmContactEntity) {
187 + if (!isSyncEnabled() || syncOpDao == null) return
188 + syncOpDao.insert(
189 + SyncOpEntity(
190 + entityType = "contact",
191 + op = "create",
192 + payloadJson = ContactSyncMapper.toCreatePayload(contact),
193 + localId = contact.id,
194 + createdAt = System.currentTimeMillis(),
195 + ),
196 + )
197 + }
198 +
199 + /** Si pas encore synchronisé, patche le create en attente (comme les tâches kanban). */
200 + private suspend fun enqueueContactUpdate(contact: CrmContactEntity) {
201 + if (!isSyncEnabled() || syncOpDao == null) return
202 + val serverId = contact.serverId
203 + if (serverId != null) {
204 + syncOpDao.insert(
205 + SyncOpEntity(
206 + entityType = "contact",
207 + op = "update",
208 + payloadJson = ContactSyncMapper.toUpdatePayload(contact),
209 + serverId = serverId,
210 + createdAt = System.currentTimeMillis(),
211 + ),
212 + )
213 + return
214 + }
215 + val pendingCreate = syncOpDao.listAll()
216 + .find { it.entityType == "contact" && it.op == "create" && it.localId == contact.id }
217 + if (pendingCreate != null) {
218 + syncOpDao.insert(
219 + pendingCreate.copy(payloadJson = ContactSyncMapper.toCreatePayload(contact)),
220 + )
221 + }
222 + }
223 +
224 + private suspend fun enqueueContactDelete(contact: CrmContactEntity) {
225 + if (!isSyncEnabled() || syncOpDao == null) return
226 + val serverId = contact.serverId
227 + if (serverId != null) {
228 + syncOpDao.insert(
229 + SyncOpEntity(
230 + entityType = "contact",
231 + op = "delete",
232 + serverId = serverId,
233 + createdAt = System.currentTimeMillis(),
234 + ),
235 + )
236 + } else {
237 + clearPendingContactOps(contact.id)
238 + }
239 + }
240 +
241 + private suspend fun clearPendingContactOps(localId: Long) {
242 + syncOpDao?.listAll()
243 + ?.filter { it.entityType == "contact" && it.localId == localId }
244 + ?.forEach { syncOpDao.deleteById(it.id) }
245 + }
176 246 }
177 247
178 248 fun ContactCard.toEntity(notes: String?, now: Long) = CrmContactEntity(
M android/app/src/main/java/fr/ebii/card2vcf/data/CrmContactDao.kt
+6 -0
@@ -21,6 +21,12 @@ interface CrmContactDao {
21 21 @Query("SELECT * FROM crm_contacts WHERE id = :id")
22 22 suspend fun getById(id: Long): CrmContactEntity?
23 23
24 + @Query("SELECT * FROM crm_contacts WHERE serverId = :serverId")
25 + suspend fun getByServerId(serverId: String): CrmContactEntity?
26 +
27 + @Query("DELETE FROM crm_contacts WHERE serverId = :serverId")
28 + suspend fun deleteByServerId(serverId: String)
29 +
24 30 @Query("SELECT * FROM crm_contacts ORDER BY createdAt DESC")
25 31 fun observeAll(): Flow<List<CrmContactEntity>>
26 32
M android/app/src/main/java/fr/ebii/card2vcf/data/CrmContactEntity.kt
+5 -0
@@ -22,4 +22,9 @@ data class CrmContactEntity(
22 22 val updatedAt: Long = 0L,
23 23 val phonesText: String = "", // space-joined, pour FTS
24 24 val emailsText: String = "",
25 + val serverId: String? = null,
26 + val statut: String? = null,
27 + val etape: String? = null,
28 + val tags: List<String> = emptyList(),
29 + val entrepriseServerId: String? = null,
25 30 )
M android/app/src/main/java/fr/ebii/card2vcf/data/CrmDatabase.kt
+27 -2
@@ -5,30 +5,55 @@ import androidx.room.Database
5 5 import androidx.room.Room
6 6 import androidx.room.RoomDatabase
7 7 import androidx.room.TypeConverters
8 +import fr.ebii.card2vcf.sync.SyncMetaDao
9 +import fr.ebii.card2vcf.sync.SyncMetaEntity
10 +import fr.ebii.card2vcf.sync.SyncOpDao
11 +import fr.ebii.card2vcf.sync.SyncOpEntity
8 12
9 13 @Database(
10 14 entities = [
11 15 CrmContactEntity::class,
12 16 CrmContactFts::class,
13 17 DuplicateExclusionEntity::class,
18 + EntrepriseEntity::class,
19 + ProjetEntity::class,
20 + TacheEntity::class,
21 + InteractionEntity::class,
22 + WorkflowEntity::class,
23 + SyncOpEntity::class,
24 + SyncMetaEntity::class,
14 25 ],
15 - version = 1,
26 + version = 2,
16 27 exportSchema = false,
17 28 )
18 29 @TypeConverters(Converters::class)
19 30 abstract class CrmDatabase : RoomDatabase() {
20 31 abstract fun crmContactDao(): CrmContactDao
32 + abstract fun entrepriseDao(): EntrepriseDao
33 + abstract fun projetDao(): ProjetDao
34 + abstract fun tacheDao(): TacheDao
35 + abstract fun interactionDao(): InteractionDao
36 + abstract fun workflowDao(): WorkflowDao
37 + abstract fun syncOpDao(): SyncOpDao
38 + abstract fun syncMetaDao(): SyncMetaDao
21 39
22 40 companion object {
23 41 @Volatile private var instance: CrmDatabase? = null
24 42
43 + /**
44 + * v1 sync schema : pas de migration incrémentale — reset local à l'upgrade (acceptable v1).
45 + * Les tests utilisent Room.inMemoryDatabaseBuilder sans fallback destructif.
46 + */
25 47 fun get(context: Context): CrmDatabase =
26 48 instance ?: synchronized(this) {
27 49 instance ?: Room.databaseBuilder(
28 50 context.applicationContext,
29 51 CrmDatabase::class.java,
30 52 "card2vcf-crm.db",
31 - ).build().also { instance = it }
53 + )
54 + .fallbackToDestructiveMigration()
55 + .build()
56 + .also { instance = it }
32 57 }
33 58 }
34 59 }
A android/app/src/main/java/fr/ebii/card2vcf/data/EntrepriseDao.kt
+24 -0
@@ -0,0 +1,24 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +
8 +@Dao
9 +interface EntrepriseDao {
10 + @Insert(onConflict = OnConflictStrategy.REPLACE)
11 + suspend fun upsert(entity: EntrepriseEntity): Long
12 +
13 + @Query("SELECT * FROM entreprises WHERE serverId = :serverId")
14 + suspend fun getByServerId(serverId: String): EntrepriseEntity?
15 +
16 + @Query("SELECT * FROM entreprises WHERE localId = :localId")
17 + suspend fun getByLocalId(localId: Long): EntrepriseEntity?
18 +
19 + @Query("DELETE FROM entreprises WHERE serverId = :serverId")
20 + suspend fun deleteByServerId(serverId: String)
21 +
22 + @Query("SELECT * FROM entreprises")
23 + suspend fun listAll(): List<EntrepriseEntity>
24 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/EntrepriseEntity.kt
+15 -0
@@ -0,0 +1,15 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "entreprises")
7 +data class EntrepriseEntity(
8 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
9 + val serverId: String? = null,
10 + val nom: String = "",
11 + val secteur: String = "",
12 + val creePar: String = "",
13 + val createdAt: Long = 0L,
14 + val updatedAt: Long = 0L,
15 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/InteractionDao.kt
+28 -0
@@ -0,0 +1,28 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +import kotlinx.coroutines.flow.Flow
8 +
9 +@Dao
10 +interface InteractionDao {
11 + @Insert(onConflict = OnConflictStrategy.REPLACE)
12 + suspend fun upsert(entity: InteractionEntity): Long
13 +
14 + @Query("SELECT * FROM interactions WHERE serverId = :serverId")
15 + suspend fun getByServerId(serverId: String): InteractionEntity?
16 +
17 + @Query("DELETE FROM interactions WHERE serverId = :serverId")
18 + suspend fun deleteByServerId(serverId: String)
19 +
20 + @Query("SELECT * FROM interactions WHERE contactServerId = :contactServerId")
21 + suspend fun listByContactServerId(contactServerId: String): List<InteractionEntity>
22 +
23 + @Query("SELECT * FROM interactions WHERE contactServerId = :contactServerId ORDER BY createdAt DESC")
24 + fun observeByContactServerId(contactServerId: String): Flow<List<InteractionEntity>>
25 +
26 + @Query("SELECT * FROM interactions")
27 + suspend fun listAll(): List<InteractionEntity>
28 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/InteractionEntity.kt
+17 -0
@@ -0,0 +1,17 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "interactions")
7 +data class InteractionEntity(
8 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
9 + val serverId: String? = null,
10 + val contactServerId: String = "",
11 + val type: String = "note",
12 + val sujet: String = "",
13 + val description: String = "",
14 + val creePar: String = "",
15 + val createdAt: Long = 0L,
16 + val updatedAt: Long? = null,
17 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/ProjetDao.kt
+28 -0
@@ -0,0 +1,28 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +import kotlinx.coroutines.flow.Flow
8 +
9 +@Dao
10 +interface ProjetDao {
11 + @Insert(onConflict = OnConflictStrategy.REPLACE)
12 + suspend fun upsert(entity: ProjetEntity): Long
13 +
14 + @Query("SELECT * FROM projets WHERE serverId = :serverId")
15 + suspend fun getByServerId(serverId: String): ProjetEntity?
16 +
17 + @Query("SELECT * FROM projets WHERE localId = :localId")
18 + suspend fun getByLocalId(localId: Long): ProjetEntity?
19 +
20 + @Query("DELETE FROM projets WHERE serverId = :serverId")
21 + suspend fun deleteByServerId(serverId: String)
22 +
23 + @Query("SELECT * FROM projets")
24 + suspend fun listAll(): List<ProjetEntity>
25 +
26 + @Query("SELECT * FROM projets ORDER BY nom ASC")
27 + fun observeAll(): Flow<List<ProjetEntity>>
28 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/ProjetEntity.kt
+17 -0
@@ -0,0 +1,17 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "projets")
7 +data class ProjetEntity(
8 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
9 + val serverId: String? = null,
10 + val nom: String = "",
11 + val description: String = "",
12 + val workflowServerId: String = "",
13 + val membresJson: String = "[]",
14 + val creePar: String = "",
15 + val createdAt: Long = 0L,
16 + val updatedAt: Long = 0L,
17 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/TacheDao.kt
+34 -0
@@ -0,0 +1,34 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +import kotlinx.coroutines.flow.Flow
8 +
9 +@Dao
10 +interface TacheDao {
11 + @Insert(onConflict = OnConflictStrategy.REPLACE)
12 + suspend fun upsert(entity: TacheEntity): Long
13 +
14 + @Query("SELECT * FROM taches WHERE serverId = :serverId")
15 + suspend fun getByServerId(serverId: String): TacheEntity?
16 +
17 + @Query("SELECT * FROM taches WHERE localId = :localId")
18 + suspend fun getByLocalId(localId: Long): TacheEntity?
19 +
20 + @Query("DELETE FROM taches WHERE serverId = :serverId")
21 + suspend fun deleteByServerId(serverId: String)
22 +
23 + @Query("DELETE FROM taches WHERE localId = :localId")
24 + suspend fun deleteByLocalId(localId: Long)
25 +
26 + @Query("SELECT * FROM taches WHERE projetServerId = :projetServerId")
27 + suspend fun listByProjetServerId(projetServerId: String): List<TacheEntity>
28 +
29 + @Query("SELECT * FROM taches WHERE projetServerId = :projetServerId ORDER BY ordre ASC")
30 + fun observeByProjetServerId(projetServerId: String): Flow<List<TacheEntity>>
31 +
32 + @Query("SELECT * FROM taches")
33 + suspend fun listAll(): List<TacheEntity>
34 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/TacheEntity.kt
+20 -0
@@ -0,0 +1,20 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "taches")
7 +data class TacheEntity(
8 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
9 + val serverId: String? = null,
10 + val projetLocalId: Long? = null,
11 + val projetServerId: String = "",
12 + val titre: String = "",
13 + val description: String = "",
14 + val colonneId: String = "",
15 + val ordre: Int = 0,
16 + val assigneA: String? = null,
17 + val auteur: String = "",
18 + val createdAt: Long = 0L,
19 + val updatedAt: Long = 0L,
20 +)
A android/app/src/main/java/fr/ebii/card2vcf/data/WorkflowDao.kt
+21 -0
@@ -0,0 +1,21 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +
8 +@Dao
9 +interface WorkflowDao {
10 + @Insert(onConflict = OnConflictStrategy.REPLACE)
11 + suspend fun upsert(entity: WorkflowEntity)
12 +
13 + @Query("SELECT * FROM workflows WHERE serverId = :serverId")
14 + suspend fun getByServerId(serverId: String): WorkflowEntity?
15 +
16 + @Query("DELETE FROM workflows WHERE serverId = :serverId")
17 + suspend fun deleteByServerId(serverId: String)
18 +
19 + @Query("SELECT * FROM workflows")
20 + suspend fun listAll(): List<WorkflowEntity>
21 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/WorkflowEntity.kt
+14 -0
@@ -0,0 +1,14 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "workflows")
7 +data class WorkflowEntity(
8 + @PrimaryKey val serverId: String,
9 + val nom: String = "",
10 + val description: String = "",
11 + val colonnesJson: String = "[]",
12 + val creePar: String = "",
13 + val createdAt: Long = 0L,
14 +)
A android/app/src/main/java/fr/ebii/card2vcf/kanban/KanbanFilter.kt
+13 -0
@@ -0,0 +1,13 @@
1 +package fr.ebii.card2vcf.kanban
2 +
3 +import fr.ebii.card2vcf.data.TacheEntity
4 +
5 +/** Filtre « Mes tâches » (défaut) vs « Toutes » — non assigné visible seulement dans Toutes. */
6 +object KanbanFilter {
7 + enum class Mode { MINE, ALL }
8 +
9 + fun filter(tasks: List<TacheEntity>, mode: Mode, userName: String): List<TacheEntity> = when (mode) {
10 + Mode.ALL -> tasks
11 + Mode.MINE -> tasks.filter { it.assigneA == userName }
12 + }
13 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApi.kt
+38 -0
@@ -0,0 +1,38 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +/** Contrat HTTP utilisé par [SyncEngine] ; [AilianceApiClient] l'implémente, fakes en test. */
4 +interface AilianceApi {
5 + fun authCle(nom: String, motDePasse: String): AilianceApiClient.ApiResult<AuthCleResponse>
6 +
7 + fun syncStatus(sinceIso: String): AilianceApiClient.ApiResult<SyncStatusResponse>
8 +
9 + fun syncPull(sinceIso: String): AilianceApiClient.ApiResult<SyncPullResponse>
10 +
11 + fun createContact(jsonBody: String): AilianceApiClient.ApiResult<String>
12 +
13 + fun updateContact(id: String, jsonBody: String): AilianceApiClient.ApiResult<String>
14 +
15 + fun deleteContact(id: String): AilianceApiClient.ApiResult<Unit>
16 +
17 + fun createEntreprise(jsonBody: String): AilianceApiClient.ApiResult<String>
18 +
19 + fun updateEntreprise(id: String, jsonBody: String): AilianceApiClient.ApiResult<String>
20 +
21 + fun deleteEntreprise(id: String): AilianceApiClient.ApiResult<Unit>
22 +
23 + fun createProjet(jsonBody: String): AilianceApiClient.ApiResult<String>
24 +
25 + fun updateProjet(id: String, jsonBody: String): AilianceApiClient.ApiResult<String>
26 +
27 + fun deleteProjet(id: String): AilianceApiClient.ApiResult<Unit>
28 +
29 + fun createTache(projetId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
30 +
31 + fun updateTache(projetId: String, tacheId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
32 +
33 + fun deleteTache(projetId: String, tacheId: String): AilianceApiClient.ApiResult<Unit>
34 +
35 + fun moveTache(projetId: String, tacheId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
36 +
37 + fun createInteraction(contactId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
38 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApiClient.kt
+170 -0
@@ -0,0 +1,170 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import okhttp3.MediaType.Companion.toMediaType
4 +import okhttp3.OkHttpClient
5 +import okhttp3.Request
6 +import okhttp3.RequestBody.Companion.toRequestBody
7 +import java.io.IOException
8 +
9 +class AilianceApiClient(
10 + baseUrl: String,
11 + private val apiKey: String? = null,
12 + private val client: OkHttpClient = OkHttpClient(),
13 +) : AilianceApi {
14 + private val base = baseUrl.trimEnd('/')
15 + private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
16 +
17 + sealed class ApiResult<out T> {
18 + data class Ok<T>(val value: T) : ApiResult<T>()
19 + data class Err(val code: Int, val message: String) : ApiResult<Nothing>()
20 + }
21 +
22 + override fun authCle(nom: String, motDePasse: String): ApiResult<AuthCleResponse> {
23 + val body = syncJson.encodeToString(AuthCleRequest.serializer(), AuthCleRequest(nom, motDePasse))
24 + return post("/api/auth/cle", body, useBearer = false) { responseBody ->
25 + syncJson.decodeFromString(AuthCleResponse.serializer(), responseBody)
26 + }
27 + }
28 +
29 + override fun syncStatus(sinceIso: String): ApiResult<SyncStatusResponse> =
30 + get("/api/sync/status?since=${encodeQuery(sinceIso)}") { body ->
31 + syncJson.decodeFromString(SyncStatusResponse.serializer(), body)
32 + }
33 +
34 + override fun syncPull(sinceIso: String): ApiResult<SyncPullResponse> =
35 + get("/api/sync/pull?since=${encodeQuery(sinceIso)}") { body ->
36 + syncJson.decodeFromString(SyncPullResponse.serializer(), body)
37 + }
38 +
39 + override fun createContact(jsonBody: String): ApiResult<String> =
40 + post("/api/contacts", jsonBody) { it }
41 +
42 + override fun updateContact(id: String, jsonBody: String): ApiResult<String> =
43 + put("/api/contacts/${encodePath(id)}", jsonBody) { it }
44 +
45 + override fun deleteContact(id: String): ApiResult<Unit> =
46 + delete("/api/contacts/${encodePath(id)}")
47 +
48 + override fun createEntreprise(jsonBody: String): ApiResult<String> =
49 + post("/api/entreprises", jsonBody) { it }
50 +
51 + override fun updateEntreprise(id: String, jsonBody: String): ApiResult<String> =
52 + put("/api/entreprises/${encodePath(id)}", jsonBody) { it }
53 +
54 + override fun deleteEntreprise(id: String): ApiResult<Unit> =
55 + delete("/api/entreprises/${encodePath(id)}")
56 +
57 + override fun createProjet(jsonBody: String): ApiResult<String> =
58 + post("/api/projets", jsonBody) { it }
59 +
60 + override fun updateProjet(id: String, jsonBody: String): ApiResult<String> =
61 + put("/api/projets/${encodePath(id)}", jsonBody) { it }
62 +
63 + override fun deleteProjet(id: String): ApiResult<Unit> =
64 + delete("/api/projets/${encodePath(id)}")
65 +
66 + override fun createTache(projetId: String, jsonBody: String): ApiResult<String> =
67 + post("/api/projets/${encodePath(projetId)}/taches", jsonBody) { it }
68 +
69 + override fun updateTache(projetId: String, tacheId: String, jsonBody: String): ApiResult<String> =
70 + put("/api/projets/${encodePath(projetId)}/taches/${encodePath(tacheId)}", jsonBody) { it }
71 +
72 + override fun deleteTache(projetId: String, tacheId: String): ApiResult<Unit> =
73 + delete("/api/projets/${encodePath(projetId)}/taches/${encodePath(tacheId)}")
74 +
75 + override fun moveTache(projetId: String, tacheId: String, jsonBody: String): ApiResult<String> =
76 + post("/api/projets/${encodePath(projetId)}/taches/${encodePath(tacheId)}/deplacer", jsonBody) { it }
77 +
78 + override fun createInteraction(contactId: String, jsonBody: String): ApiResult<String> =
79 + post("/api/contacts/${encodePath(contactId)}/interactions", jsonBody) { it }
80 +
81 + private inline fun <T> get(path: String, crossinline parse: (String) -> T): ApiResult<T> =
82 + execute(buildRequest("GET", path, null, useBearer = true), parse)
83 +
84 + private inline fun <T> post(
85 + path: String,
86 + jsonBody: String,
87 + useBearer: Boolean = true,
88 + crossinline parse: (String) -> T,
89 + ): ApiResult<T> =
90 + execute(buildRequest("POST", path, jsonBody, useBearer), parse)
91 +
92 + private inline fun <T> put(
93 + path: String,
94 + jsonBody: String,
95 + crossinline parse: (String) -> T,
96 + ): ApiResult<T> =
97 + execute(buildRequest("PUT", path, jsonBody, useBearer = true), parse)
98 +
99 + private fun delete(path: String): ApiResult<Unit> =
100 + when (val result = execute(buildRequest("DELETE", path, null, useBearer = true)) { it }) {
101 + is ApiResult.Ok -> ApiResult.Ok(Unit)
102 + is ApiResult.Err -> result
103 + }
104 +
105 + private fun buildRequest(
106 + method: String,
107 + path: String,
108 + jsonBody: String?,
109 + useBearer: Boolean,
110 + ): Request {
111 + val builder = Request.Builder().url("$base$path")
112 + if (useBearer && !apiKey.isNullOrBlank()) {
113 + builder.header("Authorization", "Bearer $apiKey")
114 + }
115 + when (method) {
116 + "GET" -> builder.get()
117 + "DELETE" -> builder.delete()
118 + "POST" -> builder.post(jsonBody!!.toRequestBody(jsonMediaType))
119 + "PUT" -> builder.put(jsonBody!!.toRequestBody(jsonMediaType))
120 + else -> error("Unsupported method: $method")
121 + }
122 + return builder.build()
123 + }
124 +
125 + private inline fun <T> execute(
126 + request: Request,
127 + crossinline parse: (String) -> T,
128 + ): ApiResult<T> {
129 + return try {
130 + client.newCall(request).execute().use { response ->
131 + val body = response.body?.string().orEmpty()
132 + if (response.isSuccessful) {
133 + ApiResult.Ok(parse(body))
134 + } else {
135 + ApiResult.Err(response.code, errorMessage(body, response.message))
136 + }
137 + }
138 + } catch (e: IOException) {
139 + ApiResult.Err(-1, e.message ?: "Erreur réseau")
140 + } catch (e: Exception) {
141 + ApiResult.Err(-1, e.message ?: "Erreur inattendue")
142 + }
143 + }
144 +
145 + private fun errorMessage(body: String, fallback: String): String {
146 + if (body.isBlank()) return fallback
147 + return try {
148 + val err = syncJson.decodeFromString(ErrorBody.serializer(), body)
149 + err.message ?: err.error ?: body
150 + } catch (_: Exception) {
151 + body.trim().ifEmpty { fallback }
152 + }
153 + }
154 +
155 + @kotlinx.serialization.Serializable
156 + private data class ErrorBody(
157 + val message: String? = null,
158 + val error: String? = null,
159 + )
160 +
161 + private companion object {
162 + fun encodeQuery(value: String): String =
163 + java.net.URLEncoder.encode(value, Charsets.UTF_8)
164 +
165 + fun encodePath(segment: String): String =
166 + segment.split("/").joinToString("/") { part ->
167 + java.net.URLEncoder.encode(part, Charsets.UTF_8)
168 + }
169 + }
170 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/ApiCleCrypto.kt
+80 -0
@@ -0,0 +1,80 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import org.bouncycastle.crypto.generators.Argon2BytesGenerator
4 +import org.bouncycastle.crypto.modes.XChaCha20Poly1305
5 +import org.bouncycastle.crypto.params.AEADParameters
6 +import org.bouncycastle.crypto.params.Argon2Parameters
7 +import org.bouncycastle.crypto.params.KeyParameter
8 +import java.util.Base64
9 +
10 +class ApiCleCryptoException(message: String, cause: Throwable? = null) : Exception(message, cause)
11 +
12 +object ApiCleCrypto {
13 + private const val NONCE_LEN = 24
14 + private const val TAG_LEN = 16
15 + private const val KEY_LEN = 32
16 + private const val SALT_LEN = 16
17 + private const val ARGON2_M_KB = 19456
18 + private const val ARGON2_T = 2
19 + private const val ARGON2_P = 1
20 + private const val MAC_BITS = 128
21 +
22 + fun decryptApiKey(password: String, saltB64: String, cipherB64: String): String {
23 + val salt = decodeB64(saltB64, "sel")
24 + if (salt.size != SALT_LEN) {
25 + throw ApiCleCryptoException("sel invalide")
26 + }
27 +
28 + val blob = decodeB64(cipherB64, "cle_chiffree")
29 + if (blob.size < NONCE_LEN + TAG_LEN) {
30 + throw ApiCleCryptoException("blob trop court")
31 + }
32 +
33 + val key = deriveKey(password, salt)
34 + val nonce = blob.copyOfRange(0, NONCE_LEN)
35 + val ciphertext = blob.copyOfRange(NONCE_LEN, blob.size)
36 +
37 + val plaintext = try {
38 + decryptXChaCha20Poly1305(key, nonce, ciphertext)
39 + } catch (e: Exception) {
40 + throw ApiCleCryptoException("échec déchiffrement clé API", e)
41 + }
42 +
43 + return plaintext.toString(Charsets.UTF_8)
44 + }
45 +
46 + private fun deriveKey(password: String, salt: ByteArray): ByteArray {
47 + val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
48 + .withVersion(Argon2Parameters.ARGON2_VERSION_13)
49 + .withIterations(ARGON2_T)
50 + .withMemoryAsKB(ARGON2_M_KB)
51 + .withParallelism(ARGON2_P)
52 + .withSalt(salt)
53 + .build()
54 + val generator = Argon2BytesGenerator()
55 + generator.init(params)
56 + val key = ByteArray(KEY_LEN)
57 + generator.generateBytes(password.toByteArray(Charsets.UTF_8), key)
58 + return key
59 + }
60 +
61 + private fun decryptXChaCha20Poly1305(key: ByteArray, nonce24: ByteArray, ciphertext: ByteArray): ByteArray {
62 + require(nonce24.size == NONCE_LEN)
63 + val cipher = XChaCha20Poly1305()
64 + cipher.init(
65 + false,
66 + AEADParameters(KeyParameter(key), MAC_BITS, nonce24, null),
67 + )
68 + val output = ByteArray(cipher.getOutputSize(ciphertext.size))
69 + var len = cipher.processBytes(ciphertext, 0, ciphertext.size, output, 0)
70 + len += cipher.doFinal(output, len)
71 + return output.copyOf(len)
72 + }
73 +
74 + private fun decodeB64(value: String, label: String): ByteArray =
75 + try {
76 + Base64.getDecoder().decode(value)
77 + } catch (e: IllegalArgumentException) {
78 + throw ApiCleCryptoException("$label base64 invalide", e)
79 + }
80 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/ContactSyncMapper.kt
+41 -0
@@ -0,0 +1,41 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +import kotlinx.serialization.encodeToString
5 +
6 +/** Mappe un contact Room local vers le JSON `ContactInput` serveur. */
7 +object ContactSyncMapper {
8 + fun toCreatePayload(entity: CrmContactEntity): String =
9 + syncJson.encodeToString(entity.toRequest())
10 +
11 + fun toUpdatePayload(entity: CrmContactEntity): String = toCreatePayload(entity)
12 +
13 + private fun CrmContactEntity.toRequest(): CreateContactRequest {
14 + val (prenom, nom) = resolveNames()
15 + return CreateContactRequest(
16 + prenom = prenom,
17 + nom = nom,
18 + entrepriseId = entrepriseServerId?.takeIf { it.isNotBlank() },
19 + fonction = jobTitle.orEmpty(),
20 + emails = emails.filter { it.isNotBlank() }.map { ContactValeurDto(valeur = it.trim()) },
21 + telephones = phones.filter { it.isNotBlank() }.map { ContactValeurDto(valeur = it.trim()) },
22 + notes = notes.orEmpty(),
23 + statut = statut?.takeIf { it.isNotBlank() } ?: "prospect",
24 + etape = etape?.takeIf { it.isNotBlank() } ?: "nouveau",
25 + tags = tags,
26 + )
27 + }
28 +
29 + private fun CrmContactEntity.resolveNames(): Pair<String, String> {
30 + val fn = firstName?.trim().orEmpty()
31 + val ln = lastName?.trim().orEmpty()
32 + if (fn.isNotEmpty() || ln.isNotEmpty()) return fn to ln
33 + val full = fullName?.trim().orEmpty()
34 + if (full.isEmpty()) return "" to ""
35 + val parts = full.split(Regex("\\s+"), limit = 2)
36 + return when (parts.size) {
37 + 1 -> "" to parts[0]
38 + else -> parts[0] to parts.drop(1).joinToString(" ")
39 + }
40 + }
41 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/LwwMerger.kt
+24 -0
@@ -0,0 +1,24 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +object LwwMerger {
4 + /** Returns remote if remoteTs >= localTs (remote wins ties), else local. */
5 + fun <T> pickLww(local: T, localTs: Long, remote: T, remoteTs: Long): T =
6 + if (remoteTs >= localTs) remote else local
7 +
8 + data class TaskPlacement(
9 + val colonneId: String,
10 + val ordre: Int,
11 + val updatedAt: Long,
12 + )
13 +
14 + fun pickTaskMove(local: TaskPlacement, remote: TaskPlacement): TaskPlacement =
15 + pickLww(local, local.updatedAt, remote, remote.updatedAt)
16 +
17 + /** Append-only: insert interaction only if serverId is not already known locally. */
18 + fun shouldInsertInteraction(existingServerIds: Set<String>, remoteServerId: String): Boolean =
19 + remoteServerId !in existingServerIds
20 +
21 + /** Caller deletes local rows by serverId; returns remaining ids after tombstone application. */
22 + fun applyTombstoneIds(localServerIds: Set<String>, tombstoneIds: Set<String>): Set<String> =
23 + localServerIds - tombstoneIds
24 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncCredentialsStore.kt
+69 -0
@@ -0,0 +1,69 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.Context
4 +import android.content.SharedPreferences
5 +import androidx.security.crypto.EncryptedSharedPreferences
6 +import androidx.security.crypto.MasterKey
7 +
8 +class SyncCredentialsStore internal constructor(
9 + private val prefs: SharedPreferences,
10 +) {
11 + constructor(context: Context) : this(createEncryptedPrefs(context.applicationContext))
12 +
13 + var baseUrl: String?
14 + get() = prefs.getString(KEY_BASE_URL, null)?.takeIf { it.isNotBlank() }
15 + set(value) {
16 + prefs.edit().apply {
17 + if (value.isNullOrBlank()) remove(KEY_BASE_URL) else putString(KEY_BASE_URL, value)
18 + }.apply()
19 + }
20 +
21 + var apiKey: String?
22 + get() = prefs.getString(KEY_API_KEY, null)?.takeIf { it.isNotBlank() }
23 + set(value) {
24 + prefs.edit().apply {
25 + if (value.isNullOrBlank()) remove(KEY_API_KEY) else putString(KEY_API_KEY, value)
26 + }.apply()
27 + }
28 +
29 + var userName: String?
30 + get() = prefs.getString(KEY_USER_NAME, null)?.takeIf { it.isNotBlank() }
31 + set(value) {
32 + prefs.edit().apply {
33 + if (value.isNullOrBlank()) remove(KEY_USER_NAME) else putString(KEY_USER_NAME, value)
34 + }.apply()
35 + }
36 +
37 + fun save(baseUrl: String, apiKey: String, userName: String) {
38 + prefs.edit()
39 + .putString(KEY_BASE_URL, baseUrl)
40 + .putString(KEY_API_KEY, apiKey)
41 + .putString(KEY_USER_NAME, userName)
42 + .apply()
43 + }
44 +
45 + fun clear() {
46 + prefs.edit().clear().apply()
47 + }
48 +
49 + fun isConfigured(): Boolean =
50 + !baseUrl.isNullOrBlank() && !apiKey.isNullOrBlank() && !userName.isNullOrBlank()
51 +
52 + internal companion object {
53 + const val PREFS_NAME = "card2vcf_sync_creds"
54 + const val KEY_BASE_URL = "base_url"
55 + const val KEY_API_KEY = "api_key"
56 + const val KEY_USER_NAME = "user_name"
57 +
58 + internal fun createEncryptedPrefs(context: Context): SharedPreferences =
59 + EncryptedSharedPreferences.create(
60 + context,
61 + PREFS_NAME,
62 + MasterKey.Builder(context)
63 + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
64 + .build(),
65 + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
66 + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
67 + )
68 + }
69 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncEngine.kt
+376 -0
@@ -0,0 +1,376 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +import fr.ebii.card2vcf.data.CrmDatabase
5 +import fr.ebii.card2vcf.data.EntrepriseEntity
6 +import fr.ebii.card2vcf.data.InteractionEntity
7 +import fr.ebii.card2vcf.data.ProjetEntity
8 +import fr.ebii.card2vcf.data.TacheEntity
9 +import fr.ebii.card2vcf.data.WorkflowEntity
10 +import kotlinx.coroutines.Dispatchers
11 +import kotlinx.coroutines.withContext
12 +import kotlinx.serialization.Serializable
13 +import kotlinx.serialization.encodeToString
14 +
15 +data class SyncStatusResult(
16 + val pendingRemoteChanges: Int,
17 + val response: SyncStatusResponse? = null,
18 + val error: String? = null,
19 +)
20 +
21 +data class SyncResult(
22 + val success: Boolean,
23 + val serverTime: String? = null,
24 + val error: String? = null,
25 +)
26 +
27 +/** Pousse la file [SyncOpEntity] puis tire `/api/sync/pull` ; LWW / append / tombstones. */
28 +class SyncEngine(
29 + private val api: AilianceApi,
30 + private val db: CrmDatabase,
31 +) {
32 + suspend fun checkStatus(): SyncStatusResult = withContext(Dispatchers.IO) {
33 + when (val result = api.syncStatus(watermark())) {
34 + is AilianceApiClient.ApiResult.Ok ->
35 + SyncStatusResult(pendingRemoteChanges = result.value.total, response = result.value)
36 + is AilianceApiClient.ApiResult.Err ->
37 + SyncStatusResult(pendingRemoteChanges = 0, error = result.message)
38 + }
39 + }
40 +
41 + suspend fun syncNow(): SyncResult = withContext(Dispatchers.IO) {
42 + pushOps()
43 + when (val result = api.syncPull(watermark())) {
44 + is AilianceApiClient.ApiResult.Ok -> {
45 + applyPull(result.value)
46 + setWatermark(result.value.serverTime)
47 + SyncResult(success = true, serverTime = result.value.serverTime)
48 + }
49 + is AilianceApiClient.ApiResult.Err -> SyncResult(success = false, error = result.message)
50 + }
51 + }
52 +
53 + // ---- watermark ----
54 +
55 + private suspend fun watermark(): String =
56 + db.syncMetaDao().get(WATERMARK_KEY)?.value ?: DEFAULT_WATERMARK
57 +
58 + private suspend fun setWatermark(serverTime: String) {
59 + db.syncMetaDao().upsert(SyncMetaEntity(key = WATERMARK_KEY, value = serverTime))
60 + }
61 +
62 + // ---- push ----
63 +
64 + private suspend fun pushOps() {
65 + for (op in db.syncOpDao().listAll()) {
66 + val ok = when (op.entityType) {
67 + "contact" -> pushContactOp(op)
68 + "entreprise" -> pushEntrepriseOp(op)
69 + "projet" -> pushProjetOp(op)
70 + "tache" -> pushTacheOp(op)
71 + "interaction" -> pushInteractionOp(op)
72 + else -> true
73 + }
74 + if (ok) db.syncOpDao().deleteById(op.id)
75 + }
76 + }
77 +
78 + private suspend fun pushContactOp(op: SyncOpEntity): Boolean = when (op.op) {
79 + "create" -> {
80 + val result = api.createContact(op.payloadJson)
81 + if (result is AilianceApiClient.ApiResult.Ok) {
82 + val newServerId = parseCreatedId(result.value)
83 + if (newServerId != null && op.localId != null) {
84 + db.crmContactDao().getById(op.localId)?.let {
85 + db.crmContactDao().update(it.copy(serverId = newServerId))
86 + }
87 + }
88 + }
89 + result is AilianceApiClient.ApiResult.Ok
90 + }
91 + "update" -> op.serverId?.let { api.updateContact(it, op.payloadJson).isOk() } ?: false
92 + "delete" -> op.serverId?.let { api.deleteContact(it).isOk() } ?: false
93 + else -> true
94 + }
95 +
96 + private suspend fun pushEntrepriseOp(op: SyncOpEntity): Boolean = when (op.op) {
97 + "create" -> {
98 + val result = api.createEntreprise(op.payloadJson)
99 + if (result is AilianceApiClient.ApiResult.Ok) {
100 + val newServerId = parseCreatedId(result.value)
101 + if (newServerId != null && op.localId != null) {
102 + db.entrepriseDao().getByLocalId(op.localId)?.let {
103 + db.entrepriseDao().upsert(it.copy(serverId = newServerId))
104 + }
105 + }
106 + }
107 + result is AilianceApiClient.ApiResult.Ok
108 + }
109 + "update" -> op.serverId?.let { api.updateEntreprise(it, op.payloadJson).isOk() } ?: false
110 + "delete" -> op.serverId?.let { api.deleteEntreprise(it).isOk() } ?: false
111 + else -> true
112 + }
113 +
114 + private suspend fun pushProjetOp(op: SyncOpEntity): Boolean = when (op.op) {
115 + "create" -> {
116 + val result = api.createProjet(op.payloadJson)
117 + if (result is AilianceApiClient.ApiResult.Ok) {
118 + val newServerId = parseCreatedId(result.value)
119 + if (newServerId != null && op.localId != null) {
120 + db.projetDao().getByLocalId(op.localId)?.let {
121 + db.projetDao().upsert(it.copy(serverId = newServerId))
122 + }
123 + }
124 + }
125 + result is AilianceApiClient.ApiResult.Ok
126 + }
127 + "update" -> op.serverId?.let { api.updateProjet(it, op.payloadJson).isOk() } ?: false
128 + "delete" -> op.serverId?.let { api.deleteProjet(it).isOk() } ?: false
129 + else -> true
130 + }
131 +
132 + /** Résout le `projetServerId` d'une tâche via [op.localId] (create) ou [op.serverId] (update/delete/move). */
133 + private suspend fun resolveTacheProjetId(op: SyncOpEntity): String? {
134 + op.localId?.let { db.tacheDao().getByLocalId(it)?.let { t -> return t.projetServerId } }
135 + op.serverId?.let { db.tacheDao().getByServerId(it)?.let { t -> return t.projetServerId } }
136 + return null
137 + }
138 +
139 + private suspend fun pushTacheOp(op: SyncOpEntity): Boolean {
140 + val projetId = resolveTacheProjetId(op) ?: return false
141 + return when (op.op) {
142 + "create" -> {
143 + val result = api.createTache(projetId, op.payloadJson)
144 + if (result is AilianceApiClient.ApiResult.Ok) {
145 + val newServerId = parseCreatedId(result.value)
146 + if (newServerId != null && op.localId != null) {
147 + db.tacheDao().getByLocalId(op.localId)?.let {
148 + db.tacheDao().upsert(it.copy(serverId = newServerId))
149 + }
150 + }
151 + }
152 + result is AilianceApiClient.ApiResult.Ok
153 + }
154 + "update" -> op.serverId?.let { api.updateTache(projetId, it, op.payloadJson).isOk() } ?: false
155 + "delete" -> op.serverId?.let { api.deleteTache(projetId, it).isOk() } ?: false
156 + "move" -> op.serverId?.let { api.moveTache(projetId, it, op.payloadJson).isOk() } ?: false
157 + else -> true
158 + }
159 + }
160 +
161 + /** `op.serverId` porte le `serverId` du contact cible (les interactions n'ont pas d'update/delete). */
162 + private suspend fun pushInteractionOp(op: SyncOpEntity): Boolean {
163 + if (op.op != "create") return true
164 + val contactServerId = op.serverId ?: return false
165 + return api.createInteraction(contactServerId, op.payloadJson).isOk()
166 + }
167 +
168 + // ---- pull ----
169 +
170 + private suspend fun applyPull(pull: SyncPullResponse) {
171 + pull.contacts.forEach { applyContact(it) }
172 + pull.entreprises.forEach { applyEntreprise(it) }
173 + pull.projets.forEach { applyProjet(it) }
174 + pull.taches.forEach { applyTache(it) }
175 + pull.interactions.forEach { applyInteraction(it) }
176 + pull.workflows.forEach { applyWorkflow(it) }
177 + pull.tombstones.forEach { applyTombstone(it) }
178 + }
179 +
180 + private suspend fun applyContact(dto: ContactDto) {
181 + val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
182 + val local = db.crmContactDao().getByServerId(dto.id)
183 + if (local == null) {
184 + db.crmContactDao().insert(
185 + CrmContactEntity(
186 + serverId = dto.id,
187 + fullName = "${dto.prenom} ${dto.nom}".trim(),
188 + firstName = dto.prenom,
189 + lastName = dto.nom,
190 + jobTitle = dto.fonction,
191 + statut = dto.statut,
192 + etape = dto.etape,
193 + tags = dto.tags,
194 + entrepriseServerId = dto.entrepriseId,
195 + createdAt = parseIsoToEpochMs(dto.creeLe),
196 + updatedAt = remoteTs,
197 + ),
198 + )
199 + return
200 + }
201 + val remoteEntity = local.copy(
202 + fullName = "${dto.prenom} ${dto.nom}".trim(),
203 + firstName = dto.prenom,
204 + lastName = dto.nom,
205 + jobTitle = dto.fonction,
206 + statut = dto.statut,
207 + etape = dto.etape,
208 + tags = dto.tags,
209 + entrepriseServerId = dto.entrepriseId,
210 + updatedAt = remoteTs,
211 + )
212 + val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs)
213 + if (merged !== local) db.crmContactDao().update(merged)
214 + }
215 +
216 + private suspend fun applyEntreprise(dto: EntrepriseDto) {
217 + val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
218 + val local = db.entrepriseDao().getByServerId(dto.id)
219 + if (local == null) {
220 + db.entrepriseDao().upsert(
221 + EntrepriseEntity(
222 + serverId = dto.id,
223 + nom = dto.nom,
224 + secteur = dto.secteur,
225 + creePar = dto.creePar,
226 + createdAt = parseIsoToEpochMs(dto.creeLe),
227 + updatedAt = remoteTs,
228 + ),
229 + )
230 + return
231 + }
232 + val remoteEntity = local.copy(nom = dto.nom, secteur = dto.secteur, creePar = dto.creePar, updatedAt = remoteTs)
233 + val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs)
234 + if (merged !== local) db.entrepriseDao().upsert(merged)
235 + }
236 +
237 + private suspend fun applyProjet(dto: ProjetDto) {
238 + val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
239 + val local = db.projetDao().getByServerId(dto.id)
240 + val membresJson = syncJson.encodeToString(dto.membres)
241 + if (local == null) {
242 + db.projetDao().upsert(
243 + ProjetEntity(
244 + serverId = dto.id,
245 + nom = dto.nom,
246 + description = dto.description,
247 + workflowServerId = dto.workflowId,
248 + membresJson = membresJson,
249 + creePar = dto.creePar,
250 + createdAt = parseIsoToEpochMs(dto.creeLe),
251 + updatedAt = remoteTs,
252 + ),
253 + )
254 + return
255 + }
256 + val remoteEntity = local.copy(
257 + nom = dto.nom,
258 + description = dto.description,
259 + workflowServerId = dto.workflowId,
260 + membresJson = membresJson,
261 + creePar = dto.creePar,
262 + updatedAt = remoteTs,
263 + )
264 + val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs)
265 + if (merged !== local) db.projetDao().upsert(merged)
266 + }
267 +
268 + private suspend fun applyTache(dto: TacheSyncDto) {
269 + val remoteTs = parseIsoToEpochMs(dto.misAJourLe ?: dto.creeLe)
270 + val local = db.tacheDao().getByServerId(dto.id)
271 + val projetLocalId = db.projetDao().getByServerId(dto.projetId)?.localId
272 + if (local == null) {
273 + db.tacheDao().upsert(
274 + TacheEntity(
275 + serverId = dto.id,
276 + projetLocalId = projetLocalId,
277 + projetServerId = dto.projetId,
278 + titre = dto.titre,
279 + description = dto.description,
280 + colonneId = dto.colonneId,
281 + ordre = dto.ordre,
282 + assigneA = dto.assigneA,
283 + auteur = dto.auteur,
284 + createdAt = parseIsoToEpochMs(dto.creeLe),
285 + updatedAt = remoteTs,
286 + ),
287 + )
288 + return
289 + }
290 + val remoteEntity = local.copy(
291 + projetLocalId = projetLocalId ?: local.projetLocalId,
292 + projetServerId = dto.projetId,
293 + titre = dto.titre,
294 + description = dto.description,
295 + colonneId = dto.colonneId,
296 + ordre = dto.ordre,
297 + assigneA = dto.assigneA,
298 + auteur = dto.auteur,
299 + updatedAt = remoteTs,
300 + )
301 + val merged = LwwMerger.pickLww(local, local.updatedAt, remoteEntity, remoteTs)
302 + if (merged !== local) db.tacheDao().upsert(merged)
303 + }
304 +
305 + private suspend fun applyInteraction(dto: InteractionDto) {
306 + val existing = db.interactionDao().listByContactServerId(dto.contactId).mapNotNull { it.serverId }.toSet()
307 + if (!LwwMerger.shouldInsertInteraction(existing, dto.id)) return
308 + db.interactionDao().upsert(
309 + InteractionEntity(
310 + serverId = dto.id,
311 + contactServerId = dto.contactId,
312 + type = dto.typeInteraction,
313 + sujet = dto.sujet,
314 + description = dto.description,
315 + creePar = dto.creePar,
316 + createdAt = parseIsoToEpochMs(dto.creeLe),
317 + updatedAt = dto.misAJourLe?.let { parseIsoToEpochMs(it) },
318 + ),
319 + )
320 + }
321 +
322 + private suspend fun applyWorkflow(dto: WorkflowDto) {
323 + db.workflowDao().upsert(
324 + WorkflowEntity(
325 + serverId = dto.id,
326 + nom = dto.nom,
327 + description = dto.description,
328 + colonnesJson = syncJson.encodeToString(dto.colonnes),
329 + creePar = dto.creePar,
330 + createdAt = parseIsoToEpochMs(dto.creeLe),
331 + ),
332 + )
333 + }
334 +
335 + private suspend fun applyTombstone(dto: TombstoneDto) {
336 + when (dto.entityType) {
337 + "contact" -> db.crmContactDao().deleteByServerId(dto.id)
338 + "entreprise" -> db.entrepriseDao().deleteByServerId(dto.id)
339 + "projet" -> {
340 + db.tacheDao().listByProjetServerId(dto.id).mapNotNull { it.serverId }
341 + .forEach { db.tacheDao().deleteByServerId(it) }
342 + db.projetDao().deleteByServerId(dto.id)
343 + }
344 + "tache" -> db.tacheDao().deleteByServerId(dto.id)
345 + "interaction" -> db.interactionDao().deleteByServerId(dto.id)
346 + }
347 + }
348 +
349 + private companion object {
350 + const val WATERMARK_KEY = "watermark"
351 + const val DEFAULT_WATERMARK = "1970-01-01T00:00:00Z"
352 +
353 + fun <T> AilianceApiClient.ApiResult<T>.isOk(): Boolean = this is AilianceApiClient.ApiResult.Ok
354 +
355 + fun parseIsoToEpochMs(iso: String?): Long =
356 + if (iso == null) {
357 + 0L
358 + } else {
359 + try {
360 + java.time.Instant.parse(iso).toEpochMilli()
361 + } catch (_: Exception) {
362 + 0L
363 + }
364 + }
365 +
366 + @Serializable
367 + data class CreatedIdDto(val id: String? = null)
368 +
369 + fun parseCreatedId(body: String): String? =
370 + try {
371 + syncJson.decodeFromString(CreatedIdDto.serializer(), body).id
372 + } catch (_: Exception) {
373 + null
374 + }
375 + }
376 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncMetaDao.kt
+15 -0
@@ -0,0 +1,15 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +
8 +@Dao
9 +interface SyncMetaDao {
10 + @Insert(onConflict = OnConflictStrategy.REPLACE)
11 + suspend fun upsert(entity: SyncMetaEntity)
12 +
13 + @Query("SELECT * FROM sync_meta WHERE key = :key")
14 + suspend fun get(key: String): SyncMetaEntity?
15 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncMetaEntity.kt
+10 -0
@@ -0,0 +1,10 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "sync_meta")
7 +data class SyncMetaEntity(
8 + @PrimaryKey val key: String,
9 + val value: String,
10 +)
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncModels.kt
+210 -0
@@ -0,0 +1,210 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import kotlinx.serialization.ExperimentalSerializationApi
4 +import kotlinx.serialization.SerialName
5 +import kotlinx.serialization.Serializable
6 +import kotlinx.serialization.json.Json
7 +import kotlinx.serialization.json.JsonNamingStrategy
8 +
9 +/** JSON config partagée : snake_case serveur Projectiaon, champs inconnus ignorés. */
10 +@OptIn(ExperimentalSerializationApi::class)
11 +val syncJson: Json = Json {
12 + ignoreUnknownKeys = true
13 + namingStrategy = JsonNamingStrategy.SnakeCase
14 + encodeDefaults = true
15 +}
16 +
17 +// ---- Auth clé ----
18 +
19 +@Serializable
20 +data class AuthCleRequest(
21 + val nom: String,
22 + val motDePasse: String,
23 +)
24 +
25 +@Serializable
26 +data class AuthCleResponse(
27 + val nom: String,
28 + val cleChiffree: String,
29 + val sel: String,
30 + val kdf: String,
31 + val cipher: String,
32 +)
33 +
34 +// ---- Sync status / pull ----
35 +
36 +@Serializable
37 +data class SyncChanges(
38 + val contacts: Int = 0,
39 + val entreprises: Int = 0,
40 + val projets: Int = 0,
41 + val taches: Int = 0,
42 + val interactions: Int = 0,
43 + val tombstones: Int = 0,
44 +)
45 +
46 +@Serializable
47 +data class SyncStatusResponse(
48 + val serverTime: String,
49 + val changes: SyncChanges,
50 + val total: Int,
51 +)
52 +
53 +@Serializable
54 +data class SyncPullResponse(
55 + val serverTime: String,
56 + val contacts: List<ContactDto> = emptyList(),
57 + val entreprises: List<EntrepriseDto> = emptyList(),
58 + val projets: List<ProjetDto> = emptyList(),
59 + val taches: List<TacheSyncDto> = emptyList(),
60 + val interactions: List<InteractionDto> = emptyList(),
61 + val tombstones: List<TombstoneDto> = emptyList(),
62 + val workflows: List<WorkflowDto> = emptyList(),
63 +)
64 +
65 +@Serializable
66 +data class TombstoneDto(
67 + @SerialName("type") val entityType: String,
68 + val id: String,
69 + val projetId: String? = null,
70 + val supprimeLe: String,
71 +)
72 +
73 +// ---- Entités sync (champs minimaux pour LWW / merge) ----
74 +
75 +@Serializable
76 +data class ContactDto(
77 + val id: String,
78 + val prenom: String = "",
79 + val nom: String = "",
80 + val entrepriseId: String? = null,
81 + val fonction: String = "",
82 + val statut: String = "prospect",
83 + val etape: String = "nouveau",
84 + val tags: List<String> = emptyList(),
85 + val creePar: String = "",
86 + val creeLe: String,
87 + val misAJourLe: String? = null,
88 +)
89 +
90 +@Serializable
91 +data class EntrepriseDto(
92 + val id: String,
93 + val nom: String,
94 + val secteur: String = "",
95 + val creePar: String = "",
96 + val creeLe: String,
97 + val misAJourLe: String? = null,
98 +)
99 +
100 +@Serializable
101 +data class MembreProjetDto(
102 + val utilisateur: String,
103 + val niveau: String = "lecteur",
104 +)
105 +
106 +@Serializable
107 +data class ProjetDto(
108 + val id: String,
109 + val nom: String,
110 + val description: String = "",
111 + val workflowId: String,
112 + val creePar: String = "",
113 + val creeLe: String,
114 + val membres: List<MembreProjetDto> = emptyList(),
115 + val misAJourLe: String? = null,
116 +)
117 +
118 +/** Tâche aplatie : `projet_id` + champs Tache au même niveau (miroir Rust `TacheSync`). */
119 +@Serializable
120 +data class TacheSyncDto(
121 + val projetId: String,
122 + val id: String,
123 + val titre: String,
124 + val description: String = "",
125 + val colonneId: String,
126 + val ordre: Int = 0,
127 + val auteur: String = "",
128 + val creeLe: String,
129 + val assigneA: String? = null,
130 + val misAJourLe: String? = null,
131 +)
132 +
133 +@Serializable
134 +data class InteractionDto(
135 + val id: String,
136 + val contactId: String,
137 + val typeInteraction: String = "note",
138 + val sujet: String = "",
139 + val description: String = "",
140 + val creePar: String = "",
141 + val creeLe: String,
142 + val misAJourLe: String? = null,
143 +)
144 +
145 +@Serializable
146 +data class ColonneDto(
147 + val id: String,
148 + val nom: String,
149 + val ordre: Int = 0,
150 +)
151 +
152 +@Serializable
153 +data class WorkflowDto(
154 + val id: String,
155 + val nom: String,
156 + val description: String = "",
157 + val colonnes: List<ColonneDto> = emptyList(),
158 + val creePar: String = "",
159 + val creeLe: String,
160 +)
161 +
162 +// ---- Requêtes de mutation locales (payload des SyncOp, miroir des `*Input` serveur) ----
163 +
164 +@Serializable
165 +data class CreateTacheRequest(
166 + val titre: String,
167 + val description: String = "",
168 + val colonneId: String = "",
169 + val assigneA: String? = null,
170 +)
171 +
172 +@Serializable
173 +data class UpdateTacheRequest(
174 + val titre: String,
175 + val description: String = "",
176 + val assigneA: String? = null,
177 + @SerialName("assigne_a_pose") val assigneAPose: Boolean = true,
178 +)
179 +
180 +@Serializable
181 +data class MoveTacheRequest(
182 + val colonneId: String,
183 +)
184 +
185 +@Serializable
186 +data class CreateInteractionRequest(
187 + val sujet: String,
188 + val description: String = "",
189 +)
190 +
191 +@Serializable
192 +data class ContactValeurDto(
193 + val valeur: String,
194 + val categorie: String = "pro",
195 +)
196 +
197 +/** Payload create/update contact (miroir serveur `ContactInput`). */
198 +@Serializable
199 +data class CreateContactRequest(
200 + val prenom: String = "",
201 + val nom: String = "",
202 + val entrepriseId: String? = null,
203 + val fonction: String = "",
204 + val emails: List<ContactValeurDto> = emptyList(),
205 + val telephones: List<ContactValeurDto> = emptyList(),
206 + val notes: String = "",
207 + val statut: String = "prospect",
208 + val etape: String = "nouveau",
209 + val tags: List<String> = emptyList(),
210 +)
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncOpDao.kt
+21 -0
@@ -0,0 +1,21 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import androidx.room.Dao
4 +import androidx.room.Insert
5 +import androidx.room.OnConflictStrategy
6 +import androidx.room.Query
7 +
8 +@Dao
9 +interface SyncOpDao {
10 + @Insert(onConflict = OnConflictStrategy.REPLACE)
11 + suspend fun insert(entity: SyncOpEntity): Long
12 +
13 + @Query("SELECT * FROM sync_ops ORDER BY createdAt ASC")
14 + suspend fun listAll(): List<SyncOpEntity>
15 +
16 + @Query("SELECT * FROM sync_ops WHERE id = :id")
17 + suspend fun getById(id: Long): SyncOpEntity?
18 +
19 + @Query("DELETE FROM sync_ops WHERE id = :id")
20 + suspend fun deleteById(id: Long)
21 +}
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncOpEntity.kt
+16 -0
@@ -0,0 +1,16 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "sync_ops")
7 +data class SyncOpEntity(
8 + @PrimaryKey(autoGenerate = true) val id: Long = 0,
9 + val entityType: String,
10 + val op: String,
11 + val payloadJson: String = "{}",
12 + val localId: Long? = null,
13 + val serverId: String? = null,
14 + val createdAt: Long = 0L,
15 + val attempts: Int = 0,
16 +)
M android/app/src/main/java/fr/ebii/card2vcf/ui/carnet/CarnetScreen.kt
+47 -0
@@ -23,12 +23,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
23 23 import androidx.compose.material.icons.Icons
24 24 import androidx.compose.material.icons.filled.Add
25 25 import androidx.compose.material.icons.filled.PhotoCamera
26 +import androidx.compose.material.icons.filled.Settings
27 +import androidx.compose.material.icons.filled.Sync
26 28 import androidx.compose.material3.DropdownMenu
27 29 import androidx.compose.material3.DropdownMenuItem
28 30 import androidx.compose.material3.FloatingActionButton
29 31 import androidx.compose.material3.FloatingActionButtonDefaults
30 32 import androidx.compose.material3.HorizontalDivider
31 33 import androidx.compose.material3.Icon
34 +import androidx.compose.material3.IconButton
32 35 import androidx.compose.material3.MaterialTheme
33 36 import androidx.compose.material3.OutlinedTextField
34 37 import androidx.compose.material3.OutlinedTextFieldDefaults
@@ -37,6 +40,7 @@ import androidx.compose.material3.SmallFloatingActionButton
37 40 import androidx.compose.material3.Text
38 41 import androidx.compose.material3.TextButton
39 42 import androidx.compose.runtime.Composable
43 +import androidx.compose.runtime.LaunchedEffect
40 44 import androidx.compose.runtime.collectAsState
41 45 import androidx.compose.runtime.getValue
42 46 import androidx.compose.runtime.mutableStateOf
@@ -50,6 +54,10 @@ import androidx.compose.ui.unit.dp
50 54 import fr.ebii.card2vcf.R
51 55 import fr.ebii.card2vcf.crm.ContactSort
52 56 import fr.ebii.card2vcf.data.CrmContactEntity
57 +import fr.ebii.card2vcf.ui.nav.MainTab
58 +import fr.ebii.card2vcf.ui.nav.MainTabRow
59 +import fr.ebii.card2vcf.ui.sync.SyncBanner
60 +import fr.ebii.card2vcf.ui.sync.SyncChromeViewModel
53 61 import fr.ebii.card2vcf.ui.theme.Bordure
54 62 import fr.ebii.card2vcf.ui.theme.Fond
55 63 import fr.ebii.card2vcf.ui.theme.Ink
@@ -66,6 +74,9 @@ fun CarnetScreen(
66 74 onScan: () -> Unit,
67 75 onManual: () -> Unit,
68 76 onImportVcf: () -> Unit,
77 + onSettings: () -> Unit,
78 + onOpenProjets: () -> Unit,
79 + syncViewModel: SyncChromeViewModel,
69 80 modifier: Modifier = Modifier,
70 81 ) {
71 82 val sort by viewModel.sort.collectAsState()
@@ -79,6 +90,14 @@ fun CarnetScreen(
79 90 val showRail = query.isBlank() && sort != ContactSort.CREATED_AT
80 91 val presentLetters = remember(sections) { sections.map { it.first }.toSet() }
81 92 val isEmpty = sections.isEmpty() || sections.all { it.second.isEmpty() }
93 + val syncConfigured by syncViewModel.configured.collectAsState()
94 + val pendingRemoteChanges by syncViewModel.pendingRemoteChanges.collectAsState()
95 + val syncing by syncViewModel.syncing.collectAsState()
96 + val syncError by syncViewModel.error.collectAsState()
97 +
98 + LaunchedEffect(Unit) {
99 + syncViewModel.refreshStatus()
100 + }
82 101
83 102 Scaffold(
84 103 modifier = modifier.fillMaxSize().background(Fond),
@@ -160,6 +179,15 @@ fun CarnetScreen(
160 179 color = Ink,
161 180 modifier = Modifier.weight(1f),
162 181 )
182 + if (syncConfigured) {
183 + IconButton(onClick = { syncViewModel.syncNow() }) {
184 + Icon(
185 + Icons.Filled.Sync,
186 + contentDescription = stringResource(R.string.sync_icone),
187 + tint = Ink,
188 + )
189 + }
190 + }
163 191 Box {
164 192 TextButton(onClick = { sortMenuOpen = true }, shape = Square) {
165 193 Text(sortLabel(sort), color = Ink)
@@ -180,8 +208,20 @@ fun CarnetScreen(
180 208 }
181 209 }
182 210 }
211 + IconButton(onClick = onSettings) {
212 + Icon(
213 + Icons.Filled.Settings,
214 + contentDescription = stringResource(R.string.settings_titre),
215 + tint = Ink,
216 + )
217 + }
183 218 }
184 219
220 + MainTabRow(
221 + current = MainTab.CARNET,
222 + onSelect = { tab -> if (tab == MainTab.PROJETS) onOpenProjets() },
223 + )
224 +
185 225 OutlinedTextField(
186 226 value = query,
187 227 onValueChange = viewModel::setQuery,
@@ -200,6 +240,13 @@ fun CarnetScreen(
200 240 ),
201 241 )
202 242
243 + SyncBanner(
244 + pendingChanges = pendingRemoteChanges,
245 + syncing = syncing,
246 + error = syncError,
247 + onSyncClick = { syncViewModel.syncNow() },
248 + )
249 +
203 250 Spacer(Modifier.height(8.dp))
204 251
205 252 if (isEmpty) {
A android/app/src/main/java/fr/ebii/card2vcf/ui/kanban/KanbanBoard.kt
+297 -0
@@ -0,0 +1,297 @@
1 +package fr.ebii.card2vcf.ui.kanban
2 +
3 +import androidx.compose.foundation.BorderStroke
4 +import androidx.compose.foundation.border
5 +import androidx.compose.foundation.clickable
6 +import androidx.compose.foundation.layout.Arrangement
7 +import androidx.compose.foundation.layout.Box
8 +import androidx.compose.foundation.layout.Column
9 +import androidx.compose.foundation.layout.Row
10 +import androidx.compose.foundation.layout.fillMaxWidth
11 +import androidx.compose.foundation.layout.height
12 +import androidx.compose.foundation.layout.padding
13 +import androidx.compose.foundation.layout.width
14 +import androidx.compose.foundation.lazy.LazyRow
15 +import androidx.compose.foundation.lazy.itemsIndexed
16 +import androidx.compose.foundation.rememberScrollState
17 +import androidx.compose.foundation.shape.RoundedCornerShape
18 +import androidx.compose.foundation.verticalScroll
19 +import androidx.compose.material3.AlertDialog
20 +import androidx.compose.material3.DropdownMenu
21 +import androidx.compose.material3.DropdownMenuItem
22 +import androidx.compose.material3.MaterialTheme
23 +import androidx.compose.material3.OutlinedTextField
24 +import androidx.compose.material3.OutlinedTextFieldDefaults
25 +import androidx.compose.material3.Text
26 +import androidx.compose.material3.TextButton
27 +import androidx.compose.runtime.Composable
28 +import androidx.compose.runtime.getValue
29 +import androidx.compose.runtime.mutableStateOf
30 +import androidx.compose.runtime.remember
31 +import androidx.compose.runtime.setValue
32 +import androidx.compose.ui.Modifier
33 +import androidx.compose.ui.res.stringResource
34 +import androidx.compose.ui.unit.dp
35 +import fr.ebii.card2vcf.R
36 +import fr.ebii.card2vcf.data.TacheEntity
37 +import fr.ebii.card2vcf.kanban.KanbanFilter
38 +import fr.ebii.card2vcf.sync.ColonneDto
39 +import fr.ebii.card2vcf.ui.theme.Bordure
40 +import fr.ebii.card2vcf.ui.theme.Fond
41 +import fr.ebii.card2vcf.ui.theme.Ink
42 +import fr.ebii.card2vcf.ui.theme.TexteFaible
43 +
44 +private val Square = RoundedCornerShape(0.dp)
45 +private val ColumnWidth = 220.dp
46 +private val BoardHeight = 360.dp
47 +
48 +@Composable
49 +fun KanbanBoard(
50 + colonnes: List<ColonneDto>,
51 + taches: List<TacheEntity>,
52 + filterMode: KanbanFilter.Mode,
53 + onFilterModeChange: (KanbanFilter.Mode) -> Unit,
54 + assignableUsers: List<String>,
55 + onCreateTache: (titre: String, assigneA: String?) -> Unit,
56 + onEditTache: (TacheEntity, titre: String, assigneA: String?) -> Unit,
57 + onDeleteTache: (TacheEntity) -> Unit,
58 + onMoveTache: (TacheEntity, direction: Int) -> Unit,
59 + modifier: Modifier = Modifier,
60 +) {
61 + var dialogTache by remember { mutableStateOf<TacheEntity?>(null) }
62 + var showCreateDialog by remember { mutableStateOf(false) }
63 +
64 + Column(modifier.fillMaxWidth()) {
65 + Row(
66 + Modifier.fillMaxWidth().padding(vertical = 8.dp),
67 + horizontalArrangement = Arrangement.SpaceBetween,
68 + ) {
69 + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
70 + FilterChipButton(
71 + label = stringResource(R.string.kanban_filtre_mes_taches),
72 + selected = filterMode == KanbanFilter.Mode.MINE,
73 + onClick = { onFilterModeChange(KanbanFilter.Mode.MINE) },
74 + )
75 + FilterChipButton(
76 + label = stringResource(R.string.kanban_filtre_toutes),
77 + selected = filterMode == KanbanFilter.Mode.ALL,
78 + onClick = { onFilterModeChange(KanbanFilter.Mode.ALL) },
79 + )
80 + }
81 + TextButton(
82 + onClick = { showCreateDialog = true },
83 + shape = Square,
84 + enabled = colonnes.isNotEmpty(),
85 + ) {
86 + Text(stringResource(R.string.kanban_nouvelle_tache), color = Ink)
87 + }
88 + }
89 +
90 + if (colonnes.isEmpty()) {
91 + Text(
92 + stringResource(R.string.kanban_sans_workflow),
93 + color = TexteFaible,
94 + style = MaterialTheme.typography.bodyMedium,
95 + modifier = Modifier.padding(vertical = 8.dp),
96 + )
97 + } else {
98 + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
99 + itemsIndexed(colonnes) { index, colonne ->
100 + KanbanColumn(
101 + colonne = colonne,
102 + taches = taches.filter { it.colonneId == colonne.id }.sortedBy { it.ordre },
103 + canMoveLeft = index > 0,
104 + canMoveRight = index < colonnes.lastIndex,
105 + onEdit = { dialogTache = it },
106 + onDelete = onDeleteTache,
107 + onMoveLeft = { onMoveTache(it, -1) },
108 + onMoveRight = { onMoveTache(it, 1) },
109 + )
110 + }
111 + }
112 + }
113 + }
114 +
115 + if (showCreateDialog) {
116 + TacheDialog(
117 + tache = null,
118 + assignableUsers = assignableUsers,
119 + onDismiss = { showCreateDialog = false },
120 + onConfirm = { titre, assigneA -> onCreateTache(titre, assigneA) },
121 + )
122 + }
123 + dialogTache?.let { tache ->
124 + TacheDialog(
125 + tache = tache,
126 + assignableUsers = assignableUsers,
127 + onDismiss = { dialogTache = null },
128 + onConfirm = { titre, assigneA -> onEditTache(tache, titre, assigneA) },
129 + )
130 + }
131 +}
132 +
133 +@Composable
134 +private fun FilterChipButton(label: String, selected: Boolean, onClick: () -> Unit) {
135 + TextButton(
136 + onClick = onClick,
137 + shape = Square,
138 + modifier = Modifier.border(BorderStroke(1.dp, if (selected) Ink else Bordure), Square),
139 + ) {
140 + Text(label, color = if (selected) Ink else TexteFaible)
141 + }
142 +}
143 +
144 +@Composable
145 +private fun KanbanColumn(
146 + colonne: ColonneDto,
147 + taches: List<TacheEntity>,
148 + canMoveLeft: Boolean,
149 + canMoveRight: Boolean,
150 + onEdit: (TacheEntity) -> Unit,
151 + onDelete: (TacheEntity) -> Unit,
152 + onMoveLeft: (TacheEntity) -> Unit,
153 + onMoveRight: (TacheEntity) -> Unit,
154 +) {
155 + Column(Modifier.width(ColumnWidth).height(BoardHeight)) {
156 + Text(
157 + text = colonne.nom.uppercase(),
158 + style = MaterialTheme.typography.labelMedium,
159 + color = TexteFaible,
160 + modifier = Modifier.padding(bottom = 6.dp),
161 + )
162 + Column(Modifier.verticalScroll(rememberScrollState())) {
163 + taches.forEach { tache ->
164 + TacheCard(
165 + tache = tache,
166 + canMoveLeft = canMoveLeft,
167 + canMoveRight = canMoveRight,
168 + onEdit = { onEdit(tache) },
169 + onDelete = { onDelete(tache) },
170 + onMoveLeft = { onMoveLeft(tache) },
171 + onMoveRight = { onMoveRight(tache) },
172 + )
173 + }
174 + }
175 + }
176 +}
177 +
178 +@Composable
179 +private fun TacheCard(
180 + tache: TacheEntity,
181 + canMoveLeft: Boolean,
182 + canMoveRight: Boolean,
183 + onEdit: () -> Unit,
184 + onDelete: () -> Unit,
185 + onMoveLeft: () -> Unit,
186 + onMoveRight: () -> Unit,
187 +) {
188 + Column(
189 + Modifier
190 + .fillMaxWidth()
191 + .padding(vertical = 4.dp)
192 + .border(BorderStroke(1.dp, Bordure), Square)
193 + .clickable(onClick = onEdit)
194 + .padding(10.dp),
195 + ) {
196 + Text(tache.titre.ifBlank { "—" }, style = MaterialTheme.typography.bodyMedium, color = Ink)
197 + Text(
198 + text = tache.assigneA?.let { stringResource(R.string.kanban_assigne_a, it) }
199 + ?: stringResource(R.string.kanban_non_assigne),
200 + style = MaterialTheme.typography.bodySmall,
201 + color = TexteFaible,
202 + )
203 + Row(
204 + Modifier.fillMaxWidth().padding(top = 4.dp),
205 + horizontalArrangement = Arrangement.SpaceBetween,
206 + ) {
207 + Row {
208 + TextButton(onClick = onMoveLeft, enabled = canMoveLeft, shape = Square) { Text("←") }
209 + TextButton(onClick = onMoveRight, enabled = canMoveRight, shape = Square) { Text("→") }
210 + }
211 + TextButton(onClick = onDelete, shape = Square) {
212 + Text(stringResource(R.string.kanban_supprimer), color = Ink)
213 + }
214 + }
215 + }
216 +}
217 +
218 +@Composable
219 +private fun TacheDialog(
220 + tache: TacheEntity?,
221 + assignableUsers: List<String>,
222 + onDismiss: () -> Unit,
223 + onConfirm: (titre: String, assigneA: String?) -> Unit,
224 +) {
225 + var titre by remember(tache) { mutableStateOf(tache?.titre.orEmpty()) }
226 + var assigneA by remember(tache) { mutableStateOf(tache?.assigneA) }
227 + var assigneMenuOpen by remember { mutableStateOf(false) }
228 + val fieldColors = OutlinedTextFieldDefaults.colors(
229 + focusedBorderColor = Ink,
230 + unfocusedBorderColor = Bordure,
231 + focusedTextColor = Ink,
232 + unfocusedTextColor = Ink,
233 + cursorColor = Ink,
234 + )
235 +
236 + AlertDialog(
237 + onDismissRequest = onDismiss,
238 + shape = Square,
239 + containerColor = Fond,
240 + title = {
241 + Text(
242 + if (tache == null) stringResource(R.string.kanban_nouvelle_tache) else stringResource(R.string.kanban_editer_tache),
243 + color = Ink,
244 + )
245 + },
246 + text = {
247 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
248 + OutlinedTextField(
249 + value = titre,
250 + onValueChange = { titre = it },
251 + modifier = Modifier.fillMaxWidth(),
252 + singleLine = true,
253 + shape = Square,
254 + placeholder = { Text(stringResource(R.string.kanban_champ_titre), color = TexteFaible) },
255 + colors = fieldColors,
256 + )
257 + Box {
258 + TextButton(onClick = { assigneMenuOpen = true }, shape = Square) {
259 + Text(assigneA ?: stringResource(R.string.kanban_non_assigne), color = Ink)
260 + }
261 + DropdownMenu(
262 + expanded = assigneMenuOpen,
263 + onDismissRequest = { assigneMenuOpen = false },
264 + shape = Square,
265 + ) {
266 + DropdownMenuItem(
267 + text = { Text(stringResource(R.string.kanban_non_assigne)) },
268 + onClick = { assigneA = null; assigneMenuOpen = false },
269 + )
270 + assignableUsers.distinct().forEach { user ->
271 + DropdownMenuItem(
272 + text = { Text(user) },
273 + onClick = { assigneA = user; assigneMenuOpen = false },
274 + )
275 + }
276 + }
277 + }
278 + }
279 + },
280 + confirmButton = {
281 + TextButton(
282 + onClick = {
283 + onConfirm(titre, assigneA)
284 + onDismiss()
285 + },
286 + shape = Square,
287 + ) {
288 + Text(stringResource(R.string.kanban_valider), color = Ink)
289 + }
290 + },
291 + dismissButton = {
292 + TextButton(onClick = onDismiss, shape = Square) {
293 + Text(stringResource(R.string.dup_annuler), color = Ink)
294 + }
295 + },
296 + )
297 +}
M android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+75 -0
@@ -19,8 +19,12 @@ import androidx.navigation.navArgument
19 19 import fr.ebii.card2vcf.contact.ContactCard
20 20 import fr.ebii.card2vcf.crm.ContactSort
21 21 import fr.ebii.card2vcf.data.ContactRepository
22 +import fr.ebii.card2vcf.data.CrmDatabase
22 23 import fr.ebii.card2vcf.ocr.TesseractOcrEngine
23 24 import fr.ebii.card2vcf.scan.OpenCvScanEngine
25 +import fr.ebii.card2vcf.sync.AilianceApiClient
26 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
27 +import fr.ebii.card2vcf.sync.SyncEngine
24 28 import fr.ebii.card2vcf.ui.ScanCarteScreen
25 29 import fr.ebii.card2vcf.ui.ScanCarteViewModel
26 30 import fr.ebii.card2vcf.ui.carnet.CarnetScreen
@@ -34,6 +38,13 @@ import fr.ebii.card2vcf.ui.contact.DuplicateReviewViewModel
34 38 import fr.ebii.card2vcf.ui.contact.ManualContactScreen
35 39 import fr.ebii.card2vcf.ui.importvcf.VcfImportScreen
36 40 import fr.ebii.card2vcf.ui.importvcf.VcfImportViewModel
41 +import fr.ebii.card2vcf.ui.projets.ProjetDetailScreen
42 +import fr.ebii.card2vcf.ui.projets.ProjetDetailViewModel
43 +import fr.ebii.card2vcf.ui.projets.ProjetsListScreen
44 +import fr.ebii.card2vcf.ui.projets.ProjetsViewModel
45 +import fr.ebii.card2vcf.ui.settings.SettingsScreen
46 +import fr.ebii.card2vcf.ui.settings.SettingsViewModel
47 +import fr.ebii.card2vcf.ui.sync.SyncChromeViewModel
37 48 import kotlinx.coroutines.launch
38 49
39 50 object Routes {
@@ -45,6 +56,9 @@ object Routes {
45 56 const val Manual = "manual"
46 57 const val Draft = "draft"
47 58 const val ImportVcf = "import_vcf"
59 + const val Settings = "settings"
60 + const val Projets = "projets"
61 + const val ProjetDetail = "projet/{serverId}"
48 62
49 63 fun fiche(contactId: Long, sort: String? = null): String =
50 64 if (sort != null) "fiche/$contactId?sort=$sort" else "fiche/$contactId"
@@ -55,11 +69,14 @@ object Routes {
55 69
56 70 fun scan(editId: Long? = null): String =
57 71 if (editId != null) "scan?editId=$editId" else "scan"
72 +
73 + fun projetDetail(serverId: String): String = "projet/$serverId"
58 74 }
59 75
60 76 @Composable
61 77 fun Card2vcfNavHost(
62 78 repository: ContactRepository,
79 + database: CrmDatabase,
63 80 onCreateContact: (ContactCard) -> Unit,
64 81 onExportVcf: (ContactCard) -> Unit,
65 82 modifier: Modifier = Modifier,
@@ -75,6 +92,23 @@ fun Card2vcfNavHost(
75 92 )
76 93 }
77 94 val scope = rememberCoroutineScope()
95 + val credentialsStore = remember { SyncCredentialsStore(context.applicationContext) }
96 + val settingsVm = remember(credentialsStore) { SettingsViewModel(credentialsStore) }
97 + val projetsVm = remember(database) { ProjetsViewModel(database) }
98 + val syncChromeVm = remember(credentialsStore, database) {
99 + SyncChromeViewModel(
100 + credentialsStore = credentialsStore,
101 + engineFactory = {
102 + val baseUrl = credentialsStore.baseUrl
103 + val apiKey = credentialsStore.apiKey
104 + if (baseUrl != null && apiKey != null) {
105 + SyncEngine(AilianceApiClient(baseUrl, apiKey), database)
106 + } else {
107 + null
108 + }
109 + },
110 + )
111 + }
78 112
79 113 NavHost(
80 114 navController = navController,
@@ -90,6 +124,41 @@ fun Card2vcfNavHost(
90 124 onScan = { navController.navigate(Routes.scan()) },
91 125 onManual = { navController.navigate(Routes.Manual) },
92 126 onImportVcf = { navController.navigate(Routes.ImportVcf) },
127 + onSettings = { navController.navigate(Routes.Settings) },
128 + onOpenProjets = {
129 + navController.navigate(Routes.Projets) {
130 + popUpTo(Routes.Carnet) { inclusive = false }
131 + launchSingleTop = true
132 + }
133 + },
134 + syncViewModel = syncChromeVm,
135 + )
136 + }
137 + composable(Routes.Projets) {
138 + ProjetsListScreen(
139 + viewModel = projetsVm,
140 + onOpenCarnet = {
141 + navController.navigate(Routes.Carnet) {
142 + popUpTo(Routes.Carnet) { inclusive = false }
143 + launchSingleTop = true
144 + }
145 + },
146 + onOpenProjet = { serverId -> navController.navigate(Routes.projetDetail(serverId)) },
147 + onSettings = { navController.navigate(Routes.Settings) },
148 + syncViewModel = syncChromeVm,
149 + )
150 + }
151 + composable(
152 + route = Routes.ProjetDetail,
153 + arguments = listOf(navArgument("serverId") { type = NavType.StringType }),
154 + ) { entry ->
155 + val serverId = entry.arguments?.getString("serverId") ?: return@composable
156 + val projetDetailVm = remember(database, serverId) {
157 + ProjetDetailViewModel(database, credentialsStore, serverId)
158 + }
159 + ProjetDetailScreen(
160 + viewModel = projetDetailVm,
161 + onBack = { navController.popBackStack() },
93 162 )
94 163 }
95 164 composable(
@@ -211,6 +280,12 @@ fun Card2vcfNavHost(
211 280 onBack = { navController.popBackStack() },
212 281 )
213 282 }
283 + composable(Routes.Settings) {
284 + SettingsScreen(
285 + viewModel = settingsVm,
286 + onBack = { navController.popBackStack() },
287 + )
288 + }
214 289 }
215 290 }
216 291
A android/app/src/main/java/fr/ebii/card2vcf/ui/nav/MainTabRow.kt
+46 -0
@@ -0,0 +1,46 @@
1 +package fr.ebii.card2vcf.ui.nav
2 +
3 +import androidx.compose.foundation.layout.Arrangement
4 +import androidx.compose.foundation.layout.Row
5 +import androidx.compose.foundation.layout.fillMaxWidth
6 +import androidx.compose.foundation.shape.RoundedCornerShape
7 +import androidx.compose.material3.MaterialTheme
8 +import androidx.compose.material3.Text
9 +import androidx.compose.material3.TextButton
10 +import androidx.compose.runtime.Composable
11 +import androidx.compose.ui.Modifier
12 +import androidx.compose.ui.res.stringResource
13 +import androidx.compose.ui.unit.dp
14 +import fr.ebii.card2vcf.R
15 +import fr.ebii.card2vcf.ui.theme.Ink
16 +import fr.ebii.card2vcf.ui.theme.TexteFaible
17 +
18 +private val Square = RoundedCornerShape(0.dp)
19 +
20 +enum class MainTab { CARNET, PROJETS }
21 +
22 +/** Onglets éditoriaux Carnet | Projets — pas de NavigationBar Material coloré. */
23 +@Composable
24 +fun MainTabRow(
25 + current: MainTab,
26 + onSelect: (MainTab) -> Unit,
27 + modifier: Modifier = Modifier,
28 +) {
29 + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
30 + MainTab.entries.forEach { tab ->
31 + val selected = tab == current
32 + TextButton(onClick = { onSelect(tab) }, shape = Square) {
33 + Text(
34 + text = stringResource(labelFor(tab)),
35 + color = if (selected) Ink else TexteFaible,
36 + style = if (selected) MaterialTheme.typography.labelLarge else MaterialTheme.typography.labelMedium,
37 + )
38 + }
39 + }
40 + }
41 +}
42 +
43 +private fun labelFor(tab: MainTab): Int = when (tab) {
44 + MainTab.CARNET -> R.string.onglet_carnet
45 + MainTab.PROJETS -> R.string.onglet_projets
46 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetDetailScreen.kt
+219 -0
@@ -0,0 +1,219 @@
1 +package fr.ebii.card2vcf.ui.projets
2 +
3 +import androidx.compose.foundation.background
4 +import androidx.compose.foundation.layout.Box
5 +import androidx.compose.foundation.layout.Column
6 +import androidx.compose.foundation.layout.Spacer
7 +import androidx.compose.foundation.layout.fillMaxSize
8 +import androidx.compose.foundation.layout.fillMaxWidth
9 +import androidx.compose.foundation.layout.height
10 +import androidx.compose.foundation.layout.padding
11 +import androidx.compose.foundation.lazy.LazyColumn
12 +import androidx.compose.foundation.shape.RoundedCornerShape
13 +import androidx.compose.material.icons.Icons
14 +import androidx.compose.material.icons.automirrored.outlined.ArrowBack
15 +import androidx.compose.material3.DropdownMenu
16 +import androidx.compose.material3.DropdownMenuItem
17 +import androidx.compose.material3.HorizontalDivider
18 +import androidx.compose.material3.Icon
19 +import androidx.compose.material3.IconButton
20 +import androidx.compose.material3.MaterialTheme
21 +import androidx.compose.material3.OutlinedTextField
22 +import androidx.compose.material3.OutlinedTextFieldDefaults
23 +import androidx.compose.material3.Text
24 +import androidx.compose.material3.TextButton
25 +import androidx.compose.runtime.Composable
26 +import androidx.compose.runtime.collectAsState
27 +import androidx.compose.runtime.getValue
28 +import androidx.compose.runtime.mutableStateOf
29 +import androidx.compose.runtime.remember
30 +import androidx.compose.runtime.setValue
31 +import androidx.compose.ui.Alignment
32 +import androidx.compose.ui.Modifier
33 +import androidx.compose.ui.res.stringResource
34 +import androidx.compose.ui.unit.dp
35 +import fr.ebii.card2vcf.R
36 +import fr.ebii.card2vcf.data.CrmContactEntity
37 +import fr.ebii.card2vcf.data.InteractionEntity
38 +import fr.ebii.card2vcf.ui.kanban.KanbanBoard
39 +import fr.ebii.card2vcf.ui.theme.Bordure
40 +import fr.ebii.card2vcf.ui.theme.Fond
41 +import fr.ebii.card2vcf.ui.theme.Ink
42 +import fr.ebii.card2vcf.ui.theme.Surface
43 +import fr.ebii.card2vcf.ui.theme.TexteFaible
44 +
45 +private val Square = RoundedCornerShape(0.dp)
46 +
47 +@Composable
48 +fun ProjetDetailScreen(
49 + viewModel: ProjetDetailViewModel,
50 + onBack: () -> Unit,
51 + modifier: Modifier = Modifier,
52 +) {
53 + val projet by viewModel.projet.collectAsState()
54 + val membres by viewModel.membres.collectAsState()
55 + val colonnes by viewModel.colonnes.collectAsState()
56 + val visibleTaches by viewModel.visibleTaches.collectAsState()
57 + val filterMode by viewModel.filterMode.collectAsState()
58 + val contacts by viewModel.contactsAvecServerId.collectAsState()
59 + val crContactServerId by viewModel.crContactServerId.collectAsState()
60 + val crSujet by viewModel.crSujet.collectAsState()
61 + val crDescription by viewModel.crDescription.collectAsState()
62 + val crInteractions by viewModel.crInteractions.collectAsState()
63 + val assignableUsers = remember(membres, viewModel.userName) {
64 + (membres.map { it.utilisateur } + viewModel.userName).filter { it.isNotBlank() }
65 + }
66 +
67 + Column(modifier.fillMaxSize().background(Fond)) {
68 + Box(Modifier.fillMaxWidth().background(Surface).padding(horizontal = 8.dp, vertical = 6.dp)) {
69 + IconButton(onClick = onBack) {
70 + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.scan_retour))
71 + }
72 + Text(
73 + projet?.nom.orEmpty(),
74 + style = MaterialTheme.typography.titleMedium,
75 + color = Ink,
76 + modifier = Modifier.align(Alignment.Center),
77 + )
78 + }
79 +
80 + LazyColumn(Modifier.fillMaxSize().padding(horizontal = 18.dp)) {
81 + item {
82 + Spacer(Modifier.height(12.dp))
83 + projet?.description?.takeIf { it.isNotBlank() }?.let { description ->
84 + Text(description, style = MaterialTheme.typography.bodyMedium, color = TexteFaible)
85 + Spacer(Modifier.height(8.dp))
86 + }
87 + if (membres.isNotEmpty()) {
88 + Text(
89 + stringResource(R.string.projet_membres),
90 + style = MaterialTheme.typography.labelMedium,
91 + color = TexteFaible,
92 + )
93 + membres.forEach { membre ->
94 + Text("${membre.utilisateur} — ${membre.niveau}", color = Ink, style = MaterialTheme.typography.bodyMedium)
95 + }
96 + }
97 + HorizontalDivider(color = Bordure, thickness = 1.dp, modifier = Modifier.padding(vertical = 12.dp))
98 +
99 + KanbanBoard(
100 + colonnes = colonnes,
101 + taches = visibleTaches,
102 + filterMode = filterMode,
103 + onFilterModeChange = viewModel::setFilterMode,
104 + assignableUsers = assignableUsers,
105 + onCreateTache = viewModel::createTache,
106 + onEditTache = viewModel::editTache,
107 + onDeleteTache = viewModel::deleteTache,
108 + onMoveTache = viewModel::moveTache,
109 + )
110 +
111 + HorizontalDivider(color = Bordure, thickness = 1.dp, modifier = Modifier.padding(vertical = 12.dp))
112 +
113 + CrSection(
114 + contacts = contacts,
115 + selectedContactServerId = crContactServerId,
116 + onSelectContact = viewModel::selectCrContact,
117 + sujet = crSujet,
118 + onSujetChange = viewModel::setCrSujet,
119 + description = crDescription,
120 + onDescriptionChange = viewModel::setCrDescription,
121 + interactions = crInteractions,
122 + onSubmit = viewModel::submitCr,
123 + )
124 + Spacer(Modifier.height(24.dp))
125 + }
126 + }
127 + }
128 +}
129 +
130 +@Composable
131 +private fun CrSection(
132 + contacts: List<CrmContactEntity>,
133 + selectedContactServerId: String?,
134 + onSelectContact: (String?) -> Unit,
135 + sujet: String,
136 + onSujetChange: (String) -> Unit,
137 + description: String,
138 + onDescriptionChange: (String) -> Unit,
139 + interactions: List<InteractionEntity>,
140 + onSubmit: () -> Unit,
141 +) {
142 + var contactMenuOpen by remember { mutableStateOf(false) }
143 + val selectedContact = contacts.find { it.serverId == selectedContactServerId }
144 + val fieldColors = OutlinedTextFieldDefaults.colors(
145 + focusedBorderColor = Ink,
146 + unfocusedBorderColor = Bordure,
147 + focusedTextColor = Ink,
148 + unfocusedTextColor = Ink,
149 + cursorColor = Ink,
150 + )
151 +
152 + Column {
153 + Text(stringResource(R.string.projet_cr_titre), style = MaterialTheme.typography.labelMedium, color = TexteFaible)
154 + Spacer(Modifier.height(6.dp))
155 +
156 + Box {
157 + TextButton(onClick = { contactMenuOpen = true }, shape = Square) {
158 + Text(
159 + selectedContact?.let { contactDisplayName(it) } ?: stringResource(R.string.projet_cr_choisir_contact),
160 + color = Ink,
161 + )
162 + }
163 + DropdownMenu(expanded = contactMenuOpen, onDismissRequest = { contactMenuOpen = false }, shape = Square) {
164 + contacts.forEach { contact ->
165 + DropdownMenuItem(
166 + text = { Text(contactDisplayName(contact)) },
167 + onClick = {
168 + contactMenuOpen = false
169 + onSelectContact(contact.serverId)
170 + },
171 + )
172 + }
173 + }
174 + }
175 +
176 + if (selectedContactServerId != null) {
177 + Spacer(Modifier.height(8.dp))
178 + OutlinedTextField(
179 + value = sujet,
180 + onValueChange = onSujetChange,
181 + modifier = Modifier.fillMaxWidth(),
182 + singleLine = true,
183 + shape = Square,
184 + placeholder = { Text(stringResource(R.string.projet_cr_sujet_placeholder), color = TexteFaible) },
185 + colors = fieldColors,
186 + )
187 + Spacer(Modifier.height(8.dp))
188 + OutlinedTextField(
189 + value = description,
190 + onValueChange = onDescriptionChange,
191 + modifier = Modifier.fillMaxWidth(),
192 + shape = Square,
193 + placeholder = { Text(stringResource(R.string.projet_cr_description_placeholder), color = TexteFaible) },
194 + colors = fieldColors,
195 + )
196 + Spacer(Modifier.height(8.dp))
197 + TextButton(onClick = onSubmit, shape = Square, enabled = sujet.isNotBlank()) {
198 + Text(stringResource(R.string.projet_cr_ajouter), color = Ink)
199 + }
200 +
201 + Spacer(Modifier.height(8.dp))
202 + interactions.forEach { interaction ->
203 + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
204 + Text(interaction.sujet, style = MaterialTheme.typography.bodyMedium, color = Ink)
205 + if (interaction.description.isNotBlank()) {
206 + Text(interaction.description, style = MaterialTheme.typography.bodySmall, color = TexteFaible)
207 + }
208 + }
209 + HorizontalDivider(color = Bordure, thickness = 1.dp)
210 + }
211 + }
212 + }
213 +}
214 +
215 +private fun contactDisplayName(contact: CrmContactEntity): String {
216 + contact.fullName?.takeIf { it.isNotBlank() }?.let { return it }
217 + val composed = listOfNotNull(contact.firstName, contact.lastName).joinToString(" ").trim()
218 + return composed.ifEmpty { "—" }
219 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetDetailViewModel.kt
+280 -0
@@ -0,0 +1,280 @@
1 +package fr.ebii.card2vcf.ui.projets
2 +
3 +import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.data.CrmContactEntity
6 +import fr.ebii.card2vcf.data.CrmDatabase
7 +import fr.ebii.card2vcf.data.InteractionEntity
8 +import fr.ebii.card2vcf.data.ProjetEntity
9 +import fr.ebii.card2vcf.data.TacheEntity
10 +import fr.ebii.card2vcf.kanban.KanbanFilter
11 +import fr.ebii.card2vcf.sync.ColonneDto
12 +import fr.ebii.card2vcf.sync.CreateInteractionRequest
13 +import fr.ebii.card2vcf.sync.CreateTacheRequest
14 +import fr.ebii.card2vcf.sync.MembreProjetDto
15 +import fr.ebii.card2vcf.sync.MoveTacheRequest
16 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
17 +import fr.ebii.card2vcf.sync.SyncOpDao
18 +import fr.ebii.card2vcf.sync.SyncOpEntity
19 +import fr.ebii.card2vcf.sync.UpdateTacheRequest
20 +import fr.ebii.card2vcf.sync.syncJson
21 +import kotlinx.coroutines.ExperimentalCoroutinesApi
22 +import kotlinx.coroutines.flow.MutableStateFlow
23 +import kotlinx.coroutines.flow.SharingStarted
24 +import kotlinx.coroutines.flow.StateFlow
25 +import kotlinx.coroutines.flow.asStateFlow
26 +import kotlinx.coroutines.flow.combine
27 +import kotlinx.coroutines.flow.distinctUntilChanged
28 +import kotlinx.coroutines.flow.flatMapLatest
29 +import kotlinx.coroutines.flow.flow
30 +import kotlinx.coroutines.flow.map
31 +import kotlinx.coroutines.flow.stateIn
32 +import kotlinx.coroutines.launch
33 +import kotlinx.serialization.decodeFromString
34 +import kotlinx.serialization.encodeToString
35 +
36 +/** Fiche projet : membres, kanban (colonnes du workflow), et CR (interaction) sur un contact. */
37 +@OptIn(ExperimentalCoroutinesApi::class)
38 +class ProjetDetailViewModel(
39 + private val database: CrmDatabase,
40 + credentialsStore: SyncCredentialsStore,
41 + private val projetServerId: String,
42 +) : ViewModel() {
43 +
44 + val userName: String = credentialsStore.userName.orEmpty()
45 +
46 + val projet: StateFlow<ProjetEntity?> = database.projetDao().observeAll()
47 + .map { list -> list.find { it.serverId == projetServerId } }
48 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
49 +
50 + val membres: StateFlow<List<MembreProjetDto>> = projet
51 + .map { it?.let { p -> parseMembres(p.membresJson) } ?: emptyList() }
52 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
53 +
54 + val colonnes: StateFlow<List<ColonneDto>> = projet
55 + .map { it?.workflowServerId.orEmpty() }
56 + .distinctUntilChanged()
57 + .flatMapLatest { workflowServerId ->
58 + flow {
59 + val colonnes = if (workflowServerId.isNotBlank()) {
60 + database.workflowDao().getByServerId(workflowServerId)?.let { parseColonnes(it.colonnesJson) }
61 + } else {
62 + null
63 + }
64 + emit(colonnes.orEmpty().sortedBy { it.ordre })
65 + }
66 + }
67 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
68 +
69 + val taches: StateFlow<List<TacheEntity>> = database.tacheDao().observeByProjetServerId(projetServerId)
70 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
71 +
72 + private val _filterMode = MutableStateFlow(KanbanFilter.Mode.MINE)
73 + val filterMode: StateFlow<KanbanFilter.Mode> = _filterMode.asStateFlow()
74 +
75 + val visibleTaches: StateFlow<List<TacheEntity>> = combine(taches, _filterMode) { list, mode ->
76 + KanbanFilter.filter(list, mode, userName)
77 + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
78 +
79 + val contactsAvecServerId: StateFlow<List<CrmContactEntity>> = database.crmContactDao().observeAll()
80 + .map { contacts -> contacts.filter { it.serverId != null } }
81 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
82 +
83 + private val _crContactServerId = MutableStateFlow<String?>(null)
84 + val crContactServerId: StateFlow<String?> = _crContactServerId.asStateFlow()
85 +
86 + private val _crSujet = MutableStateFlow("")
87 + val crSujet: StateFlow<String> = _crSujet.asStateFlow()
88 +
89 + private val _crDescription = MutableStateFlow("")
90 + val crDescription: StateFlow<String> = _crDescription.asStateFlow()
91 +
92 + val crInteractions: StateFlow<List<InteractionEntity>> = _crContactServerId
93 + .flatMapLatest { contactServerId ->
94 + if (contactServerId == null) flow { emit(emptyList()) } else database.interactionDao().observeByContactServerId(contactServerId)
95 + }
96 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
97 +
98 + fun setFilterMode(mode: KanbanFilter.Mode) {
99 + _filterMode.value = mode
100 + }
101 +
102 + fun selectCrContact(serverId: String?) {
103 + _crContactServerId.value = serverId
104 + }
105 +
106 + fun setCrSujet(value: String) {
107 + _crSujet.value = value
108 + }
109 +
110 + fun setCrDescription(value: String) {
111 + _crDescription.value = value
112 + }
113 +
114 + fun submitCr() {
115 + val contactServerId = _crContactServerId.value ?: return
116 + val sujet = _crSujet.value.trim()
117 + if (sujet.isEmpty()) return
118 + val description = _crDescription.value.trim()
119 + viewModelScope.launch {
120 + val now = System.currentTimeMillis()
121 + database.interactionDao().upsert(
122 + InteractionEntity(
123 + contactServerId = contactServerId,
124 + sujet = sujet,
125 + description = description,
126 + creePar = userName,
127 + createdAt = now,
128 + ),
129 + )
130 + database.syncOpDao().insert(
131 + SyncOpEntity(
132 + entityType = "interaction",
133 + op = "create",
134 + payloadJson = syncJson.encodeToString(CreateInteractionRequest(sujet = sujet, description = description)),
135 + serverId = contactServerId,
136 + createdAt = now,
137 + ),
138 + )
139 + _crSujet.value = ""
140 + _crDescription.value = ""
141 + }
142 + }
143 +
144 + fun createTache(titre: String, assigneA: String?) {
145 + val titreTrim = titre.trim()
146 + if (titreTrim.isEmpty()) return
147 + val colonneId = colonnes.value.firstOrNull()?.id ?: return
148 + viewModelScope.launch {
149 + val now = System.currentTimeMillis()
150 + val localId = database.tacheDao().upsert(
151 + TacheEntity(
152 + projetLocalId = projet.value?.localId,
153 + projetServerId = projetServerId,
154 + titre = titreTrim,
155 + colonneId = colonneId,
156 + assigneA = assigneA,
157 + auteur = userName,
158 + createdAt = now,
159 + updatedAt = now,
160 + ),
161 + )
162 + database.syncOpDao().insert(
163 + SyncOpEntity(
164 + entityType = "tache",
165 + op = "create",
166 + payloadJson = syncJson.encodeToString(
167 + CreateTacheRequest(titre = titreTrim, colonneId = colonneId, assigneA = assigneA),
168 + ),
169 + localId = localId,
170 + createdAt = now,
171 + ),
172 + )
173 + }
174 + }
175 +
176 + fun editTache(tache: TacheEntity, titre: String, assigneA: String?) {
177 + val titreTrim = titre.trim()
178 + if (titreTrim.isEmpty()) return
179 + viewModelScope.launch {
180 + val now = System.currentTimeMillis()
181 + database.tacheDao().upsert(tache.copy(titre = titreTrim, assigneA = assigneA, updatedAt = now))
182 + val request = UpdateTacheRequest(titre = titreTrim, description = tache.description, assigneA = assigneA)
183 + enqueueTacheMutation(tache, "update", syncJson.encodeToString(request)) {
184 + CreateTacheRequest(titre = titreTrim, description = tache.description, colonneId = tache.colonneId, assigneA = assigneA)
185 + }
186 + }
187 + }
188 +
189 + fun deleteTache(tache: TacheEntity) {
190 + viewModelScope.launch {
191 + val serverId = tache.serverId
192 + if (serverId != null) {
193 + database.tacheDao().deleteByServerId(serverId)
194 + database.syncOpDao().insert(
195 + SyncOpEntity(
196 + entityType = "tache",
197 + op = "delete",
198 + serverId = serverId,
199 + createdAt = System.currentTimeMillis(),
200 + ),
201 + )
202 + } else {
203 + database.tacheDao().deleteByLocalId(tache.localId)
204 + clearPendingCreate(database.syncOpDao(), tache.localId)
205 + }
206 + }
207 + }
208 +
209 + fun moveTache(tache: TacheEntity, direction: Int) {
210 + val ordered = colonnes.value
211 + val currentIndex = ordered.indexOfFirst { it.id == tache.colonneId }
212 + val targetIndex = currentIndex + direction
213 + if (currentIndex == -1 || targetIndex !in ordered.indices) return
214 + val targetColonneId = ordered[targetIndex].id
215 + viewModelScope.launch {
216 + val now = System.currentTimeMillis()
217 + database.tacheDao().upsert(tache.copy(colonneId = targetColonneId, updatedAt = now))
218 + val request = MoveTacheRequest(colonneId = targetColonneId)
219 + enqueueTacheMutation(tache, "move", syncJson.encodeToString(request)) {
220 + CreateTacheRequest(
221 + titre = tache.titre,
222 + description = tache.description,
223 + colonneId = targetColonneId,
224 + assigneA = tache.assigneA,
225 + )
226 + }
227 + }
228 + }
229 +
230 + /** Si la tâche n'a pas encore de `serverId` (create pas encore synchronisé), on patche le create en attente. */
231 + private suspend fun enqueueTacheMutation(
232 + tache: TacheEntity,
233 + op: String,
234 + payloadJson: String,
235 + fallbackCreatePayload: () -> CreateTacheRequest,
236 + ) {
237 + val serverId = tache.serverId
238 + if (serverId != null) {
239 + database.syncOpDao().insert(
240 + SyncOpEntity(
241 + entityType = "tache",
242 + op = op,
243 + payloadJson = payloadJson,
244 + serverId = serverId,
245 + createdAt = System.currentTimeMillis(),
246 + ),
247 + )
248 + return
249 + }
250 + val pendingCreate = database.syncOpDao().listAll()
251 + .find { it.entityType == "tache" && it.op == "create" && it.localId == tache.localId }
252 + if (pendingCreate != null) {
253 + database.syncOpDao().insert(
254 + pendingCreate.copy(payloadJson = syncJson.encodeToString(fallbackCreatePayload())),
255 + )
256 + }
257 + }
258 +
259 + private suspend fun clearPendingCreate(syncOpDao: SyncOpDao, localId: Long) {
260 + syncOpDao.listAll()
261 + .filter { it.entityType == "tache" && it.localId == localId }
262 + .forEach { syncOpDao.deleteById(it.id) }
263 + }
264 +
265 + private companion object {
266 + fun parseMembres(json: String): List<MembreProjetDto> =
267 + try {
268 + syncJson.decodeFromString<List<MembreProjetDto>>(json)
269 + } catch (_: Exception) {
270 + emptyList()
271 + }
272 +
273 + fun parseColonnes(json: String): List<ColonneDto> =
274 + try {
275 + syncJson.decodeFromString<List<ColonneDto>>(json)
276 + } catch (_: Exception) {
277 + emptyList()
278 + }
279 + }
280 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetsListScreen.kt
+138 -0
@@ -0,0 +1,138 @@
1 +package fr.ebii.card2vcf.ui.projets
2 +
3 +import androidx.compose.foundation.background
4 +import androidx.compose.foundation.clickable
5 +import androidx.compose.foundation.layout.Box
6 +import androidx.compose.foundation.layout.Column
7 +import androidx.compose.foundation.layout.Row
8 +import androidx.compose.foundation.layout.Spacer
9 +import androidx.compose.foundation.layout.fillMaxSize
10 +import androidx.compose.foundation.layout.fillMaxWidth
11 +import androidx.compose.foundation.layout.height
12 +import androidx.compose.foundation.layout.padding
13 +import androidx.compose.foundation.lazy.LazyColumn
14 +import androidx.compose.foundation.lazy.items
15 +import androidx.compose.material.icons.Icons
16 +import androidx.compose.material.icons.filled.Settings
17 +import androidx.compose.material.icons.filled.Sync
18 +import androidx.compose.material3.HorizontalDivider
19 +import androidx.compose.material3.Icon
20 +import androidx.compose.material3.IconButton
21 +import androidx.compose.material3.MaterialTheme
22 +import androidx.compose.material3.Scaffold
23 +import androidx.compose.material3.Text
24 +import androidx.compose.runtime.Composable
25 +import androidx.compose.runtime.LaunchedEffect
26 +import androidx.compose.runtime.collectAsState
27 +import androidx.compose.runtime.getValue
28 +import androidx.compose.ui.Alignment
29 +import androidx.compose.ui.Modifier
30 +import androidx.compose.ui.res.stringResource
31 +import androidx.compose.ui.unit.dp
32 +import fr.ebii.card2vcf.R
33 +import fr.ebii.card2vcf.data.ProjetEntity
34 +import fr.ebii.card2vcf.ui.nav.MainTab
35 +import fr.ebii.card2vcf.ui.nav.MainTabRow
36 +import fr.ebii.card2vcf.ui.sync.SyncBanner
37 +import fr.ebii.card2vcf.ui.sync.SyncChromeViewModel
38 +import fr.ebii.card2vcf.ui.theme.Bordure
39 +import fr.ebii.card2vcf.ui.theme.Fond
40 +import fr.ebii.card2vcf.ui.theme.Ink
41 +import fr.ebii.card2vcf.ui.theme.TexteFaible
42 +
43 +@Composable
44 +fun ProjetsListScreen(
45 + viewModel: ProjetsViewModel,
46 + onOpenCarnet: () -> Unit,
47 + onOpenProjet: (serverId: String) -> Unit,
48 + onSettings: () -> Unit,
49 + syncViewModel: SyncChromeViewModel,
50 + modifier: Modifier = Modifier,
51 +) {
52 + val projets by viewModel.projets.collectAsState()
53 + val syncConfigured by syncViewModel.configured.collectAsState()
54 + val pendingRemoteChanges by syncViewModel.pendingRemoteChanges.collectAsState()
55 + val syncing by syncViewModel.syncing.collectAsState()
56 + val syncError by syncViewModel.error.collectAsState()
57 +
58 + LaunchedEffect(Unit) {
59 + syncViewModel.refreshStatus()
60 + }
61 +
62 + Scaffold(modifier = modifier.fillMaxSize().background(Fond), containerColor = Fond) { padding ->
63 + Column(
64 + Modifier.fillMaxSize().padding(padding).padding(horizontal = 18.dp),
65 + ) {
66 + Row(
67 + Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 8.dp),
68 + verticalAlignment = Alignment.CenterVertically,
69 + ) {
70 + Text(
71 + text = stringResource(R.string.onglet_projets).uppercase(),
72 + style = MaterialTheme.typography.titleLarge,
73 + color = Ink,
74 + modifier = Modifier.weight(1f),
75 + )
76 + if (syncConfigured) {
77 + IconButton(onClick = { syncViewModel.syncNow() }) {
78 + Icon(Icons.Filled.Sync, contentDescription = stringResource(R.string.sync_icone), tint = Ink)
79 + }
80 + }
81 + IconButton(onClick = onSettings) {
82 + Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings_titre), tint = Ink)
83 + }
84 + }
85 +
86 + MainTabRow(
87 + current = MainTab.PROJETS,
88 + onSelect = { tab -> if (tab == MainTab.CARNET) onOpenCarnet() },
89 + )
90 +
91 + SyncBanner(
92 + pendingChanges = pendingRemoteChanges,
93 + syncing = syncing,
94 + error = syncError,
95 + onSyncClick = { syncViewModel.syncNow() },
96 + )
97 +
98 + Spacer(Modifier.height(8.dp))
99 +
100 + if (projets.isEmpty()) {
101 + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
102 + Text(
103 + stringResource(R.string.projets_vide),
104 + color = TexteFaible,
105 + style = MaterialTheme.typography.bodyLarge,
106 + )
107 + }
108 + } else {
109 + LazyColumn(Modifier.weight(1f).fillMaxWidth()) {
110 + items(projets, key = { it.localId }) { projet ->
111 + ProjetRow(projet = projet, onClick = { projet.serverId?.let(onOpenProjet) })
112 + }
113 + }
114 + }
115 + }
116 + }
117 +}
118 +
119 +@Composable
120 +private fun ProjetRow(projet: ProjetEntity, onClick: () -> Unit) {
121 + Column(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
122 + Column(Modifier.padding(vertical = 10.dp)) {
123 + Text(
124 + text = projet.nom.ifBlank { "—" },
125 + style = MaterialTheme.typography.bodyLarge,
126 + color = Ink,
127 + )
128 + if (projet.description.isNotBlank()) {
129 + Text(
130 + text = projet.description,
131 + style = MaterialTheme.typography.bodyMedium,
132 + color = TexteFaible,
133 + )
134 + }
135 + }
136 + HorizontalDivider(color = Bordure, thickness = 1.dp)
137 + }
138 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetsViewModel.kt
+17 -0
@@ -0,0 +1,17 @@
1 +package fr.ebii.card2vcf.ui.projets
2 +
3 +import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.data.CrmDatabase
6 +import fr.ebii.card2vcf.data.ProjetEntity
7 +import kotlinx.coroutines.flow.SharingStarted
8 +import kotlinx.coroutines.flow.StateFlow
9 +import kotlinx.coroutines.flow.stateIn
10 +
11 +class ProjetsViewModel(
12 + database: CrmDatabase,
13 +) : ViewModel() {
14 +
15 + val projets: StateFlow<List<ProjetEntity>> = database.projetDao().observeAll()
16 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
17 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsScreen.kt
+198 -0
@@ -0,0 +1,198 @@
1 +package fr.ebii.card2vcf.ui.settings
2 +
3 +import androidx.compose.foundation.background
4 +import androidx.compose.foundation.layout.Arrangement
5 +import androidx.compose.foundation.layout.Box
6 +import androidx.compose.foundation.layout.Column
7 +import androidx.compose.foundation.layout.fillMaxSize
8 +import androidx.compose.foundation.layout.fillMaxWidth
9 +import androidx.compose.foundation.layout.padding
10 +import androidx.compose.foundation.shape.RoundedCornerShape
11 +import androidx.compose.material.icons.Icons
12 +import androidx.compose.material.icons.automirrored.outlined.ArrowBack
13 +import androidx.compose.material3.AlertDialog
14 +import androidx.compose.material3.Button
15 +import androidx.compose.material3.ButtonDefaults
16 +import androidx.compose.material3.Icon
17 +import androidx.compose.material3.IconButton
18 +import androidx.compose.material3.MaterialTheme
19 +import androidx.compose.material3.OutlinedTextField
20 +import androidx.compose.material3.OutlinedTextFieldDefaults
21 +import androidx.compose.material3.Text
22 +import androidx.compose.material3.TextButton
23 +import androidx.compose.runtime.Composable
24 +import androidx.compose.runtime.collectAsState
25 +import androidx.compose.runtime.getValue
26 +import androidx.compose.ui.Alignment
27 +import androidx.compose.ui.Modifier
28 +import androidx.compose.ui.res.stringResource
29 +import androidx.compose.ui.text.input.PasswordVisualTransformation
30 +import androidx.compose.ui.unit.dp
31 +import fr.ebii.card2vcf.R
32 +import fr.ebii.card2vcf.ui.theme.Bordure
33 +import fr.ebii.card2vcf.ui.theme.Fond
34 +import fr.ebii.card2vcf.ui.theme.Ink
35 +import fr.ebii.card2vcf.ui.theme.OnPrimary
36 +import fr.ebii.card2vcf.ui.theme.Surface
37 +import fr.ebii.card2vcf.ui.theme.TexteFaible
38 +
39 +private val Square = RoundedCornerShape(0.dp)
40 +
41 +@Composable
42 +fun SettingsScreen(
43 + viewModel: SettingsViewModel,
44 + onBack: () -> Unit,
45 + modifier: Modifier = Modifier,
46 +) {
47 + val state by viewModel.state.collectAsState()
48 +
49 + Column(modifier.fillMaxSize().background(Fond)) {
50 + Box(
51 + Modifier.fillMaxWidth().background(Surface).padding(horizontal = 8.dp, vertical = 6.dp),
52 + ) {
53 + IconButton(onClick = onBack) {
54 + Icon(
55 + Icons.AutoMirrored.Outlined.ArrowBack,
56 + contentDescription = stringResource(R.string.scan_retour),
57 + )
58 + }
59 + Text(
60 + stringResource(R.string.settings_titre),
61 + style = MaterialTheme.typography.titleMedium,
62 + modifier = Modifier.align(Alignment.Center),
63 + color = Ink,
64 + )
65 + }
66 +
67 + when (state) {
68 + is SettingsUiState.LoggedIn -> LoggedInContent(
69 + state = state as SettingsUiState.LoggedIn,
70 + onDisconnect = viewModel::disconnect,
71 + )
72 + is SettingsUiState.LoggedOut -> LoggedOutContent(
73 + state = state as SettingsUiState.LoggedOut,
74 + viewModel = viewModel,
75 + )
76 + }
77 + }
78 +}
79 +
80 +@Composable
81 +private fun LoggedInContent(
82 + state: SettingsUiState.LoggedIn,
83 + onDisconnect: () -> Unit,
84 +) {
85 + Column(
86 + Modifier.fillMaxSize().padding(18.dp),
87 + verticalArrangement = Arrangement.spacedBy(10.dp),
88 + ) {
89 + Text(
90 + stringResource(R.string.settings_connecte_comme, state.userName),
91 + style = MaterialTheme.typography.bodyLarge,
92 + color = Ink,
93 + )
94 + Text(state.baseUrl, color = TexteFaible)
95 + Text(stringResource(R.string.settings_cle_configuree), color = TexteFaible)
96 + TextButton(onClick = onDisconnect, shape = Square) {
97 + Text(stringResource(R.string.settings_deconnecter), color = Ink)
98 + }
99 + }
100 +}
101 +
102 +@Composable
103 +private fun LoggedOutContent(
104 + state: SettingsUiState.LoggedOut,
105 + viewModel: SettingsViewModel,
106 +) {
107 + val fieldColors = OutlinedTextFieldDefaults.colors(
108 + focusedBorderColor = Ink,
109 + unfocusedBorderColor = Bordure,
110 + focusedTextColor = Ink,
111 + unfocusedTextColor = Ink,
112 + cursorColor = Ink,
113 + )
114 +
115 + Column(
116 + Modifier.fillMaxSize().padding(18.dp),
117 + verticalArrangement = Arrangement.spacedBy(12.dp),
118 + ) {
119 + OutlinedTextField(
120 + value = state.baseUrl,
121 + onValueChange = viewModel::setBaseUrl,
122 + modifier = Modifier.fillMaxWidth(),
123 + singleLine = true,
124 + shape = Square,
125 + placeholder = { Text(stringResource(R.string.settings_url_placeholder), color = TexteFaible) },
126 + colors = fieldColors,
127 + )
128 + OutlinedTextField(
129 + value = state.userName,
130 + onValueChange = viewModel::setUserName,
131 + modifier = Modifier.fillMaxWidth(),
132 + singleLine = true,
133 + shape = Square,
134 + placeholder = { Text(stringResource(R.string.settings_utilisateur_placeholder), color = TexteFaible) },
135 + colors = fieldColors,
136 + )
137 + OutlinedTextField(
138 + value = state.password,
139 + onValueChange = viewModel::setPassword,
140 + modifier = Modifier.fillMaxWidth(),
141 + singleLine = true,
142 + shape = Square,
143 + visualTransformation = PasswordVisualTransformation(),
144 + placeholder = { Text(stringResource(R.string.settings_mot_de_passe_placeholder), color = TexteFaible) },
145 + colors = fieldColors,
146 + )
147 + if (state.error != null) {
148 + Text(state.error, color = Ink)
149 + }
150 + Button(
151 + onClick = viewModel::obtainKey,
152 + enabled = !state.loading,
153 + modifier = Modifier.fillMaxWidth(),
154 + shape = Square,
155 + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
156 + ) {
157 + Text(stringResource(R.string.settings_obtenir_cle))
158 + }
159 + }
160 +
161 + if (state.pendingAuth != null) {
162 + AlertDialog(
163 + onDismissRequest = viewModel::cancelRetype,
164 + shape = Square,
165 + title = { Text(stringResource(R.string.settings_retype_titre), color = Ink) },
166 + text = {
167 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
168 + OutlinedTextField(
169 + value = state.retypePassword,
170 + onValueChange = viewModel::setRetypePassword,
171 + modifier = Modifier.fillMaxWidth(),
172 + singleLine = true,
173 + shape = Square,
174 + visualTransformation = PasswordVisualTransformation(),
175 + placeholder = {
176 + Text(stringResource(R.string.settings_mot_de_passe_placeholder), color = TexteFaible)
177 + },
178 + colors = fieldColors,
179 + )
180 + if (state.retypeError != null) {
181 + Text(state.retypeError, color = Ink)
182 + }
183 + }
184 + },
185 + confirmButton = {
186 + TextButton(onClick = viewModel::confirmRetype, shape = Square) {
187 + Text(stringResource(R.string.settings_retype_confirmer), color = Ink)
188 + }
189 + },
190 + dismissButton = {
191 + TextButton(onClick = viewModel::cancelRetype, shape = Square) {
192 + Text(stringResource(R.string.settings_retype_annuler), color = Ink)
193 + }
194 + },
195 + containerColor = Fond,
196 + )
197 + }
198 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsViewModel.kt
+134 -0
@@ -0,0 +1,134 @@
1 +package fr.ebii.card2vcf.ui.settings
2 +
3 +import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.sync.AilianceApi
6 +import fr.ebii.card2vcf.sync.AilianceApiClient
7 +import fr.ebii.card2vcf.sync.ApiCleCrypto
8 +import fr.ebii.card2vcf.sync.ApiCleCryptoException
9 +import fr.ebii.card2vcf.sync.AuthCleResponse
10 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
11 +import kotlinx.coroutines.CoroutineDispatcher
12 +import kotlinx.coroutines.Dispatchers
13 +import kotlinx.coroutines.flow.MutableStateFlow
14 +import kotlinx.coroutines.flow.StateFlow
15 +import kotlinx.coroutines.flow.asStateFlow
16 +import kotlinx.coroutines.launch
17 +import kotlinx.coroutines.withContext
18 +
19 +/** État de l'écran Paramètres : connecté, ou formulaire de login (+ retape en attente). */
20 +sealed interface SettingsUiState {
21 + data class LoggedIn(val userName: String, val baseUrl: String) : SettingsUiState
22 +
23 + data class LoggedOut(
24 + val baseUrl: String = "",
25 + val userName: String = "",
26 + val password: String = "",
27 + val loading: Boolean = false,
28 + val error: String? = null,
29 + val pendingAuth: AuthCleResponse? = null,
30 + val retypePassword: String = "",
31 + val retypeError: String? = null,
32 + ) : SettingsUiState
33 +}
34 +
35 +/** Login clé API : [obtainKey] appelle `/api/auth/cle`, [confirmRetype] déchiffre et stocke. */
36 +class SettingsViewModel(
37 + private val credentialsStore: SyncCredentialsStore,
38 + private val apiFactory: (String) -> AilianceApi = { baseUrl -> AilianceApiClient(baseUrl) },
39 + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
40 +) : ViewModel() {
41 +
42 + private val _state = MutableStateFlow(initialState())
43 + val state: StateFlow<SettingsUiState> = _state.asStateFlow()
44 +
45 + private fun initialState(): SettingsUiState {
46 + val baseUrl = credentialsStore.baseUrl
47 + val userName = credentialsStore.userName
48 + return if (credentialsStore.isConfigured() && baseUrl != null && userName != null) {
49 + SettingsUiState.LoggedIn(userName = userName, baseUrl = baseUrl)
50 + } else {
51 + SettingsUiState.LoggedOut(baseUrl = baseUrl.orEmpty())
52 + }
53 + }
54 +
55 + fun setBaseUrl(value: String) = updateLoggedOut { it.copy(baseUrl = value, error = null) }
56 +
57 + fun setUserName(value: String) = updateLoggedOut { it.copy(userName = value, error = null) }
58 +
59 + fun setPassword(value: String) = updateLoggedOut { it.copy(password = value, error = null) }
60 +
61 + fun setRetypePassword(value: String) = updateLoggedOut { it.copy(retypePassword = value, retypeError = null) }
62 +
63 + fun obtainKey() {
64 + val s = _state.value
65 + if (s !is SettingsUiState.LoggedOut) return
66 + val baseUrl = s.baseUrl.trim()
67 + val userName = s.userName.trim()
68 + if (baseUrl.isBlank() || userName.isBlank() || s.password.isBlank()) {
69 + _state.value = s.copy(error = "Renseignez l'URL, le nom d'utilisateur et le mot de passe")
70 + return
71 + }
72 + viewModelScope.launch {
73 + _state.value = s.copy(loading = true, error = null)
74 + val result = withContext(ioDispatcher) {
75 + apiFactory(baseUrl).authCle(userName, s.password)
76 + }
77 + val current = _state.value
78 + if (current !is SettingsUiState.LoggedOut) return@launch
79 + when (result) {
80 + is AilianceApiClient.ApiResult.Ok -> {
81 + _state.value = current.copy(
82 + loading = false,
83 + password = "",
84 + pendingAuth = result.value,
85 + error = null,
86 + )
87 + }
88 + is AilianceApiClient.ApiResult.Err -> {
89 + _state.value = current.copy(
90 + loading = false,
91 + password = "",
92 + error = errorMessageFor(result.code, result.message),
93 + )
94 + }
95 + }
96 + }
97 + }
98 +
99 + fun confirmRetype() {
100 + val s = _state.value
101 + if (s !is SettingsUiState.LoggedOut) return
102 + val auth = s.pendingAuth ?: return
103 + if (s.retypePassword.isBlank()) {
104 + _state.value = s.copy(retypeError = "Mot de passe requis")
105 + return
106 + }
107 + try {
108 + val apiKey = ApiCleCrypto.decryptApiKey(s.retypePassword, auth.sel, auth.cleChiffree)
109 + credentialsStore.save(baseUrl = s.baseUrl.trim(), apiKey = apiKey, userName = auth.nom)
110 + _state.value = SettingsUiState.LoggedIn(userName = auth.nom, baseUrl = s.baseUrl.trim())
111 + } catch (e: ApiCleCryptoException) {
112 + _state.value = s.copy(retypePassword = "", retypeError = "Mot de passe incorrect")
113 + }
114 + }
115 +
116 + fun cancelRetype() = updateLoggedOut { it.copy(pendingAuth = null, retypePassword = "", retypeError = null) }
117 +
118 + fun disconnect() {
119 + credentialsStore.clear()
120 + _state.value = SettingsUiState.LoggedOut()
121 + }
122 +
123 + private inline fun updateLoggedOut(transform: (SettingsUiState.LoggedOut) -> SettingsUiState.LoggedOut) {
124 + val s = _state.value
125 + if (s is SettingsUiState.LoggedOut) _state.value = transform(s)
126 + }
127 +
128 + private fun errorMessageFor(code: Int, message: String): String = when (code) {
129 + 401 -> "Identifiants invalides"
130 + 404 -> "Aucune clé API pour cet utilisateur"
131 + -1 -> "Erreur réseau : $message"
132 + else -> message
133 + }
134 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/sync/SyncBanner.kt
+56 -0
@@ -0,0 +1,56 @@
1 +package fr.ebii.card2vcf.ui.sync
2 +
3 +import androidx.compose.foundation.layout.Arrangement
4 +import androidx.compose.foundation.layout.Column
5 +import androidx.compose.foundation.layout.Row
6 +import androidx.compose.foundation.layout.fillMaxWidth
7 +import androidx.compose.foundation.layout.padding
8 +import androidx.compose.foundation.shape.RoundedCornerShape
9 +import androidx.compose.material3.HorizontalDivider
10 +import androidx.compose.material3.Text
11 +import androidx.compose.material3.TextButton
12 +import androidx.compose.runtime.Composable
13 +import androidx.compose.ui.Alignment
14 +import androidx.compose.ui.Modifier
15 +import androidx.compose.ui.res.stringResource
16 +import androidx.compose.ui.unit.dp
17 +import fr.ebii.card2vcf.R
18 +import fr.ebii.card2vcf.ui.theme.Bordure
19 +import fr.ebii.card2vcf.ui.theme.Ink
20 +
21 +private val Square = RoundedCornerShape(0.dp)
22 +
23 +/** Bandeau hairline « N changement(s) sur le serveur » + Sync, ou message d'erreur ; masqué sinon. */
24 +@Composable
25 +fun SyncBanner(
26 + pendingChanges: Int?,
27 + syncing: Boolean,
28 + error: String?,
29 + onSyncClick: () -> Unit,
30 + modifier: Modifier = Modifier,
31 +) {
32 + val label = when {
33 + error != null -> error
34 + pendingChanges != null && pendingChanges > 0 ->
35 + stringResource(R.string.sync_pending, pendingChanges)
36 + else -> null
37 + }
38 + if (label == null) return
39 +
40 + Column(modifier.fillMaxWidth()) {
41 + Row(
42 + Modifier.fillMaxWidth().padding(vertical = 8.dp),
43 + verticalAlignment = Alignment.CenterVertically,
44 + horizontalArrangement = Arrangement.SpaceBetween,
45 + ) {
46 + Text(label, color = Ink, modifier = Modifier.weight(1f))
47 + TextButton(onClick = onSyncClick, enabled = !syncing, shape = Square) {
48 + Text(
49 + if (syncing) stringResource(R.string.sync_en_cours) else stringResource(R.string.sync_bouton),
50 + color = Ink,
51 + )
52 + }
53 + }
54 + HorizontalDivider(color = Bordure, thickness = 1.dp)
55 + }
56 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/sync/SyncChromeViewModel.kt
+56 -0
@@ -0,0 +1,56 @@
1 +package fr.ebii.card2vcf.ui.sync
2 +
3 +import androidx.lifecycle.ViewModel
4 +import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
6 +import fr.ebii.card2vcf.sync.SyncEngine
7 +import kotlinx.coroutines.flow.MutableStateFlow
8 +import kotlinx.coroutines.flow.StateFlow
9 +import kotlinx.coroutines.flow.asStateFlow
10 +import kotlinx.coroutines.launch
11 +
12 +/** État bandeau sync (bouton toujours dispo) : status à l'ouverture, sync manuelle à la demande. */
13 +class SyncChromeViewModel(
14 + private val credentialsStore: SyncCredentialsStore,
15 + private val engineFactory: () -> SyncEngine?,
16 +) : ViewModel() {
17 +
18 + private val _configured = MutableStateFlow(credentialsStore.isConfigured())
19 + val configured: StateFlow<Boolean> = _configured.asStateFlow()
20 +
21 + private val _pendingRemoteChanges = MutableStateFlow<Int?>(null)
22 + val pendingRemoteChanges: StateFlow<Int?> = _pendingRemoteChanges.asStateFlow()
23 +
24 + private val _syncing = MutableStateFlow(false)
25 + val syncing: StateFlow<Boolean> = _syncing.asStateFlow()
26 +
27 + private val _error = MutableStateFlow<String?>(null)
28 + val error: StateFlow<String?> = _error.asStateFlow()
29 +
30 + fun refreshStatus() {
31 + _configured.value = credentialsStore.isConfigured()
32 + val engine = engineFactory() ?: return
33 + viewModelScope.launch {
34 + val result = engine.checkStatus()
35 + _pendingRemoteChanges.value = result.pendingRemoteChanges
36 + _error.value = result.error
37 + }
38 + }
39 +
40 + fun syncNow() {
41 + val engine = engineFactory() ?: return
42 + viewModelScope.launch {
43 + _syncing.value = true
44 + _error.value = null
45 + val result = engine.syncNow()
46 + if (result.success) {
47 + val status = engine.checkStatus()
48 + _pendingRemoteChanges.value = status.pendingRemoteChanges
49 + _error.value = status.error
50 + } else {
51 + _error.value = result.error
52 + }
53 + _syncing.value = false
54 + }
55 + }
56 +}
M android/app/src/main/res/values-en/strings.xml
+39 -0
@@ -82,4 +82,43 @@
82 82 <string name="import_retour_carnet">Back to address book</string>
83 83 <string name="import_autre_fichier">Import another file</string>
84 84 <string name="import_reessayer">Try again</string>
85 +
86 + <string name="settings_titre">Settings</string>
87 + <string name="settings_url_placeholder">Server URL (https://…)</string>
88 + <string name="settings_utilisateur_placeholder">Username</string>
89 + <string name="settings_mot_de_passe_placeholder">Password</string>
90 + <string name="settings_obtenir_cle">Get the key</string>
91 + <string name="settings_retype_titre">Retype the password to decrypt</string>
92 + <string name="settings_retype_confirmer">Confirm</string>
93 + <string name="settings_retype_annuler">Cancel</string>
94 + <string name="settings_connecte_comme">Signed in as %1$s</string>
95 + <string name="settings_cle_configuree">API key: •••• (configured)</string>
96 + <string name="settings_deconnecter">Sign out</string>
97 +
98 + <string name="sync_pending">%d change(s) on the server</string>
99 + <string name="sync_bouton">Sync</string>
100 + <string name="sync_en_cours">Syncing…</string>
101 + <string name="sync_icone">Sync</string>
102 +
103 + <string name="onglet_carnet">Address book</string>
104 + <string name="onglet_projets">Projects</string>
105 +
106 + <string name="projets_vide">No projects — sync to fetch them</string>
107 + <string name="projet_membres">Members</string>
108 + <string name="projet_cr_titre">Report</string>
109 + <string name="projet_cr_choisir_contact">Choose a contact</string>
110 + <string name="projet_cr_sujet_placeholder">Subject</string>
111 + <string name="projet_cr_description_placeholder">Description</string>
112 + <string name="projet_cr_ajouter">Add</string>
113 +
114 + <string name="kanban_filtre_mes_taches">My tasks</string>
115 + <string name="kanban_filtre_toutes">All</string>
116 + <string name="kanban_nouvelle_tache">New task</string>
117 + <string name="kanban_editer_tache">Edit task</string>
118 + <string name="kanban_champ_titre">Title</string>
119 + <string name="kanban_non_assigne">Unassigned</string>
120 + <string name="kanban_assigne_a">Assigned: %s</string>
121 + <string name="kanban_supprimer">Delete</string>
122 + <string name="kanban_valider">Save</string>
123 + <string name="kanban_sans_workflow">No workflow linked to this project</string>
85 124 </resources>
M android/app/src/main/res/values/strings.xml
+39 -0
@@ -82,4 +82,43 @@
82 82 <string name="import_retour_carnet">Retour au carnet</string>
83 83 <string name="import_autre_fichier">Importer un autre fichier</string>
84 84 <string name="import_reessayer">Réessayer</string>
85 +
86 + <string name="settings_titre">Paramètres</string>
87 + <string name="settings_url_placeholder">URL du serveur (https://…)</string>
88 + <string name="settings_utilisateur_placeholder">Nom d\'utilisateur</string>
89 + <string name="settings_mot_de_passe_placeholder">Mot de passe</string>
90 + <string name="settings_obtenir_cle">Obtenir la clé</string>
91 + <string name="settings_retype_titre">Retapez le mot de passe pour déchiffrer</string>
92 + <string name="settings_retype_confirmer">Confirmer</string>
93 + <string name="settings_retype_annuler">Annuler</string>
94 + <string name="settings_connecte_comme">Connecté comme %1$s</string>
95 + <string name="settings_cle_configuree">Clé API : •••• (configurée)</string>
96 + <string name="settings_deconnecter">Déconnecter</string>
97 +
98 + <string name="sync_pending">%d changement(s) sur le serveur</string>
99 + <string name="sync_bouton">Synchroniser</string>
100 + <string name="sync_en_cours">Synchronisation…</string>
101 + <string name="sync_icone">Synchroniser</string>
102 +
103 + <string name="onglet_carnet">Carnet</string>
104 + <string name="onglet_projets">Projets</string>
105 +
106 + <string name="projets_vide">Aucun projet — synchronisez pour les récupérer</string>
107 + <string name="projet_membres">Membres</string>
108 + <string name="projet_cr_titre">Compte-rendu</string>
109 + <string name="projet_cr_choisir_contact">Choisir un contact</string>
110 + <string name="projet_cr_sujet_placeholder">Sujet</string>
111 + <string name="projet_cr_description_placeholder">Description</string>
112 + <string name="projet_cr_ajouter">Ajouter</string>
113 +
114 + <string name="kanban_filtre_mes_taches">Mes tâches</string>
115 + <string name="kanban_filtre_toutes">Toutes</string>
116 + <string name="kanban_nouvelle_tache">Nouvelle tâche</string>
117 + <string name="kanban_editer_tache">Modifier la tâche</string>
118 + <string name="kanban_champ_titre">Titre</string>
119 + <string name="kanban_non_assigne">Non assigné</string>
120 + <string name="kanban_assigne_a">Assigné : %s</string>
121 + <string name="kanban_supprimer">Supprimer</string>
122 + <string name="kanban_valider">Valider</string>
123 + <string name="kanban_sans_workflow">Aucun workflow associé à ce projet</string>
85 124 </resources>
M android/app/src/test/java/fr/ebii/card2vcf/data/ContactRepositoryTest.kt
+41 -0
@@ -6,7 +6,10 @@ import androidx.test.core.app.ApplicationProvider
6 6 import fr.ebii.card2vcf.contact.ContactCard
7 7 import fr.ebii.card2vcf.crm.MergeFieldChoices
8 8 import fr.ebii.card2vcf.crm.NotesMode
9 +import fr.ebii.card2vcf.sync.CreateContactRequest
10 +import fr.ebii.card2vcf.sync.syncJson
9 11 import kotlinx.coroutines.runBlocking
12 +import kotlinx.serialization.decodeFromString
10 13 import org.junit.After
11 14 import org.junit.Assert.assertEquals
12 15 import org.junit.Assert.assertNull
@@ -90,6 +93,44 @@ class ContactRepositoryTest {
90 93 }
91 94
92 95 @Test
96 + fun enqueueCreateSyncOpWhenSyncEnabled() = runBlocking {
97 + val ctx = ApplicationProvider.getApplicationContext<Context>()
98 + val syncRepo = ContactRepository(
99 + dao = dao,
100 + images = ContactImageStore(ctx),
101 + syncOpDao = db.syncOpDao(),
102 + isSyncEnabled = { true },
103 + )
104 + val id = syncRepo.insertFromCard(
105 + ContactCard(fullName = "Sync Me", emails = listOf("sync@ex.com")),
106 + null,
107 + null,
108 + )
109 + val ops = db.syncOpDao().listAll()
110 + assertEquals(1, ops.size)
111 + assertEquals("contact", ops[0].entityType)
112 + assertEquals("create", ops[0].op)
113 + assertEquals(id, ops[0].localId)
114 + val payload = syncJson.decodeFromString<CreateContactRequest>(ops[0].payloadJson)
115 + assertEquals("sync@ex.com", payload.emails.single().valeur)
116 + }
117 +
118 + @Test
119 + fun deleteUnsyncedContactRemovesPendingCreateOp() = runBlocking {
120 + val ctx = ApplicationProvider.getApplicationContext<Context>()
121 + val syncRepo = ContactRepository(
122 + dao = dao,
123 + images = ContactImageStore(ctx),
124 + syncOpDao = db.syncOpDao(),
125 + isSyncEnabled = { true },
126 + )
127 + val id = syncRepo.insertFromCard(ContactCard(fullName = "Temp"), null, null)
128 + assertEquals(1, db.syncOpDao().listAll().size)
129 + syncRepo.delete(id)
130 + assertTrue(db.syncOpDao().listAll().isEmpty())
131 + }
132 +
133 + @Test
93 134 fun mergeContactsKeepsOneAndDeletesOthers() = runBlocking {
94 135 val idA = repo.insertFromCard(
95 136 ContactCard(fullName = "A", emails = listOf("a@ex.com"), phones = listOf("111")),
A android/app/src/test/java/fr/ebii/card2vcf/kanban/KanbanFilterTest.kt
+48 -0
@@ -0,0 +1,48 @@
1 +package fr.ebii.card2vcf.kanban
2 +
3 +import fr.ebii.card2vcf.data.TacheEntity
4 +import kotlin.test.Test
5 +import kotlin.test.assertEquals
6 +
7 +class KanbanFilterTest {
8 + private fun tache(localId: Long, assigneA: String? = null) = TacheEntity(
9 + localId = localId,
10 + titre = "T$localId",
11 + assigneA = assigneA,
12 + )
13 +
14 + @Test
15 + fun mine_keepsOnlyTasksAssignedToUser() {
16 + val tasks = listOf(
17 + tache(1, "alice"),
18 + tache(2, "bob"),
19 + tache(3, null),
20 + )
21 +
22 + val result = KanbanFilter.filter(tasks, KanbanFilter.Mode.MINE, "alice")
23 +
24 + assertEquals(listOf(tasks[0]), result)
25 + }
26 +
27 + @Test
28 + fun all_keepsEveryTaskIncludingUnassigned() {
29 + val tasks = listOf(
30 + tache(1, "alice"),
31 + tache(2, "bob"),
32 + tache(3, null),
33 + )
34 +
35 + val result = KanbanFilter.filter(tasks, KanbanFilter.Mode.ALL, "alice")
36 +
37 + assertEquals(tasks, result)
38 + }
39 +
40 + @Test
41 + fun mine_excludesUnassignedTasks() {
42 + val tasks = listOf(tache(1, null))
43 +
44 + val result = KanbanFilter.filter(tasks, KanbanFilter.Mode.MINE, "alice")
45 +
46 + assertEquals(emptyList(), result)
47 + }
48 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/AilianceApiClientTest.kt
+199 -0
@@ -0,0 +1,199 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import okhttp3.mockwebserver.MockResponse
4 +import okhttp3.mockwebserver.MockWebServer
5 +import org.junit.After
6 +import org.junit.Assert.assertEquals
7 +import org.junit.Assert.assertTrue
8 +import org.junit.Before
9 +import org.junit.Test
10 +
11 +class AilianceApiClientTest {
12 + private lateinit var server: MockWebServer
13 + private lateinit var client: AilianceApiClient
14 +
15 + @Before
16 + fun setUp() {
17 + server = MockWebServer()
18 + server.start()
19 + client = AilianceApiClient(
20 + baseUrl = server.url("/").toString().trimEnd('/'),
21 + apiKey = "test-bearer-key",
22 + )
23 + }
24 +
25 + @After
26 + fun tearDown() {
27 + server.shutdown()
28 + }
29 +
30 + @Test
31 + fun authCle200ParsesResponse() {
32 + server.enqueue(
33 + MockResponse()
34 + .setResponseCode(200)
35 + .setBody(
36 + """
37 + {
38 + "nom": "alice",
39 + "cle_chiffree": "Y2lw",
40 + "sel": "c2Vs",
41 + "kdf": "argon2id",
42 + "cipher": "xchacha20poly1305"
43 + }
44 + """.trimIndent(),
45 + ),
46 + )
47 +
48 + val result = client.authCle("alice", "secret")
49 +
50 + assertTrue(result is AilianceApiClient.ApiResult.Ok)
51 + val ok = result as AilianceApiClient.ApiResult.Ok
52 + assertEquals("alice", ok.value.nom)
53 + assertEquals("Y2lw", ok.value.cleChiffree)
54 + assertEquals("c2Vs", ok.value.sel)
55 + assertEquals("argon2id", ok.value.kdf)
56 + assertEquals("xchacha20poly1305", ok.value.cipher)
57 +
58 + val request = server.takeRequest()
59 + assertEquals("POST", request.method)
60 + assertEquals("/api/auth/cle", request.path)
61 + assertTrue(request.getHeader("Authorization") == null)
62 + }
63 +
64 + @Test
65 + fun authCle401ReturnsErr() {
66 + server.enqueue(
67 + MockResponse()
68 + .setResponseCode(401)
69 + .setBody("""{"message":"Identifiants invalides."}"""),
70 + )
71 +
72 + val result = client.authCle("alice", "wrong")
73 +
74 + assertTrue(result is AilianceApiClient.ApiResult.Err)
75 + val err = result as AilianceApiClient.ApiResult.Err
76 + assertEquals(401, err.code)
77 + assertEquals("Identifiants invalides.", err.message)
78 + }
79 +
80 + @Test
81 + fun syncStatus200ParsesTotalAndChanges() {
82 + server.enqueue(
83 + MockResponse()
84 + .setResponseCode(200)
85 + .setBody(
86 + """
87 + {
88 + "server_time": "2026-07-22T12:00:00Z",
89 + "changes": {
90 + "contacts": 2,
91 + "entreprises": 0,
92 + "projets": 1,
93 + "taches": 3,
94 + "interactions": 1,
95 + "tombstones": 1
96 + },
97 + "total": 8
98 + }
99 + """.trimIndent(),
100 + ),
101 + )
102 +
103 + val result = client.syncStatus("1970-01-01T00:00:00Z")
104 +
105 + assertTrue(result is AilianceApiClient.ApiResult.Ok)
106 + val ok = result as AilianceApiClient.ApiResult.Ok
107 + assertEquals("2026-07-22T12:00:00Z", ok.value.serverTime)
108 + assertEquals(8, ok.value.total)
109 + assertEquals(2, ok.value.changes.contacts)
110 + assertEquals(1, ok.value.changes.tombstones)
111 +
112 + val request = server.takeRequest()
113 + assertEquals("GET", request.method)
114 + assertEquals("/api/sync/status?since=1970-01-01T00%3A00%3A00Z", request.path)
115 + assertEquals("Bearer test-bearer-key", request.getHeader("Authorization"))
116 + }
117 +
118 + @Test
119 + fun syncPull200ParsesContactsAndTombstones() {
120 + server.enqueue(
121 + MockResponse()
122 + .setResponseCode(200)
123 + .setBody(
124 + """
125 + {
126 + "server_time": "2026-07-22T12:05:00Z",
127 + "contacts": [
128 + {
129 + "id": "c1",
130 + "prenom": "Jean",
131 + "nom": "Dupont",
132 + "cree_le": "2026-07-20T10:00:00Z",
133 + "mis_a_jour_le": "2026-07-21T08:00:00Z"
134 + }
135 + ],
136 + "entreprises": [],
137 + "projets": [],
138 + "taches": [
139 + {
140 + "projet_id": "p1",
141 + "id": "t1",
142 + "titre": "Appeler client",
143 + "colonne_id": "col-a-faire",
144 + "ordre": 0,
145 + "cree_le": "2026-07-21T09:00:00Z",
146 + "assigne_a": "alice"
147 + }
148 + ],
149 + "interactions": [],
150 + "tombstones": [
151 + {
152 + "type": "contact",
153 + "id": "c-old",
154 + "supprime_le": "2026-07-22T11:00:00Z"
155 + }
156 + ],
157 + "workflows": []
158 + }
159 + """.trimIndent(),
160 + ),
161 + )
162 +
163 + val result = client.syncPull("2026-07-20T00:00:00Z")
164 +
165 + assertTrue(result is AilianceApiClient.ApiResult.Ok)
166 + val ok = result as AilianceApiClient.ApiResult.Ok
167 + assertEquals(1, ok.value.contacts.size)
168 + assertEquals("c1", ok.value.contacts[0].id)
169 + assertEquals("Jean", ok.value.contacts[0].prenom)
170 + assertEquals(1, ok.value.taches.size)
171 + assertEquals("p1", ok.value.taches[0].projetId)
172 + assertEquals("alice", ok.value.taches[0].assigneA)
173 + assertEquals(1, ok.value.tombstones.size)
174 + assertEquals("contact", ok.value.tombstones[0].entityType)
175 + assertEquals("c-old", ok.value.tombstones[0].id)
176 + }
177 +
178 + @Test
179 + fun protectedEndpointWithoutBearerReturns401() {
180 + val unauthenticated = AilianceApiClient(
181 + baseUrl = server.url("/").toString().trimEnd('/'),
182 + apiKey = null,
183 + )
184 + server.enqueue(
185 + MockResponse()
186 + .setResponseCode(401)
187 + .setBody("""{"message":"Non autorisé."}"""),
188 + )
189 +
190 + val result = unauthenticated.syncStatus("1970-01-01T00:00:00Z")
191 +
192 + assertTrue(result is AilianceApiClient.ApiResult.Err)
193 + val err = result as AilianceApiClient.ApiResult.Err
194 + assertEquals(401, err.code)
195 +
196 + val request = server.takeRequest()
197 + assertTrue(request.getHeader("Authorization") == null)
198 + }
199 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/ApiCleCryptoTest.kt
+54 -0
@@ -0,0 +1,54 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import org.junit.Assert.assertEquals
4 +import org.junit.Test
5 +
6 +class ApiCleCryptoTest {
7 + @Test
8 + fun decryptRustFixture() {
9 + val key = ApiCleCrypto.decryptApiKey(
10 + password = FIXTURE_MDP,
11 + saltB64 = FIXTURE_SEL_B64,
12 + cipherB64 = FIXTURE_BLOB_B64,
13 + )
14 + assertEquals(FIXTURE_CLE, key)
15 + }
16 +
17 + @Test
18 + fun wrongPasswordFails() {
19 + try {
20 + ApiCleCrypto.decryptApiKey(
21 + password = "wrong-password",
22 + saltB64 = FIXTURE_SEL_B64,
23 + cipherB64 = FIXTURE_BLOB_B64,
24 + )
25 + error("expected ApiCleCryptoException")
26 + } catch (_: ApiCleCryptoException) {
27 + // expected
28 + }
29 + }
30 +
31 + @Test
32 + fun blobTooShortFails() {
33 + try {
34 + ApiCleCrypto.decryptApiKey(
35 + password = FIXTURE_MDP,
36 + saltB64 = FIXTURE_SEL_B64,
37 + cipherB64 = SHORT_BLOB_B64,
38 + )
39 + error("expected ApiCleCryptoException")
40 + } catch (_: ApiCleCryptoException) {
41 + // expected
42 + }
43 + }
44 +
45 + private companion object {
46 + const val FIXTURE_MDP = "card2vcf-fixture-mdp"
47 + const val FIXTURE_CLE = "sk_fixture_aabbccddeeff00112233"
48 + const val FIXTURE_SEL_B64 = "Fc+EFikMN7Cb8YoNZB+SZw=="
49 + const val FIXTURE_BLOB_B64 =
50 + "uW9uB9fP7m5ng6PoH9PGCXBKdPoVj9SceWy9GWLjg4x3Zk7MirbHhSCBVyLzoL2tK4qDir7UCOn503+s9rUFvIT/Eg5NqaU="
51 + // 23 bytes (< 24 nonce minimum)
52 + const val SHORT_BLOB_B64 = "AAAAAAAAAAAAAAAAAAAAAAA="
53 + }
54 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/ContactSyncMapperTest.kt
+57 -0
@@ -0,0 +1,57 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +import kotlinx.serialization.decodeFromString
5 +import org.junit.Assert.assertEquals
6 +import org.junit.Assert.assertNull
7 +import org.junit.Test
8 +
9 +class ContactSyncMapperTest {
10 + @Test
11 + fun mapsLocalFieldsToApiJson() {
12 + val entity = CrmContactEntity(
13 + id = 1L,
14 + fullName = "Marie Dubois",
15 + firstName = "Marie",
16 + lastName = "Dubois",
17 + jobTitle = "Directrice",
18 + phones = listOf("+33 1 23 45 67 89"),
19 + emails = listOf("marie@acme.fr"),
20 + notes = "Rencontrée au salon",
21 + entrepriseServerId = "ent-42",
22 + statut = "client",
23 + etape = "qualifie",
24 + tags = listOf("vip", "lyon"),
25 + )
26 +
27 + val payload = ContactSyncMapper.toCreatePayload(entity)
28 + val request = syncJson.decodeFromString<CreateContactRequest>(payload)
29 +
30 + assertEquals("Marie", request.prenom)
31 + assertEquals("Dubois", request.nom)
32 + assertEquals("ent-42", request.entrepriseId)
33 + assertEquals("Directrice", request.fonction)
34 + assertEquals("marie@acme.fr", request.emails.single().valeur)
35 + assertEquals("+33 1 23 45 67 89", request.telephones.single().valeur)
36 + assertEquals("Rencontrée au salon", request.notes)
37 + assertEquals("client", request.statut)
38 + assertEquals("qualifie", request.etape)
39 + assertEquals(listOf("vip", "lyon"), request.tags)
40 + }
41 +
42 + @Test
43 + fun splitsFullNameWhenFirstAndLastMissing() {
44 + val entity = CrmContactEntity(
45 + fullName = "Jean Paul Sartre",
46 + emails = listOf("jp@ex.com"),
47 + )
48 +
49 + val request = syncJson.decodeFromString<CreateContactRequest>(
50 + ContactSyncMapper.toCreatePayload(entity),
51 + )
52 +
53 + assertEquals("Jean", request.prenom)
54 + assertEquals("Paul Sartre", request.nom)
55 + assertNull(request.entrepriseId)
56 + }
57 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/LwwMergerTest.kt
+119 -0
@@ -0,0 +1,119 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import org.junit.Assert.assertEquals
4 +import org.junit.Assert.assertFalse
5 +import org.junit.Assert.assertTrue
6 +import org.junit.Test
7 +
8 +class LwwMergerTest {
9 + @Test
10 + fun pickLww_remoteNewerWins() {
11 + val result = LwwMerger.pickLww(
12 + local = "local-value",
13 + localTs = 100L,
14 + remote = "remote-value",
15 + remoteTs = 200L,
16 + )
17 + assertEquals("remote-value", result)
18 + }
19 +
20 + @Test
21 + fun pickLww_localNewerWins() {
22 + val result = LwwMerger.pickLww(
23 + local = "local-value",
24 + localTs = 300L,
25 + remote = "remote-value",
26 + remoteTs = 200L,
27 + )
28 + assertEquals("local-value", result)
29 + }
30 +
31 + @Test
32 + fun pickLww_equalTimestampsRemoteWins() {
33 + val result = LwwMerger.pickLww(
34 + local = "local-value",
35 + localTs = 100L,
36 + remote = "remote-value",
37 + remoteTs = 100L,
38 + )
39 + assertEquals("remote-value", result)
40 + }
41 +
42 + @Test
43 + fun pickTaskMove_remoteNewerWins() {
44 + val local = LwwMerger.TaskPlacement(
45 + colonneId = "todo",
46 + ordre = 1,
47 + updatedAt = 100L,
48 + )
49 + val remote = LwwMerger.TaskPlacement(
50 + colonneId = "done",
51 + ordre = 2,
52 + updatedAt = 200L,
53 + )
54 +
55 + val result = LwwMerger.pickTaskMove(local, remote)
56 +
57 + assertEquals(remote, result)
58 + }
59 +
60 + @Test
61 + fun pickTaskMove_localNewerWins() {
62 + val local = LwwMerger.TaskPlacement(
63 + colonneId = "todo",
64 + ordre = 1,
65 + updatedAt = 300L,
66 + )
67 + val remote = LwwMerger.TaskPlacement(
68 + colonneId = "done",
69 + ordre = 2,
70 + updatedAt = 200L,
71 + )
72 +
73 + val result = LwwMerger.pickTaskMove(local, remote)
74 +
75 + assertEquals(local, result)
76 + }
77 +
78 + @Test
79 + fun pickTaskMove_equalTimestampsRemoteWins() {
80 + val local = LwwMerger.TaskPlacement(
81 + colonneId = "todo",
82 + ordre = 1,
83 + updatedAt = 100L,
84 + )
85 + val remote = LwwMerger.TaskPlacement(
86 + colonneId = "done",
87 + ordre = 2,
88 + updatedAt = 100L,
89 + )
90 +
91 + val result = LwwMerger.pickTaskMove(local, remote)
92 +
93 + assertEquals(remote, result)
94 + }
95 +
96 + @Test
97 + fun shouldInsertInteraction_whenUnknownServerId() {
98 + val existing = setOf("srv-1", "srv-2")
99 +
100 + assertTrue(LwwMerger.shouldInsertInteraction(existing, "srv-3"))
101 + }
102 +
103 + @Test
104 + fun shouldInsertInteraction_whenKnownServerId() {
105 + val existing = setOf("srv-1", "srv-2")
106 +
107 + assertFalse(LwwMerger.shouldInsertInteraction(existing, "srv-1"))
108 + }
109 +
110 + @Test
111 + fun applyTombstoneIds_removesMatchingIds() {
112 + val local = setOf("a", "b", "c")
113 + val tombstones = setOf("b", "d")
114 +
115 + val remaining = LwwMerger.applyTombstoneIds(local, tombstones)
116 +
117 + assertEquals(setOf("a", "c"), remaining)
118 + }
119 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/SyncCredentialsStoreTest.kt
+91 -0
@@ -0,0 +1,91 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.Context
4 +import androidx.test.core.app.ApplicationProvider
5 +import org.junit.After
6 +import org.junit.Assert.assertEquals
7 +import org.junit.Assert.assertFalse
8 +import org.junit.Assert.assertNull
9 +import org.junit.Assert.assertTrue
10 +import org.junit.Before
11 +import org.junit.Test
12 +import org.junit.runner.RunWith
13 +import org.robolectric.RobolectricTestRunner
14 +import org.robolectric.annotation.Config
15 +
16 +@RunWith(RobolectricTestRunner::class)
17 +@Config(sdk = [31])
18 +class SyncCredentialsStoreTest {
19 + private lateinit var store: SyncCredentialsStore
20 +
21 + @Before
22 + fun setUp() {
23 + val ctx = ApplicationProvider.getApplicationContext<Context>()
24 + val prefs = ctx.getSharedPreferences("${SyncCredentialsStore.PREFS_NAME}_test", Context.MODE_PRIVATE)
25 + prefs.edit().clear().apply()
26 + store = SyncCredentialsStore(prefs)
27 + }
28 +
29 + @After
30 + fun tearDown() {
31 + store.clear()
32 + }
33 +
34 + @Test
35 + fun initiallyNotConfigured() {
36 + assertFalse(store.isConfigured())
37 + assertNull(store.baseUrl)
38 + assertNull(store.apiKey)
39 + assertNull(store.userName)
40 + }
41 +
42 + @Test
43 + fun saveRoundTrip() {
44 + store.save(
45 + baseUrl = "https://api.example.com",
46 + apiKey = "secret-bearer-token",
47 + userName = "alice",
48 + )
49 +
50 + assertTrue(store.isConfigured())
51 + assertEquals("https://api.example.com", store.baseUrl)
52 + assertEquals("secret-bearer-token", store.apiKey)
53 + assertEquals("alice", store.userName)
54 + }
55 +
56 + @Test
57 + fun propertySettersRoundTrip() {
58 + store.baseUrl = "https://sync.test"
59 + store.apiKey = "key-123"
60 + store.userName = "bob"
61 +
62 + assertTrue(store.isConfigured())
63 + assertEquals("https://sync.test", store.baseUrl)
64 + assertEquals("key-123", store.apiKey)
65 + assertEquals("bob", store.userName)
66 + }
67 +
68 + @Test
69 + fun clearRemovesAllCredentials() {
70 + store.save(
71 + baseUrl = "https://api.example.com",
72 + apiKey = "secret-bearer-token",
73 + userName = "alice",
74 + )
75 +
76 + store.clear()
77 +
78 + assertFalse(store.isConfigured())
79 + assertNull(store.baseUrl)
80 + assertNull(store.apiKey)
81 + assertNull(store.userName)
82 + }
83 +
84 + @Test
85 + fun blankValuesAreNotConfigured() {
86 + store.save(baseUrl = " ", apiKey = "key", userName = "alice")
87 +
88 + assertFalse(store.isConfigured())
89 + assertNull(store.baseUrl)
90 + }
91 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/SyncEngineTest.kt
+193 -0
@@ -0,0 +1,193 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import fr.ebii.card2vcf.data.CrmContactEntity
7 +import fr.ebii.card2vcf.data.CrmDatabase
8 +import kotlinx.coroutines.runBlocking
9 +import org.junit.After
10 +import org.junit.Assert.assertEquals
11 +import org.junit.Assert.assertNull
12 +import org.junit.Assert.assertTrue
13 +import org.junit.Before
14 +import org.junit.Test
15 +import org.junit.runner.RunWith
16 +import org.robolectric.RobolectricTestRunner
17 +import org.robolectric.annotation.Config
18 +
19 +/** Fake [AilianceApi] configurable par test ; par défaut tout répond Ok vide. */
20 +private class FakeAilianceApi : AilianceApi {
21 + var statusResult: AilianceApiClient.ApiResult<SyncStatusResponse> =
22 + AilianceApiClient.ApiResult.Ok(SyncStatusResponse("1970-01-01T00:00:00Z", SyncChanges(), 0))
23 + var pullResult: AilianceApiClient.ApiResult<SyncPullResponse> =
24 + AilianceApiClient.ApiResult.Ok(SyncPullResponse(serverTime = "1970-01-01T00:00:00Z"))
25 + var createContactResult: AilianceApiClient.ApiResult<String> =
26 + AilianceApiClient.ApiResult.Ok("""{"id":"srv-generated"}""")
27 + val createContactCalls = mutableListOf<String>()
28 +
29 + override fun authCle(nom: String, motDePasse: String) =
30 + AilianceApiClient.ApiResult.Ok(AuthCleResponse(nom, "", "", "argon2id", "xchacha20poly1305"))
31 +
32 + override fun syncStatus(sinceIso: String) = statusResult
33 +
34 + override fun syncPull(sinceIso: String) = pullResult
35 +
36 + override fun createContact(jsonBody: String): AilianceApiClient.ApiResult<String> {
37 + createContactCalls += jsonBody
38 + return createContactResult
39 + }
40 +
41 + override fun updateContact(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
42 + override fun deleteContact(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
43 + override fun createEntreprise(jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
44 + override fun updateEntreprise(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
45 + override fun deleteEntreprise(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
46 + override fun createProjet(jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
47 + override fun updateProjet(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
48 + override fun deleteProjet(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
49 + override fun createTache(projetId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
50 + override fun updateTache(projetId: String, tacheId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
51 + override fun deleteTache(projetId: String, tacheId: String) = AilianceApiClient.ApiResult.Ok(Unit)
52 + override fun moveTache(projetId: String, tacheId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
53 + override fun createInteraction(contactId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
54 +}
55 +
56 +@RunWith(RobolectricTestRunner::class)
57 +@Config(sdk = [31])
58 +class SyncEngineTest {
59 + private lateinit var db: CrmDatabase
60 + private lateinit var api: FakeAilianceApi
61 + private lateinit var engine: SyncEngine
62 +
63 + @Before
64 + fun setUp() {
65 + val ctx = ApplicationProvider.getApplicationContext<Context>()
66 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
67 + .allowMainThreadQueries()
68 + .build()
69 + api = FakeAilianceApi()
70 + engine = SyncEngine(api, db)
71 + }
72 +
73 + @After
74 + fun tearDown() = db.close()
75 +
76 + @Test
77 + fun checkStatus_returnsTotalFromFake() = runBlocking {
78 + api.statusResult = AilianceApiClient.ApiResult.Ok(
79 + SyncStatusResponse(
80 + serverTime = "2026-07-22T12:00:00Z",
81 + changes = SyncChanges(contacts = 2, tombstones = 1),
82 + total = 3,
83 + ),
84 + )
85 +
86 + val result = engine.checkStatus()
87 +
88 + assertEquals(3, result.pendingRemoteChanges)
89 + assertEquals(3, result.response?.total)
90 + }
91 +
92 + @Test
93 + fun syncNow_pullAppliesNewerContact() = runBlocking {
94 + db.crmContactDao().insert(
95 + CrmContactEntity(
96 + serverId = "c1",
97 + fullName = "Old Name",
98 + updatedAt = 1_000L,
99 + ),
100 + )
101 + api.pullResult = AilianceApiClient.ApiResult.Ok(
102 + SyncPullResponse(
103 + serverTime = "2026-07-22T12:05:00Z",
104 + contacts = listOf(
105 + ContactDto(
106 + id = "c1",
107 + prenom = "Jean",
108 + nom = "Dupont",
109 + creeLe = "2026-07-20T10:00:00Z",
110 + misAJourLe = "2026-07-22T11:00:00Z",
111 + ),
112 + ),
113 + ),
114 + )
115 +
116 + engine.syncNow()
117 +
118 + val updated = db.crmContactDao().getByServerId("c1")
119 + assertEquals("Jean Dupont", updated?.fullName)
120 + }
121 +
122 + @Test
123 + fun syncNow_pullKeepsBothAppendOnlyInteractions() = runBlocking {
124 + api.pullResult = AilianceApiClient.ApiResult.Ok(
125 + SyncPullResponse(
126 + serverTime = "2026-07-22T12:05:00Z",
127 + interactions = listOf(
128 + InteractionDto(id = "i1", contactId = "c1", sujet = "Appel", creeLe = "2026-07-22T09:00:00Z"),
129 + InteractionDto(id = "i2", contactId = "c1", sujet = "Relance", creeLe = "2026-07-22T10:00:00Z"),
130 + ),
131 + ),
132 + )
133 +
134 + engine.syncNow()
135 +
136 + val stored = db.interactionDao().listByContactServerId("c1")
137 + assertEquals(2, stored.size)
138 + assertTrue(stored.any { it.serverId == "i1" })
139 + assertTrue(stored.any { it.serverId == "i2" })
140 + }
141 +
142 + @Test
143 + fun syncNow_tombstoneDeletesContact() = runBlocking {
144 + db.crmContactDao().insert(
145 + CrmContactEntity(serverId = "c-old", fullName = "À supprimer", updatedAt = 500L),
146 + )
147 + api.pullResult = AilianceApiClient.ApiResult.Ok(
148 + SyncPullResponse(
149 + serverTime = "2026-07-22T12:05:00Z",
150 + tombstones = listOf(
151 + TombstoneDto(entityType = "contact", id = "c-old", supprimeLe = "2026-07-22T12:00:00Z"),
152 + ),
153 + ),
154 + )
155 +
156 + engine.syncNow()
157 +
158 + assertNull(db.crmContactDao().getByServerId("c-old"))
159 + }
160 +
161 + @Test
162 + fun syncNow_updatesWatermarkFromServerTime() = runBlocking {
163 + api.pullResult = AilianceApiClient.ApiResult.Ok(
164 + SyncPullResponse(serverTime = "2026-07-22T12:05:00Z"),
165 + )
166 +
167 + val result = engine.syncNow()
168 +
169 + assertTrue(result.success)
170 + assertEquals("2026-07-22T12:05:00Z", result.serverTime)
171 + assertEquals("2026-07-22T12:05:00Z", db.syncMetaDao().get("watermark")?.value)
172 + }
173 +
174 + @Test
175 + fun syncNow_pushesContactCreateOpAndMapsServerId() = runBlocking {
176 + val localId = db.crmContactDao().insert(CrmContactEntity(fullName = "Nouveau"))
177 + db.syncOpDao().insert(
178 + SyncOpEntity(
179 + entityType = "contact",
180 + op = "create",
181 + payloadJson = """{"fullName":"Nouveau"}""",
182 + localId = localId,
183 + createdAt = 1L,
184 + ),
185 + )
186 +
187 + engine.syncNow()
188 +
189 + assertEquals(1, api.createContactCalls.size)
190 + assertEquals(0, db.syncOpDao().listAll().size)
191 + assertEquals("srv-generated", db.crmContactDao().getById(localId)?.serverId)
192 + }
193 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/SyncSchemaDaoTest.kt
+92 -0
@@ -0,0 +1,92 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import fr.ebii.card2vcf.data.CrmDatabase
7 +import fr.ebii.card2vcf.data.TacheEntity
8 +import kotlinx.coroutines.runBlocking
9 +import org.junit.After
10 +import org.junit.Assert.assertEquals
11 +import org.junit.Assert.assertNotNull
12 +import org.junit.Assert.assertNull
13 +import org.junit.Before
14 +import org.junit.Test
15 +import org.junit.runner.RunWith
16 +import org.robolectric.RobolectricTestRunner
17 +import org.robolectric.annotation.Config
18 +
19 +@RunWith(RobolectricTestRunner::class)
20 +@Config(sdk = [31])
21 +class SyncSchemaDaoTest {
22 + private lateinit var db: CrmDatabase
23 + private lateinit var syncOpDao: SyncOpDao
24 + private lateinit var tacheDao: fr.ebii.card2vcf.data.TacheDao
25 +
26 + @Before
27 + fun setUp() {
28 + val ctx = ApplicationProvider.getApplicationContext<Context>()
29 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
30 + .allowMainThreadQueries()
31 + .build()
32 + syncOpDao = db.syncOpDao()
33 + tacheDao = db.tacheDao()
34 + }
35 +
36 + @After
37 + fun tearDown() = db.close()
38 +
39 + @Test
40 + fun syncOpInsertAndList() = runBlocking {
41 + val id = syncOpDao.insert(
42 + SyncOpEntity(
43 + entityType = "contact",
44 + op = "create",
45 + payloadJson = """{"fullName":"Ada"}""",
46 + localId = 42L,
47 + createdAt = 1000L,
48 + ),
49 + )
50 + val ops = syncOpDao.listAll()
51 + assertEquals(1, ops.size)
52 + assertEquals(id, ops[0].id)
53 + assertEquals("contact", ops[0].entityType)
54 + assertEquals("create", ops[0].op)
55 + assertEquals(42L, ops[0].localId)
56 + assertNotNull(syncOpDao.getById(id))
57 + syncOpDao.deleteById(id)
58 + assertNull(syncOpDao.getById(id))
59 + assertEquals(0, syncOpDao.listAll().size)
60 + }
61 +
62 + @Test
63 + fun tacheUpsertAndGetByServerId() = runBlocking {
64 + val localId = tacheDao.upsert(
65 + TacheEntity(
66 + serverId = "tache-1",
67 + projetServerId = "projet-1",
68 + titre = "Préparer démo",
69 + colonneId = "col-a",
70 + ordre = 2,
71 + assigneA = "alice",
72 + auteur = "bob",
73 + createdAt = 500L,
74 + updatedAt = 600L,
75 + ),
76 + )
77 + val found = tacheDao.getByServerId("tache-1")
78 + assertNotNull(found)
79 + assertEquals(localId, found!!.localId)
80 + assertEquals("Préparer démo", found.titre)
81 + assertEquals("projet-1", found.projetServerId)
82 + assertEquals(2, found.ordre)
83 + assertEquals("alice", found.assigneA)
84 +
85 + tacheDao.upsert(found.copy(titre = "Démo prête", updatedAt = 700L))
86 + assertEquals("Démo prête", tacheDao.getByServerId("tache-1")!!.titre)
87 +
88 + assertEquals(1, tacheDao.listByProjetServerId("projet-1").size)
89 + tacheDao.deleteByServerId("tache-1")
90 + assertNull(tacheDao.getByServerId("tache-1"))
91 + }
92 +}
A android/app/src/test/java/fr/ebii/card2vcf/ui/settings/SettingsViewModelTest.kt
+160 -0
@@ -0,0 +1,160 @@
1 +package fr.ebii.card2vcf.ui.settings
2 +
3 +import android.content.Context
4 +import androidx.test.core.app.ApplicationProvider
5 +import fr.ebii.card2vcf.sync.AilianceApi
6 +import fr.ebii.card2vcf.sync.AilianceApiClient
7 +import fr.ebii.card2vcf.sync.AuthCleResponse
8 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
9 +import kotlinx.coroutines.Dispatchers
10 +import kotlinx.coroutines.ExperimentalCoroutinesApi
11 +import kotlinx.coroutines.test.StandardTestDispatcher
12 +import kotlinx.coroutines.test.advanceUntilIdle
13 +import kotlinx.coroutines.test.resetMain
14 +import kotlinx.coroutines.test.runTest
15 +import kotlinx.coroutines.test.setMain
16 +import org.junit.After
17 +import org.junit.Assert.assertEquals
18 +import org.junit.Assert.assertTrue
19 +import org.junit.Before
20 +import org.junit.Test
21 +import org.junit.runner.RunWith
22 +import org.robolectric.RobolectricTestRunner
23 +import org.robolectric.annotation.Config
24 +
25 +/** Fake [AilianceApi] : seule `authCle` importe pour [SettingsViewModel]. */
26 +private class FakeAilianceApi(
27 + private val authResult: AilianceApiClient.ApiResult<AuthCleResponse>,
28 +) : AilianceApi {
29 + override fun authCle(nom: String, motDePasse: String) = authResult
30 + override fun syncStatus(sinceIso: String) = error("not used")
31 + override fun syncPull(sinceIso: String) = error("not used")
32 + override fun createContact(jsonBody: String) = error("not used")
33 + override fun updateContact(id: String, jsonBody: String) = error("not used")
34 + override fun deleteContact(id: String) = error("not used")
35 + override fun createEntreprise(jsonBody: String) = error("not used")
36 + override fun updateEntreprise(id: String, jsonBody: String) = error("not used")
37 + override fun deleteEntreprise(id: String) = error("not used")
38 + override fun createProjet(jsonBody: String) = error("not used")
39 + override fun updateProjet(id: String, jsonBody: String) = error("not used")
40 + override fun deleteProjet(id: String) = error("not used")
41 + override fun createTache(projetId: String, jsonBody: String) = error("not used")
42 + override fun updateTache(projetId: String, tacheId: String, jsonBody: String) = error("not used")
43 + override fun deleteTache(projetId: String, tacheId: String) = error("not used")
44 + override fun moveTache(projetId: String, tacheId: String, jsonBody: String) = error("not used")
45 + override fun createInteraction(contactId: String, jsonBody: String) = error("not used")
46 +}
47 +
48 +@OptIn(ExperimentalCoroutinesApi::class)
49 +@RunWith(RobolectricTestRunner::class)
50 +@Config(sdk = [31])
51 +class SettingsViewModelTest {
52 + private val testDispatcher = StandardTestDispatcher()
53 + private lateinit var store: SyncCredentialsStore
54 +
55 + @Before
56 + fun setUp() {
57 + Dispatchers.setMain(testDispatcher)
58 + val ctx = ApplicationProvider.getApplicationContext<Context>()
59 + val prefs = ctx.getSharedPreferences("settings_vm_test", Context.MODE_PRIVATE)
60 + prefs.edit().clear().apply()
61 + store = SyncCredentialsStore(prefs)
62 + }
63 +
64 + @After
65 + fun tearDown() {
66 + Dispatchers.resetMain()
67 + }
68 +
69 + @Test
70 + fun obtainKeyThenRetype_savesCredentials() = runTest(testDispatcher) {
71 + val authResponse = AuthCleResponse(
72 + nom = "alice",
73 + cleChiffree = FIXTURE_BLOB_B64,
74 + sel = FIXTURE_SEL_B64,
75 + kdf = "argon2id",
76 + cipher = "xchacha20poly1305",
77 + )
78 + val vm = SettingsViewModel(
79 + credentialsStore = store,
80 + apiFactory = { FakeAilianceApi(AilianceApiClient.ApiResult.Ok(authResponse)) },
81 + ioDispatcher = testDispatcher,
82 + )
83 +
84 + vm.setBaseUrl("https://api.example.com")
85 + vm.setUserName("alice")
86 + vm.setPassword(FIXTURE_MDP)
87 + vm.obtainKey()
88 + advanceUntilIdle()
89 +
90 + val afterAuth = vm.state.value
91 + assertTrue(afterAuth is SettingsUiState.LoggedOut)
92 + assertEquals(authResponse, (afterAuth as SettingsUiState.LoggedOut).pendingAuth)
93 + assertEquals("", afterAuth.password)
94 +
95 + vm.setRetypePassword(FIXTURE_MDP)
96 + vm.confirmRetype()
97 +
98 + val loggedIn = vm.state.value
99 + assertTrue(loggedIn is SettingsUiState.LoggedIn)
100 + assertEquals("alice", (loggedIn as SettingsUiState.LoggedIn).userName)
101 + assertEquals(FIXTURE_CLE, store.apiKey)
102 + assertEquals("https://api.example.com", store.baseUrl)
103 + }
104 +
105 + @Test
106 + fun obtainKey_invalidCredentials_showsInkError() = runTest(testDispatcher) {
107 + val vm = SettingsViewModel(
108 + credentialsStore = store,
109 + apiFactory = { FakeAilianceApi(AilianceApiClient.ApiResult.Err(401, "unauthorized")) },
110 + ioDispatcher = testDispatcher,
111 + )
112 +
113 + vm.setBaseUrl("https://api.example.com")
114 + vm.setUserName("alice")
115 + vm.setPassword("bad-password")
116 + vm.obtainKey()
117 + advanceUntilIdle()
118 +
119 + val state = vm.state.value
120 + assertTrue(state is SettingsUiState.LoggedOut)
121 + assertEquals("Identifiants invalides", (state as SettingsUiState.LoggedOut).error)
122 + }
123 +
124 + @Test
125 + fun confirmRetype_wrongPassword_keepsRetypeError() = runTest(testDispatcher) {
126 + val authResponse = AuthCleResponse(
127 + nom = "alice",
128 + cleChiffree = FIXTURE_BLOB_B64,
129 + sel = FIXTURE_SEL_B64,
130 + kdf = "argon2id",
131 + cipher = "xchacha20poly1305",
132 + )
133 + val vm = SettingsViewModel(
134 + credentialsStore = store,
135 + apiFactory = { FakeAilianceApi(AilianceApiClient.ApiResult.Ok(authResponse)) },
136 + ioDispatcher = testDispatcher,
137 + )
138 + vm.setBaseUrl("https://api.example.com")
139 + vm.setUserName("alice")
140 + vm.setPassword(FIXTURE_MDP)
141 + vm.obtainKey()
142 + advanceUntilIdle()
143 +
144 + vm.setRetypePassword("wrong-password")
145 + vm.confirmRetype()
146 +
147 + val state = vm.state.value
148 + assertTrue(state is SettingsUiState.LoggedOut)
149 + assertEquals("Mot de passe incorrect", (state as SettingsUiState.LoggedOut).retypeError)
150 + assertTrue(!store.isConfigured())
151 + }
152 +
153 + private companion object {
154 + const val FIXTURE_MDP = "card2vcf-fixture-mdp"
155 + const val FIXTURE_CLE = "sk_fixture_aabbccddeeff00112233"
156 + const val FIXTURE_SEL_B64 = "Fc+EFikMN7Cb8YoNZB+SZw=="
157 + const val FIXTURE_BLOB_B64 =
158 + "uW9uB9fP7m5ng6PoH9PGCXBKdPoVj9SceWy9GWLjg4x3Zk7MirbHhSCBVyLzoL2tK4qDir7UCOn503+s9rUFvIT/Eg5NqaU="
159 + }
160 +}
M android/build.gradle.kts
+1 -0
@@ -2,5 +2,6 @@ plugins {
2 2 id("com.android.application") version "8.6.0" apply false
3 3 id("org.jetbrains.kotlin.android") version "2.0.20" apply false
4 4 id("org.jetbrains.kotlin.plugin.compose") version "2.0.20" apply false
5 + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.20" apply false
5 6 id("com.google.devtools.ksp") version "2.0.20-1.0.25" apply false
6 7 }
A docs/plans/sync_card2vcf_projectiaon_v1.md
+341 -0
@@ -0,0 +1,341 @@
1 +# Sync Card2vcf ↔ Projectiaon — Plan client v1
2 +
3 +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. TDD (RED → GREEN). **Ne pas committer** sauf demande explicite de l’utilisateur.
4 +
5 +**Goal :** Card2vcf synchronise offline bidirectionnellement CRM + projets/tâches kanban avec Projectiaon (ailiance-brain), via clés API par utilisateur et sync incrémental.
6 +
7 +**Architecture :** Room local + file `SyncOp` ; `SyncEngine` pousse les ops puis tire `/api/sync/pull` ; LWW sur horodatages pour contacts/entreprises/projets/tâches ; append-only pour interactions (CR). Auth : login `POST /api/auth/cle` → déchiffrement local Argon2id + XChaCha20-Poly1305 → Bearer en stockage chiffré.
8 +
9 +**Tech Stack :** Kotlin, Jetpack Compose, Room 2.6, OkHttp + kotlinx.serialization, EncryptedSharedPreferences, Argon2 (params crate Rust `argon2` 0.5) + XChaCha20-Poly1305, Coroutines. Design : `design.md` (N&B éditorial, coins 0).
10 +
11 +**Prérequis serveur (FAIT) :** plan [`Projectiaon/docs/plans/sync_api_mobile_v1.md`](../../../Projectiaon/docs/plans/sync_api_mobile_v1.md) — code présent : `AILIANCE_API_CLES`, `POST /api/auth/cle`, tombstones, `GET /api/sync/status|pull`, `assigne_a` / `mis_a_jour_le`.
12 +
13 +**Hors scope v1 :** RDV, ressources/réservations, Agenda Android, médias serveur, multi-assignés.
14 +
15 +---
16 +
17 +## Décisions produit (validées)
18 +
19 +| Sujet | Décision |
20 +|---|---|
21 +| Rôle mobile | CRM terrain offline = mêmes infos serveur, partage équipe |
22 +| Sync | Incrémental + file d’ops (pas full overwrite seul) |
23 +| Auth | Clés par user ; mdp jamais stocké ; retape pour déchiffrer la clé une fois |
24 +| Conflits | LWW horodatage ; CR append-only ; move kanban = LWW `(colonne_id, ordre)` |
25 +| Déclenchement | Manuel + bandeau status à l’ouverture (pas de sync auto silencieuse) |
26 +| Kanban | v1 (API taches déjà là) |
27 +| Filtre tâches | Défaut « Mes tâches » (`assigne_a == user`) \| « Toutes » |
28 +
29 +---
30 +
31 +## Contrats serveur à respecter
32 +
33 +### Auth clé
34 +
35 +```http
36 +POST /api/auth/cle
37 +Content-Type: application/json
38 +
39 +{"nom":"alice","mot_de_passe":"…"}
40 +```
41 +
42 +```json
43 +{
44 + "nom": "alice",
45 + "cle_chiffree": "<base64>",
46 + "sel": "<base64>",
47 + "kdf": "argon2id",
48 + "cipher": "xchacha20poly1305"
49 +}
50 +```
51 +
52 +Blob `cle_chiffree` (binaire) : `[0..24) nonce XChaCha20` + ciphertext (clé API UTF-8 + tag Poly1305).
53 +Sel : 16 octets.
54 +KDF : Argon2id **defaults crate `argon2` 0.5.3** — `m=19456` (KiB), `t=2`, `p=1`, output 32 octets.
55 +401 credentials ; 404 user sans clé API.
56 +
57 +### Sync
58 +
59 +```http
60 +GET /api/sync/status?since=1970-01-01T00:00:00Z
61 +Authorization: Bearer <cle>
62 +```
63 +
64 +```json
65 +{
66 + "server_time": "…",
67 + "changes": {
68 + "contacts": 2, "entreprises": 0, "projets": 1,
69 + "taches": 3, "interactions": 1, "tombstones": 1
70 + },
71 + "total": 8
72 +}
73 +```
74 +
75 +```http
76 +GET /api/sync/pull?since=…
77 +```
78 +
79 +```json
80 +{
81 + "server_time": "…",
82 + "contacts": [], "entreprises": [], "projets": [],
83 + "taches": [{ "projet_id": "…", /* flatten Tache */ }],
84 + "interactions": [], "tombstones": [], "workflows": []
85 +}
86 +```
87 +
88 +Tombstone : `{ "type": "contact|entreprise|projet|tache|interaction", "id", "projet_id?", "supprime_le" }`.
89 +Projets pull : `taches[]` vidé (tâches dans `taches[]` top-level). ACL : si `membres` non vide, user Bearer doit ∈ membres.
90 +
91 +### CRUD utiles (Bearer)
92 +
93 +| Action | Méthode |
94 +|---|---|
95 +| Contact | `POST/PUT/DELETE /api/contacts[/:id]` |
96 +| Entreprise | `POST/PUT/DELETE /api/entreprises[/:id]` |
97 +| Projet | `POST/PUT/DELETE /api/projets[/:id]` |
98 +| Tâche | `POST/PUT/DELETE /api/projets/:id/taches[/:tid]` |
99 +| Déplacer | `POST /api/projets/:id/taches/:tid/deplacer` body `{colonne_id, ordre?}` |
100 +| CR | `POST /api/contacts/:id/interactions` |
101 +
102 +Champs clés tâche : `titre`, `description`, `colonne_id`, `ordre`, `assigne_a`, `mis_a_jour_le`.
103 +
104 +---
105 +
106 +## Fichiers (carte)
107 +
108 +| Fichier | Rôle |
109 +|---|---|
110 +| `android/app/src/main/AndroidManifest.xml` | `INTERNET` |
111 +| `android/app/build.gradle.kts` | OkHttp, serialization, security-crypto, argon2, libsodium/BC |
112 +| `…/sync/ApiCleCrypto.kt` | Déchiffrement clé API (miroir Rust) |
113 +| `…/sync/SyncCredentialsStore.kt` | URL + Bearer + nom user (EncryptedSharedPreferences) |
114 +| `…/sync/AilianceApiClient.kt` | HTTP auth/status/pull/CRUD |
115 +| `…/sync/SyncModels.kt` | DTO JSON (pull, tombstone, tache…) |
116 +| `…/sync/SyncOpEntity.kt` + Dao | File d’ops offline |
117 +| `…/sync/SyncEngine.kt` | push → pull → LWW / append / watermark |
118 +| `…/sync/LwwMerger.kt` | Règles fusion horodatage |
119 +| `…/data/*Entity.kt` | `serverId`, CRM fields, Projet/Tache/Interaction/Workflow |
120 +| `…/data/CrmDatabase.kt` | version ↑, entities, `fallbackToDestructiveMigration` v1 OK |
121 +| `…/ui/settings/SettingsScreen.kt` | URL + login clé |
122 +| `…/ui/projets/*` | Liste, fiche, CR |
123 +| `…/ui/kanban/*` | Board + filtre Mes/Toutes |
124 +| `…/ui/nav/Card2vcfNavHost.kt` | Routes + bottom nav Carnet \| Projets \| … |
125 +| `…/ui/sync/SyncBanner.kt` | Bandeau « N changements » |
126 +| `README.md` | Sync optionnelle documentée |
127 +| Tests unitaires sous `android/app/src/test/…` | Crypto, LWW, SyncEngine, filtre assigné |
128 +
129 +---
130 +
131 +## Task 0 — Prérequis serveur (skip si déjà vert)
132 +
133 +**Verify :**
134 +
135 +```bash
136 +cd /home/monsieurb/Documents/devel/Projectiaon
137 +cargo test -p ailiance-brain --test api_sync_http -- --nocapture
138 +```
139 +
140 +- [x] Multi-clés + `/api/auth/cle` + sync status/pull (code Projectiaon présent).
141 +Si rouge : corriger le serveur **avant** le mobile.
142 +
143 +---
144 +
145 +## Task 1 — Dépendances + `INTERNET` + store credentials
146 +
147 +**Files :**
148 +- Modify: `android/app/src/main/AndroidManifest.xml`
149 +- Modify: `android/app/build.gradle.kts`
150 +- Create: `…/sync/SyncCredentialsStore.kt`
151 +- Test: `…/test/…/sync/SyncCredentialsStoreTest.kt` (Robolectric)
152 +
153 +- [x] **Step 1 :** Ajouter permission `INTERNET` (et `usesCleartextTraffic` **non** — HTTPS only ; debug exception optionnelle documentée).
154 +- [x] **Step 2 :** Dépendances : `okhttp`, `kotlinx-serialization-json`, `androidx.security:security-crypto`, lib Argon2 JVM compatible params, lib XChaCha20-Poly1305 (ex. `com.github.ionspin.kotlin:multiplatform-crypto-libsodium` ou BouncyCastle + wrapper — choisir une stack qui tourne en unit test JVM).
155 +- [x] **Step 3 :** `SyncCredentialsStore` : `baseUrl`, `apiKey`, `userName` ; clear ; jamais le mot de passe.
156 +- [x] **Step 4 :** Test Robolectric round-trip store.
157 +- [x] **Verify :** `./gradlew :app:testDebugUnitTest --tests '*SyncCredentialsStore*'`
158 +
159 +---
160 +
161 +## Task 2 — Crypto `ApiCleCrypto` (TDD)
162 +
163 +**Files :**
164 +- Create: `…/sync/ApiCleCrypto.kt`
165 +- Test: `…/test/…/sync/ApiCleCryptoTest.kt`
166 +
167 +- [x] **Step 1 (RED) :** Test vector : sel 16 bytes, mdp connu, blob produit par un fixture base64 **généré côté Rust** (`chiffrer_cle_api`) — coller sel+blob+mdp+clair attendu dans le test.
168 +- [x] **Step 2 (GREEN) :** Implémenter `decryptApiKey(password, saltB64, cipherB64)` avec m=19456, t=2, p=1, Argon2id, XChaCha20-Poly1305, nonce 24 prefix.
169 +- [x] **Step 3 :** Test mauvais mdp → erreur ; blob trop court → erreur.
170 +- [x] **Verify :** unit tests verts. **Ne jamais logger** la clé claire.
171 +
172 +**Aide génération fixture (une fois) :**
173 +
174 +```bash
175 +cd Projectiaon && cargo test -p ailiance-brain --lib api_cle_crypto -- --nocapture
176 +# ou petit bin/println temporaire — retirer ensuite
177 +```
178 +
179 +---
180 +
181 +## Task 3 — Client HTTP + DTOs sync
182 +
183 +**Files :**
184 +- Create: `…/sync/SyncModels.kt`, `…/sync/AilianceApiClient.kt`
185 +- Test: `…/test/…/sync/AilianceApiClientTest.kt` (MockWebServer)
186 +
187 +- [x] `authCle(nom, mdp)` → parse réponse.
188 +- [x] `syncStatus(since)`, `syncPull(since)` Bearer.
189 +- [x] Stubs CRUD contacts / taches create|update|delete|move / interactions create (signatures utilisées par SyncEngine).
190 +- [x] 401/404 mappés en sealed class `ApiResult`.
191 +- [x] **Verify :** MockWebServer tests verts.
192 +
193 +---
194 +
195 +## Task 4 — Schéma Room sync
196 +
197 +**Files :** modifier entities + `CrmDatabase` version `2` ; DAOs ; `Converters` si besoin.
198 +
199 +Extensions contact :
200 +- `serverId: String?`
201 +- `statut`, `etape`, `tags: List<String>`, `entrepriseServerId: String?`
202 +- garder `updatedAt` local ; mapper `mis_a_jour_le` ISO ↔ epoch ms
203 +
204 +Nouvelles tables :
205 +- `entreprises` (`localId`, `serverId`, champs miroir, `updatedAt`)
206 +- `projets` (+ `membresJson`, `workflowServerId`, `updatedAt`)
207 +- `taches` (`projetLocalId` / `projetServerId`, `serverId`, `colonneId`, `ordre`, `assigneA`, `updatedAt`, …)
208 +- `interactions` (`contactServerId`, `serverId`, `type`, `sujet`, `description`, `createdAt` — append-only)
209 +- `workflows` + `workflow_colonnes` (ou JSON colonnes)
210 +- `sync_ops` : `id`, `entityType`, `op` (`create|update|delete|move`), `payloadJson`, `localId?`, `serverId?`, `createdAt`, `attempts`
211 +- prefs watermark : table `sync_meta(key,value)` clé `watermark` = ISO last `server_time`
212 +
213 +- [x] Migration : `fallbackToDestructiveMigration()` acceptable v1 (doc README : reset local à l’upgrade schema).
214 +- [x] Tests DAO smoke (insert/query) Robolectric comme `CrmContactDaoTest`.
215 +- [x] **Verify :** `./gradlew :app:testDebugUnitTest --tests '*Dao*'`
216 +
217 +---
218 +
219 +## Task 5 — `LwwMerger` + règles (TDD)
220 +
221 +**Files :** `…/sync/LwwMerger.kt` + tests
222 +
223 +- [x] Contact/Entreprise/Projet/Tache : si `remote.updatedAt > local.updatedAt` → remote gagne ; égalité → remote gagne (déterministe).
224 +- [x] Interaction : jamais écraser ; insert si `serverId` inconnu.
225 +- [x] Tombstone : supprimer local par `serverId` (+ cascade tâches si projet).
226 +- [x] Move : LWW sur `(colonneId, ordre, updatedAt)`.
227 +- [x] **Verify :** tests pure Kotlin verts.
228 +
229 +---
230 +
231 +## Task 6 — `SyncEngine`
232 +
233 +**Files :** `…/sync/SyncEngine.kt` + tests avec fake API + Room in-memory
234 +
235 +Séquence bouton Sync :
236 +1. Push chaque `SyncOp` (ordre FIFO) via CRUD ; sur succès retirer op + mapper `serverId` si create.
237 +2. `pull(since=watermark)` ; appliquer LWW / append / tombstones ; stocker workflows.
238 +3. Watermark = `server_time` du pull.
239 +
240 +À l’ouverture (léger) :
241 +- Si credentials : `status(since)` → exposer `pendingRemoteChanges = total` (UI bandeau). **Ne pas** pull auto.
242 +
243 +- [x] Tests : 2 contacts LWW ; 2 CR append ; move conflit LWW ; tombstone delete.
244 +- [x] **Verify :** unit tests SyncEngine.
245 +
246 +---
247 +
248 +## Task 7 — UI Paramètres + auth clé
249 +
250 +**Files :** `SettingsScreen`, ViewModel, routes nav, strings FR/EN
251 +
252 +- [x] Champs : URL base HTTPS, nom, mot de passe (temporaire).
253 +- [x] Bouton « Obtenir la clé » → `authCle` → dialog retape mdp → `ApiCleCrypto.decrypt` → store.
254 +- [x] Afficher user connecté + « Déconnecter » (clear store).
255 +- [x] Style `design.md` : coins 0, ink/canvas, Manrope/Lora/Playfair existants.
256 +- [x] Entrée depuis overflow / engrenage carnet.
257 +- [x] **Verify :** compile + smoke manuel ou UI test minimal si déjà du setup.
258 +
259 +---
260 +
261 +## Task 8 — Bandeau sync + câblage ouverture
262 +
263 +**Files :** `SyncBanner.kt`, `CarnetScreen` / scaffold app, `MainActivity`/`Card2vcfApp`
264 +
265 +- [x] Si `pendingRemoteChanges > 0` : bandeau hairline « N changement(s) sur le serveur » + bouton Sync.
266 +- [x] Bouton Sync toujours accessible (Paramètres ou bandeau) même si total=0 (push local).
267 +- [x] Pendant sync : état loading ; erreur réseau affichée (ink, pas snackbar coloré flashy).
268 +- [x] **Verify :** compile ; test ViewModel status mock.
269 +
270 +---
271 +
272 +## Task 9 — Onglet Projets + CR
273 +
274 +**Files :** nav bottom `Carnet | Projets` ; `ProjetsListScreen`, `ProjetDetailScreen` ; création CR = `Interaction` type note liée contact
275 +
276 +- [x] Liste projets Room (après sync).
277 +- [x] Fiche : nom, description, membres, contacts liés (ids serveur résolus localement si possible).
278 +- [x] Section CR : liste interactions du contact sélectionné / du projet (v1 : CR sur contact, append SyncOp create).
279 +- [x] Mutations locales → enqueue SyncOp + update Room optimiste.
280 +- [x] **Verify :** unit tests repository ; compile UI.
281 +
282 +---
283 +
284 +## Task 10 — Kanban + `assigne_a` + filtre
285 +
286 +**Files :** `KanbanBoard.kt`, filter Mes/Toutes, CRUD tâche, swipe/déplacer
287 +
288 +- [x] Colonnes = workflow local du `workflowServerId` du projet.
289 +- [x] Cartes : titre, pastille assigné, ordre.
290 +- [x] Créer / éditer / supprimer / déplacer → Room + SyncOp (`move` avec colonne+ordre).
291 +- [x] Sélecteur `assigne_a` : membres projet (+ soi = `userName` du store).
292 +- [x] Filtre défaut **Mes tâches** ; toggle **Toutes**. Non assigné = visible seulement dans Toutes.
293 +- [x] **Verify :** tests filtre + merge move ; compile.
294 +
295 +---
296 +
297 +## Task 11 — Mapping IDs + push contacts carnet existant
298 +
299 +**Files :** adapters repository carnet ↔ server contact
300 +
301 +- [x] À la création locale d’un contact après login : enqueue create avec payload API.
302 +- [x] Pull contact → upsert par `serverId` ; si match email/tél fort optionnel hors v1 (LWW sur serverId suffit).
303 +- [x] Édition / suppression locale → update/delete SyncOp.
304 +- [x] **Verify :** tests mapping ; pas de régression `ContactRepositoryTest`.
305 +
306 +---
307 +
308 +## Task 12 — Doc README
309 +
310 +**Files :** `README.md`
311 +
312 +- [x] Section Sync (optionnelle) : prérequis serveur, flux clé, hors-ligne, pas GMS.
313 +- [x] Nuancer « aucune permission Internet » → Internet **uniquement** pour sync (OCR reste offline).
314 +- [x] Lien vers ce plan + plan serveur Projectiaon.
315 +
316 +---
317 +
318 +## Ordre d’exécution / commits suggérés (si demandé)
319 +
320 +1. `feat(sync): credentials store + INTERNET + deps`
321 +2. `feat(sync): ApiCleCrypto argon2id xchacha`
322 +3. `feat(sync): AilianceApiClient + DTOs`
323 +4. `feat(sync): Room schema v2 entities`
324 +5. `feat(sync): LwwMerger + SyncEngine`
325 +6. `feat(ui): settings auth key`
326 +7. `feat(ui): sync banner + projets + CR`
327 +8. `feat(ui): kanban assigne_a filter`
328 +9. `docs: README sync optionnelle`
329 +
330 +## Critère de done client v1
331 +
332 +- Login Paramètres → clé déchiffrée stockée ; Bearer sur status/pull
333 +- Sync manuelle : push ops + pull delta ; watermark avancé
334 +- 2 devices / 2 users : LWW contact ; 2 CR présents ; move tâche LWW ; filtre Mes tâches
335 +- Bandeau à l’ouverture si `total > 0` sans pull auto
336 +- `./gradlew :app:testDebugUnitTest` vert ; `assembleDebug` OK
337 +- Pas de clé/mdp en clair dans logs
338 +
339 +## v2 (plus tard, plan séparé)
340 +
341 +API RDV/ressources/réservations + Agenda Android (`CalendarContract`).
M icon.png
+0 -0