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 | [](LICENSE) |
| 8 | 8 |  |
@@ -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
GitRust