ajout notes vocale

EBO <eric.bouhana@softalys.com> committé le 2026-09-15 20:36

70b703a84499b0a71c224cee8bad7e7c63e92127

1 parent(s)

50 fichiers modifiés +4256 -73
M android/app/build.gradle.kts
+68 -0
@@ -1,6 +1,7 @@
1 1 import java.net.URI
2 2 import java.security.MessageDigest
3 3 import java.util.Properties
4 +import java.util.zip.ZipInputStream
4 5
5 6 plugins {
6 7 id("com.android.application")
@@ -21,6 +22,17 @@ val tessdataLanguages = listOf("fra", "deu", "eng", "spa", "por", "ita", "pol")
21 22 val tessdataCacheDir = rootProject.layout.projectDirectory.dir("tessdata-cache/$tessdataVariant")
22 23 val tessdataGeneratedAssets = layout.buildDirectory.dir("generated/tessdataAssets")
23 24
25 +/** `standard` (défaut, ~41 Mo) ou `none` (désactive le modèle). Ex. : ./gradlew assembleDebug -Pvosk=none */
26 +val voskVariante: String =
27 + (findProperty("vosk") as String?)?.lowercase()?.trim().orEmpty().ifEmpty { "standard" }
28 +require(voskVariante == "standard" || voskVariante == "none") {
29 + "Propriété -Pvosk= invalide (« $voskVariante ») — utiliser standard ou none"
30 +}
31 +
32 +val VOSK_MODEL_NOM = "vosk-model-small-fr-0.22"
33 +val voskCacheDir = rootProject.layout.projectDirectory.dir("vosk-cache")
34 +val voskGeneratedAssets = layout.buildDirectory.dir("generated/voskAssets")
35 +
24 36 /** Signature release : lue depuis android/keystore.properties (hors git).
25 37 * Absente → assembleRelease produit un APK non signé, comme avant. */
26 38 val keystoreProperties = Properties().apply {
@@ -55,6 +67,7 @@ android {
55 67 unitTests.isIncludeAndroidResources = true
56 68 }
57 69 sourceSets.getByName("main").assets.srcDir(tessdataGeneratedAssets)
70 + sourceSets.getByName("main").assets.srcDir(voskGeneratedAssets)
58 71 signingConfigs {
59 72 if (keystoreProperties.getProperty("storeFile") != null) {
60 73 create("release") {
@@ -176,6 +189,58 @@ val tessdataStatus by tasks.registering {
176 189 }
177 190 }
178 191
192 +val prepareVoskModel by tasks.registering {
193 + group = "build"
194 + description = "Télécharge (cache) et prépare le modèle Vosk ($VOSK_MODEL_NOM)"
195 + inputs.property("variante", voskVariante)
196 + outputs.dir(voskGeneratedAssets)
197 + doLast {
198 + val outDir = voskGeneratedAssets.get().asFile.apply { mkdirs() }
199 + if (voskVariante == "none") {
200 + logger.lifecycle("Vosk désactivé (-Pvosk=none) — modèle absent des assets.")
201 + return@doLast
202 + }
203 + val cache = voskCacheDir.asFile.apply { mkdirs() }
204 + val zip = cache.resolve("$VOSK_MODEL_NOM.zip")
205 + if (!zip.isFile || zip.length() < 1_000_000L) {
206 + val url = "https://alphacephei.com/vosk/models/$VOSK_MODEL_NOM.zip"
207 + logger.lifecycle("Téléchargement $VOSK_MODEL_NOM.zip (~41 Mo) …")
208 + URI(url).toURL().openStream().use { input ->
209 + zip.outputStream().use { output -> input.copyTo(output) }
210 + }
211 + check(zip.isFile && zip.length() >= 1_000_000L) {
212 + "Échec téléchargement $url"
213 + }
214 + }
215 + val modelDir = outDir.resolve(VOSK_MODEL_NOM)
216 + if (!modelDir.isDirectory) {
217 + logger.lifecycle("Extraction $VOSK_MODEL_NOM.zip …")
218 + ZipInputStream(zip.inputStream()).use { zis ->
219 + var entry = zis.nextEntry
220 + while (entry != null) {
221 + val target = outDir.resolve(entry.name)
222 + if (entry.isDirectory) {
223 + target.mkdirs()
224 + } else {
225 + target.parentFile?.mkdirs()
226 + target.outputStream().use { zis.copyTo(it) }
227 + }
228 + zis.closeEntry()
229 + entry = zis.nextEntry
230 + }
231 + }
232 + }
233 + logger.lifecycle("Modèle Vosk $VOSK_MODEL_NOM prêt.")
234 + }
235 +}
236 +
237 +tasks.matching {
238 + it.name.startsWith("merge") && it.name.endsWith("Assets")
239 +}.configureEach {
240 + dependsOn(prepareVoskModel)
241 +}
242 +tasks.named("preBuild").configure { dependsOn(prepareVoskModel) }
243 +
179 244 dependencies {
180 245 implementation(platform("androidx.compose:compose-bom:2024.09.02"))
181 246 implementation("androidx.compose.ui:ui")
@@ -209,6 +274,7 @@ dependencies {
209 274
210 275 implementation("com.squareup.okhttp3:okhttp:4.12.0")
211 276 testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
277 + implementation("io.noties.markwon:core:4.6.2")
212 278 implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
213 279 implementation("androidx.security:security-crypto:1.1.0-alpha06")
214 280 implementation("org.bouncycastle:bcprov-jdk18on:1.85")
@@ -216,4 +282,6 @@ dependencies {
216 282 androidTestImplementation("androidx.test.ext:junit:1.2.1")
217 283 androidTestImplementation("androidx.test:runner:1.6.2")
218 284 androidTestImplementation("androidx.test:rules:1.6.1")
285 +
286 + implementation("com.alphacephei:vosk-android:0.3.47")
219 287 }
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.RECORD_AUDIO" />
4 5 <uses-permission android:name="android.permission.INTERNET" />
5 6 <uses-permission android:name="android.permission.READ_CALENDAR" />
6 7 <uses-permission android:name="android.permission.WRITE_CALENDAR" />
A android/app/src/main/java/fr/ebii/card2vcf/audio/AudioNoteRecorder.kt
+99 -0
@@ -0,0 +1,99 @@
1 +package fr.ebii.card2vcf.audio
2 +
3 +import android.media.AudioFormat
4 +import android.media.AudioRecord
5 +import android.media.MediaRecorder
6 +import android.util.Log
7 +import java.io.File
8 +
9 +/** Résultat d'un enregistrement terminé. */
10 +data class ResultatEnregistrement(val chemin: String, val dureeMsec: Long)
11 +
12 +/**
13 + * Enregistre un fichier WAV (PCM 16 kHz mono 16 bits) via [AudioRecord].
14 + *
15 + * Chaque buffer PCM capturé est aussi transmis via [onPcm] pour alimenter
16 + * [TranscripteurVosk] en streaming pendant l'enregistrement.
17 + *
18 + * Durée max : 10 minutes (arrêt automatique).
19 + *
20 + * API :
21 + * - [demarrer] : lance l'enregistrement dans un thread dédié.
22 + * - [arreter] : stoppe l'enregistrement et attend la fin du thread.
23 + *
24 + * La demande de permission RECORD_AUDIO est à la charge de l'UI (P2-A4).
25 + */
26 +class AudioNoteRecorder {
27 +
28 + companion object {
29 + private const val TAG = "AudioNoteRecorder"
30 + private const val FREQUENCE = 16_000
31 + private const val DUREE_MAX_MS = 10L * 60 * 1_000
32 + private const val CANAL = AudioFormat.CHANNEL_IN_MONO
33 + private const val FORMAT = AudioFormat.ENCODING_PCM_16BIT
34 + }
35 +
36 + @Volatile private var enCours = false
37 + @Volatile private var dernierResultat: ResultatEnregistrement? = null
38 + private var threadEnregistrement: Thread? = null
39 +
40 + /**
41 + * Démarre l'enregistrement vers [fichierCible].
42 + * [onPcm] est appelé sur le thread d'enregistrement à chaque buffer capturé.
43 + */
44 + fun demarrer(fichierCible: File, onPcm: (ShortArray, Int) -> Unit) {
45 + check(!enCours) { "Enregistrement déjà en cours" }
46 + dernierResultat = null
47 + enCours = true
48 + threadEnregistrement = Thread(
49 + { executer(fichierCible, onPcm) },
50 + "AudioNoteRecorder",
51 + ).also { it.start() }
52 + }
53 +
54 + /**
55 + * Stoppe l'enregistrement et attend la fin du thread (max 3 s).
56 + * @return Le résultat de l'enregistrement, ou null si non démarré.
57 + */
58 + fun arreter(): ResultatEnregistrement? {
59 + enCours = false
60 + threadEnregistrement?.join(3_000)
61 + threadEnregistrement = null
62 + return dernierResultat
63 + }
64 +
65 + private fun executer(fichierCible: File, onPcm: (ShortArray, Int) -> Unit) {
66 + val tailleBuffer = AudioRecord.getMinBufferSize(FREQUENCE, CANAL, FORMAT)
67 + .coerceAtLeast(FREQUENCE * 2) // >= 0,5 s de tampon
68 + val record = AudioRecord(
69 + MediaRecorder.AudioSource.MIC,
70 + FREQUENCE,
71 + CANAL,
72 + FORMAT,
73 + tailleBuffer,
74 + )
75 + val ecriture = EcritureWav(fichierCible, FREQUENCE)
76 + val buffer = ShortArray(tailleBuffer / 2)
77 + val debut = System.currentTimeMillis()
78 + try {
79 + record.startRecording()
80 + while (enCours && System.currentTimeMillis() - debut < DUREE_MAX_MS) {
81 + val lu = record.read(buffer, 0, buffer.size)
82 + if (lu > 0) {
83 + ecriture.ecrireEchantillons(buffer, lu)
84 + onPcm(buffer, lu)
85 + }
86 + }
87 + } catch (e: Exception) {
88 + Log.e(TAG, "Erreur pendant l'enregistrement", e)
89 + } finally {
90 + record.stop()
91 + record.release()
92 + ecriture.fermer()
93 + dernierResultat = ResultatEnregistrement(
94 + chemin = fichierCible.absolutePath,
95 + dureeMsec = System.currentTimeMillis() - debut,
96 + )
97 + }
98 + }
99 +}
A android/app/src/main/java/fr/ebii/card2vcf/audio/EcritureWav.kt
+71 -0
@@ -0,0 +1,71 @@
1 +package fr.ebii.card2vcf.audio
2 +
3 +import java.io.File
4 +import java.io.RandomAccessFile
5 +import java.nio.ByteBuffer
6 +import java.nio.ByteOrder
7 +
8 +/**
9 + * Écrit un fichier WAV PCM (16 kHz mono 16 bits par défaut).
10 + *
11 + * Séparée de [AudioNoteRecorder] pour être testable en JVM pur, sans dépendance Android.
12 + * L'en-tête RIFF/fmt/data est réécrit à la clôture avec les tailles exactes.
13 + */
14 +class EcritureWav(
15 + private val fichier: File,
16 + private val frequence: Int = 16_000,
17 +) {
18 + private val raf = RandomAccessFile(fichier, "rw")
19 + private var octetsData: Long = 0L
20 +
21 + init {
22 + // Réserve 44 octets pour l'en-tête ; sera réécrit à fermer()
23 + raf.write(ByteArray(44))
24 + }
25 +
26 + /** Écrit des échantillons PCM 16 bits signés (little-endian). */
27 + fun ecrireEchantillons(data: ShortArray, longueur: Int) {
28 + val buf = ByteArray(longueur * 2)
29 + val bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN)
30 + for (i in 0 until longueur) bb.putShort(data[i])
31 + raf.write(buf)
32 + octetsData += longueur * 2L
33 + }
34 +
35 + /**
36 + * Finalise l'en-tête WAV et ferme le fichier.
37 + * @return Nombre d'octets de données PCM écrits.
38 + */
39 + fun fermer(): Long {
40 + // 10 min à 16 kHz 16 bits mono ≈ 18,3 Mo — largement sous Int.MAX_VALUE.
41 + // La garde protège contre un enregistrement pathologiquement long ou un bug de comptage.
42 + check(octetsData <= Int.MAX_VALUE) { "Fichier WAV trop grand : $octetsData octets (max ${Int.MAX_VALUE})" }
43 + val tailleData = octetsData.toInt()
44 + // tailleFichier = octets après "RIFF"+int32 = 4(WAVE)+8(fmt tag+size)+16(fmt)+8(data tag+size)+data
45 + val tailleFichier = tailleData + 36
46 + val canaux = 1
47 + val bitsParEchantillon = 16
48 + val byteRate = frequence * canaux * bitsParEchantillon / 8
49 + val blockAlign = canaux * bitsParEchantillon / 8
50 +
51 + val entete = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN)
52 + entete.put("RIFF".toByteArray(Charsets.US_ASCII))
53 + entete.putInt(tailleFichier)
54 + entete.put("WAVE".toByteArray(Charsets.US_ASCII))
55 + entete.put("fmt ".toByteArray(Charsets.US_ASCII))
56 + entete.putInt(16) // taille du chunk fmt
57 + entete.putShort(1) // format PCM
58 + entete.putShort(canaux.toShort())
59 + entete.putInt(frequence)
60 + entete.putInt(byteRate)
61 + entete.putShort(blockAlign.toShort())
62 + entete.putShort(bitsParEchantillon.toShort())
63 + entete.put("data".toByteArray(Charsets.US_ASCII))
64 + entete.putInt(tailleData)
65 +
66 + raf.seek(0)
67 + raf.write(entete.array())
68 + raf.close()
69 + return octetsData
70 + }
71 +}
A android/app/src/main/java/fr/ebii/card2vcf/audio/TranscripteurLocal.kt
+32 -0
@@ -0,0 +1,32 @@
1 +package fr.ebii.card2vcf.audio
2 +
3 +/**
4 + * Interface de transcription locale (ASR sur l'appareil).
5 + *
6 + * Implémentée par [TranscripteurVosk] ; un fake est fourni dans les tests.
7 + * Quand le modèle est absent des assets (build `-Pvosk=none`), l'[etat] vaut
8 + * [Etat.INDISPONIBLE] et l'UI doit forcer le mode serveur.
9 + */
10 +interface TranscripteurLocal {
11 +
12 + enum class Etat { DISPONIBLE, INDISPONIBLE }
13 +
14 + val etat: Etat
15 +
16 + /**
17 + * Alimente le moteur avec un buffer PCM capturé par [AudioNoteRecorder].
18 + * Appelé sur le thread d'enregistrement.
19 + *
20 + * @return Un résultat partiel si disponible, null sinon.
21 + */
22 + fun accepterEchantillons(data: ShortArray, longueur: Int): String?
23 +
24 + /**
25 + * Demande le résultat final après la fin de l'enregistrement.
26 + * @return Le texte transcrit (peut être vide).
27 + */
28 + fun finaliser(): String
29 +
30 + /** Réinitialise l'état interne pour une nouvelle session. */
31 + fun reinitialiser()
32 +}
A android/app/src/main/java/fr/ebii/card2vcf/audio/TranscripteurVosk.kt
+135 -0
@@ -0,0 +1,135 @@
1 +package fr.ebii.card2vcf.audio
2 +
3 +import android.content.Context
4 +import android.util.Log
5 +import kotlinx.serialization.json.Json
6 +import kotlinx.serialization.json.JsonPrimitive
7 +import kotlinx.serialization.json.jsonObject
8 +import org.vosk.Model
9 +import org.vosk.Recognizer
10 +import java.io.File
11 +
12 +/**
13 + * Transcription locale via le moteur Vosk (modèle français small ~41 Mo).
14 + *
15 + * Sur le premier appel à [accepterEchantillons], le modèle est extrait des assets
16 + * vers [Context.filesDir] si ce n'est pas déjà fait.
17 + *
18 + * Si le modèle est absent des assets (build `-Pvosk=none`), [etat] vaut
19 + * [TranscripteurLocal.Etat.INDISPONIBLE] et toutes les méthodes de transcription
20 + * renvoient des valeurs vides/null sans planter.
21 + */
22 +class TranscripteurVosk(private val context: Context) : TranscripteurLocal {
23 +
24 + companion object {
25 + private const val TAG = "TranscripteurVosk"
26 + private const val NOM_MODELE = "vosk-model-small-fr-0.22"
27 + private const val FREQUENCE = 16_000f
28 +
29 + private val json = Json { ignoreUnknownKeys = true }
30 +
31 + /**
32 + * Extrait la valeur de la clé "partial" du JSON Vosk.
33 + * Retourne null si le texte est vide ou le JSON invalide.
34 + */
35 + fun parsagePartiel(jsonStr: String): String? = runCatching {
36 + (json.parseToJsonElement(jsonStr).jsonObject["partial"] as? JsonPrimitive)
37 + ?.content
38 + ?.takeIf { it.isNotEmpty() }
39 + }.getOrNull()
40 +
41 + /**
42 + * Extrait la valeur de la clé "text" du JSON Vosk.
43 + * Retourne une chaîne vide si le JSON est invalide.
44 + */
45 + fun parsageTexte(jsonStr: String): String = runCatching {
46 + (json.parseToJsonElement(jsonStr).jsonObject["text"] as? JsonPrimitive)
47 + ?.content
48 + ?: ""
49 + }.getOrDefault("")
50 + }
51 +
52 + override val etat: TranscripteurLocal.Etat by lazy { detecterEtat() }
53 +
54 + private var modele: Model? = null
55 + private var reconnaisseur: Recognizer? = null
56 +
57 + private fun detecterEtat(): TranscripteurLocal.Etat {
58 + val disponible = runCatching {
59 + context.assets.list("")?.contains(NOM_MODELE) == true
60 + }.getOrDefault(false)
61 + if (!disponible) {
62 + Log.w(TAG, "Modèle Vosk absent des assets — mode serveur requis (build -Pvosk=none ?)")
63 + }
64 + return if (disponible) TranscripteurLocal.Etat.DISPONIBLE else TranscripteurLocal.Etat.INDISPONIBLE
65 + }
66 +
67 + /** Retourne le [Recognizer] prêt à l'emploi, ou null si modèle indisponible. */
68 + private fun obtenirReconnaisseur(): Recognizer? {
69 + if (etat == TranscripteurLocal.Etat.INDISPONIBLE) return null
70 + if (reconnaisseur != null) return reconnaisseur
71 + return runCatching {
72 + val repModele = extraireModele()
73 + val m = Model(repModele.absolutePath).also { modele = it }
74 + Recognizer(m, FREQUENCE).also { reconnaisseur = it }
75 + }.onFailure { e ->
76 + Log.e(TAG, "Impossible d'initialiser le moteur Vosk", e)
77 + }.getOrNull()
78 + }
79 +
80 + /** Retourne le répertoire du modèle dans filesDir, après extraction si nécessaire. */
81 + private fun extraireModele(): File {
82 + val dest = File(context.filesDir, NOM_MODELE)
83 + if (dest.isDirectory && dest.list()?.isNotEmpty() == true) return dest
84 + Log.i(TAG, "Extraction du modèle Vosk vers ${dest.absolutePath} …")
85 + copierRepertoireAssets(NOM_MODELE, dest)
86 + Log.i(TAG, "Modèle Vosk extrait.")
87 + return dest
88 + }
89 +
90 + private fun copierRepertoireAssets(cheminAsset: String, destDir: File) {
91 + destDir.mkdirs()
92 + val enfants = context.assets.list(cheminAsset) ?: return
93 + for (enfant in enfants) {
94 + val sousAsset = "$cheminAsset/$enfant"
95 + val sousDest = File(destDir, enfant)
96 + val sousEnfants = context.assets.list(sousAsset) ?: emptyArray()
97 + if (sousEnfants.isEmpty()) {
98 + // fichier feuille
99 + context.assets.open(sousAsset).use { input ->
100 + sousDest.outputStream().use { input.copyTo(it) }
101 + }
102 + } else {
103 + copierRepertoireAssets(sousAsset, sousDest)
104 + }
105 + }
106 + }
107 +
108 + override fun accepterEchantillons(data: ShortArray, longueur: Int): String? {
109 + val rec = obtenirReconnaisseur() ?: return null
110 + val octets = shortArrayVersOctets(data, longueur)
111 + val complet = rec.acceptWaveForm(octets, octets.size)
112 + return if (complet) parsageTexte(rec.result) else parsagePartiel(rec.partialResult)
113 + }
114 +
115 + override fun finaliser(): String {
116 + val rec = reconnaisseur ?: return ""
117 + return parsageTexte(rec.finalResult)
118 + }
119 +
120 + override fun reinitialiser() {
121 + reconnaisseur?.close()
122 + reconnaisseur = null
123 + }
124 +
125 + /** Convertit un tableau de Short PCM en ByteArray little-endian. */
126 + private fun shortArrayVersOctets(data: ShortArray, longueur: Int): ByteArray {
127 + val octets = ByteArray(longueur * 2)
128 + for (i in 0 until longueur) {
129 + val s = data[i].toInt()
130 + octets[i * 2] = (s and 0xFF).toByte()
131 + octets[i * 2 + 1] = (s ushr 8 and 0xFF).toByte()
132 + }
133 + return octets
134 + }
135 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/AudioNoteStore.kt
+62 -0
@@ -0,0 +1,62 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import android.content.Context
4 +import java.io.File
5 +
6 +/**
7 + * Stockage local des fichiers audio WAV téléchargés depuis le serveur.
8 + * Le téléchargement est à la demande (P2-A4) ; cette classe expose uniquement
9 + * les primitives save/path/delete — le pull n'y touche pas.
10 + *
11 + * Pattern : [ContactImageStore] mais pour les WAV d'interactions et de notes.
12 + */
13 +class AudioNoteStore(private val context: Context) {
14 +
15 + private fun interactionDir(localId: Long): File =
16 + File(context.filesDir, "interactions/$localId").also { it.mkdirs() }
17 +
18 + private fun noteDir(localId: Long): File =
19 + File(context.filesDir, "notes_audio/$localId").also { it.mkdirs() }
20 +
21 + /** Enregistre les octets d'un audio d'interaction ; retourne le chemin absolu. */
22 + fun saveInteractionAudio(localId: Long, filename: String, bytes: ByteArray): String {
23 + val file = File(interactionDir(localId), filename)
24 + file.writeBytes(bytes)
25 + return file.absolutePath
26 + }
27 +
28 + /** Enregistre les octets d'un audio de note projet ; retourne le chemin absolu. */
29 + fun saveNoteAudio(localId: Long, filename: String, bytes: ByteArray): String {
30 + val file = File(noteDir(localId), filename)
31 + file.writeBytes(bytes)
32 + return file.absolutePath
33 + }
34 +
35 + fun deleteInteractionAudio(localId: Long) {
36 + interactionDir(localId).deleteRecursively()
37 + }
38 +
39 + fun deleteNoteAudio(localId: Long) {
40 + noteDir(localId).deleteRecursively()
41 + }
42 +
43 + /** Déplace [fichierTemp] vers le dossier de l'interaction ; retourne le chemin absolu final. */
44 + fun deplacerInteractionAudio(localId: Long, fichierTemp: File): String =
45 + deplacerFichierAudio(fichierTemp, File(interactionDir(localId), "audio.wav"))
46 +
47 + /** Déplace [fichierTemp] vers le dossier de la note projet ; retourne le chemin absolu final. */
48 + fun deplacerNoteAudio(localId: Long, fichierTemp: File): String =
49 + deplacerFichierAudio(fichierTemp, File(noteDir(localId), "audio.wav"))
50 +}
51 +
52 +/**
53 + * Copie [fichierTemp] vers [cible] (en créant les dossiers parents), supprime la source,
54 + * et retourne le chemin absolu de la cible.
55 + * Fonction interne extraite pour les tests JVM (pas de dépendance Android).
56 + */
57 +internal fun deplacerFichierAudio(fichierTemp: File, cible: File): String {
58 + cible.parentFile?.mkdirs()
59 + fichierTemp.copyTo(cible, overwrite = true)
60 + fichierTemp.delete()
61 + return cible.absolutePath
62 +}
M android/app/src/main/java/fr/ebii/card2vcf/data/CrmDatabase.kt
+42 -2
@@ -27,8 +27,9 @@ import fr.ebii.card2vcf.sync.SyncOpEntity
27 27 RdvEntity::class,
28 28 ReservationEntity::class,
29 29 IndisponibiliteEntity::class,
30 + NoteProjetEntity::class,
30 31 ],
31 - version = 4,
32 + version = 7,
32 33 exportSchema = false,
33 34 )
34 35 @TypeConverters(Converters::class)
@@ -44,6 +45,7 @@ abstract class CrmDatabase : RoomDatabase() {
44 45 abstract fun rdvDao(): RdvDao
45 46 abstract fun reservationDao(): ReservationDao
46 47 abstract fun indisponibiliteDao(): IndisponibiliteDao
48 + abstract fun noteProjetDao(): NoteProjetDao
47 49
48 50 companion object {
49 51 @Volatile private var instance: CrmDatabase? = null
@@ -55,6 +57,44 @@ abstract class CrmDatabase : RoomDatabase() {
55 57 }
56 58 }
57 59
60 + /** v5→v6 : colonnes `audioPath` et `transcriptionStatut` sur la table `interactions`. */
61 + val MIGRATION_5_6 = object : Migration(5, 6) {
62 + override fun migrate(db: SupportSQLiteDatabase) {
63 + db.execSQL("ALTER TABLE interactions ADD COLUMN audioPath TEXT")
64 + db.execSQL("ALTER TABLE interactions ADD COLUMN transcriptionStatut TEXT")
65 + }
66 + }
67 +
68 + /** v6→v7 : colonne `transcriptionErreur` sur `interactions` et `notes_projet`. */
69 + val MIGRATION_6_7 = object : Migration(6, 7) {
70 + override fun migrate(db: SupportSQLiteDatabase) {
71 + db.execSQL("ALTER TABLE interactions ADD COLUMN transcriptionErreur TEXT")
72 + db.execSQL("ALTER TABLE notes_projet ADD COLUMN transcriptionErreur TEXT")
73 + }
74 + }
75 +
76 + /** v4→v5 : table `notes_projet` (notes markdown de projet, champs audio pour phase 2). */
77 + val MIGRATION_4_5 = object : Migration(4, 5) {
78 + override fun migrate(db: SupportSQLiteDatabase) {
79 + db.execSQL(
80 + """
81 + CREATE TABLE IF NOT EXISTS notes_projet (
82 + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
83 + serverId TEXT,
84 + projetServerId TEXT NOT NULL,
85 + titre TEXT NOT NULL,
86 + texte TEXT NOT NULL,
87 + audioPath TEXT,
88 + transcriptionStatut TEXT,
89 + auteur TEXT NOT NULL,
90 + createdAt INTEGER NOT NULL,
91 + updatedAt INTEGER
92 + )
93 + """.trimIndent(),
94 + )
95 + }
96 + }
97 +
58 98 /**
59 99 * v1/v2/v3 sync schema : pas de migration incrémentale — reset local à l'upgrade (acceptable v1-v3).
60 100 * À partir de v3→v4 les migrations sont additives ; le fallback destructif reste en filet.
@@ -67,7 +107,7 @@ abstract class CrmDatabase : RoomDatabase() {
67 107 CrmDatabase::class.java,
68 108 "card2vcf-crm.db",
69 109 )
70 - .addMigrations(MIGRATION_3_4)
110 + .addMigrations(MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7)
71 111 .fallbackToDestructiveMigration()
72 112 .build()
73 113 .also { instance = it }
M android/app/src/main/java/fr/ebii/card2vcf/data/InteractionDao.kt
+10 -0
@@ -4,6 +4,7 @@ import androidx.room.Dao
4 4 import androidx.room.Insert
5 5 import androidx.room.OnConflictStrategy
6 6 import androidx.room.Query
7 +import androidx.room.Update
7 8 import kotlinx.coroutines.flow.Flow
8 9
9 10 @Dao
@@ -11,12 +12,21 @@ interface InteractionDao {
11 12 @Insert(onConflict = OnConflictStrategy.REPLACE)
12 13 suspend fun upsert(entity: InteractionEntity): Long
13 14
15 + @Update
16 + suspend fun update(entity: InteractionEntity)
17 +
18 + @Query("SELECT * FROM interactions WHERE localId = :localId")
19 + suspend fun getByLocalId(localId: Long): InteractionEntity?
20 +
14 21 @Query("SELECT * FROM interactions WHERE serverId = :serverId")
15 22 suspend fun getByServerId(serverId: String): InteractionEntity?
16 23
17 24 @Query("DELETE FROM interactions WHERE serverId = :serverId")
18 25 suspend fun deleteByServerId(serverId: String)
19 26
27 + @Query("DELETE FROM interactions WHERE localId = :localId")
28 + suspend fun deleteByLocalId(localId: Long)
29 +
20 30 @Query("SELECT * FROM interactions WHERE contactServerId = :contactServerId")
21 31 suspend fun listByContactServerId(contactServerId: String): List<InteractionEntity>
22 32
M android/app/src/main/java/fr/ebii/card2vcf/data/InteractionEntity.kt
+6 -0
@@ -14,4 +14,10 @@ data class InteractionEntity(
14 14 val creePar: String = "",
15 15 val createdAt: Long = 0L,
16 16 val updatedAt: Long? = null,
17 + /** Chemin local vers le fichier audio WAV (null si pas de note vocale). */
18 + val audioPath: String? = null,
19 + /** Statut de transcription : null | "en_attente" | "terminee" | "echec". */
20 + val transcriptionStatut: String? = null,
21 + /** Message d'erreur de transcription côté serveur (null si pas d'erreur). */
22 + val transcriptionErreur: String? = null,
17 23 )
A android/app/src/main/java/fr/ebii/card2vcf/data/NoteProjetDao.kt
+31 -0
@@ -0,0 +1,31 @@
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 NoteProjetDao {
11 + @Insert(onConflict = OnConflictStrategy.REPLACE)
12 + suspend fun upsert(entity: NoteProjetEntity): Long
13 +
14 + @Query("SELECT * FROM notes_projet WHERE serverId = :serverId")
15 + suspend fun getByServerId(serverId: String): NoteProjetEntity?
16 +
17 + @Query("SELECT * FROM notes_projet WHERE localId = :localId")
18 + suspend fun getByLocalId(localId: Long): NoteProjetEntity?
19 +
20 + @Query("DELETE FROM notes_projet WHERE serverId = :serverId")
21 + suspend fun deleteByServerId(serverId: String)
22 +
23 + @Query("DELETE FROM notes_projet WHERE localId = :localId")
24 + suspend fun deleteByLocalId(localId: Long)
25 +
26 + @Query("SELECT * FROM notes_projet WHERE projetServerId = :projetServerId ORDER BY createdAt DESC")
27 + fun listByProjetServerId(projetServerId: String): Flow<List<NoteProjetEntity>>
28 +
29 + @Query("SELECT * FROM notes_projet")
30 + suspend fun listAll(): List<NoteProjetEntity>
31 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/NoteProjetEntity.kt
+22 -0
@@ -0,0 +1,22 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import androidx.room.Entity
4 +import androidx.room.PrimaryKey
5 +
6 +@Entity(tableName = "notes_projet")
7 +data class NoteProjetEntity(
8 + @PrimaryKey(autoGenerate = true) val localId: Long = 0,
9 + val serverId: String? = null,
10 + val projetServerId: String = "",
11 + val titre: String = "",
12 + val texte: String = "",
13 + /** Chemin local vers le fichier audio WAV (null en phase 1, préparé pour phase 2). */
14 + val audioPath: String? = null,
15 + /** Statut de transcription : null | "en_attente" | "terminee" | "echec" (null en phase 1). */
16 + val transcriptionStatut: String? = null,
17 + /** Message d'erreur de transcription côté serveur (null si pas d'erreur). */
18 + val transcriptionErreur: String? = null,
19 + val auteur: String = "",
20 + val createdAt: Long = 0L,
21 + val updatedAt: Long? = null,
22 +)
M android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApi.kt
+32 -0
@@ -36,6 +36,12 @@ interface AilianceApi {
36 36
37 37 fun createInteraction(contactId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
38 38
39 + fun deleteInteraction(iid: String): AilianceApiClient.ApiResult<Unit>
40 +
41 + fun relancerTranscriptionInteraction(contactServerId: String, iid: String): AilianceApiClient.ApiResult<Unit>
42 +
43 + fun relancerTranscriptionNote(projetServerId: String, nid: String): AilianceApiClient.ApiResult<Unit>
44 +
39 45 fun listRdv(): AilianceApiClient.ApiResult<List<RendezVousDto>>
40 46
41 47 fun createRdv(jsonBody: String): AilianceApiClient.ApiResult<String>
@@ -80,4 +86,30 @@ interface AilianceApi {
80 86 fun downloadContactCarte(id: String): AilianceApiClient.ApiResult<ByteArray>
81 87
82 88 fun downloadContactPhoto(id: String): AilianceApiClient.ApiResult<ByteArray>
89 +
90 + fun uploadInteractionAudio(
91 + contactId: String,
92 + iid: String,
93 + bytes: ByteArray,
94 + filename: String,
95 + contentType: String = "audio/wav",
96 + ): AilianceApiClient.ApiResult<String>
97 +
98 + fun downloadInteractionAudio(contactId: String, iid: String): AilianceApiClient.ApiResult<ByteArray>
99 +
100 + fun createNoteProjet(projetId: String, jsonBody: String): AilianceApiClient.ApiResult<String>
101 +
102 + fun updateNoteProjet(projetId: String, nid: String, jsonBody: String): AilianceApiClient.ApiResult<String>
103 +
104 + fun deleteNoteProjet(projetId: String, nid: String): AilianceApiClient.ApiResult<Unit>
105 +
106 + fun uploadNoteAudio(
107 + projetId: String,
108 + nid: String,
109 + bytes: ByteArray,
110 + filename: String,
111 + contentType: String = "audio/wav",
112 + ): AilianceApiClient.ApiResult<String>
113 +
114 + fun downloadNoteAudio(projetId: String, nid: String): AilianceApiClient.ApiResult<ByteArray>
83 115 }
M android/app/src/main/java/fr/ebii/card2vcf/sync/AilianceApiClient.kt
+78 -0
@@ -84,6 +84,27 @@ class AilianceApiClient(
84 84 override fun createInteraction(contactId: String, jsonBody: String): ApiResult<String> =
85 85 post("/api/contacts/${encodePath(contactId)}/interactions", jsonBody) { it }
86 86
87 + override fun deleteInteraction(iid: String): ApiResult<Unit> =
88 + delete("/api/interactions/${encodePath(iid)}")
89 +
90 + override fun relancerTranscriptionInteraction(contactServerId: String, iid: String): ApiResult<Unit> =
91 + when (val r = post(
92 + "/api/contacts/${encodePath(contactServerId)}/interactions/${encodePath(iid)}/transcription",
93 + "{}",
94 + ) { it }) {
95 + is ApiResult.Ok -> ApiResult.Ok(Unit)
96 + is ApiResult.Err -> r
97 + }
98 +
99 + override fun relancerTranscriptionNote(projetServerId: String, nid: String): ApiResult<Unit> =
100 + when (val r = post(
101 + "/api/projets/${encodePath(projetServerId)}/notes/${encodePath(nid)}/transcription",
102 + "{}",
103 + ) { it }) {
104 + is ApiResult.Ok -> ApiResult.Ok(Unit)
105 + is ApiResult.Err -> r
106 + }
107 +
87 108 override fun listRdv(): ApiResult<List<RendezVousDto>> =
88 109 get("/api/rdv") { body -> syncJson.decodeFromString(ListSerializer(RendezVousDto.serializer()), body) }
89 110
@@ -151,6 +172,63 @@ class AilianceApiClient(
151 172 override fun downloadContactPhoto(id: String): ApiResult<ByteArray> =
152 173 executeBytes(buildRequest("GET", "/api/contacts/${encodePath(id)}/photo", null, useBearer = true))
153 174
175 + override fun uploadInteractionAudio(
176 + contactId: String,
177 + iid: String,
178 + bytes: ByteArray,
179 + filename: String,
180 + contentType: String,
181 + ): ApiResult<String> =
182 + uploadMultipart(
183 + "/api/contacts/${encodePath(contactId)}/interactions/${encodePath(iid)}/audio",
184 + bytes,
185 + filename,
186 + contentType,
187 + )
188 +
189 + override fun downloadInteractionAudio(contactId: String, iid: String): ApiResult<ByteArray> =
190 + executeBytes(
191 + buildRequest(
192 + "GET",
193 + "/api/contacts/${encodePath(contactId)}/interactions/${encodePath(iid)}/audio",
194 + null,
195 + useBearer = true,
196 + ),
197 + )
198 +
199 + override fun createNoteProjet(projetId: String, jsonBody: String): ApiResult<String> =
200 + post("/api/projets/${encodePath(projetId)}/notes", jsonBody) { it }
201 +
202 + override fun updateNoteProjet(projetId: String, nid: String, jsonBody: String): ApiResult<String> =
203 + put("/api/projets/${encodePath(projetId)}/notes/${encodePath(nid)}", jsonBody) { it }
204 +
205 + override fun deleteNoteProjet(projetId: String, nid: String): ApiResult<Unit> =
206 + delete("/api/projets/${encodePath(projetId)}/notes/${encodePath(nid)}")
207 +
208 + override fun uploadNoteAudio(
209 + projetId: String,
210 + nid: String,
211 + bytes: ByteArray,
212 + filename: String,
213 + contentType: String,
214 + ): ApiResult<String> =
215 + uploadMultipart(
216 + "/api/projets/${encodePath(projetId)}/notes/${encodePath(nid)}/audio",
217 + bytes,
218 + filename,
219 + contentType,
220 + )
221 +
222 + override fun downloadNoteAudio(projetId: String, nid: String): ApiResult<ByteArray> =
223 + executeBytes(
224 + buildRequest(
225 + "GET",
226 + "/api/projets/${encodePath(projetId)}/notes/${encodePath(nid)}/audio",
227 + null,
228 + useBearer = true,
229 + ),
230 + )
231 +
154 232 private fun uploadMultipart(
155 233 path: String,
156 234 bytes: ByteArray,
M android/app/src/main/java/fr/ebii/card2vcf/sync/LwwMerger.kt
+4 -0
@@ -18,6 +18,10 @@ object LwwMerger {
18 18 fun shouldInsertInteraction(existingServerIds: Set<String>, remoteServerId: String): Boolean =
19 19 remoteServerId !in existingServerIds
20 20
21 + /** LWW update : true si le distant est strictement plus récent que le local. */
22 + fun shouldUpdateInteraction(localTs: Long, remoteTs: Long): Boolean =
23 + remoteTs > localTs
24 +
21 25 /** Caller deletes local rows by serverId; returns remaining ids after tombstone application. */
22 26 fun applyTombstoneIds(localServerIds: Set<String>, tombstoneIds: Set<String>): Set<String> =
23 27 localServerIds - tombstoneIds
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncCredentialsStore.kt
+12 -0
@@ -45,6 +45,17 @@ class SyncCredentialsStore internal constructor(
45 45 prefs.edit().putBoolean(KEY_ALLOW_CLEARTEXT, value).apply()
46 46 }
47 47
48 + /**
49 + * Mode de transcription des notes vocales.
50 + * `true` = sur l'appareil (Vosk, défaut) ; `false` = sur le serveur.
51 + * Conservé après [clear] : préférence locale non sensible.
52 + */
53 + var modeTranscriptionLocal: Boolean
54 + get() = prefs.getBoolean(KEY_MODE_TRANSCRIPTION_LOCAL, true)
55 + set(value) {
56 + prefs.edit().putBoolean(KEY_MODE_TRANSCRIPTION_LOCAL, value).apply()
57 + }
58 +
48 59 fun save(baseUrl: String, apiKey: String, userName: String) {
49 60 prefs.edit()
50 61 .putString(KEY_BASE_URL, baseUrl)
@@ -71,6 +82,7 @@ class SyncCredentialsStore internal constructor(
71 82 const val KEY_API_KEY = "api_key"
72 83 const val KEY_USER_NAME = "user_name"
73 84 const val KEY_ALLOW_CLEARTEXT = "allow_cleartext_http"
85 + const val KEY_MODE_TRANSCRIPTION_LOCAL = "mode_transcription_local"
74 86
75 87 internal fun createEncryptedPrefs(context: Context): SharedPreferences =
76 88 EncryptedSharedPreferences.create(
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncEngine.kt
+234 -18
@@ -4,6 +4,7 @@ import fr.ebii.card2vcf.data.CrmContactEntity
4 4 import fr.ebii.card2vcf.data.CrmDatabase
5 5 import fr.ebii.card2vcf.data.EntrepriseEntity
6 6 import fr.ebii.card2vcf.data.InteractionEntity
7 +import fr.ebii.card2vcf.data.NoteProjetEntity
7 8 import fr.ebii.card2vcf.data.ProjetEntity
8 9 import fr.ebii.card2vcf.data.TacheEntity
9 10 import fr.ebii.card2vcf.data.WorkflowEntity
@@ -40,6 +41,9 @@ data class SyncResult(
40 41 val pushFailures: List<PushFailure> = emptyList(),
41 42 )
42 43
44 +/** Résultat d'un push immédiat (sans pull) via [SyncEngine.pousserEnAttente]. */
45 +data class RapportPush(val envoyes: Int, val echecs: Int)
46 +
43 47 /**
44 48 * Pousse la file [SyncOpEntity] puis tire `/api/sync/pull` ; LWW / append / tombstones.
45 49 * Le pont Agenda↔Room (RDV + réservations/indispos liés à des calendriers Android) est délégué à
@@ -70,6 +74,15 @@ class SyncEngine(
70 74 }
71 75
72 76 /**
77 + * Pousse uniquement les ops en file, sans pull ni pont Agenda.
78 + * Utilisé pour un envoi immédiat après création d'une note vocale en mode serveur.
79 + */
80 + suspend fun pousserEnAttente(): RapportPush = withContext(Dispatchers.IO) {
81 + val rapport = pushOps()
82 + RapportPush(envoyes = rapport.succeeded, echecs = rapport.failures.size)
83 + }
84 +
85 + /**
73 86 * [bindings]/[bridge] vides ou `null` : chemin CRM v1 inchangé (pas de pont Agenda, `ressources=` omis).
74 87 */
75 88 suspend fun syncNow(
@@ -186,6 +199,9 @@ class SyncEngine(
186 199 "projet" -> pushProjetOp(op)
187 200 "tache" -> pushTacheOp(op)
188 201 "interaction" -> pushInteractionOp(op)
202 + ENTITY_INTERACTION_MEDIA -> pushInteractionMediaOp(op)
203 + "note_projet" -> pushNoteProjetOp(op)
204 + ENTITY_NOTE_MEDIA -> pushNoteMediaOp(op)
189 205 AgendaSyncCoordinator.KIND_RDV -> pushRdvOp(op)
190 206 AgendaSyncCoordinator.ENTITY_RESERVATION -> pushReservationOp(op)
191 207 else -> PushOutcome(true)
@@ -351,11 +367,146 @@ class SyncEngine(
351 367 }
352 368 }
353 369
354 - /** `op.serverId` porte le `serverId` du contact cible (les interactions n'ont pas d'update/delete). */
370 + /**
371 + * `op.serverId` porte le `serverId` du contact cible (create) ou l'iid de l'interaction (delete).
372 + * Après create réussi : sauvegarde le serverId et tente l'upload audio.
373 + */
355 374 private suspend fun pushInteractionOp(op: SyncOpEntity): PushOutcome {
356 - if (op.op != "create") return PushOutcome(true)
357 - val contactServerId = op.serverId ?: return PushOutcome(false)
358 - return api.createInteraction(contactServerId, op.payloadJson).toOutcome()
375 + return when (op.op) {
376 + "create" -> {
377 + val contactServerId = op.serverId ?: return PushOutcome(false)
378 + val result = api.createInteraction(contactServerId, op.payloadJson)
379 + if (result is AilianceApiClient.ApiResult.Ok) {
380 + val newServerId = parseCreatedId(result.value)
381 + if (newServerId != null && op.localId != null) {
382 + db.interactionDao().getByLocalId(op.localId)?.let {
383 + db.interactionDao().update(it.copy(serverId = newServerId))
384 + }
385 + if (!pushInteractionAudio(op.localId, newServerId)) {
386 + enqueueInteractionMediaRetry(op.localId, newServerId)
387 + }
388 + }
389 + }
390 + result.toOutcome()
391 + }
392 + "delete" -> {
393 + val iid = op.serverId ?: return PushOutcome(false)
394 + api.deleteInteraction(iid).toOutcome()
395 + }
396 + else -> PushOutcome(true)
397 + }
398 + }
399 +
400 + private suspend fun pushInteractionMediaOp(op: SyncOpEntity): PushOutcome {
401 + val interactionServerId = op.serverId ?: return PushOutcome(true)
402 + val localId = op.localId
403 + ?: db.interactionDao().getByServerId(interactionServerId)?.localId
404 + ?: return PushOutcome(true)
405 + return PushOutcome(pushInteractionAudio(localId, interactionServerId))
406 + }
407 +
408 + /** Upload audio d'une interaction. `true` si rien à envoyer ou upload OK. */
409 + private suspend fun pushInteractionAudio(localId: Long, interactionServerId: String): Boolean {
410 + val interaction = db.interactionDao().getByLocalId(localId) ?: return true
411 + val audioPath = interaction.audioPath?.takeIf { it.isNotBlank() } ?: return true
412 + val file = java.io.File(audioPath)
413 + if (!file.isFile) return true
414 + val result = api.uploadInteractionAudio(
415 + interaction.contactServerId,
416 + interactionServerId,
417 + file.readBytes(),
418 + "audio.wav",
419 + )
420 + return result is AilianceApiClient.ApiResult.Ok
421 + }
422 +
423 + private suspend fun enqueueInteractionMediaRetry(localId: Long, interactionServerId: String) {
424 + val already = db.syncOpDao().findByEntityTypeAndLocalId(ENTITY_INTERACTION_MEDIA, localId) != null
425 + if (already) return
426 + db.syncOpDao().insert(
427 + SyncOpEntity(
428 + entityType = ENTITY_INTERACTION_MEDIA,
429 + op = "upload",
430 + payloadJson = "{}",
431 + localId = localId,
432 + serverId = interactionServerId,
433 + createdAt = System.currentTimeMillis(),
434 + ),
435 + )
436 + }
437 +
438 + private suspend fun resolveNoteProjetServerId(op: SyncOpEntity): String? {
439 + op.localId?.let { db.noteProjetDao().getByLocalId(it)?.let { n -> return n.projetServerId } }
440 + op.serverId?.let { db.noteProjetDao().getByServerId(it)?.let { n -> return n.projetServerId } }
441 + return null
442 + }
443 +
444 + private suspend fun pushNoteProjetOp(op: SyncOpEntity): PushOutcome {
445 + return when (op.op) {
446 + "create" -> {
447 + val projetId = resolveNoteProjetServerId(op) ?: return PushOutcome(false)
448 + val result = api.createNoteProjet(projetId, op.payloadJson)
449 + if (result is AilianceApiClient.ApiResult.Ok) {
450 + val newServerId = parseCreatedId(result.value)
451 + if (newServerId != null && op.localId != null) {
452 + db.noteProjetDao().getByLocalId(op.localId)?.let {
453 + db.noteProjetDao().upsert(it.copy(serverId = newServerId))
454 + }
455 + if (!pushNoteAudio(op.localId, newServerId, projetId)) {
456 + enqueueNoteMediaRetry(op.localId, newServerId)
457 + }
458 + }
459 + }
460 + result.toOutcome()
461 + }
462 + "update" -> {
463 + val noteServerId = op.serverId ?: return PushOutcome(false)
464 + val projetId = resolveNoteProjetServerId(op) ?: return PushOutcome(false)
465 + api.updateNoteProjet(projetId, noteServerId, op.payloadJson).toOutcome()
466 + }
467 + "delete" -> {
468 + val noteServerId = op.serverId ?: return PushOutcome(false)
469 + val projetId = resolveNoteProjetServerId(op) ?: return PushOutcome(false)
470 + api.deleteNoteProjet(projetId, noteServerId).toOutcome()
471 + }
472 + else -> PushOutcome(true)
473 + }
474 + }
475 +
476 + private suspend fun pushNoteMediaOp(op: SyncOpEntity): PushOutcome {
477 + val noteServerId = op.serverId ?: return PushOutcome(true)
478 + val localId = op.localId
479 + ?: db.noteProjetDao().getByServerId(noteServerId)?.localId
480 + ?: return PushOutcome(true)
481 + val projetId = db.noteProjetDao().getByLocalId(localId)?.projetServerId ?: return PushOutcome(true)
482 + return PushOutcome(pushNoteAudio(localId, noteServerId, projetId))
483 + }
484 +
485 + /** Upload audio d'une note projet. `true` si rien à envoyer ou upload OK. */
486 + private suspend fun pushNoteAudio(localId: Long, noteServerId: String, projetId: String): Boolean {
487 + val note = db.noteProjetDao().getByLocalId(localId) ?: return true
488 + val audioPath = note.audioPath?.takeIf { it.isNotBlank() } ?: return true
489 + val file = java.io.File(audioPath)
490 + if (!file.isFile) return true
491 + val result = api.uploadNoteAudio(projetId, noteServerId, file.readBytes(), "audio.wav")
492 + return result is AilianceApiClient.ApiResult.Ok
493 + }
494 +
495 + private suspend fun enqueueNoteMediaRetry(localId: Long, noteServerId: String) {
496 + val already = db.syncOpDao().listAll().any {
497 + it.entityType == ENTITY_NOTE_MEDIA && it.localId == localId
498 + }
499 + if (already) return
500 + db.syncOpDao().insert(
501 + SyncOpEntity(
502 + entityType = ENTITY_NOTE_MEDIA,
503 + op = "upload",
504 + payloadJson = "{}",
505 + localId = localId,
506 + serverId = noteServerId,
507 + createdAt = System.currentTimeMillis(),
508 + ),
509 + )
359 510 }
360 511
361 512 private suspend fun pushRdvOp(op: SyncOpEntity): PushOutcome = when (op.op) {
@@ -446,13 +597,14 @@ class SyncEngine(
446 597 pull.projets.forEach { applyProjet(it) }
447 598 pull.taches.forEach { applyTache(it) }
448 599 pull.interactions.forEach { applyInteraction(it) }
600 + pull.notes.forEach { applyNoteProjet(it) }
449 601 pull.workflows.forEach { applyWorkflow(it) }
450 602 pull.rdv.forEach { agenda.applyRdvPull(it) }
451 603 pull.reservations.forEach { agenda.applyReservationPull(it) }
452 604 pull.indisponibilites.forEach { agenda.applyIndisponibilitePull(it) }
453 605 pull.tombstones.forEach { applyTombstone(it) }
454 606 return pull.contacts.size + pull.entreprises.size + pull.projets.size + pull.taches.size +
455 - pull.interactions.size + pull.rdv.size + pull.reservations.size + pull.indisponibilites.size
607 + pull.interactions.size + pull.notes.size + pull.rdv.size + pull.reservations.size + pull.indisponibilites.size
456 608 }
457 609
458 610 private suspend fun applyContact(dto: ContactDto) {
@@ -638,19 +790,80 @@ class SyncEngine(
638 790
639 791 private suspend fun applyInteraction(dto: InteractionDto) {
640 792 val existing = db.interactionDao().listByContactServerId(dto.contactId).mapNotNull { it.serverId }.toSet()
641 - if (!LwwMerger.shouldInsertInteraction(existing, dto.id)) return
642 - db.interactionDao().upsert(
643 - InteractionEntity(
644 - serverId = dto.id,
645 - contactServerId = dto.contactId,
646 - type = dto.typeInteraction,
647 - sujet = dto.sujet,
648 - description = dto.description,
649 - creePar = dto.creePar,
650 - createdAt = parseIsoToEpochMs(dto.creeLe),
651 - updatedAt = dto.misAJourLe?.let { parseIsoToEpochMs(it) },
652 - ),
653 - )
793 + if (LwwMerger.shouldInsertInteraction(existing, dto.id)) {
794 + db.interactionDao().upsert(
795 + InteractionEntity(
796 + serverId = dto.id,
797 + contactServerId = dto.contactId,
798 + type = dto.typeInteraction,
799 + sujet = dto.sujet,
800 + description = dto.description,
801 + creePar = dto.creePar,
802 + transcriptionStatut = dto.transcription,
803 + transcriptionErreur = dto.transcriptionErreur,
804 + createdAt = parseIsoToEpochMs(dto.creeLe),
805 + updatedAt = dto.misAJourLe?.let { parseIsoToEpochMs(it) },
806 + ),
807 + )
808 + return
809 + }
810 + // Déjà connue : mise à jour LWW si le distant est plus récent
811 + val local = db.interactionDao().getByServerId(dto.id) ?: return
812 + val remoteTs = dto.misAJourLe?.let { parseIsoToEpochMs(it) } ?: return
813 + val localTs = local.updatedAt ?: local.createdAt
814 + if (LwwMerger.shouldUpdateInteraction(localTs, remoteTs)) {
815 + db.interactionDao().upsert(
816 + local.copy(
817 + type = dto.typeInteraction,
818 + sujet = dto.sujet,
819 + description = dto.description,
820 + transcriptionStatut = dto.transcription,
821 + transcriptionErreur = dto.transcriptionErreur,
822 + updatedAt = remoteTs,
823 + // audioPath préservé : local.copy() ne l'écrase pas
824 + ),
825 + )
826 + }
827 + }
828 +
829 + /**
830 + * Insert si [dto.id] inconnu ; met à jour si le timestamp distant ([majLe] ou [creeLe])
831 + * est plus récent que [NoteProjetEntity.updatedAt] local ; préserve [NoteProjetEntity.audioPath].
832 + */
833 + private suspend fun applyNoteProjet(dto: NoteProjetDto) {
834 + val remoteTs = parseIsoToEpochMs(dto.majLe ?: dto.creeLe)
835 + val local = db.noteProjetDao().getByServerId(dto.id)
836 + if (local == null) {
837 + db.noteProjetDao().upsert(
838 + NoteProjetEntity(
839 + serverId = dto.id,
840 + projetServerId = dto.projetId,
841 + titre = dto.titre,
842 + texte = dto.contenu,
843 + auteur = dto.auteur,
844 + createdAt = parseIsoToEpochMs(dto.creeLe),
845 + updatedAt = remoteTs,
846 + transcriptionStatut = dto.transcription,
847 + transcriptionErreur = dto.transcriptionErreur,
848 + ),
849 + )
850 + return
851 + }
852 + val localTs = local.updatedAt ?: local.createdAt
853 + if (remoteTs > localTs) {
854 + db.noteProjetDao().upsert(
855 + local.copy(
856 + projetServerId = dto.projetId,
857 + titre = dto.titre,
858 + texte = dto.contenu,
859 + auteur = dto.auteur,
860 + updatedAt = remoteTs,
861 + transcriptionStatut = dto.transcription,
862 + transcriptionErreur = dto.transcriptionErreur,
863 + // audioPath préservé : local.copy() ne l'écrase pas
864 + ),
865 + )
866 + }
654 867 }
655 868
656 869 private suspend fun applyWorkflow(dto: WorkflowDto) {
@@ -677,6 +890,7 @@ class SyncEngine(
677 890 }
678 891 "tache" -> db.tacheDao().deleteByServerId(dto.id)
679 892 "interaction" -> db.interactionDao().deleteByServerId(dto.id)
893 + "note" -> db.noteProjetDao().deleteByServerId(dto.id)
680 894 else -> agenda.applyAgendaTombstone(dto)
681 895 }
682 896 }
@@ -686,6 +900,8 @@ class SyncEngine(
686 900 const val WATERMARK_ORIGIN_KEY = "watermark_origin"
687 901 const val DEFAULT_WATERMARK = "1970-01-01T00:00:00Z"
688 902 const val ENTITY_CONTACT_MEDIA = "contact_media"
903 + const val ENTITY_INTERACTION_MEDIA = "interaction_media"
904 + const val ENTITY_NOTE_MEDIA = "note_media"
689 905
690 906 fun <T> AilianceApiClient.ApiResult<T>.isOk(): Boolean = this is AilianceApiClient.ApiResult.Ok
691 907
A android/app/src/main/java/fr/ebii/card2vcf/sync/SyncEngineFactory.kt
+14 -0
@@ -0,0 +1,14 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import fr.ebii.card2vcf.data.CrmDatabase
4 +
5 +/** Crée un [SyncEngine] à partir des identifiants stockés. `null` si l'app n'est pas configurée. */
6 +fun creerSyncEngine(credentialsStore: SyncCredentialsStore, database: CrmDatabase): SyncEngine? {
7 + val baseUrl = credentialsStore.baseUrl ?: return null
8 + val apiKey = credentialsStore.apiKey ?: return null
9 + return SyncEngine(
10 + AilianceApiClient(baseUrl, apiKey),
11 + database,
12 + serverIdentity = "$baseUrl|${credentialsStore.userName.orEmpty()}",
13 + )
14 +}
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncModels.kt
+43 -0
@@ -66,6 +66,8 @@ data class SyncPullResponse(
66 66 val indisponibilites: List<IndisponibiliteDto> = emptyList(),
67 67 val tombstones: List<TombstoneDto> = emptyList(),
68 68 val workflows: List<WorkflowDto> = emptyList(),
69 + /** Notes markdown des projets ; défaut `emptyList()` = compat anciens serveurs sans ce champ. */
70 + val notes: List<NoteProjetDto> = emptyList(),
69 71 )
70 72
71 73 @Serializable
@@ -153,6 +155,12 @@ data class InteractionDto(
153 155 val creePar: String = "",
154 156 val creeLe: String,
155 157 val misAJourLe: String? = null,
158 + /** Nom de fichier audio côté serveur (ex. `interaction_uuid.wav`). */
159 + val pieceJointe: String? = null,
160 + /** Statut de transcription : "en_attente" | "terminee" | "echec" | null. */
161 + val transcription: String? = null,
162 + /** Message d'erreur de transcription côté serveur (null si pas d'erreur). */
163 + val transcriptionErreur: String? = null,
156 164 )
157 165
158 166 @Serializable
@@ -213,6 +221,24 @@ data class IndisponibiliteDto(
213 221 val misAJourLe: String? = null,
214 222 )
215 223
224 +/** Note markdown d'un projet (contrat P1-S1 / P1-A2 / P2-A3). */
225 +@Serializable
226 +data class NoteProjetDto(
227 + val id: String,
228 + val projetId: String,
229 + val titre: String,
230 + val contenu: String,
231 + val auteur: String = "",
232 + val creeLe: String,
233 + val majLe: String? = null,
234 + /** Nom de fichier audio côté serveur (ex. `note_uuid.wav`), null si aucun audio. */
235 + val audio: String? = null,
236 + /** Statut de transcription : "en_attente" | "terminee" | "echec" | null. */
237 + val transcription: String? = null,
238 + /** Message d'erreur de transcription côté serveur (null si pas d'erreur). */
239 + val transcriptionErreur: String? = null,
240 +)
241 +
216 242 /** Ressource catalogue allégée (salle/matériel/véhicule) : champs communs uniquement. */
217 243 @Serializable
218 244 data class RessourceItemDto(
@@ -267,6 +293,8 @@ data class MoveTacheRequest(
267 293 data class CreateInteractionRequest(
268 294 val sujet: String,
269 295 val description: String = "",
296 + val typeInteraction: String = "note",
297 + val demandeTranscription: Boolean = false,
270 298 )
271 299
272 300 @Serializable
@@ -320,3 +348,18 @@ data class ReservationUpsertRequest(
320 348 val projetId: String? = null,
321 349 val rdvId: String? = null,
322 350 )
351 +
352 +/** Payload création note projet (body `POST /api/projets/:id/notes`). */
353 +@Serializable
354 +data class CreateNoteProjetRequest(
355 + val titre: String,
356 + val contenu: String = "",
357 + val demandeTranscription: Boolean = false,
358 +)
359 +
360 +/** Payload mise à jour note projet (body `PUT /api/projets/:id/notes/:nid`). */
361 +@Serializable
362 +data class UpdateNoteProjetRequest(
363 + val titre: String,
364 + val contenu: String = "",
365 +)
M android/app/src/main/java/fr/ebii/card2vcf/sync/SyncOpDao.kt
+3 -0
@@ -25,4 +25,7 @@ interface SyncOpDao {
25 25
26 26 @Query("DELETE FROM sync_ops WHERE id = :id")
27 27 suspend fun deleteById(id: Long)
28 +
29 + @Query("SELECT * FROM sync_ops WHERE entityType = :entityType AND localId = :localId LIMIT 1")
30 + suspend fun findByEntityTypeAndLocalId(entityType: String, localId: Long): SyncOpEntity?
28 31 }
A android/app/src/main/java/fr/ebii/card2vcf/ui/audio/ConfirmationNote.kt
+4 -0
@@ -0,0 +1,4 @@
1 +package fr.ebii.card2vcf.ui.audio
2 +
3 +/** Résultat de l'ajout d'une note vocale — permet à l'écran d'afficher le message de confirmation adapté. */
4 +enum class ConfirmationNote { APPAREIL, SERVEUR_OK, SERVEUR_REPLI }
A android/app/src/main/java/fr/ebii/card2vcf/ui/audio/EnregistrementNoteSheet.kt
+431 -0
@@ -0,0 +1,431 @@
1 +package fr.ebii.card2vcf.ui.audio
2 +
3 +import android.Manifest
4 +import android.content.pm.PackageManager
5 +import android.media.MediaPlayer
6 +import androidx.activity.compose.rememberLauncherForActivityResult
7 +import androidx.activity.result.contract.ActivityResultContracts
8 +import androidx.compose.foundation.layout.Arrangement
9 +import androidx.compose.foundation.layout.Box
10 +import androidx.compose.foundation.layout.Column
11 +import androidx.compose.foundation.layout.Row
12 +import androidx.compose.foundation.layout.Spacer
13 +import androidx.compose.foundation.layout.fillMaxWidth
14 +import androidx.compose.foundation.layout.height
15 +import androidx.compose.foundation.layout.padding
16 +import androidx.compose.foundation.layout.size
17 +import androidx.compose.material.icons.Icons
18 +import androidx.compose.material.icons.filled.Mic
19 +import androidx.compose.material.icons.filled.Pause
20 +import androidx.compose.material.icons.filled.PlayArrow
21 +import androidx.compose.material.icons.filled.Stop
22 +import androidx.compose.material3.Button
23 +import androidx.compose.material3.CircularProgressIndicator
24 +import androidx.compose.material3.ExperimentalMaterial3Api
25 +import androidx.compose.material3.Icon
26 +import androidx.compose.material3.IconButton
27 +import androidx.compose.material3.MaterialTheme
28 +import androidx.compose.material3.ModalBottomSheet
29 +import androidx.compose.material3.OutlinedButton
30 +import androidx.compose.material3.Text
31 +import androidx.compose.material3.rememberModalBottomSheetState
32 +import androidx.compose.runtime.Composable
33 +import androidx.compose.runtime.DisposableEffect
34 +import androidx.compose.runtime.LaunchedEffect
35 +import androidx.compose.runtime.getValue
36 +import androidx.compose.runtime.mutableStateOf
37 +import androidx.compose.runtime.remember
38 +import androidx.compose.runtime.rememberCoroutineScope
39 +import androidx.compose.runtime.setValue
40 +import androidx.compose.ui.Alignment
41 +import androidx.compose.ui.Modifier
42 +import androidx.compose.ui.platform.LocalContext
43 +import androidx.compose.ui.res.stringResource
44 +import androidx.compose.ui.unit.dp
45 +import androidx.core.content.ContextCompat
46 +import fr.ebii.card2vcf.R
47 +import fr.ebii.card2vcf.audio.AudioNoteRecorder
48 +import fr.ebii.card2vcf.audio.TranscripteurVosk
49 +import fr.ebii.card2vcf.ui.composants.ChampTexte
50 +import fr.ebii.card2vcf.ui.theme.Bordure
51 +import fr.ebii.card2vcf.ui.theme.Fond
52 +import fr.ebii.card2vcf.ui.theme.Ink
53 +import fr.ebii.card2vcf.ui.theme.TexteFaible
54 +import kotlinx.coroutines.Dispatchers
55 +import kotlinx.coroutines.delay
56 +import kotlinx.coroutines.launch
57 +import kotlinx.coroutines.withContext
58 +import java.io.File
59 +
60 +/** Formate une durée en ms en "mm:ss". */
61 +fun formaterDuree(ms: Long): String {
62 + val totalSec = ms / 1000L
63 + val min = totalSec / 60L
64 + val sec = totalSec % 60L
65 + return "%02d:%02d".format(min, sec)
66 +}
67 +
68 +private enum class Phase { EN_ATTENTE_PERMISSION, EN_COURS, ARRET, EDITION }
69 +
70 +/**
71 + * Feuille d'enregistrement modale commune (contact et projet).
72 + *
73 + * Demande la permission RECORD_AUDIO, démarre l'enregistrement WAV, affiche le timer et la
74 + * transcription partielle Vosk si disponible, puis propose l'édition du texte avant sauvegarde.
75 + *
76 + * @param modeServeur true = transcription différée côté serveur ; false = Vosk local.
77 + * @param voskDisponible false si le modèle est absent (build -Pvosk=none).
78 + * @param onSauvegarder appelé avec (sujet, texte, audioPath?) quand l'utilisateur valide.
79 + * @param onDismiss appelé sur annulation / fermeture.
80 + */
81 +@OptIn(ExperimentalMaterial3Api::class)
82 +@Composable
83 +fun EnregistrementNoteSheet(
84 + modeServeur: Boolean,
85 + voskDisponible: Boolean,
86 + onSauvegarder: (sujet: String, texte: String, audioPath: String?) -> Unit,
87 + onDismiss: () -> Unit,
88 +) {
89 + val context = LocalContext.current
90 + val modeLocal = !modeServeur && voskDisponible
91 +
92 + val recorder = remember { AudioNoteRecorder() }
93 + val transcripteur = remember { if (modeLocal) TranscripteurVosk(context.applicationContext) else null }
94 +
95 + var micAccorde by remember {
96 + mutableStateOf(
97 + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED,
98 + )
99 + }
100 + val permLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
101 + micAccorde = it
102 + }
103 +
104 + var phase by remember { mutableStateOf(if (micAccorde) Phase.EN_COURS else Phase.EN_ATTENTE_PERMISSION) }
105 + var dureeMs by remember { mutableStateOf(0L) }
106 + var textePartiel by remember { mutableStateOf("") }
107 + var texteTranscrit by remember { mutableStateOf("") }
108 + var sujet by remember { mutableStateOf("") }
109 + var audioPath by remember { mutableStateOf<String?>(null) }
110 +
111 + val scope = rememberCoroutineScope()
112 + val formatSujetDefaut = stringResource(R.string.note_vocale_sujet_defaut)
113 + var sauvegarde by remember { mutableStateOf(false) }
114 +
115 + // Demande la permission si absente
116 + LaunchedEffect(Unit) {
117 + if (!micAccorde) permLauncher.launch(Manifest.permission.RECORD_AUDIO)
118 + }
119 +
120 + // Démarre l'enregistrement dès que la permission est accordée
121 + LaunchedEffect(micAccorde) {
122 + if (!micAccorde) return@LaunchedEffect
123 + if (phase != Phase.EN_ATTENTE_PERMISSION && phase != Phase.EN_COURS) return@LaunchedEffect
124 + phase = Phase.EN_COURS
125 + val startMs = System.currentTimeMillis()
126 + val file = withContext(Dispatchers.IO) {
127 + val dir = File(context.filesDir, "notes_audio_temp").also { it.mkdirs() }
128 + // Nettoyage des fichiers orphelins de plus de 24 h
129 + val seuilMs = System.currentTimeMillis() - 24L * 60 * 60 * 1000
130 + dir.listFiles()?.filter { it.lastModified() < seuilMs }?.forEach { it.delete() }
131 + File.createTempFile("note_vocale_", ".wav", dir)
132 + }
133 + audioPath = file.absolutePath
134 + recorder.demarrer(file) { data, len ->
135 + val partial = transcripteur?.accepterEchantillons(data, len)
136 + if (partial != null) scope.launch(Dispatchers.Main.immediate) { textePartiel = partial }
137 + }
138 + while (phase == Phase.EN_COURS) {
139 + dureeMs = System.currentTimeMillis() - startMs
140 + delay(100L)
141 + }
142 + }
143 +
144 + // Libère les ressources lors de la fermeture
145 + DisposableEffect(Unit) {
146 + onDispose {
147 + if (phase == Phase.EN_COURS) recorder.arreter()
148 + transcripteur?.reinitialiser()
149 + // Supprime le fichier WAV temporaire si la note n'a pas été sauvegardée
150 + if (!sauvegarde) audioPath?.let { File(it).delete() }
151 + }
152 + }
153 +
154 + ModalBottomSheet(
155 + onDismissRequest = onDismiss,
156 + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
157 + containerColor = Fond,
158 + ) {
159 + Column(
160 + Modifier
161 + .fillMaxWidth()
162 + .padding(horizontal = 24.dp, vertical = 8.dp)
163 + .padding(bottom = 32.dp),
164 + verticalArrangement = Arrangement.spacedBy(12.dp),
165 + ) {
166 + // Avertissement permanent
167 + Text(
168 + stringResource(R.string.note_vocale_avertissement),
169 + style = MaterialTheme.typography.bodySmall,
170 + color = MaterialTheme.colorScheme.error,
171 + )
172 +
173 + when {
174 + !micAccorde -> PermissionContent(
175 + onDemander = { permLauncher.launch(Manifest.permission.RECORD_AUDIO) },
176 + )
177 + phase == Phase.EN_COURS -> EnCoursContent(
178 + dureeMs = dureeMs,
179 + textePartiel = textePartiel,
180 + modeLocal = modeLocal,
181 + onStop = {
182 + scope.launch {
183 + phase = Phase.ARRET
184 + val resultat = withContext(Dispatchers.IO) { recorder.arreter() }
185 + val texteFinal = if (modeLocal) {
186 + withContext(Dispatchers.IO) { transcripteur?.finaliser().orEmpty() }
187 + } else {
188 + ""
189 + }
190 + texteTranscrit = texteFinal.ifBlank { textePartiel }
191 + audioPath = resultat?.chemin
192 + sujet = genererSujetParDefaut(formatSujetDefaut)
193 + phase = Phase.EDITION
194 + }
195 + },
196 + )
197 + phase == Phase.ARRET -> Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
198 + CircularProgressIndicator(color = MaterialTheme.colorScheme.secondary)
199 + }
200 + phase == Phase.EDITION -> EditionContent(
201 + sujet = sujet,
202 + texteTranscrit = texteTranscrit,
203 + modeServeur = modeServeur,
204 + onSujetChange = { sujet = it },
205 + onTexteChange = { texteTranscrit = it },
206 + onSauvegarder = {
207 + onSauvegarder(sujet.trim(), texteTranscrit.trim(), audioPath)
208 + sauvegarde = true
209 + onDismiss()
210 + },
211 + onAnnuler = onDismiss,
212 + )
213 + else -> Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
214 + CircularProgressIndicator(color = MaterialTheme.colorScheme.secondary)
215 + }
216 + }
217 + }
218 + }
219 +}
220 +
221 +@Composable
222 +private fun PermissionContent(onDemander: () -> Unit) {
223 + Column(
224 + Modifier.fillMaxWidth(),
225 + horizontalAlignment = Alignment.CenterHorizontally,
226 + verticalArrangement = Arrangement.spacedBy(12.dp),
227 + ) {
228 + Text(
229 + stringResource(R.string.note_vocale_permission_manquante),
230 + color = TexteFaible,
231 + style = MaterialTheme.typography.bodyMedium,
232 + )
233 + OutlinedButton(onClick = onDemander) {
234 + Text(stringResource(R.string.note_vocale_autoriser_micro))
235 + }
236 + }
237 +}
238 +
239 +@Composable
240 +private fun EnCoursContent(
241 + dureeMs: Long,
242 + textePartiel: String,
243 + modeLocal: Boolean,
244 + onStop: () -> Unit,
245 +) {
246 + Column(
247 + Modifier.fillMaxWidth(),
248 + horizontalAlignment = Alignment.CenterHorizontally,
249 + verticalArrangement = Arrangement.spacedBy(16.dp),
250 + ) {
251 + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
252 + Icon(Icons.Filled.Mic, contentDescription = null, tint = MaterialTheme.colorScheme.error)
253 + Text(
254 + formaterDuree(dureeMs),
255 + style = MaterialTheme.typography.titleLarge,
256 + color = Ink,
257 + )
258 + }
259 + if (modeLocal && textePartiel.isNotBlank()) {
260 + Text(
261 + textePartiel,
262 + style = MaterialTheme.typography.bodyMedium,
263 + color = TexteFaible,
264 + )
265 + } else if (!modeLocal) {
266 + Text(
267 + stringResource(R.string.note_vocale_transcription_serveur),
268 + style = MaterialTheme.typography.bodySmall,
269 + color = TexteFaible,
270 + )
271 + } else {
272 + Text(
273 + stringResource(R.string.note_vocale_transcription_en_cours),
274 + style = MaterialTheme.typography.bodySmall,
275 + color = TexteFaible,
276 + )
277 + }
278 + Button(onClick = onStop, modifier = Modifier.fillMaxWidth()) {
279 + Icon(Icons.Filled.Stop, contentDescription = null)
280 + Spacer(Modifier.size(8.dp))
281 + Text(stringResource(R.string.note_vocale_stop))
282 + }
283 + }
284 +}
285 +
286 +@Composable
287 +private fun EditionContent(
288 + sujet: String,
289 + texteTranscrit: String,
290 + modeServeur: Boolean,
291 + onSujetChange: (String) -> Unit,
292 + onTexteChange: (String) -> Unit,
293 + onSauvegarder: () -> Unit,
294 + onAnnuler: () -> Unit,
295 +) {
296 + Column(
297 + Modifier.fillMaxWidth(),
298 + verticalArrangement = Arrangement.spacedBy(12.dp),
299 + ) {
300 + ChampTexte(
301 + value = sujet,
302 + onValueChange = onSujetChange,
303 + label = stringResource(R.string.note_vocale_sujet_label),
304 + )
305 + if (sujet.isBlank()) {
306 + Text(
307 + stringResource(R.string.note_vocale_sujet_aide),
308 + style = MaterialTheme.typography.bodySmall,
309 + color = MaterialTheme.colorScheme.error,
310 + )
311 + }
312 + if (modeServeur) {
313 + Text(
314 + stringResource(R.string.note_vocale_transcription_serveur),
315 + style = MaterialTheme.typography.bodySmall,
316 + color = TexteFaible,
317 + )
318 + } else {
319 + ChampTexte(
320 + value = texteTranscrit,
321 + onValueChange = onTexteChange,
322 + label = stringResource(R.string.note_vocale_texte_label),
323 + singleLine = false,
324 + minLines = 3,
325 + )
326 + }
327 + Row(
328 + Modifier.fillMaxWidth(),
329 + horizontalArrangement = Arrangement.spacedBy(8.dp),
330 + ) {
331 + OutlinedButton(onClick = onAnnuler, modifier = Modifier.weight(1f)) {
332 + Text(stringResource(R.string.note_vocale_annuler))
333 + }
334 + Button(
335 + onClick = onSauvegarder,
336 + enabled = sujet.isNotBlank(),
337 + modifier = Modifier.weight(1f),
338 + ) {
339 + Text(stringResource(R.string.note_vocale_sauvegarder))
340 + }
341 + }
342 + }
343 +}
344 +
345 +// ---------------------------------------------------------------------------
346 +// Lecteur audio
347 +// ---------------------------------------------------------------------------
348 +
349 +/** État d'un lecteur audio. */
350 +enum class EtatLecture { ARRET, CHARGEMENT, LECTURE, ERREUR }
351 +
352 +/**
353 + * Lecteur audio minimal : bouton play/pause si [audioPath] est disponible localement,
354 + * bouton téléchargement sinon (si [onTelechargement] non null).
355 + * Gère le cycle de vie [MediaPlayer] via [DisposableEffect].
356 + */
357 +@Composable
358 +fun LecteurAudio(
359 + audioPath: String?,
360 + enChargement: Boolean,
361 + onTelechargement: (() -> Unit)?,
362 + modifier: Modifier = Modifier,
363 +) {
364 + var etat by remember(audioPath) { mutableStateOf(EtatLecture.ARRET) }
365 + val player = remember { MediaPlayer() }
366 + val scope = rememberCoroutineScope()
367 +
368 + // Libère définitivement les ressources natives quand le composable quitte la composition.
369 + // Déclaré avant DisposableEffect(audioPath) : l'ordre LIFO garantit reset() avant release().
370 + DisposableEffect(Unit) {
371 + onDispose { player.release() }
372 + }
373 + DisposableEffect(audioPath) {
374 + onDispose {
375 + if (player.isPlaying) player.stop()
376 + player.reset()
377 + }
378 + }
379 +
380 + Row(modifier, verticalAlignment = Alignment.CenterVertically) {
381 + when {
382 + enChargement -> CircularProgressIndicator(Modifier.size(24.dp), color = MaterialTheme.colorScheme.secondary)
383 + audioPath != null -> {
384 + IconButton(
385 + onClick = {
386 + scope.launch {
387 + when (etat) {
388 + EtatLecture.ARRET, EtatLecture.ERREUR -> {
389 + runCatching {
390 + player.reset()
391 + player.setDataSource(audioPath)
392 + withContext(Dispatchers.IO) { player.prepare() }
393 + player.start()
394 + etat = EtatLecture.LECTURE
395 + player.setOnCompletionListener { etat = EtatLecture.ARRET }
396 + }.onFailure { etat = EtatLecture.ERREUR }
397 + }
398 + EtatLecture.LECTURE -> {
399 + player.pause()
400 + etat = EtatLecture.ARRET
401 + }
402 + EtatLecture.CHARGEMENT -> Unit
403 + }
404 + }
405 + },
406 + ) {
407 + Icon(
408 + if (etat == EtatLecture.LECTURE) Icons.Filled.Pause else Icons.Filled.PlayArrow,
409 + contentDescription = if (etat == EtatLecture.LECTURE)
410 + stringResource(R.string.lecteur_audio_pause)
411 + else
412 + stringResource(R.string.lecteur_audio_lire),
413 + tint = Ink,
414 + )
415 + }
416 + if (etat == EtatLecture.ERREUR) {
417 + Text(
418 + stringResource(R.string.lecteur_audio_erreur),
419 + color = MaterialTheme.colorScheme.error,
420 + style = MaterialTheme.typography.bodySmall,
421 + )
422 + }
423 + }
424 + onTelechargement != null -> {
425 + OutlinedButton(onClick = onTelechargement) {
426 + Text(stringResource(R.string.lecteur_audio_telecharger), style = MaterialTheme.typography.bodySmall)
427 + }
428 + }
429 + }
430 + }
431 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/audio/NoteVocaleUtil.kt
+25 -0
@@ -0,0 +1,25 @@
1 +package fr.ebii.card2vcf.ui.audio
2 +
3 +import java.util.Calendar
4 +
5 +/**
6 + * Génère un sujet par défaut « Notes du jj/MM/yyyy à HH:mm ».
7 + *
8 + * [formatModele] doit contenir un placeholder %1$s pour la date formatée
9 + * (ex. "Notes du %1$s" depuis strings.xml).
10 + * [maintenant] est injectable pour les tests (millisecondes depuis l'epoch).
11 + */
12 +fun genererSujetParDefaut(
13 + formatModele: String,
14 + maintenant: Long = System.currentTimeMillis(),
15 +): String {
16 + val cal = Calendar.getInstance().also { it.timeInMillis = maintenant }
17 + val dateFormatee = "%02d/%02d/%04d à %02d:%02d".format(
18 + cal.get(Calendar.DAY_OF_MONTH),
19 + cal.get(Calendar.MONTH) + 1,
20 + cal.get(Calendar.YEAR),
21 + cal.get(Calendar.HOUR_OF_DAY),
22 + cal.get(Calendar.MINUTE),
23 + )
24 + return String.format(formatModele, dateFormatee)
25 +}
M android/app/src/main/java/fr/ebii/card2vcf/ui/composants/Composants.kt
+30 -0
@@ -27,7 +27,9 @@ import androidx.compose.runtime.remember
27 27 import androidx.compose.runtime.setValue
28 28 import androidx.compose.ui.Alignment
29 29 import androidx.compose.ui.Modifier
30 +import androidx.compose.ui.res.stringResource
30 31 import androidx.compose.ui.unit.dp
32 +import fr.ebii.card2vcf.R
31 33 import fr.ebii.card2vcf.data.CrmContactEntity
32 34
33 35 /** Barre de titre commune : retour + titre + actions à droite. */
@@ -171,3 +173,31 @@ fun nomAffiche(contact: CrmContactEntity): String {
171 173 .joinToString(" ")
172 174 return compose.ifEmpty { contact.company?.trim().orEmpty().ifEmpty { "Sans nom" } }
173 175 }
176 +
177 +/**
178 + * Badge de statut de transcription (en_attente / echec) — commun aux notes contact et projet.
179 + * Si [statut] == "echec" et [erreur] est fourni, affiche le message d'erreur en dessous du badge.
180 + */
181 +@Composable
182 +fun BadgeStatutTranscription(statut: String?, erreur: String? = null) {
183 + val label = when (statut) {
184 + "en_attente" -> stringResource(R.string.note_vocale_badge_en_attente)
185 + "echec" -> stringResource(R.string.note_vocale_badge_echec)
186 + else -> return
187 + }
188 + val color = if (statut == "echec") MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary
189 + Column {
190 + Text(
191 + label,
192 + style = MaterialTheme.typography.labelSmall,
193 + color = color,
194 + )
195 + if (statut == "echec" && !erreur.isNullOrBlank()) {
196 + Text(
197 + erreur,
198 + style = MaterialTheme.typography.bodySmall,
199 + color = color,
200 + )
201 + }
202 + }
203 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/composants/MarkdownText.kt
+48 -0
@@ -0,0 +1,48 @@
1 +package fr.ebii.card2vcf.ui.composants
2 +
3 +import android.text.method.LinkMovementMethod
4 +import android.widget.TextView
5 +import androidx.compose.material3.LocalContentColor
6 +import androidx.compose.material3.LocalTextStyle
7 +import androidx.compose.runtime.Composable
8 +import androidx.compose.runtime.remember
9 +import androidx.compose.ui.Modifier
10 +import androidx.compose.ui.graphics.toArgb
11 +import androidx.compose.ui.platform.LocalContext
12 +import androidx.compose.ui.unit.isSpecified
13 +import io.noties.markwon.Markwon
14 +
15 +/** Rendu markdown via Markwon dans un TextView Android.
16 + * Couleur et taille de texte héritées de [LocalContentColor] / [LocalTextStyle].
17 + * Les liens sont cliquables.
18 + */
19 +@Composable
20 +fun MarkdownText(
21 + markdown: String,
22 + modifier: Modifier = Modifier,
23 +) {
24 + val context = LocalContext.current
25 + val contentColor = LocalContentColor.current
26 + val textStyle = LocalTextStyle.current
27 +
28 + val markwon = remember(context) { Markwon.create(context) }
29 +
30 + val textColor = contentColor.toArgb()
31 + val fontSizeSp = textStyle.fontSize
32 +
33 + androidx.compose.ui.viewinterop.AndroidView(
34 + factory = { ctx ->
35 + TextView(ctx).apply {
36 + movementMethod = LinkMovementMethod.getInstance()
37 + }
38 + },
39 + update = { tv ->
40 + tv.setTextColor(textColor)
41 + if (fontSizeSp.isSpecified && fontSizeSp.value > 0f) {
42 + tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, fontSizeSp.value)
43 + }
44 + markwon.setMarkdown(tv, markdown)
45 + },
46 + modifier = modifier,
47 + )
48 +}
M android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactPagerScreen.kt
+235 -44
@@ -3,6 +3,7 @@ package fr.ebii.card2vcf.ui.contact
3 3 import android.content.ActivityNotFoundException
4 4 import android.content.Intent
5 5 import android.net.Uri
6 +import android.widget.Toast
6 7 import java.io.File
7 8 import androidx.compose.foundation.Image
8 9 import androidx.compose.foundation.background
@@ -23,13 +24,15 @@ import androidx.compose.foundation.layout.size
23 24 import androidx.compose.foundation.pager.HorizontalPager
24 25 import androidx.compose.foundation.pager.rememberPagerState
25 26 import androidx.compose.foundation.rememberScrollState
26 -import androidx.compose.foundation.shape.RoundedCornerShape
27 27 import androidx.compose.foundation.verticalScroll
28 28 import androidx.compose.material.icons.Icons
29 29 import androidx.compose.material.icons.automirrored.outlined.ArrowBack
30 30 import androidx.compose.material.icons.automirrored.outlined.RotateLeft
31 31 import androidx.compose.material.icons.filled.Call
32 +import androidx.compose.material.icons.filled.Delete
32 33 import androidx.compose.material.icons.filled.Email
34 +import androidx.compose.material.icons.filled.Mic
35 +import androidx.compose.material3.AlertDialog
33 36 import androidx.compose.material3.HorizontalDivider
34 37 import androidx.compose.material3.Icon
35 38 import androidx.compose.material3.IconButton
@@ -38,10 +41,14 @@ import androidx.compose.material3.OutlinedButton
38 41 import androidx.compose.material3.Text
39 42 import androidx.compose.material3.TextButton
40 43 import androidx.compose.runtime.Composable
44 +import androidx.compose.runtime.LaunchedEffect
41 45 import androidx.compose.runtime.collectAsState
42 46 import androidx.compose.runtime.getValue
47 +import androidx.compose.runtime.mutableStateOf
43 48 import androidx.compose.runtime.remember
49 +import androidx.compose.runtime.setValue
44 50 import androidx.compose.ui.Alignment
51 +import androidx.compose.ui.platform.LocalContext
45 52 import androidx.compose.ui.Modifier
46 53 import androidx.compose.ui.graphics.ImageBitmap
47 54 import androidx.compose.ui.graphics.asImageBitmap
@@ -51,14 +58,21 @@ import androidx.compose.ui.res.stringResource
51 58 import androidx.compose.ui.unit.dp
52 59 import fr.ebii.card2vcf.R
53 60 import fr.ebii.card2vcf.data.CrmContactEntity
61 +import fr.ebii.card2vcf.data.InteractionEntity
54 62 import fr.ebii.card2vcf.scan.ImageOrientation
63 +import fr.ebii.card2vcf.ui.audio.ConfirmationNote
64 +import fr.ebii.card2vcf.ui.audio.EnregistrementNoteSheet
65 +import fr.ebii.card2vcf.ui.audio.LecteurAudio
66 +import fr.ebii.card2vcf.ui.composants.BadgeStatutTranscription
67 +import fr.ebii.card2vcf.ui.composants.EtatVide
68 +import fr.ebii.card2vcf.ui.composants.nomAffiche
55 69 import fr.ebii.card2vcf.ui.theme.Bordure
56 70 import fr.ebii.card2vcf.ui.theme.Encre
57 71 import fr.ebii.card2vcf.ui.theme.Fond
58 72 import fr.ebii.card2vcf.ui.theme.Ink
59 -import fr.ebii.card2vcf.ui.theme.Link
60 73 import fr.ebii.card2vcf.ui.theme.Surface
61 74 import fr.ebii.card2vcf.ui.theme.TexteFaible
75 +import kotlinx.coroutines.flow.flowOf
62 76
63 77
64 78 @Composable
@@ -67,60 +81,103 @@ fun ContactPagerScreen(
67 81 onBack: () -> Unit,
68 82 onEdit: (contactId: Long) -> Unit,
69 83 onDuplicates: (contactId: Long) -> Unit,
84 + modeServeur: Boolean,
85 + voskDisponible: Boolean,
70 86 modifier: Modifier = Modifier,
71 87 ) {
72 88 val contacts by viewModel.contacts.collectAsState()
73 89 val duplicateCountById by viewModel.duplicateCountById.collectAsState()
90 + val downloadEnCours by viewModel.downloadEnCours.collectAsState()
91 + val confirmationNote by viewModel.confirmationNote.collectAsState()
92 + val messageErreur by viewModel.messageErreur.collectAsState()
93 + var sheetVisible by remember { mutableStateOf(false) }
94 +
95 + val context = LocalContext.current
96 + LaunchedEffect(confirmationNote) {
97 + val confirmation = confirmationNote ?: return@LaunchedEffect
98 + val msgId = when (confirmation) {
99 + ConfirmationNote.APPAREIL -> R.string.note_vocale_confirmation_appareil
100 + ConfirmationNote.SERVEUR_OK -> R.string.note_vocale_confirmation_serveur_envoyee
101 + ConfirmationNote.SERVEUR_REPLI -> R.string.note_vocale_confirmation_serveur_repli
102 + }
103 + Toast.makeText(context, msgId, Toast.LENGTH_LONG).show()
104 + viewModel.consommerConfirmation()
105 + }
106 + LaunchedEffect(messageErreur) {
107 + if (messageErreur == null) return@LaunchedEffect
108 + Toast.makeText(context, R.string.note_vocale_relance_impossible, Toast.LENGTH_LONG).show()
109 + viewModel.consommerMessageErreur()
110 + }
111 +
112 + // rememberPagerState doit être appelé inconditionnellement (règle Compose).
113 + val initialPage = if (contacts.isNotEmpty()) viewModel.initialPageIndex(contacts) else 0
114 + val pagerState = rememberPagerState(
115 + initialPage = initialPage,
116 + pageCount = { contacts.size },
117 + )
118 + val current: CrmContactEntity? = contacts.getOrNull(pagerState.currentPage)
119 + val dupCount = current?.let { duplicateCountById[it.id] } ?: 0
74 120
75 121 Column(
76 122 modifier
77 123 .fillMaxSize()
78 124 .background(Fond),
79 125 ) {
126 + FicheTopBar(
127 + onBack = onBack,
128 + onEdit = current?.let { c -> { onEdit(c.id) } },
129 + duplicateCount = dupCount,
130 + onDuplicates = if (dupCount > 0 && current != null) {
131 + { onDuplicates(current.id) }
132 + } else {
133 + null
134 + },
135 + )
136 +
80 137 if (contacts.isEmpty()) {
81 - FicheTopBar(
82 - onBack = onBack,
83 - onEdit = null,
84 - duplicateCount = 0,
85 - onDuplicates = null,
86 - )
87 138 Box(
88 139 Modifier.fillMaxSize(),
89 140 contentAlignment = Alignment.Center,
90 141 ) {
91 142 Text(stringResource(R.string.carnet_vide), color = TexteFaible)
92 143 }
93 - return@Column
94 - }
144 + } else {
145 + HorizontalPager(
146 + state = pagerState,
147 + modifier = Modifier.fillMaxSize(),
148 + ) { page ->
149 + val contact = contacts[page]
150 + val serverId = contact.serverId
151 + val notesVocales by remember(serverId) {
152 + if (serverId != null) viewModel.observeNotesVocales(serverId) else flowOf(emptyList())
153 + }.collectAsState(initial = emptyList())
95 154
96 - val initialPage = viewModel.initialPageIndex(contacts)
97 - val pagerState = rememberPagerState(
98 - initialPage = initialPage,
99 - pageCount = { contacts.size },
100 - )
101 - val current = contacts.getOrNull(pagerState.currentPage) ?: contacts[initialPage]
102 - val dupCount = duplicateCountById[current.id] ?: 0
155 + ContactFichePage(
156 + contact = contact,
157 + notesVocales = notesVocales,
158 + downloadEnCours = downloadEnCours,
159 + onRotateCardLeft = { viewModel.rotateCardLeft(contacts[page].id) },
160 + onAjouterNote = { sheetVisible = true },
161 + onTelechargement = viewModel::telechargerAudioInteraction,
162 + onSupprimer = viewModel::supprimerNoteVocale,
163 + onRelancer = viewModel::relancerTranscriptionInteraction,
164 + )
165 + }
166 + }
167 + }
103 168
104 - FicheTopBar(
105 - onBack = onBack,
106 - onEdit = { onEdit(current.id) },
107 - duplicateCount = dupCount,
108 - onDuplicates = if (dupCount > 0) {
109 - { onDuplicates(current.id) }
110 - } else {
111 - null
169 + // Feuille d'enregistrement — affichée au-dessus du pager.
170 + // Le bouton micro n'est visible que si serverId != null, donc ce cas ne survient pas normalement.
171 + val currentServerId = current?.serverId
172 + if (sheetVisible && currentServerId != null) {
173 + EnregistrementNoteSheet(
174 + modeServeur = modeServeur,
175 + voskDisponible = voskDisponible,
176 + onSauvegarder = { sujet, texte, audioPath ->
177 + viewModel.ajouterNoteVocale(currentServerId, sujet, texte, audioPath, modeServeur)
112 178 },
179 + onDismiss = { sheetVisible = false },
113 180 )
114 -
115 - HorizontalPager(
116 - state = pagerState,
117 - modifier = Modifier.fillMaxSize(),
118 - ) { page ->
119 - ContactFichePage(
120 - contact = contacts[page],
121 - onRotateCardLeft = { viewModel.rotateCardLeft(contacts[page].id) },
122 - )
123 - }
124 181 }
125 182 }
126 183
@@ -162,7 +219,13 @@ private fun FicheTopBar(
162 219 @Composable
163 220 private fun ContactFichePage(
164 221 contact: CrmContactEntity,
222 + notesVocales: List<InteractionEntity>,
223 + downloadEnCours: Set<Long>,
165 224 onRotateCardLeft: () -> Unit,
225 + onAjouterNote: () -> Unit,
226 + onTelechargement: (InteractionEntity) -> Unit,
227 + onSupprimer: (InteractionEntity) -> Unit,
228 + onRelancer: (InteractionEntity) -> Unit,
166 229 modifier: Modifier = Modifier,
167 230 ) {
168 231 val context = LocalContext.current
@@ -206,7 +269,7 @@ private fun ContactFichePage(
206 269 )
207 270
208 271 Text(
209 - text = contactDisplayName(contact),
272 + text = nomAffiche(contact),
210 273 style = MaterialTheme.typography.titleLarge,
211 274 color = Encre,
212 275 )
@@ -260,6 +323,142 @@ private fun ContactFichePage(
260 323 HorizontalDivider(color = Bordure, thickness = 1.dp)
261 324 FieldBlock(stringResource(R.string.scan_champ_note), contact.notes)
262 325 }
326 +
327 + // Section notes vocales — uniquement si le contact est synchronisé (serverId connu)
328 + if (contact.serverId != null) {
329 + HorizontalDivider(color = Bordure, thickness = 1.dp)
330 + NotesVocalesSection(
331 + notes = notesVocales,
332 + downloadEnCours = downloadEnCours,
333 + onAjouterNote = onAjouterNote,
334 + onTelechargement = onTelechargement,
335 + onSupprimer = onSupprimer,
336 + onRelancer = onRelancer,
337 + )
338 + }
339 + }
340 +}
341 +
342 +@Composable
343 +private fun NotesVocalesSection(
344 + notes: List<InteractionEntity>,
345 + downloadEnCours: Set<Long>,
346 + onAjouterNote: () -> Unit,
347 + onTelechargement: (InteractionEntity) -> Unit,
348 + onSupprimer: (InteractionEntity) -> Unit,
349 + onRelancer: (InteractionEntity) -> Unit,
350 +) {
351 + var pendingDelete by remember { mutableStateOf<InteractionEntity?>(null) }
352 +
353 + Row(
354 + Modifier.fillMaxWidth(),
355 + verticalAlignment = Alignment.CenterVertically,
356 + horizontalArrangement = Arrangement.SpaceBetween,
357 + ) {
358 + Text(
359 + stringResource(R.string.note_vocale_titre_section),
360 + style = MaterialTheme.typography.labelMedium,
361 + color = TexteFaible,
362 + )
363 + IconButton(onClick = onAjouterNote) {
364 + Icon(
365 + Icons.Filled.Mic,
366 + contentDescription = stringResource(R.string.note_vocale_enregistrer),
367 + tint = Ink,
368 + )
369 + }
370 + }
371 + if (notes.isEmpty()) {
372 + EtatVide(
373 + texte = stringResource(R.string.note_vocale_vide),
374 + modifier = Modifier
375 + .fillMaxWidth()
376 + .height(60.dp),
377 + )
378 + } else {
379 + notes.forEach { note ->
380 + NoteVocaleRow(
381 + note = note,
382 + enChargement = downloadEnCours.contains(note.localId),
383 + onTelechargement = { onTelechargement(note) },
384 + onSupprimer = { pendingDelete = note },
385 + onRelancer = if (note.transcriptionStatut == "echec") {
386 + { onRelancer(note) }
387 + } else {
388 + null
389 + },
390 + )
391 + HorizontalDivider(color = Bordure, thickness = 1.dp)
392 + }
393 + }
394 +
395 + if (pendingDelete != null) {
396 + AlertDialog(
397 + onDismissRequest = { pendingDelete = null },
398 + title = { Text(stringResource(R.string.note_vocale_supprimer_titre), color = Ink) },
399 + text = { Text(stringResource(R.string.note_vocale_supprimer_message), color = Ink) },
400 + confirmButton = {
401 + TextButton(onClick = {
402 + pendingDelete?.let { onSupprimer(it) }
403 + pendingDelete = null
404 + }) {
405 + Text(stringResource(R.string.note_vocale_supprimer_confirmer), color = MaterialTheme.colorScheme.error)
406 + }
407 + },
408 + dismissButton = {
409 + TextButton(onClick = { pendingDelete = null }) {
410 + Text(stringResource(R.string.note_vocale_supprimer_annuler), color = Ink)
411 + }
412 + },
413 + )
414 + }
415 +}
416 +
417 +@Composable
418 +private fun NoteVocaleRow(
419 + note: InteractionEntity,
420 + enChargement: Boolean,
421 + onTelechargement: () -> Unit,
422 + onSupprimer: () -> Unit,
423 + onRelancer: (() -> Unit)?,
424 +) {
425 + Column(
426 + Modifier
427 + .fillMaxWidth()
428 + .padding(vertical = 6.dp),
429 + ) {
430 + Row(
431 + Modifier.fillMaxWidth(),
432 + verticalAlignment = Alignment.CenterVertically,
433 + ) {
434 + Text(
435 + note.sujet,
436 + style = MaterialTheme.typography.bodyMedium,
437 + color = Ink,
438 + modifier = Modifier.weight(1f),
439 + )
440 + IconButton(onClick = onSupprimer) {
441 + Icon(
442 + Icons.Filled.Delete,
443 + contentDescription = stringResource(R.string.note_vocale_supprimer),
444 + tint = TexteFaible,
445 + )
446 + }
447 + }
448 + BadgeStatutTranscription(note.transcriptionStatut, note.transcriptionErreur)
449 + if (note.description.isNotBlank()) {
450 + Text(note.description, style = MaterialTheme.typography.bodySmall, color = TexteFaible)
451 + }
452 + if (onRelancer != null) {
453 + TextButton(onClick = onRelancer) {
454 + Text(stringResource(R.string.note_vocale_relancer), color = MaterialTheme.colorScheme.error)
455 + }
456 + }
457 + LecteurAudio(
458 + audioPath = note.audioPath,
459 + enChargement = enChargement,
460 + onTelechargement = if (note.audioPath == null && note.serverId != null) onTelechargement else null,
461 + )
263 462 }
264 463 }
265 464
@@ -328,11 +527,3 @@ private fun PathImage(
328 527 )
329 528 }
330 529 }
331 -
332 -private fun contactDisplayName(c: CrmContactEntity): String {
333 - c.fullName?.takeIf { it.isNotBlank() }?.let { return it }
334 - val composed = listOfNotNull(c.firstName, c.lastName).joinToString(" ").trim()
335 - if (composed.isNotEmpty()) return composed
336 - c.company?.takeIf { it.isNotBlank() }?.let { return it }
337 - return "—"
338 -}
M android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactPagerViewModel.kt
+190 -0
@@ -5,21 +5,44 @@ import androidx.lifecycle.viewModelScope
5 5 import fr.ebii.card2vcf.crm.AlphabetIndex
6 6 import fr.ebii.card2vcf.crm.ContactSort
7 7 import fr.ebii.card2vcf.crm.DuplicateDetector
8 +import fr.ebii.card2vcf.data.AudioNoteStore
8 9 import fr.ebii.card2vcf.data.ContactRepository
9 10 import fr.ebii.card2vcf.data.CrmContactEntity
11 +import fr.ebii.card2vcf.data.CrmDatabase
12 +import fr.ebii.card2vcf.data.InteractionEntity
13 +import fr.ebii.card2vcf.sync.AilianceApi
14 +import fr.ebii.card2vcf.sync.AilianceApiClient
15 +import fr.ebii.card2vcf.sync.CreateInteractionRequest
16 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
17 +import fr.ebii.card2vcf.sync.SyncEngine
18 +import fr.ebii.card2vcf.sync.SyncOpEntity
19 +import fr.ebii.card2vcf.sync.syncJson
20 +import fr.ebii.card2vcf.ui.audio.ConfirmationNote
21 +import kotlinx.coroutines.Dispatchers
10 22 import kotlinx.coroutines.ExperimentalCoroutinesApi
23 +import java.io.File
24 +import kotlinx.coroutines.flow.Flow
25 +import kotlinx.coroutines.flow.MutableStateFlow
11 26 import kotlinx.coroutines.flow.SharingStarted
12 27 import kotlinx.coroutines.flow.StateFlow
28 +import kotlinx.coroutines.flow.asStateFlow
13 29 import kotlinx.coroutines.flow.map
14 30 import kotlinx.coroutines.flow.mapLatest
15 31 import kotlinx.coroutines.flow.stateIn
32 +import kotlinx.coroutines.flow.update
16 33 import kotlinx.coroutines.launch
34 +import kotlinx.coroutines.withContext
35 +import kotlinx.serialization.encodeToString
17 36
18 37 @OptIn(ExperimentalCoroutinesApi::class)
19 38 class ContactPagerViewModel(
20 39 private val repository: ContactRepository,
40 + private val database: CrmDatabase,
41 + private val credentialsStore: SyncCredentialsStore,
42 + private val audioStore: AudioNoteStore,
21 43 val initialContactId: Long,
22 44 val sort: ContactSort,
45 + private val engineFabrique: () -> SyncEngine?,
23 46 ) : ViewModel() {
24 47
25 48 val contacts: StateFlow<List<CrmContactEntity>> = repository.observeAll()
@@ -36,6 +59,25 @@ class ContactPagerViewModel(
36 59 }
37 60 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
38 61
62 + /** localId des interactions dont le téléchargement audio est en cours. */
63 + private val _downloadEnCours = MutableStateFlow<Set<Long>>(emptySet())
64 + val downloadEnCours: StateFlow<Set<Long>> = _downloadEnCours
65 +
66 + /** Confirmation à afficher après ajout d'une note vocale ; consommer via [consommerConfirmation]. */
67 + private val _confirmationNote = MutableStateFlow<ConfirmationNote?>(null)
68 + val confirmationNote: StateFlow<ConfirmationNote?> = _confirmationNote.asStateFlow()
69 +
70 + fun consommerConfirmation() { _confirmationNote.value = null }
71 +
72 + /** Message d'erreur ponctuel (relance hors ligne, etc.) ; consommer via [consommerMessageErreur]. */
73 + private val _messageErreur = MutableStateFlow<String?>(null)
74 + val messageErreur: StateFlow<String?> = _messageErreur.asStateFlow()
75 +
76 + fun consommerMessageErreur() { _messageErreur.value = null }
77 +
78 + /** Fabrique l'API client ; remplaçable dans les tests. */
79 + internal var apiFactory: (String, String) -> AilianceApi = { baseUrl, apiKey -> AilianceApiClient(baseUrl, apiKey) }
80 +
39 81 fun initialPageIndex(list: List<CrmContactEntity>): Int {
40 82 val idx = list.indexOfFirst { it.id == initialContactId }
41 83 return if (idx >= 0) idx else 0
@@ -47,4 +89,152 @@ class ContactPagerViewModel(
47 89 repository.rotateCardImage(contactId, degrees = -90)
48 90 }
49 91 }
92 +
93 + /** Observe les interactions de type note_vocale pour un contact (triées par date desc). */
94 + fun observeNotesVocales(contactServerId: String): Flow<List<InteractionEntity>> =
95 + database.interactionDao().observeByContactServerId(contactServerId)
96 + .map { list -> list.filter { it.type == "note_vocale" }.sortedByDescending { it.createdAt } }
97 +
98 + /**
99 + * Insère une interaction note_vocale et l'op de sync correspondante.
100 + * [modeServeur] = true → demandeTranscription=true + statut "en_attente" + push immédiat.
101 + */
102 + fun ajouterNoteVocale(
103 + contactServerId: String,
104 + sujet: String,
105 + description: String,
106 + audioPath: String?,
107 + modeServeur: Boolean,
108 + ) {
109 + if (sujet.isBlank()) return
110 + viewModelScope.launch {
111 + val now = System.currentTimeMillis()
112 + val fichierTemp = audioPath?.let { File(it) }
113 + val entite = InteractionEntity(
114 + contactServerId = contactServerId,
115 + type = "note_vocale",
116 + sujet = sujet,
117 + description = description,
118 + creePar = credentialsStore.userName.orEmpty(),
119 + audioPath = audioPath,
120 + transcriptionStatut = if (modeServeur && audioPath != null) "en_attente" else null,
121 + createdAt = now,
122 + )
123 + val localId = database.interactionDao().upsert(entite)
124 + // Déplacer le WAV depuis le dossier temporaire vers le stockage définitif (filesDir)
125 + val cheminFinal = if (fichierTemp != null && fichierTemp.exists()) {
126 + withContext(Dispatchers.IO) { audioStore.deplacerInteractionAudio(localId, fichierTemp) }
127 + } else audioPath
128 + if (cheminFinal != audioPath) {
129 + database.interactionDao().upsert(entite.copy(localId = localId, audioPath = cheminFinal))
130 + }
131 + database.syncOpDao().insert(
132 + SyncOpEntity(
133 + entityType = "interaction",
134 + op = "create",
135 + payloadJson = syncJson.encodeToString(
136 + CreateInteractionRequest(
137 + sujet = sujet,
138 + description = description,
139 + typeInteraction = "note_vocale",
140 + demandeTranscription = modeServeur && cheminFinal != null,
141 + ),
142 + ),
143 + localId = localId,
144 + serverId = contactServerId,
145 + createdAt = now,
146 + ),
147 + )
148 + if (modeServeur) {
149 + val engine = engineFabrique()
150 + _confirmationNote.value = if (engine != null) {
151 + val rapport = engine.pousserEnAttente()
152 + if (rapport.echecs == 0) ConfirmationNote.SERVEUR_OK else ConfirmationNote.SERVEUR_REPLI
153 + } else {
154 + ConfirmationNote.SERVEUR_REPLI
155 + }
156 + } else {
157 + _confirmationNote.value = ConfirmationNote.APPAREIL
158 + }
159 + }
160 + }
161 +
162 + /** Télécharge l'audio d'une interaction depuis le serveur et met à jour audioPath. */
163 + fun telechargerAudioInteraction(interaction: InteractionEntity) {
164 + if (interaction.audioPath != null) return
165 + val interactionServerId = interaction.serverId ?: return
166 + if (_downloadEnCours.value.contains(interaction.localId)) return
167 + viewModelScope.launch {
168 + _downloadEnCours.update { it + interaction.localId }
169 + withContext(Dispatchers.IO) {
170 + val baseUrl = credentialsStore.baseUrl ?: return@withContext
171 + val apiKey = credentialsStore.apiKey ?: return@withContext
172 + val api = apiFactory(baseUrl, apiKey)
173 + when (val result = api.downloadInteractionAudio(interaction.contactServerId, interactionServerId)) {
174 + is AilianceApiClient.ApiResult.Ok -> {
175 + val path = audioStore.saveInteractionAudio(interaction.localId, "audio.wav", result.value)
176 + database.interactionDao().upsert(interaction.copy(audioPath = path))
177 + }
178 + is AilianceApiClient.ApiResult.Err -> Unit
179 + }
180 + }
181 + _downloadEnCours.update { it - interaction.localId }
182 + }
183 + }
184 +
185 + /**
186 + * Supprime une note vocale : fichier audio local, entité DB, et op de sync delete
187 + * (seulement si la note a été synchronisée avec le serveur).
188 + */
189 + fun supprimerNoteVocale(interaction: InteractionEntity) {
190 + viewModelScope.launch {
191 + withContext(Dispatchers.IO) { audioStore.deleteInteractionAudio(interaction.localId) }
192 + val serverId = interaction.serverId
193 + if (serverId != null) {
194 + database.interactionDao().deleteByServerId(serverId)
195 + database.syncOpDao().insert(
196 + SyncOpEntity(
197 + entityType = "interaction",
198 + op = "delete",
199 + payloadJson = "{}",
200 + serverId = serverId,
201 + createdAt = System.currentTimeMillis(),
202 + ),
203 + )
204 + } else {
205 + // Jamais synchronisée : suppression locale + annulation du create en attente
206 + database.interactionDao().deleteByLocalId(interaction.localId)
207 + database.syncOpDao().listAll()
208 + .filter { it.entityType == "interaction" && it.localId == interaction.localId }
209 + .forEach { database.syncOpDao().deleteById(it.id) }
210 + }
211 + }
212 + }
213 +
214 + /**
215 + * Relance la transcription d'une interaction (appel direct API, hors sync).
216 + * Met à jour le statut local en `en_attente` et efface l'erreur si la relance réussit.
217 + */
218 + fun relancerTranscriptionInteraction(interaction: InteractionEntity) {
219 + val iid = interaction.serverId ?: return
220 + viewModelScope.launch {
221 + withContext(Dispatchers.IO) {
222 + val baseUrl = credentialsStore.baseUrl
223 + val apiKey = credentialsStore.apiKey
224 + if (baseUrl == null || apiKey == null) {
225 + _messageErreur.value = "relance_impossible"
226 + return@withContext
227 + }
228 + val api = apiFactory(baseUrl, apiKey)
229 + when (api.relancerTranscriptionInteraction(interaction.contactServerId, iid)) {
230 + is AilianceApiClient.ApiResult.Ok ->
231 + database.interactionDao().upsert(
232 + interaction.copy(transcriptionStatut = "en_attente", transcriptionErreur = null),
233 + )
234 + is AilianceApiClient.ApiResult.Err ->
235 + _messageErreur.value = "relance_impossible"
236 + }
237 + }
238 + }
239 + }
50 240 }
M android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+15 -5
@@ -18,6 +18,7 @@ import androidx.navigation.compose.rememberNavController
18 18 import androidx.navigation.navArgument
19 19 import fr.ebii.card2vcf.contact.ContactCard
20 20 import fr.ebii.card2vcf.crm.ContactSort
21 +import fr.ebii.card2vcf.data.AudioNoteStore
21 22 import fr.ebii.card2vcf.data.ContactRepository
22 23 import fr.ebii.card2vcf.data.CrmDatabase
23 24 import fr.ebii.card2vcf.ocr.TesseractOcrEngine
@@ -27,6 +28,7 @@ import fr.ebii.card2vcf.sync.CalendarBindingsStore
27 28 import fr.ebii.card2vcf.sync.CalendarBridge
28 29 import fr.ebii.card2vcf.sync.SyncCredentialsStore
29 30 import fr.ebii.card2vcf.sync.SyncEngine
31 +import fr.ebii.card2vcf.sync.creerSyncEngine
30 32 import fr.ebii.card2vcf.ui.ScanCarteScreen
31 33 import fr.ebii.card2vcf.ui.ScanCarteViewModel
32 34 import fr.ebii.card2vcf.ui.carnet.CarnetScreen
@@ -97,8 +99,10 @@ fun Card2vcfNavHost(
97 99 val credentialsStore = remember { SyncCredentialsStore(context.applicationContext) }
98 100 val calendarBindingsStore = remember { CalendarBindingsStore(context.applicationContext) }
99 101 val calendarBridge = remember { CalendarBridge(context.applicationContext.contentResolver) }
100 - val settingsVm = remember(credentialsStore, calendarBindingsStore, calendarBridge) {
101 - SettingsViewModel(credentialsStore, calendarBindingsStore, calendarBridge)
102 + val audioStore = remember { AudioNoteStore(context.applicationContext) }
103 + val voskDisponible = remember { context.assets.list("")?.contains("vosk-model-small-fr-0.22") == true }
104 + val settingsVm = remember(credentialsStore, calendarBindingsStore, calendarBridge, voskDisponible) {
105 + SettingsViewModel(credentialsStore, calendarBindingsStore, calendarBridge, voskDisponible)
102 106 }
103 107 val projetsVm = remember(database) { ProjetsViewModel(database) }
104 108 val syncChromeVm = remember(credentialsStore, calendarBindingsStore, calendarBridge, database) {
@@ -167,11 +171,14 @@ fun Card2vcfNavHost(
167 171 ) { entry ->
168 172 val serverId = entry.arguments?.getString("serverId") ?: return@composable
169 173 val projetDetailVm = remember(database, serverId) {
170 - ProjetDetailViewModel(database, credentialsStore, serverId)
174 + ProjetDetailViewModel(database, credentialsStore, audioStore, serverId,
175 + engineFabrique = { creerSyncEngine(credentialsStore, database) })
171 176 }
172 177 ProjetDetailScreen(
173 178 viewModel = projetDetailVm,
174 179 onBack = { navController.popBackStack() },
180 + modeServeur = !credentialsStore.modeTranscriptionLocal,
181 + voskDisponible = voskDisponible,
175 182 )
176 183 }
177 184 composable(
@@ -190,8 +197,9 @@ fun Card2vcfNavHost(
190 197 val sort = sortName
191 198 ?.let { runCatching { ContactSort.valueOf(it) }.getOrNull() }
192 199 ?: ContactSort.LAST_NAME
193 - val pagerVm = remember(repository, id, sort) {
194 - ContactPagerViewModel(repository, id, sort)
200 + val pagerVm = remember(repository, database, id, sort) {
201 + ContactPagerViewModel(repository, database, credentialsStore, audioStore, id, sort,
202 + engineFabrique = { creerSyncEngine(credentialsStore, database) })
195 203 }
196 204 ContactPagerScreen(
197 205 viewModel = pagerVm,
@@ -200,6 +208,8 @@ fun Card2vcfNavHost(
200 208 onDuplicates = { contactId ->
201 209 navController.navigate(Routes.duplicates(contactId))
202 210 },
211 + modeServeur = !credentialsStore.modeTranscriptionLocal,
212 + voskDisponible = voskDisponible,
203 213 )
204 214 }
205 215 composable(
M android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetDetailScreen.kt
+187 -1
@@ -1,18 +1,24 @@
1 1 package fr.ebii.card2vcf.ui.projets
2 2
3 +import android.text.format.DateUtils
4 +import android.widget.Toast
3 5 import androidx.compose.foundation.background
6 +import androidx.compose.foundation.layout.Arrangement
4 7 import androidx.compose.foundation.layout.Box
5 8 import androidx.compose.foundation.layout.Column
9 +import androidx.compose.foundation.layout.Row
6 10 import androidx.compose.foundation.layout.Spacer
7 11 import androidx.compose.foundation.layout.fillMaxSize
8 12 import androidx.compose.foundation.layout.fillMaxWidth
9 13 import androidx.compose.foundation.layout.height
10 14 import androidx.compose.foundation.layout.padding
11 15 import androidx.compose.foundation.lazy.LazyColumn
12 -import androidx.compose.foundation.shape.RoundedCornerShape
13 16 import androidx.compose.material.icons.Icons
14 17 import androidx.compose.material.icons.automirrored.outlined.ArrowBack
15 18 import androidx.compose.material.icons.filled.ArrowDropDown
19 +import androidx.compose.material.icons.filled.Delete
20 +import androidx.compose.material.icons.filled.Mic
21 +import androidx.compose.material3.AlertDialog
16 22 import androidx.compose.material3.DropdownMenu
17 23 import androidx.compose.material3.DropdownMenuItem
18 24 import androidx.compose.material3.HorizontalDivider
@@ -24,18 +30,27 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
24 30 import androidx.compose.material3.Text
25 31 import androidx.compose.material3.TextButton
26 32 import androidx.compose.runtime.Composable
33 +import androidx.compose.runtime.LaunchedEffect
27 34 import androidx.compose.runtime.collectAsState
28 35 import androidx.compose.runtime.getValue
29 36 import androidx.compose.runtime.mutableStateOf
30 37 import androidx.compose.runtime.remember
31 38 import androidx.compose.runtime.setValue
32 39 import androidx.compose.ui.Alignment
40 +import androidx.compose.ui.platform.LocalContext
33 41 import androidx.compose.ui.Modifier
34 42 import androidx.compose.ui.res.stringResource
35 43 import androidx.compose.ui.unit.dp
36 44 import fr.ebii.card2vcf.R
37 45 import fr.ebii.card2vcf.data.CrmContactEntity
38 46 import fr.ebii.card2vcf.data.InteractionEntity
47 +import fr.ebii.card2vcf.data.NoteProjetEntity
48 +import fr.ebii.card2vcf.ui.audio.ConfirmationNote
49 +import fr.ebii.card2vcf.ui.audio.EnregistrementNoteSheet
50 +import fr.ebii.card2vcf.ui.audio.LecteurAudio
51 +import fr.ebii.card2vcf.ui.composants.BadgeStatutTranscription
52 +import fr.ebii.card2vcf.ui.composants.EtatVide
53 +import fr.ebii.card2vcf.ui.composants.MarkdownText
39 54 import fr.ebii.card2vcf.ui.kanban.KanbanBoard
40 55 import fr.ebii.card2vcf.ui.theme.Bordure
41 56 import fr.ebii.card2vcf.ui.theme.Fond
@@ -48,6 +63,8 @@ import fr.ebii.card2vcf.ui.theme.TexteFaible
48 63 fun ProjetDetailScreen(
49 64 viewModel: ProjetDetailViewModel,
50 65 onBack: () -> Unit,
66 + modeServeur: Boolean,
67 + voskDisponible: Boolean,
51 68 modifier: Modifier = Modifier,
52 69 ) {
53 70 val projet by viewModel.projet.collectAsState()
@@ -60,9 +77,31 @@ fun ProjetDetailScreen(
60 77 val crSujet by viewModel.crSujet.collectAsState()
61 78 val crDescription by viewModel.crDescription.collectAsState()
62 79 val crInteractions by viewModel.crInteractions.collectAsState()
80 + val notes by viewModel.notes.collectAsState()
81 + val downloadEnCours by viewModel.downloadEnCours.collectAsState()
82 + val confirmationNote by viewModel.confirmationNote.collectAsState()
83 + val messageErreur by viewModel.messageErreur.collectAsState()
63 84 val assignableUsers = remember(membres, viewModel.userName) {
64 85 (membres.map { it.utilisateur } + viewModel.userName).filter { it.isNotBlank() }
65 86 }
87 + var sheetVisible by remember { mutableStateOf(false) }
88 +
89 + val context = LocalContext.current
90 + LaunchedEffect(confirmationNote) {
91 + val confirmation = confirmationNote ?: return@LaunchedEffect
92 + val msgId = when (confirmation) {
93 + ConfirmationNote.APPAREIL -> R.string.note_vocale_confirmation_appareil
94 + ConfirmationNote.SERVEUR_OK -> R.string.note_vocale_confirmation_serveur_envoyee
95 + ConfirmationNote.SERVEUR_REPLI -> R.string.note_vocale_confirmation_serveur_repli
96 + }
97 + Toast.makeText(context, msgId, Toast.LENGTH_LONG).show()
98 + viewModel.consommerConfirmation()
99 + }
100 + LaunchedEffect(messageErreur) {
101 + if (messageErreur == null) return@LaunchedEffect
102 + Toast.makeText(context, R.string.note_vocale_relance_impossible, Toast.LENGTH_LONG).show()
103 + viewModel.consommerMessageErreur()
104 + }
66 105
67 106 Column(modifier.fillMaxSize().background(Fond)) {
68 107 Box(Modifier.fillMaxWidth().background(Surface).padding(horizontal = 8.dp, vertical = 6.dp)) {
@@ -121,10 +160,31 @@ fun ProjetDetailScreen(
121 160 interactions = crInteractions,
122 161 onSubmit = viewModel::submitCr,
123 162 )
163 +
164 + HorizontalDivider(color = Bordure, thickness = 1.dp, modifier = Modifier.padding(vertical = 12.dp))
165 + NotesSection(
166 + notes = notes,
167 + downloadEnCours = downloadEnCours,
168 + onAjouterNoteVocale = { sheetVisible = true },
169 + onTelechargementNote = viewModel::telechargerAudioNote,
170 + onSupprimerNote = viewModel::supprimerNote,
171 + onRelancerNote = viewModel::relancerTranscriptionNote,
172 + )
124 173 Spacer(Modifier.height(24.dp))
125 174 }
126 175 }
127 176 }
177 +
178 + if (sheetVisible) {
179 + EnregistrementNoteSheet(
180 + modeServeur = modeServeur,
181 + voskDisponible = voskDisponible,
182 + onSauvegarder = { sujet, texte, audioPath ->
183 + viewModel.ajouterNoteVocale(sujet, texte, audioPath, modeServeur)
184 + },
185 + onDismiss = { sheetVisible = false },
186 + )
187 + }
128 188 }
129 189
130 190 @Composable
@@ -216,3 +276,129 @@ private fun contactDisplayName(contact: CrmContactEntity): String {
216 276 val composed = listOfNotNull(contact.firstName, contact.lastName).joinToString(" ").trim()
217 277 return composed.ifEmpty { "—" }
218 278 }
279 +
280 +@Composable
281 +private fun NotesSection(
282 + notes: List<NoteProjetEntity>,
283 + downloadEnCours: Set<Long>,
284 + onAjouterNoteVocale: () -> Unit,
285 + onTelechargementNote: (NoteProjetEntity) -> Unit,
286 + onSupprimerNote: (NoteProjetEntity) -> Unit,
287 + onRelancerNote: (NoteProjetEntity) -> Unit,
288 +) {
289 + var pendingDelete by remember { mutableStateOf<NoteProjetEntity?>(null) }
290 +
291 + Row(
292 + Modifier.fillMaxWidth(),
293 + verticalAlignment = Alignment.CenterVertically,
294 + horizontalArrangement = Arrangement.SpaceBetween,
295 + ) {
296 + Text(
297 + stringResource(R.string.projet_notes_titre),
298 + style = MaterialTheme.typography.labelMedium,
299 + color = TexteFaible,
300 + )
301 + IconButton(onClick = onAjouterNoteVocale) {
302 + Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.note_vocale_enregistrer), tint = Ink)
303 + }
304 + }
305 + Spacer(Modifier.height(6.dp))
306 + if (notes.isEmpty()) {
307 + EtatVide(
308 + texte = stringResource(R.string.projet_notes_vide),
309 + modifier = Modifier.fillMaxWidth().height(80.dp),
310 + )
311 + } else {
312 + notes.forEach { note ->
313 + NoteCard(
314 + note = note,
315 + enChargement = downloadEnCours.contains(note.localId),
316 + onTelechargement = { onTelechargementNote(note) },
317 + onSupprimer = { pendingDelete = note },
318 + onRelancer = if (note.transcriptionStatut == "echec") { { onRelancerNote(note) } } else null,
319 + )
320 + HorizontalDivider(color = Bordure, thickness = 1.dp)
321 + }
322 + }
323 +
324 + pendingDelete?.let { note ->
325 + AlertDialog(
326 + onDismissRequest = { pendingDelete = null },
327 + title = { Text(stringResource(R.string.note_vocale_supprimer_titre)) },
328 + text = { Text(stringResource(R.string.note_vocale_supprimer_message)) },
329 + confirmButton = {
330 + TextButton(onClick = {
331 + onSupprimerNote(note)
332 + pendingDelete = null
333 + }) {
334 + Text(stringResource(R.string.note_vocale_supprimer_confirmer))
335 + }
336 + },
337 + dismissButton = {
338 + TextButton(onClick = { pendingDelete = null }) {
339 + Text(stringResource(R.string.note_vocale_supprimer_annuler))
340 + }
341 + },
342 + )
343 + }
344 +}
345 +
346 +@Composable
347 +private fun NoteCard(
348 + note: NoteProjetEntity,
349 + enChargement: Boolean,
350 + onTelechargement: () -> Unit,
351 + onSupprimer: () -> Unit,
352 + onRelancer: (() -> Unit)?,
353 +) {
354 + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
355 + Row(
356 + Modifier.fillMaxWidth(),
357 + verticalAlignment = Alignment.CenterVertically,
358 + horizontalArrangement = Arrangement.SpaceBetween,
359 + ) {
360 + if (note.titre.isNotBlank()) {
361 + Text(note.titre, style = MaterialTheme.typography.bodyMedium, color = Ink, modifier = Modifier.weight(1f))
362 + }
363 + IconButton(onClick = onSupprimer) {
364 + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.note_vocale_supprimer), tint = TexteFaible)
365 + }
366 + }
367 + BadgeStatutTranscription(note.transcriptionStatut, note.transcriptionErreur)
368 + val ts = note.updatedAt ?: note.createdAt
369 + val metaText = buildString {
370 + if (note.auteur.isNotBlank()) append(note.auteur)
371 + if (ts > 0L) {
372 + if (isNotEmpty()) append(" · ")
373 + append(
374 + DateUtils.getRelativeTimeSpanString(
375 + ts,
376 + System.currentTimeMillis(),
377 + DateUtils.MINUTE_IN_MILLIS,
378 + ),
379 + )
380 + }
381 + }
382 + if (metaText.isNotEmpty()) {
383 + Text(metaText, style = MaterialTheme.typography.bodySmall, color = TexteFaible)
384 + }
385 + if (note.texte.isNotBlank()) {
386 + Spacer(Modifier.height(4.dp))
387 + MarkdownText(note.texte, modifier = Modifier.fillMaxWidth())
388 + }
389 + // Lecteur audio — visible si le fichier existe localement ou si un serverId permet le téléchargement
390 + if (note.audioPath != null || note.serverId != null) {
391 + LecteurAudio(
392 + audioPath = note.audioPath,
393 + enChargement = enChargement,
394 + onTelechargement = if (note.audioPath == null && note.serverId != null) onTelechargement else null,
395 + )
396 + }
397 + if (onRelancer != null) {
398 + TextButton(onClick = onRelancer) {
399 + Text(stringResource(R.string.note_vocale_relancer))
400 + }
401 + }
402 + }
403 +}
404 +
M android/app/src/main/java/fr/ebii/card2vcf/ui/projets/ProjetDetailViewModel.kt
+180 -2
@@ -2,23 +2,32 @@ package fr.ebii.card2vcf.ui.projets
2 2
3 3 import androidx.lifecycle.ViewModel
4 4 import androidx.lifecycle.viewModelScope
5 +import fr.ebii.card2vcf.data.AudioNoteStore
5 6 import fr.ebii.card2vcf.data.CrmContactEntity
6 7 import fr.ebii.card2vcf.data.CrmDatabase
7 8 import fr.ebii.card2vcf.data.InteractionEntity
9 +import fr.ebii.card2vcf.data.NoteProjetEntity
8 10 import fr.ebii.card2vcf.data.ProjetEntity
9 11 import fr.ebii.card2vcf.data.TacheEntity
10 12 import fr.ebii.card2vcf.kanban.KanbanFilter
13 +import fr.ebii.card2vcf.sync.AilianceApi
14 +import fr.ebii.card2vcf.sync.AilianceApiClient
11 15 import fr.ebii.card2vcf.sync.ColonneDto
12 16 import fr.ebii.card2vcf.sync.CreateInteractionRequest
17 +import fr.ebii.card2vcf.sync.CreateNoteProjetRequest
13 18 import fr.ebii.card2vcf.sync.CreateTacheRequest
14 19 import fr.ebii.card2vcf.sync.MembreProjetDto
15 20 import fr.ebii.card2vcf.sync.MoveTacheRequest
16 21 import fr.ebii.card2vcf.sync.SyncCredentialsStore
22 +import fr.ebii.card2vcf.sync.SyncEngine
17 23 import fr.ebii.card2vcf.sync.SyncOpDao
18 24 import fr.ebii.card2vcf.sync.SyncOpEntity
19 25 import fr.ebii.card2vcf.sync.UpdateTacheRequest
20 26 import fr.ebii.card2vcf.sync.syncJson
27 +import fr.ebii.card2vcf.ui.audio.ConfirmationNote
28 +import kotlinx.coroutines.Dispatchers
21 29 import kotlinx.coroutines.ExperimentalCoroutinesApi
30 +import java.io.File
22 31 import kotlinx.coroutines.flow.MutableStateFlow
23 32 import kotlinx.coroutines.flow.SharingStarted
24 33 import kotlinx.coroutines.flow.StateFlow
@@ -29,20 +38,43 @@ import kotlinx.coroutines.flow.flatMapLatest
29 38 import kotlinx.coroutines.flow.flow
30 39 import kotlinx.coroutines.flow.map
31 40 import kotlinx.coroutines.flow.stateIn
41 +import kotlinx.coroutines.flow.update
32 42 import kotlinx.coroutines.launch
43 +import kotlinx.coroutines.withContext
33 44 import kotlinx.serialization.decodeFromString
34 45 import kotlinx.serialization.encodeToString
35 46
36 -/** Fiche projet : membres, kanban (colonnes du workflow), et CR (interaction) sur un contact. */
47 +/** Fiche projet : membres, kanban (colonnes du workflow), CR (interaction) et notes vocales. */
37 48 @OptIn(ExperimentalCoroutinesApi::class)
38 49 class ProjetDetailViewModel(
39 50 private val database: CrmDatabase,
40 - credentialsStore: SyncCredentialsStore,
51 + private val credentialsStore: SyncCredentialsStore,
52 + private val audioStore: AudioNoteStore,
41 53 private val projetServerId: String,
54 + private val engineFabrique: () -> SyncEngine?,
42 55 ) : ViewModel() {
43 56
44 57 val userName: String = credentialsStore.userName.orEmpty()
45 58
59 + /** localId des notes dont le téléchargement audio est en cours. */
60 + private val _downloadEnCours = MutableStateFlow<Set<Long>>(emptySet())
61 + val downloadEnCours: StateFlow<Set<Long>> = _downloadEnCours
62 +
63 + /** Confirmation à afficher après ajout d'une note vocale ; consommer via [consommerConfirmation]. */
64 + private val _confirmationNote = MutableStateFlow<ConfirmationNote?>(null)
65 + val confirmationNote: StateFlow<ConfirmationNote?> = _confirmationNote.asStateFlow()
66 +
67 + fun consommerConfirmation() { _confirmationNote.value = null }
68 +
69 + /** Message d'erreur ponctuel (relance hors ligne, etc.) ; consommer via [consommerMessageErreur]. */
70 + private val _messageErreur = MutableStateFlow<String?>(null)
71 + val messageErreur: StateFlow<String?> = _messageErreur.asStateFlow()
72 +
73 + fun consommerMessageErreur() { _messageErreur.value = null }
74 +
75 + /** Fabrique l'API client ; remplaçable dans les tests. */
76 + internal var apiFactory: (String, String) -> AilianceApi = { baseUrl, apiKey -> AilianceApiClient(baseUrl, apiKey) }
77 +
46 78 val projet: StateFlow<ProjetEntity?> = database.projetDao().observeAll()
47 79 .map { list -> list.find { it.serverId == projetServerId } }
48 80 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@@ -95,6 +127,11 @@ class ProjetDetailViewModel(
95 127 }
96 128 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
97 129
130 + val notes: StateFlow<List<NoteProjetEntity>> = database.noteProjetDao()
131 + .listByProjetServerId(projetServerId)
132 + .map { list -> list.sortedByDescending { it.updatedAt ?: it.createdAt } }
133 + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
134 +
98 135 fun setFilterMode(mode: KanbanFilter.Mode) {
99 136 _filterMode.value = mode
100 137 }
@@ -262,6 +299,147 @@ class ProjetDetailViewModel(
262 299 .forEach { syncOpDao.deleteById(it.id) }
263 300 }
264 301
302 + /**
303 + * Insère une note vocale pour ce projet.
304 + * [modeServeur] = true → demandeTranscription + statut "en_attente" + push immédiat.
305 + */
306 + fun ajouterNoteVocale(
307 + sujet: String,
308 + description: String,
309 + audioPath: String?,
310 + modeServeur: Boolean,
311 + ) {
312 + if (sujet.isBlank()) return
313 + viewModelScope.launch {
314 + val now = System.currentTimeMillis()
315 + val fichierTemp = audioPath?.let { File(it) }
316 + val entite = NoteProjetEntity(
317 + projetServerId = projetServerId,
318 + titre = sujet,
319 + texte = description,
320 + auteur = userName,
321 + audioPath = audioPath,
322 + transcriptionStatut = if (modeServeur && audioPath != null) "en_attente" else null,
323 + createdAt = now,
324 + updatedAt = now,
325 + )
326 + val localId = database.noteProjetDao().upsert(entite)
327 + // Déplacer le WAV depuis le dossier temporaire vers le stockage définitif (filesDir)
328 + val cheminFinal = if (fichierTemp != null && fichierTemp.exists()) {
329 + withContext(Dispatchers.IO) { audioStore.deplacerNoteAudio(localId, fichierTemp) }
330 + } else audioPath
331 + if (cheminFinal != audioPath) {
332 + database.noteProjetDao().upsert(entite.copy(localId = localId, audioPath = cheminFinal))
333 + }
334 + database.syncOpDao().insert(
335 + SyncOpEntity(
336 + entityType = "note_projet",
337 + op = "create",
338 + payloadJson = syncJson.encodeToString(
339 + CreateNoteProjetRequest(
340 + titre = sujet,
341 + contenu = description,
342 + demandeTranscription = modeServeur && cheminFinal != null,
343 + ),
344 + ),
345 + localId = localId,
346 + serverId = projetServerId,
347 + createdAt = now,
348 + ),
349 + )
350 + if (modeServeur) {
351 + val engine = engineFabrique()
352 + _confirmationNote.value = if (engine != null) {
353 + val rapport = engine.pousserEnAttente()
354 + if (rapport.echecs == 0) ConfirmationNote.SERVEUR_OK else ConfirmationNote.SERVEUR_REPLI
355 + } else {
356 + ConfirmationNote.SERVEUR_REPLI
357 + }
358 + } else {
359 + _confirmationNote.value = ConfirmationNote.APPAREIL
360 + }
361 + }
362 + }
363 +
364 + /** Télécharge l'audio d'une note projet depuis le serveur et met à jour audioPath. */
365 + fun telechargerAudioNote(note: NoteProjetEntity) {
366 + if (note.audioPath != null) return
367 + val noteServerId = note.serverId ?: return
368 + if (_downloadEnCours.value.contains(note.localId)) return
369 + viewModelScope.launch {
370 + _downloadEnCours.update { it + note.localId }
371 + withContext(Dispatchers.IO) {
372 + val baseUrl = credentialsStore.baseUrl ?: return@withContext
373 + val apiKey = credentialsStore.apiKey ?: return@withContext
374 + val api = apiFactory(baseUrl, apiKey)
375 + when (val result = api.downloadNoteAudio(projetServerId, noteServerId)) {
376 + is AilianceApiClient.ApiResult.Ok -> {
377 + val path = audioStore.saveNoteAudio(note.localId, "audio.wav", result.value)
378 + database.noteProjetDao().upsert(note.copy(audioPath = path))
379 + }
380 + is AilianceApiClient.ApiResult.Err -> Unit
381 + }
382 + }
383 + _downloadEnCours.update { it - note.localId }
384 + }
385 + }
386 +
387 + /**
388 + * Supprime une note projet : fichier audio local, entité DB, et op de sync delete
389 + * (seulement si la note a été synchronisée avec le serveur).
390 + */
391 + fun supprimerNote(note: NoteProjetEntity) {
392 + viewModelScope.launch {
393 + withContext(Dispatchers.IO) { audioStore.deleteNoteAudio(note.localId) }
394 + val serverId = note.serverId
395 + if (serverId != null) {
396 + database.noteProjetDao().deleteByServerId(serverId)
397 + database.syncOpDao().insert(
398 + SyncOpEntity(
399 + entityType = "note_projet",
400 + op = "delete",
401 + payloadJson = "{}",
402 + serverId = serverId,
403 + createdAt = System.currentTimeMillis(),
404 + ),
405 + )
406 + } else {
407 + // Jamais synchronisée : suppression locale + annulation du create en attente
408 + database.noteProjetDao().deleteByLocalId(note.localId)
409 + database.syncOpDao().listAll()
410 + .filter { it.entityType == "note_projet" && it.localId == note.localId }
411 + .forEach { database.syncOpDao().deleteById(it.id) }
412 + }
413 + }
414 + }
415 +
416 + /**
417 + * Relance la transcription d'une note projet (appel direct API, hors sync).
418 + * Met à jour le statut local en `en_attente` et efface l'erreur si la relance réussit.
419 + */
420 + fun relancerTranscriptionNote(note: NoteProjetEntity) {
421 + val nid = note.serverId ?: return
422 + viewModelScope.launch {
423 + withContext(Dispatchers.IO) {
424 + val baseUrl = credentialsStore.baseUrl
425 + val apiKey = credentialsStore.apiKey
426 + if (baseUrl == null || apiKey == null) {
427 + _messageErreur.value = "relance_impossible"
428 + return@withContext
429 + }
430 + val api = apiFactory(baseUrl, apiKey)
431 + when (api.relancerTranscriptionNote(projetServerId, nid)) {
432 + is AilianceApiClient.ApiResult.Ok ->
433 + database.noteProjetDao().upsert(
434 + note.copy(transcriptionStatut = "en_attente", transcriptionErreur = null),
435 + )
436 + is AilianceApiClient.ApiResult.Err ->
437 + _messageErreur.value = "relance_impossible"
438 + }
439 + }
440 + }
441 + }
442 +
265 443 private companion object {
266 444 fun parseMembres(json: String): List<MembreProjetDto> =
267 445 try {
M android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsScreen.kt
+37 -0
@@ -145,6 +145,10 @@ private fun LoggedInContent(
145 145 hasCalendarPermission = hasCalendarPermission,
146 146 onRequestCalendarPermission = onRequestCalendarPermission,
147 147 )
148 +
149 + HorizontalDivider(color = Bordure, thickness = 1.dp)
150 +
151 + TranscriptionSection(state = state, viewModel = viewModel)
148 152 }
149 153
150 154 if (state.pendingRemoval != null) {
@@ -269,6 +273,39 @@ private fun ToggleRow(
269 273 }
270 274
271 275 @Composable
276 +private fun TranscriptionSection(
277 + state: SettingsUiState.LoggedIn,
278 + viewModel: SettingsViewModel,
279 +) {
280 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
281 + Text(
282 + stringResource(R.string.settings_transcription_titre),
283 + style = MaterialTheme.typography.titleSmall,
284 + color = Ink,
285 + )
286 + if (!state.voskDisponible) {
287 + Text(
288 + stringResource(R.string.settings_transcription_vosk_indisponible),
289 + color = MaterialTheme.colorScheme.error,
290 + style = MaterialTheme.typography.bodySmall,
291 + )
292 + }
293 + ToggleRow(
294 + label = stringResource(R.string.settings_transcription_local),
295 + checked = state.modeTranscriptionLocal,
296 + enabled = state.voskDisponible,
297 + onCheckedChange = { viewModel.setModeTranscriptionLocal(true) },
298 + )
299 + ToggleRow(
300 + label = stringResource(R.string.settings_transcription_serveur),
301 + checked = !state.modeTranscriptionLocal,
302 + enabled = true,
303 + onCheckedChange = { viewModel.setModeTranscriptionLocal(false) },
304 + )
305 + }
306 +}
307 +
308 +@Composable
272 309 private fun LoggedOutContent(
273 310 state: SettingsUiState.LoggedOut,
274 311 viewModel: SettingsViewModel,
M android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsViewModel.kt
+18 -0
@@ -51,6 +51,10 @@ sealed interface SettingsUiState {
51 51 val catalogueLoading: Boolean = false,
52 52 val catalogueError: String? = null,
53 53 val pendingRemoval: PendingCalendarRemoval? = null,
54 + /** true = Sur l'appareil (Vosk) ; false = Sur le serveur. */
55 + val modeTranscriptionLocal: Boolean = true,
56 + /** false si le modèle Vosk est absent des assets (build -Pvosk=none). */
57 + val voskDisponible: Boolean = true,
54 58 ) : SettingsUiState
55 59
56 60 data class LoggedOut(
@@ -76,6 +80,8 @@ class SettingsViewModel(
76 80 private val credentialsStore: SyncCredentialsStore,
77 81 private val calendarBindingsStore: CalendarBindingsStore,
78 82 private val calendarBridge: CalendarBridgeApi,
83 + /** false si le modèle Vosk est absent des assets — force le mode serveur en Paramètres. */
84 + private val voskDisponible: Boolean = true,
79 85 private val apiFactory: (String) -> AilianceApi = { baseUrl -> AilianceApiClient(baseUrl) },
80 86 private val authenticatedApiFactory: (String, String) -> AilianceApi =
81 87 { baseUrl, apiKey -> AilianceApiClient(baseUrl, apiKey) },
@@ -93,6 +99,8 @@ class SettingsViewModel(
93 99 userName = userName,
94 100 baseUrl = baseUrl,
95 101 mesRdvLinked = calendarBindingsStore.list().any { it.kind == AgendaSyncCoordinator.KIND_RDV },
102 + modeTranscriptionLocal = credentialsStore.modeTranscriptionLocal,
103 + voskDisponible = voskDisponible,
96 104 )
97 105 } else {
98 106 SettingsUiState.LoggedOut(
@@ -171,6 +179,8 @@ class SettingsViewModel(
171 179 userName = auth.nom,
172 180 baseUrl = baseUrl,
173 181 mesRdvLinked = calendarBindingsStore.list().any { it.kind == AgendaSyncCoordinator.KIND_RDV },
182 + modeTranscriptionLocal = credentialsStore.modeTranscriptionLocal,
183 + voskDisponible = voskDisponible,
174 184 )
175 185 } catch (e: ApiCleCryptoException) {
176 186 _state.value = s.copy(retypePassword = "", retypeError = "Mot de passe incorrect")
@@ -285,6 +295,14 @@ class SettingsViewModel(
285 295
286 296 fun dismissRemoveBinding() = updateLoggedIn { it.copy(pendingRemoval = null) }
287 297
298 + // ---- Transcription ----
299 +
300 + fun setModeTranscriptionLocal(local: Boolean) {
301 + val effectif = local && voskDisponible
302 + credentialsStore.modeTranscriptionLocal = effectif
303 + updateLoggedIn { it.copy(modeTranscriptionLocal = effectif) }
304 + }
305 +
288 306 private fun RessourceToggleState.withLinked(kind: String, serverResourceId: String?, linked: Boolean): RessourceToggleState =
289 307 if (this.kind == kind && this.serverResourceId == serverResourceId) copy(linked = linked) else this
290 308
M android/app/src/main/res/values-en/strings.xml
+37 -0
@@ -153,4 +153,41 @@
153 153 <string name="kanban_supprimer">Delete</string>
154 154 <string name="kanban_valider">Save</string>
155 155 <string name="kanban_sans_workflow">No workflow linked to this project</string>
156 +
157 + <!-- Voice notes — recording -->
158 + <string name="note_vocale_titre_section">Voice notes</string>
159 + <string name="note_vocale_enregistrer">Record a voice note</string>
160 + <string name="note_vocale_avertissement">Transcription does not distinguish between speakers</string>
161 + <string name="note_vocale_stop">Stop recording</string>
162 + <string name="note_vocale_transcription_en_cours">Transcription in progress…</string>
163 + <string name="note_vocale_transcription_serveur">Server transcription after sync</string>
164 + <string name="note_vocale_sujet_label">Note subject</string>
165 + <string name="note_vocale_texte_label">Transcription</string>
166 + <string name="note_vocale_sauvegarder">Save</string>
167 + <string name="note_vocale_annuler">Cancel</string>
168 + <string name="note_vocale_permission_manquante">Allow microphone access to record voice notes.</string>
169 + <string name="note_vocale_autoriser_micro">Allow microphone</string>
170 + <string name="note_vocale_sujet_defaut">Notes from %1$s</string>
171 + <string name="note_vocale_sujet_aide">A subject is required to save the note</string>
172 + <string name="note_vocale_confirmation_serveur">Voice note saved — will be sent to the server at the next sync; transcription will appear afterwards.</string>
173 + <string name="note_vocale_confirmation_appareil">Voice note saved — will be sent to the server at the next sync.</string>
174 + <!-- Voice notes — status badges -->
175 + <string name="note_vocale_badge_en_attente">Transcription pending</string>
176 + <string name="note_vocale_badge_echec">Transcription failed</string>
177 + <!-- Voice notes — list -->
178 + <string name="note_vocale_vide">No voice notes</string>
179 + <!-- Audio player -->
180 + <string name="lecteur_audio_lire">Play</string>
181 + <string name="lecteur_audio_pause">Pause</string>
182 + <string name="lecteur_audio_telecharger">Download audio</string>
183 + <string name="lecteur_audio_en_chargement">Loading…</string>
184 + <string name="lecteur_audio_erreur">Unable to play audio</string>
185 + <!-- Settings — transcription -->
186 + <string name="settings_transcription_titre">Voice note transcription</string>
187 + <string name="settings_transcription_local">On device</string>
188 + <string name="settings_transcription_serveur">On server</string>
189 + <string name="settings_transcription_vosk_indisponible">Local model unavailable — server transcription forced</string>
190 + <string name="scan_champ_a_verifier">To verify — uncertain reading</string>
191 + <string name="projet_notes_titre">Notes</string>
192 + <string name="projet_notes_vide">No notes for this project</string>
156 193 </resources>
M android/app/src/main/res/values/strings.xml
+48 -0
@@ -144,6 +144,9 @@
144 144 <string name="projet_cr_description_placeholder">Description</string>
145 145 <string name="projet_cr_ajouter">Ajouter</string>
146 146
147 + <string name="projet_notes_titre">Notes</string>
148 + <string name="projet_notes_vide">Aucune note pour ce projet</string>
149 +
147 150 <string name="kanban_filtre_mes_taches">Mes tâches</string>
148 151 <string name="kanban_filtre_toutes">Toutes</string>
149 152 <string name="kanban_nouvelle_tache">Nouvelle tâche</string>
@@ -154,4 +157,49 @@
154 157 <string name="kanban_supprimer">Supprimer</string>
155 158 <string name="kanban_valider">Valider</string>
156 159 <string name="kanban_sans_workflow">Aucun workflow associé à ce projet</string>
160 +
161 + <!-- Notes vocales — enregistrement -->
162 + <string name="note_vocale_titre_section">Notes vocales</string>
163 + <string name="note_vocale_enregistrer">Enregistrer une note vocale</string>
164 + <string name="note_vocale_avertissement">La transcription ne distingue pas les interlocuteurs</string>
165 + <string name="note_vocale_stop">Arrêter l\'enregistrement</string>
166 + <string name="note_vocale_transcription_en_cours">Transcription en cours…</string>
167 + <string name="note_vocale_transcription_serveur">Transcription serveur après synchronisation</string>
168 + <string name="note_vocale_sujet_label">Sujet de la note</string>
169 + <string name="note_vocale_texte_label">Transcription</string>
170 + <string name="note_vocale_sauvegarder">Sauvegarder</string>
171 + <string name="note_vocale_annuler">Annuler</string>
172 + <string name="note_vocale_permission_manquante">Autorisez l\'accès au microphone pour enregistrer des notes vocales.</string>
173 + <string name="note_vocale_autoriser_micro">Autoriser le microphone</string>
174 + <string name="note_vocale_sujet_defaut">Notes du %1$s</string>
175 + <string name="note_vocale_sujet_aide">Un sujet est nécessaire pour enregistrer la note</string>
176 + <string name="note_vocale_confirmation_serveur">Note vocale enregistrée — envoyée au serveur à la prochaine synchronisation ; la transcription apparaîtra ensuite.</string>
177 + <string name="note_vocale_confirmation_appareil">Note vocale enregistrée — envoyée au serveur à la prochaine synchronisation.</string>
178 + <string name="note_vocale_confirmation_serveur_envoyee">Note vocale envoyée au serveur — transcription en cours, le texte apparaîtra à la prochaine synchronisation.</string>
179 + <string name="note_vocale_confirmation_serveur_repli">Note vocale enregistrée — envoi au serveur à la prochaine synchronisation.</string>
180 + <!-- Notes vocales — badges statut -->
181 + <string name="note_vocale_badge_en_attente">Transcription en attente</string>
182 + <string name="note_vocale_badge_echec">Échec de transcription</string>
183 + <!-- Notes vocales — liste -->
184 + <string name="note_vocale_vide">Aucune note vocale</string>
185 + <!-- Notes vocales — suppression -->
186 + <string name="note_vocale_supprimer">Supprimer la note</string>
187 + <string name="note_vocale_supprimer_titre">Supprimer cette note ?</string>
188 + <string name="note_vocale_supprimer_message">La note vocale et son audio local seront supprimés définitivement.</string>
189 + <string name="note_vocale_supprimer_confirmer">Supprimer</string>
190 + <string name="note_vocale_supprimer_annuler">Annuler</string>
191 + <!-- Notes vocales — relance transcription -->
192 + <string name="note_vocale_relancer">Relancer la transcription</string>
193 + <string name="note_vocale_relance_impossible">Relance impossible : serveur injoignable</string>
194 + <!-- Lecteur audio -->
195 + <string name="lecteur_audio_lire">Lire</string>
196 + <string name="lecteur_audio_pause">Pause</string>
197 + <string name="lecteur_audio_telecharger">Télécharger l\'audio</string>
198 + <string name="lecteur_audio_en_chargement">Chargement…</string>
199 + <string name="lecteur_audio_erreur">Impossible de lire l\'audio</string>
200 + <!-- Paramètres — transcription -->
201 + <string name="settings_transcription_titre">Transcription des notes vocales</string>
202 + <string name="settings_transcription_local">Sur l\'appareil</string>
203 + <string name="settings_transcription_serveur">Sur le serveur</string>
204 + <string name="settings_transcription_vosk_indisponible">Modèle local absent — transcription serveur forcée</string>
157 205 </resources>
A android/app/src/test/java/fr/ebii/card2vcf/audio/EcritureWavTest.kt
+128 -0
@@ -0,0 +1,128 @@
1 +package fr.ebii.card2vcf.audio
2 +
3 +import java.io.File
4 +import java.nio.ByteBuffer
5 +import java.nio.ByteOrder
6 +import kotlin.test.Test
7 +import kotlin.test.assertEquals
8 +import kotlin.test.assertFailsWith
9 +
10 +class EcritureWavTest {
11 +
12 + @Test
13 + fun enTeteRiffCorrect() {
14 + val fichier = File.createTempFile("test_wav", ".wav")
15 + try {
16 + val ecriture = EcritureWav(fichier, 16_000)
17 + // 160 échantillons = 10 ms à 16 kHz
18 + val echantillons = ShortArray(160) { (it % 500).toShort() }
19 + ecriture.ecrireEchantillons(echantillons, echantillons.size)
20 + ecriture.fermer()
21 +
22 + val octets = fichier.readBytes()
23 + val buf = ByteBuffer.wrap(octets).order(ByteOrder.LITTLE_ENDIAN)
24 +
25 + // Marqueur RIFF
26 + assertEquals('R', buf.get().toInt().toChar())
27 + assertEquals('I', buf.get().toInt().toChar())
28 + assertEquals('F', buf.get().toInt().toChar())
29 + assertEquals('F', buf.get().toInt().toChar())
30 +
31 + // Taille RIFF = taille fichier - 8
32 + val tailleRiff = buf.int
33 + assertEquals(octets.size - 8, tailleRiff)
34 +
35 + // WAVE
36 + assertEquals('W', buf.get().toInt().toChar())
37 + assertEquals('A', buf.get().toInt().toChar())
38 + assertEquals('V', buf.get().toInt().toChar())
39 + assertEquals('E', buf.get().toInt().toChar())
40 +
41 + // Chunk fmt
42 + assertEquals('f', buf.get().toInt().toChar())
43 + assertEquals('m', buf.get().toInt().toChar())
44 + assertEquals('t', buf.get().toInt().toChar())
45 + assertEquals(' ', buf.get().toInt().toChar())
46 + assertEquals(16, buf.int) // taille chunk fmt = 16
47 + assertEquals(1, buf.short.toInt()) // format PCM = 1
48 + assertEquals(1, buf.short.toInt()) // mono = 1 canal
49 + assertEquals(16_000, buf.int) // fréquence d'échantillonnage
50 + assertEquals(32_000, buf.int) // byte rate = 16000 * 1 * 16/8
51 + assertEquals(2, buf.short.toInt()) // block align = 1 * 16/8
52 + assertEquals(16, buf.short.toInt()) // bits par échantillon
53 +
54 + // Chunk data
55 + assertEquals('d', buf.get().toInt().toChar())
56 + assertEquals('a', buf.get().toInt().toChar())
57 + assertEquals('t', buf.get().toInt().toChar())
58 + assertEquals('a', buf.get().toInt().toChar())
59 +
60 + val tailleData = buf.int
61 + assertEquals(echantillons.size * 2, tailleData)
62 + assertEquals(octets.size - 44, tailleData)
63 + } finally {
64 + fichier.delete()
65 + }
66 + }
67 +
68 + @Test
69 + fun octetsDataRetournes() {
70 + val fichier = File.createTempFile("test_data", ".wav")
71 + try {
72 + val ecriture = EcritureWav(fichier, 16_000)
73 + // 1 seconde = 16 000 échantillons → 32 000 octets
74 + val echantillons = ShortArray(16_000)
75 + ecriture.ecrireEchantillons(echantillons, echantillons.size)
76 + val octetsEcrits = ecriture.fermer()
77 + assertEquals(32_000L, octetsEcrits)
78 + } finally {
79 + fichier.delete()
80 + }
81 + }
82 +
83 + @Test
84 + fun fichiertailleCoherente() {
85 + val fichier = File.createTempFile("test_taille", ".wav")
86 + try {
87 + val ecriture = EcritureWav(fichier, 16_000)
88 + val echantillons = ShortArray(800) // 50 ms
89 + ecriture.ecrireEchantillons(echantillons, echantillons.size)
90 + ecriture.fermer()
91 + // 44 octets d'en-tête + 800 * 2 = 1644 octets
92 + assertEquals(44 + 800 * 2, fichier.length().toInt())
93 + } finally {
94 + fichier.delete()
95 + }
96 + }
97 +
98 + @Test
99 + fun fermerLanceExceptionSiTropGrand() {
100 + val fichier = File.createTempFile("test_check_taille", ".wav")
101 + try {
102 + val ecriture = EcritureWav(fichier, 16_000)
103 + // Injecte une taille trop grande via réflexion (écrire 2 Go en test est impraticable)
104 + val champ = EcritureWav::class.java.getDeclaredField("octetsData")
105 + champ.isAccessible = true
106 + champ.setLong(ecriture, Int.MAX_VALUE.toLong() + 1L)
107 + assertFailsWith<IllegalStateException> { ecriture.fermer() }
108 + } finally {
109 + fichier.delete()
110 + }
111 + }
112 +
113 + @Test
114 + fun ecritureEnPlusieursAppels() {
115 + val fichier = File.createTempFile("test_multi", ".wav")
116 + try {
117 + val ecriture = EcritureWav(fichier, 16_000)
118 + ecriture.ecrireEchantillons(ShortArray(100), 100)
119 + ecriture.ecrireEchantillons(ShortArray(200), 200)
120 + ecriture.ecrireEchantillons(ShortArray(50), 50)
121 + val octetsEcrits = ecriture.fermer()
122 + assertEquals((100 + 200 + 50) * 2L, octetsEcrits)
123 + assertEquals(44 + (100 + 200 + 50) * 2, fichier.length().toInt())
124 + } finally {
125 + fichier.delete()
126 + }
127 + }
128 +}
A android/app/src/test/java/fr/ebii/card2vcf/audio/NoteVocaleUtilTest.kt
+56 -0
@@ -0,0 +1,56 @@
1 +package fr.ebii.card2vcf.audio
2 +
3 +import fr.ebii.card2vcf.ui.audio.genererSujetParDefaut
4 +import java.util.Calendar
5 +import kotlin.test.Test
6 +import kotlin.test.assertEquals
7 +import kotlin.test.assertTrue
8 +
9 +class NoteVocaleUtilTest {
10 +
11 + private fun horodatage(annee: Int, mois: Int, jour: Int, heure: Int, minute: Int): Long {
12 + return Calendar.getInstance().apply {
13 + set(annee, mois - 1, jour, heure, minute, 0)
14 + set(Calendar.MILLISECOND, 0)
15 + }.timeInMillis
16 + }
17 +
18 + @Test
19 + fun sujet_formate_date_complete() {
20 + val ts = horodatage(2026, 9, 5, 9, 7)
21 + val resultat = genererSujetParDefaut("Notes du %1\$s", maintenant = ts)
22 + assertEquals("Notes du 05/09/2026 à 09:07", resultat)
23 + }
24 +
25 + @Test
26 + fun sujet_formate_date_fin_de_mois() {
27 + val ts = horodatage(2026, 12, 31, 23, 59)
28 + val resultat = genererSujetParDefaut("Notes du %1\$s", maintenant = ts)
29 + assertEquals("Notes du 31/12/2026 à 23:59", resultat)
30 + }
31 +
32 + @Test
33 + fun sujet_formate_minuit() {
34 + val ts = horodatage(2026, 1, 1, 0, 0)
35 + val resultat = genererSujetParDefaut("Notes du %1\$s", maintenant = ts)
36 + assertEquals("Notes du 01/01/2026 à 00:00", resultat)
37 + }
38 +
39 + @Test
40 + fun sujet_contient_date_formatee() {
41 + val resultat = genererSujetParDefaut("Notes du %1\$s")
42 + // Vérifie que le résultat commence par le préfixe et contient le format jj/MM/yyyy à HH:mm
43 + assertTrue(resultat.startsWith("Notes du "), "Devrait commencer par 'Notes du '")
44 + assertTrue(
45 + Regex("""Notes du \d{2}/\d{2}/\d{4} à \d{2}:\d{2}""").matches(resultat),
46 + "Format attendu : 'Notes du jj/MM/yyyy à HH:mm', obtenu : '$resultat'",
47 + )
48 + }
49 +
50 + @Test
51 + fun sujet_utilise_le_modele_fourni() {
52 + val ts = horodatage(2026, 6, 15, 14, 30)
53 + val resultat = genererSujetParDefaut("Enregistrement du %1\$s", maintenant = ts)
54 + assertEquals("Enregistrement du 15/06/2026 à 14:30", resultat)
55 + }
56 +}
A android/app/src/test/java/fr/ebii/card2vcf/audio/TranscripteurLocalFake.kt
+27 -0
@@ -0,0 +1,27 @@
1 +package fr.ebii.card2vcf.audio
2 +
3 +/**
4 + * Implémentation factice de [TranscripteurLocal] pour les tests unitaires.
5 + */
6 +class TranscripteurLocalFake(disponible: Boolean = true) : TranscripteurLocal {
7 +
8 + override val etat: TranscripteurLocal.Etat =
9 + if (disponible) TranscripteurLocal.Etat.DISPONIBLE else TranscripteurLocal.Etat.INDISPONIBLE
10 +
11 + private val buffersRecus = mutableListOf<Pair<ShortArray, Int>>()
12 + var reinitialiseAppele = false
13 +
14 + override fun accepterEchantillons(data: ShortArray, longueur: Int): String? {
15 + buffersRecus += data to longueur
16 + return null // pas de résultat partiel simulé
17 + }
18 +
19 + override fun finaliser(): String = "transcription simulée"
20 +
21 + override fun reinitialiser() {
22 + reinitialiseAppele = true
23 + buffersRecus.clear()
24 + }
25 +
26 + fun nombreBuffersRecus(): Int = buffersRecus.size
27 +}
A android/app/src/test/java/fr/ebii/card2vcf/audio/TranscripteurVoskTest.kt
+104 -0
@@ -0,0 +1,104 @@
1 +package fr.ebii.card2vcf.audio
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertEquals
5 +import kotlin.test.assertNull
6 +import kotlin.test.assertTrue
7 +
8 +/**
9 + * Tests JVM des méthodes de parsing JSON de Vosk et du fake.
10 + * N'instancie pas TranscripteurVosk (nécessite un Context Android + lib native).
11 + */
12 +class TranscripteurVoskTest {
13 +
14 + // --- Parsing "partial" ---
15 +
16 + @Test
17 + fun parsagePartielNormal() {
18 + val json = """{"partial":"bonjour le"}"""
19 + assertEquals("bonjour le", TranscripteurVosk.parsagePartiel(json))
20 + }
21 +
22 + @Test
23 + fun parsagePartielAvecEspaces() {
24 + val json = """{ "partial" : "comment allez vous" }"""
25 + assertEquals("comment allez vous", TranscripteurVosk.parsagePartiel(json))
26 + }
27 +
28 + @Test
29 + fun parsagePartielVideRetourneNull() {
30 + val json = """{"partial":""}"""
31 + assertNull(TranscripteurVosk.parsagePartiel(json))
32 + }
33 +
34 + @Test
35 + fun parsagePartielJsonInvalidRetourneNull() {
36 + assertNull(TranscripteurVosk.parsagePartiel("not json at all"))
37 + assertNull(TranscripteurVosk.parsagePartiel(""))
38 + assertNull(TranscripteurVosk.parsagePartiel("{}"))
39 + }
40 +
41 + // --- Parsing "text" (résultat final) ---
42 +
43 + @Test
44 + fun parsageTexteFinal() {
45 + val json = """{"text":"bonjour le monde"}"""
46 + assertEquals("bonjour le monde", TranscripteurVosk.parsageTexte(json))
47 + }
48 +
49 + @Test
50 + fun parsageTexteVide() {
51 + val json = """{"text":""}"""
52 + assertEquals("", TranscripteurVosk.parsageTexte(json))
53 + }
54 +
55 + @Test
56 + fun parsageTexteJsonInvalidRetourneVide() {
57 + assertEquals("", TranscripteurVosk.parsageTexte("not json"))
58 + assertEquals("", TranscripteurVosk.parsageTexte(""))
59 + }
60 +
61 + @Test
62 + fun parsageTexteAvecChampsSupplementaires() {
63 + // Vosk peut renvoyer des champs supplémentaires (résultats détaillés)
64 + val json = """{"result":[],"text":"merci beaucoup"}"""
65 + assertEquals("merci beaucoup", TranscripteurVosk.parsageTexte(json))
66 + }
67 +
68 + // --- Fake ---
69 +
70 + @Test
71 + fun fakeDisponibleParDefaut() {
72 + val fake = TranscripteurLocalFake()
73 + assertEquals(TranscripteurLocal.Etat.DISPONIBLE, fake.etat)
74 + }
75 +
76 + @Test
77 + fun fakeIndisponible() {
78 + val fake = TranscripteurLocalFake(disponible = false)
79 + assertEquals(TranscripteurLocal.Etat.INDISPONIBLE, fake.etat)
80 + }
81 +
82 + @Test
83 + fun fakeAccepteEchantillons() {
84 + val fake = TranscripteurLocalFake()
85 + assertNull(fake.accepterEchantillons(ShortArray(160), 160))
86 + assertNull(fake.accepterEchantillons(ShortArray(160), 160))
87 + assertEquals(2, fake.nombreBuffersRecus())
88 + }
89 +
90 + @Test
91 + fun fakeFinaliserRetourneTexte() {
92 + val fake = TranscripteurLocalFake()
93 + assertEquals("transcription simulée", fake.finaliser())
94 + }
95 +
96 + @Test
97 + fun fakeReinitialiser() {
98 + val fake = TranscripteurLocalFake()
99 + fake.accepterEchantillons(ShortArray(100), 100)
100 + fake.reinitialiser()
101 + assertTrue(fake.reinitialiseAppele)
102 + assertEquals(0, fake.nombreBuffersRecus())
103 + }
104 +}
A android/app/src/test/java/fr/ebii/card2vcf/data/AudioNoteStoreMoveTest.kt
+57 -0
@@ -0,0 +1,57 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import java.io.File
4 +import kotlin.test.Test
5 +import kotlin.test.assertEquals
6 +import kotlin.test.assertFalse
7 +import kotlin.test.assertTrue
8 +
9 +class AudioNoteStoreMoveTest {
10 +
11 + @Test
12 + fun deplacement_copie_contenu_et_supprime_source() {
13 + val source = File.createTempFile("source_move", ".wav")
14 + val cible = File.createTempFile("cible_move", ".wav").also { it.delete() }
15 + try {
16 + source.writeBytes(byteArrayOf(1, 2, 3, 4))
17 + val chemin = deplacerFichierAudio(source, cible)
18 + assertEquals(cible.absolutePath, chemin)
19 + assertTrue(cible.exists(), "La cible doit exister après le déplacement")
20 + assertEquals(4, cible.length().toInt(), "La cible doit avoir le même contenu")
21 + assertFalse(source.exists(), "La source doit être supprimée")
22 + } finally {
23 + cible.delete()
24 + }
25 + }
26 +
27 + @Test
28 + fun deplacement_cree_repertoire_parent() {
29 + val source = File.createTempFile("source_mkdir", ".wav")
30 + val dir = File(System.getProperty("java.io.tmpdir"), "test_audio_move_${System.currentTimeMillis()}")
31 + val cible = File(dir, "audio.wav")
32 + try {
33 + source.writeBytes(byteArrayOf(5, 6, 7))
34 + deplacerFichierAudio(source, cible)
35 + assertTrue(cible.exists(), "La cible doit exister même si le dossier parent n'existait pas")
36 + assertEquals(3, cible.length().toInt())
37 + } finally {
38 + cible.delete()
39 + dir.delete()
40 + }
41 + }
42 +
43 + @Test
44 + fun deplacement_ecrase_cible_existante() {
45 + val source = File.createTempFile("source_overwrite", ".wav")
46 + val cible = File.createTempFile("cible_overwrite", ".wav")
47 + try {
48 + source.writeBytes(byteArrayOf(10, 20))
49 + cible.writeBytes(byteArrayOf(99, 99, 99))
50 + deplacerFichierAudio(source, cible)
51 + assertEquals(2, cible.length().toInt(), "La cible doit contenir les octets de la source")
52 + assertFalse(source.exists())
53 + } finally {
54 + cible.delete()
55 + }
56 + }
57 +}
M android/app/src/test/java/fr/ebii/card2vcf/sync/FakeAilianceApi.kt
+90 -1
@@ -58,7 +58,39 @@ class FakeAilianceApi : AilianceApi {
58 58 override fun updateTache(projetId: String, tacheId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
59 59 override fun deleteTache(projetId: String, tacheId: String) = AilianceApiClient.ApiResult.Ok(Unit)
60 60 override fun moveTache(projetId: String, tacheId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
61 - override fun createInteraction(contactId: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
61 + var createInteractionResult: AilianceApiClient.ApiResult<String> =
62 + AilianceApiClient.ApiResult.Ok("""{"id":"interaction-generated"}""")
63 + val createInteractionCalls = mutableListOf<Pair<String, String>>() // contactId, jsonBody
64 +
65 + override fun createInteraction(contactId: String, jsonBody: String): AilianceApiClient.ApiResult<String> {
66 + createInteractionCalls += contactId to jsonBody
67 + return createInteractionResult
68 + }
69 +
70 + var deleteInteractionResult: AilianceApiClient.ApiResult<Unit> = AilianceApiClient.ApiResult.Ok(Unit)
71 + val deleteInteractionCalls = mutableListOf<String>() // iid
72 +
73 + override fun deleteInteraction(iid: String): AilianceApiClient.ApiResult<Unit> {
74 + deleteInteractionCalls += iid
75 + return deleteInteractionResult
76 + }
77 +
78 + var relancerTranscriptionInteractionResult: AilianceApiClient.ApiResult<Unit> = AilianceApiClient.ApiResult.Ok(Unit)
79 + val relancerTranscriptionInteractionCalls = mutableListOf<Pair<String, String>>() // contactServerId, iid
80 +
81 + override fun relancerTranscriptionInteraction(contactServerId: String, iid: String): AilianceApiClient.ApiResult<Unit> {
82 + relancerTranscriptionInteractionCalls += contactServerId to iid
83 + return relancerTranscriptionInteractionResult
84 + }
85 +
86 + var relancerTranscriptionNoteResult: AilianceApiClient.ApiResult<Unit> = AilianceApiClient.ApiResult.Ok(Unit)
87 + val relancerTranscriptionNoteCalls = mutableListOf<Pair<String, String>>() // projetServerId, nid
88 +
89 + override fun relancerTranscriptionNote(projetServerId: String, nid: String): AilianceApiClient.ApiResult<Unit> {
90 + relancerTranscriptionNoteCalls += projetServerId to nid
91 + return relancerTranscriptionNoteResult
92 + }
93 +
62 94 override fun listRdv() = AilianceApiClient.ApiResult.Ok(emptyList<RendezVousDto>())
63 95
64 96 override fun createRdv(jsonBody: String): AilianceApiClient.ApiResult<String> {
@@ -129,4 +161,61 @@ class FakeAilianceApi : AilianceApi {
129 161 downloadPhotoCalls += id
130 162 return downloadPhotoResult
131 163 }
164 +
165 + var uploadInteractionAudioResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("{}")
166 + val uploadInteractionAudioCalls = mutableListOf<Triple<String, String, Int>>() // contactId, iid, bytes size
167 +
168 + override fun uploadInteractionAudio(
169 + contactId: String,
170 + iid: String,
171 + bytes: ByteArray,
172 + filename: String,
173 + contentType: String,
174 + ): AilianceApiClient.ApiResult<String> {
175 + uploadInteractionAudioCalls += Triple(contactId, iid, bytes.size)
176 + return uploadInteractionAudioResult
177 + }
178 +
179 + override fun downloadInteractionAudio(contactId: String, iid: String): AilianceApiClient.ApiResult<ByteArray> =
180 + AilianceApiClient.ApiResult.Err(404, "absent")
181 +
182 + var createNoteProjetResult: AilianceApiClient.ApiResult<String> =
183 + AilianceApiClient.ApiResult.Ok("""{"id":"note-generated"}""")
184 + val createNoteProjetCalls = mutableListOf<Pair<String, String>>() // projetId, jsonBody
185 +
186 + override fun createNoteProjet(projetId: String, jsonBody: String): AilianceApiClient.ApiResult<String> {
187 + createNoteProjetCalls += projetId to jsonBody
188 + return createNoteProjetResult
189 + }
190 +
191 + val updateNoteProjetCalls = mutableListOf<Triple<String, String, String>>() // projetId, nid, jsonBody
192 +
193 + override fun updateNoteProjet(projetId: String, nid: String, jsonBody: String): AilianceApiClient.ApiResult<String> {
194 + updateNoteProjetCalls += Triple(projetId, nid, jsonBody)
195 + return AilianceApiClient.ApiResult.Ok("{}")
196 + }
197 +
198 + val deleteNoteProjetCalls = mutableListOf<Pair<String, String>>() // projetId, nid
199 +
200 + override fun deleteNoteProjet(projetId: String, nid: String): AilianceApiClient.ApiResult<Unit> {
201 + deleteNoteProjetCalls += projetId to nid
202 + return AilianceApiClient.ApiResult.Ok(Unit)
203 + }
204 +
205 + var uploadNoteAudioResult: AilianceApiClient.ApiResult<String> = AilianceApiClient.ApiResult.Ok("{}")
206 + val uploadNoteAudioCalls = mutableListOf<Triple<String, String, Int>>() // projetId, nid, bytes size
207 +
208 + override fun uploadNoteAudio(
209 + projetId: String,
210 + nid: String,
211 + bytes: ByteArray,
212 + filename: String,
213 + contentType: String,
214 + ): AilianceApiClient.ApiResult<String> {
215 + uploadNoteAudioCalls += Triple(projetId, nid, bytes.size)
216 + return uploadNoteAudioResult
217 + }
218 +
219 + override fun downloadNoteAudio(projetId: String, nid: String): AilianceApiClient.ApiResult<ByteArray> =
220 + AilianceApiClient.ApiResult.Err(404, "absent")
132 221 }
A android/app/src/test/java/fr/ebii/card2vcf/sync/InteractionVocaleTest.kt
+410 -0
@@ -0,0 +1,410 @@
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.InteractionEntity
8 +import fr.ebii.card2vcf.data.NoteProjetEntity
9 +import kotlinx.coroutines.runBlocking
10 +import org.junit.After
11 +import org.junit.Assert.assertEquals
12 +import org.junit.Assert.assertNotNull
13 +import org.junit.Assert.assertNull
14 +import org.junit.Before
15 +import org.junit.Test
16 +import org.junit.runner.RunWith
17 +import org.robolectric.RobolectricTestRunner
18 +import org.robolectric.annotation.Config
19 +import java.io.File
20 +
21 +@RunWith(RobolectricTestRunner::class)
22 +@Config(sdk = [31])
23 +class InteractionVocaleTest {
24 +
25 + private lateinit var db: CrmDatabase
26 + private lateinit var api: FakeAilianceApi
27 + private lateinit var engine: SyncEngine
28 +
29 + @Before
30 + fun setUp() {
31 + val ctx = ApplicationProvider.getApplicationContext<Context>()
32 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
33 + .allowMainThreadQueries()
34 + .build()
35 + api = FakeAilianceApi()
36 + engine = SyncEngine(api, db)
37 + }
38 +
39 + @After
40 + fun tearDown() = db.close()
41 +
42 + // ---- Push interaction note_vocale ----
43 +
44 + @Test
45 + fun push_interactionNoteVocale_createPuisUploadEtServerIdEnregistre() = runBlocking {
46 + // Prépare un fichier audio local
47 + val audioFile = File.createTempFile("note_vocale", ".wav")
48 + audioFile.writeBytes(byteArrayOf(1, 2, 3, 4, 5))
49 +
50 + // Interaction locale avec audioPath
51 + val localId = db.interactionDao().upsert(
52 + InteractionEntity(
53 + contactServerId = "contact-srv-1",
54 + type = "note_vocale",
55 + sujet = "Note vocale",
56 + audioPath = audioFile.absolutePath,
57 + ),
58 + )
59 + // Op de create dans la file de sync (serverId = contactServerId)
60 + db.syncOpDao().insert(
61 + SyncOpEntity(
62 + entityType = "interaction",
63 + op = "create",
64 + payloadJson = """{"sujet":"Note vocale","type_interaction":"note_vocale","demande_transcription":true}""",
65 + localId = localId,
66 + serverId = "contact-srv-1",
67 + createdAt = 1L,
68 + ),
69 + )
70 +
71 + engine.syncNow()
72 +
73 + // L'op create a été appelée
74 + assertEquals(1, api.createInteractionCalls.size)
75 + assertEquals("contact-srv-1", api.createInteractionCalls[0].first)
76 +
77 + // L'upload audio a été appelé
78 + assertEquals(1, api.uploadInteractionAudioCalls.size)
79 + assertEquals("contact-srv-1", api.uploadInteractionAudioCalls[0].first)
80 + assertEquals("interaction-generated", api.uploadInteractionAudioCalls[0].second)
81 + assertEquals(5, api.uploadInteractionAudioCalls[0].third)
82 +
83 + // Le serverId a été enregistré sur l'entité locale
84 + val updated = db.interactionDao().getByLocalId(localId)
85 + assertEquals("interaction-generated", updated?.serverId)
86 +
87 + // L'op a été dépilée (upload OK)
88 + assertEquals(0, db.syncOpDao().listAll().size)
89 +
90 + audioFile.deleteOnExit()
91 + }
92 +
93 + @Test
94 + fun push_interactionNoteVocale_echecUpload_opMediaEnFile() = runBlocking {
95 + val audioFile = File.createTempFile("note_vocale", ".wav")
96 + audioFile.writeBytes(byteArrayOf(1, 2, 3))
97 + api.uploadInteractionAudioResult = AilianceApiClient.ApiResult.Err(503, "Service indisponible")
98 +
99 + val localId = db.interactionDao().upsert(
100 + InteractionEntity(
101 + contactServerId = "contact-srv-2",
102 + type = "note_vocale",
103 + sujet = "Note avec erreur upload",
104 + audioPath = audioFile.absolutePath,
105 + ),
106 + )
107 + db.syncOpDao().insert(
108 + SyncOpEntity(
109 + entityType = "interaction",
110 + op = "create",
111 + payloadJson = """{"sujet":"Note avec erreur upload","type_interaction":"note_vocale","demande_transcription":true}""",
112 + localId = localId,
113 + serverId = "contact-srv-2",
114 + createdAt = 1L,
115 + ),
116 + )
117 +
118 + engine.syncNow()
119 +
120 + // L'op create a été dépilée (create OK)
121 + val remainingOps = db.syncOpDao().listAll()
122 + // Une op media doit être en file pour retry
123 + assertEquals(1, remainingOps.size)
124 + assertEquals("interaction_media", remainingOps[0].entityType)
125 + assertEquals("upload", remainingOps[0].op)
126 + assertEquals(localId, remainingOps[0].localId)
127 + assertEquals("interaction-generated", remainingOps[0].serverId)
128 +
129 + audioFile.deleteOnExit()
130 + }
131 +
132 + // ---- Pull interaction LWW ----
133 +
134 + @Test
135 + fun pull_interactionAvecMisAJourLeRecent_descriptionMisAJourAudioPathPreserve() = runBlocking {
136 + val localAudioPath = "/data/audio/interaction_locale.wav"
137 + db.interactionDao().upsert(
138 + InteractionEntity(
139 + serverId = "int-srv-1",
140 + contactServerId = "contact-srv-1",
141 + type = "note_vocale",
142 + sujet = "Ancien sujet",
143 + description = "Ancienne description",
144 + audioPath = localAudioPath,
145 + createdAt = parseIsoToEpochMs("2026-09-10T08:00:00Z"),
146 + updatedAt = parseIsoToEpochMs("2026-09-10T08:00:00Z"),
147 + ),
148 + )
149 +
150 + api.pullResult = AilianceApiClient.ApiResult.Ok(
151 + SyncPullResponse(
152 + serverTime = "2026-09-15T10:00:00Z",
153 + interactions = listOf(
154 + InteractionDto(
155 + id = "int-srv-1",
156 + contactId = "contact-srv-1",
157 + typeInteraction = "note_vocale",
158 + sujet = "Sujet mis à jour",
159 + description = "Description mise à jour",
160 + creeLe = "2026-09-10T08:00:00Z",
161 + misAJourLe = "2026-09-15T09:00:00Z", // plus récent que updatedAt local
162 + transcription = "terminee",
163 + ),
164 + ),
165 + ),
166 + )
167 +
168 + engine.syncNow()
169 +
170 + val updated = db.interactionDao().getByServerId("int-srv-1")
171 + assertNotNull(updated)
172 + assertEquals("Sujet mis à jour", updated?.sujet)
173 + assertEquals("Description mise à jour", updated?.description)
174 + assertEquals("terminee", updated?.transcriptionStatut)
175 + // audioPath local préservé
176 + assertEquals(localAudioPath, updated?.audioPath)
177 + }
178 +
179 + @Test
180 + fun pull_interactionAvecMisAJourLeAncien_intact() = runBlocking {
181 + val sujetOriginal = "Sujet original"
182 + db.interactionDao().upsert(
183 + InteractionEntity(
184 + serverId = "int-srv-2",
185 + contactServerId = "contact-srv-1",
186 + type = "note",
187 + sujet = sujetOriginal,
188 + description = "Description originale",
189 + createdAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"),
190 + updatedAt = parseIsoToEpochMs("2026-09-15T09:00:00Z"), // local plus récent
191 + ),
192 + )
193 +
194 + api.pullResult = AilianceApiClient.ApiResult.Ok(
195 + SyncPullResponse(
196 + serverTime = "2026-09-15T10:00:00Z",
197 + interactions = listOf(
198 + InteractionDto(
199 + id = "int-srv-2",
200 + contactId = "contact-srv-1",
201 + typeInteraction = "note",
202 + sujet = "Sujet distant obsolète",
203 + description = "Description obsolète",
204 + creeLe = "2026-09-14T08:00:00Z",
205 + misAJourLe = "2026-09-14T10:00:00Z", // plus ancien que updatedAt local
206 + ),
207 + ),
208 + ),
209 + )
210 +
211 + engine.syncNow()
212 +
213 + val unchanged = db.interactionDao().getByServerId("int-srv-2")
214 + assertEquals(sujetOriginal, unchanged?.sujet)
215 + }
216 +
217 + // ---- Push note_projet create/update/delete ----
218 +
219 + @Test
220 + fun push_noteProjetCreate_serverIdEnregistre() = runBlocking {
221 + val localId = db.noteProjetDao().upsert(
222 + NoteProjetEntity(
223 + projetServerId = "projet-srv-1",
224 + titre = "Ma note",
225 + texte = "Contenu",
226 + auteur = "alice",
227 + createdAt = 1L,
228 + ),
229 + )
230 + db.syncOpDao().insert(
231 + SyncOpEntity(
232 + entityType = "note_projet",
233 + op = "create",
234 + payloadJson = """{"titre":"Ma note","contenu":"Contenu","demande_transcription":false}""",
235 + localId = localId,
236 + createdAt = 1L,
237 + ),
238 + )
239 +
240 + engine.syncNow()
241 +
242 + assertEquals(1, api.createNoteProjetCalls.size)
243 + assertEquals("projet-srv-1", api.createNoteProjetCalls[0].first)
244 + val updated = db.noteProjetDao().getByLocalId(localId)
245 + assertEquals("note-generated", updated?.serverId)
246 + assertEquals(0, db.syncOpDao().listAll().size)
247 + }
248 +
249 + @Test
250 + fun push_noteProjetUpdate_appelApi() = runBlocking {
251 + db.noteProjetDao().upsert(
252 + NoteProjetEntity(
253 + serverId = "note-srv-1",
254 + projetServerId = "projet-srv-1",
255 + titre = "Titre modifié",
256 + texte = "Contenu modifié",
257 + auteur = "alice",
258 + createdAt = 1L,
259 + ),
260 + )
261 + db.syncOpDao().insert(
262 + SyncOpEntity(
263 + entityType = "note_projet",
264 + op = "update",
265 + payloadJson = """{"titre":"Titre modifié","contenu":"Contenu modifié"}""",
266 + serverId = "note-srv-1",
267 + createdAt = 1L,
268 + ),
269 + )
270 +
271 + engine.syncNow()
272 +
273 + assertEquals(1, api.updateNoteProjetCalls.size)
274 + assertEquals("projet-srv-1", api.updateNoteProjetCalls[0].first)
275 + assertEquals("note-srv-1", api.updateNoteProjetCalls[0].second)
276 + assertEquals(0, db.syncOpDao().listAll().size)
277 + }
278 +
279 + @Test
280 + fun push_noteProjetDelete_appelApi() = runBlocking {
281 + db.noteProjetDao().upsert(
282 + NoteProjetEntity(
283 + serverId = "note-srv-2",
284 + projetServerId = "projet-srv-2",
285 + titre = "À supprimer",
286 + texte = "Contenu",
287 + auteur = "alice",
288 + createdAt = 1L,
289 + ),
290 + )
291 + db.syncOpDao().insert(
292 + SyncOpEntity(
293 + entityType = "note_projet",
294 + op = "delete",
295 + payloadJson = "{}",
296 + serverId = "note-srv-2",
297 + createdAt = 1L,
298 + ),
299 + )
300 +
301 + engine.syncNow()
302 +
303 + assertEquals(1, api.deleteNoteProjetCalls.size)
304 + assertEquals("projet-srv-2", api.deleteNoteProjetCalls[0].first)
305 + assertEquals("note-srv-2", api.deleteNoteProjetCalls[0].second)
306 + assertEquals(0, db.syncOpDao().listAll().size)
307 + }
308 +
309 + @Test
310 + fun pull_tombstoneInteraction_suppression() = runBlocking {
311 + db.interactionDao().upsert(
312 + InteractionEntity(
313 + serverId = "int-delete",
314 + contactServerId = "contact-srv-1",
315 + type = "note",
316 + sujet = "À supprimer",
317 + createdAt = parseIsoToEpochMs("2026-09-10T08:00:00Z"),
318 + ),
319 + )
320 + api.pullResult = AilianceApiClient.ApiResult.Ok(
321 + SyncPullResponse(
322 + serverTime = "2026-09-15T10:00:00Z",
323 + tombstones = listOf(
324 + TombstoneDto(
325 + entityType = "interaction",
326 + id = "int-delete",
327 + supprimeLe = "2026-09-15T09:00:00Z",
328 + ),
329 + ),
330 + ),
331 + )
332 +
333 + engine.syncNow()
334 +
335 + assertNull(db.interactionDao().getByServerId("int-delete"))
336 + }
337 +
338 + // ---- pousserEnAttente ----
339 +
340 + @Test
341 + fun pousserEnAttente_modeServeur_pushDeclencheEtOpConsommee() = runBlocking {
342 + val localId = db.interactionDao().upsert(
343 + InteractionEntity(
344 + contactServerId = "contact-srv-1",
345 + type = "note_vocale",
346 + sujet = "Note rapide",
347 + transcriptionStatut = "en_attente",
348 + createdAt = 1L,
349 + ),
350 + )
351 + db.syncOpDao().insert(
352 + SyncOpEntity(
353 + entityType = "interaction",
354 + op = "create",
355 + payloadJson = """{"sujet":"Note rapide","type_interaction":"note_vocale","demande_transcription":true}""",
356 + localId = localId,
357 + serverId = "contact-srv-1",
358 + createdAt = 1L,
359 + ),
360 + )
361 +
362 + val rapport = engine.pousserEnAttente()
363 +
364 + assertEquals(1, api.createInteractionCalls.size)
365 + assertEquals(1, rapport.envoyes)
366 + assertEquals(0, rapport.echecs)
367 + assertEquals(0, db.syncOpDao().listAll().size)
368 + }
369 +
370 + @Test
371 + fun pousserEnAttente_echecReseau_opConserveeEnFile() = runBlocking {
372 + api.createInteractionResult = AilianceApiClient.ApiResult.Err(503, "Service indisponible")
373 + val localId = db.interactionDao().upsert(
374 + InteractionEntity(
375 + contactServerId = "contact-srv-1",
376 + type = "note_vocale",
377 + sujet = "Note réseau KO",
378 + transcriptionStatut = "en_attente",
379 + createdAt = 1L,
380 + ),
381 + )
382 + db.syncOpDao().insert(
383 + SyncOpEntity(
384 + entityType = "interaction",
385 + op = "create",
386 + payloadJson = """{"sujet":"Note réseau KO","type_interaction":"note_vocale","demande_transcription":true}""",
387 + localId = localId,
388 + serverId = "contact-srv-1",
389 + createdAt = 1L,
390 + ),
391 + )
392 +
393 + val rapport = engine.pousserEnAttente()
394 +
395 + assertEquals(0, rapport.envoyes)
396 + assertEquals(1, rapport.echecs)
397 + val ops = db.syncOpDao().listAll()
398 + assertEquals(1, ops.size)
399 + assertEquals("interaction", ops[0].entityType)
400 + }
401 +
402 + @Test
403 + fun pousserEnAttente_fileVide_aucunAppelApi() = runBlocking {
404 + val rapport = engine.pousserEnAttente()
405 +
406 + assertEquals(0, api.createInteractionCalls.size)
407 + assertEquals(0, rapport.envoyes)
408 + assertEquals(0, rapport.echecs)
409 + }
410 +}
M android/app/src/test/java/fr/ebii/card2vcf/sync/LwwMergerTest.kt
+16 -0
@@ -108,6 +108,22 @@ class LwwMergerTest {
108 108 }
109 109
110 110 @Test
111 + fun shouldUpdateInteraction_whenRemoteStrictlyNewer() {
112 + assertTrue(LwwMerger.shouldUpdateInteraction(localTs = 100L, remoteTs = 200L))
113 + }
114 +
115 + @Test
116 + fun shouldUpdateInteraction_whenLocalNewer() {
117 + assertFalse(LwwMerger.shouldUpdateInteraction(localTs = 300L, remoteTs = 200L))
118 + }
119 +
120 + @Test
121 + fun shouldUpdateInteraction_whenEqual() {
122 + // Pas de maj si timestamps égaux (contrairement à pickLww qui préfère le distant)
123 + assertFalse(LwwMerger.shouldUpdateInteraction(localTs = 100L, remoteTs = 100L))
124 + }
125 +
126 + @Test
111 127 fun applyTombstoneIds_removesMatchingIds() {
112 128 val local = setOf("a", "b", "c")
113 129 val tombstones = setOf("b", "d")
A android/app/src/test/java/fr/ebii/card2vcf/sync/NoteProjetSyncEngineTest.kt
+213 -0
@@ -0,0 +1,213 @@
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.NoteProjetEntity
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 NoteProjetSyncEngineTest {
22 +
23 + private lateinit var db: CrmDatabase
24 + private lateinit var api: FakeAilianceApi
25 + private lateinit var engine: SyncEngine
26 +
27 + @Before
28 + fun setUp() {
29 + val ctx = ApplicationProvider.getApplicationContext<Context>()
30 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
31 + .allowMainThreadQueries()
32 + .build()
33 + api = FakeAilianceApi()
34 + engine = SyncEngine(api, db)
35 + }
36 +
37 + @After
38 + fun tearDown() = db.close()
39 +
40 + // ---- Tâche 1 : pull avec notes → insérées ----
41 +
42 + @Test
43 + fun pull_notesInserees() = runBlocking {
44 + api.pullResult = AilianceApiClient.ApiResult.Ok(
45 + SyncPullResponse(
46 + serverTime = "2026-09-15T10:00:00Z",
47 + notes = listOf(
48 + NoteProjetDto(
49 + id = "n1",
50 + projetId = "p1",
51 + titre = "Réunion kick-off",
52 + contenu = "**Objectif** : lancer le projet.",
53 + auteur = "alice",
54 + creeLe = "2026-09-14T08:00:00Z",
55 + majLe = null,
56 + ),
57 + NoteProjetDto(
58 + id = "n2",
59 + projetId = "p1",
60 + titre = "Suivi hebdo",
61 + contenu = "Point de la semaine.",
62 + auteur = "bob",
63 + creeLe = "2026-09-15T09:00:00Z",
64 + ),
65 + ),
66 + ),
67 + )
68 +
69 + val result = engine.syncNow()
70 +
71 + assertEquals(2, db.noteProjetDao().listAll().size)
72 + val n1 = db.noteProjetDao().getByServerId("n1")
73 + assertNotNull(n1)
74 + assertEquals("Réunion kick-off", n1?.titre)
75 + assertEquals("**Objectif** : lancer le projet.", n1?.texte)
76 + assertEquals("alice", n1?.auteur)
77 + assertEquals("p1", n1?.projetServerId)
78 + // Notes comptées dans received
79 + assertEquals(2, result.received)
80 + }
81 +
82 + // ---- Tâche 2 : majLe plus récent → mise à jour, audioPath local préservé ----
83 +
84 + @Test
85 + fun pull_noteExistante_majLeRecent_miseAJourEtAudioPathPreserve() = runBlocking {
86 + val localAudioPath = "/data/app/audio/n1.wav"
87 + db.noteProjetDao().upsert(
88 + NoteProjetEntity(
89 + serverId = "n1",
90 + projetServerId = "p1",
91 + titre = "Ancien titre",
92 + texte = "Ancien contenu",
93 + auteur = "alice",
94 + audioPath = localAudioPath,
95 + createdAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"),
96 + updatedAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"),
97 + ),
98 + )
99 +
100 + api.pullResult = AilianceApiClient.ApiResult.Ok(
101 + SyncPullResponse(
102 + serverTime = "2026-09-15T10:00:00Z",
103 + notes = listOf(
104 + NoteProjetDto(
105 + id = "n1",
106 + projetId = "p1",
107 + titre = "Nouveau titre",
108 + contenu = "Contenu mis à jour.",
109 + auteur = "alice",
110 + creeLe = "2026-09-14T08:00:00Z",
111 + majLe = "2026-09-15T09:00:00Z", // plus récent que updatedAt local
112 + ),
113 + ),
114 + ),
115 + )
116 +
117 + engine.syncNow()
118 +
119 + val updated = db.noteProjetDao().getByServerId("n1")
120 + assertNotNull(updated)
121 + assertEquals("Nouveau titre", updated?.titre)
122 + assertEquals("Contenu mis à jour.", updated?.texte)
123 + // audioPath local préservé
124 + assertEquals(localAudioPath, updated?.audioPath)
125 + }
126 +
127 + // ---- Tâche 3 : majLe plus ancien → pas d'écrasement ----
128 +
129 + @Test
130 + fun pull_noteExistante_majLeAncien_pasDecrasement() = runBlocking {
131 + val titreOriginal = "Titre original"
132 + db.noteProjetDao().upsert(
133 + NoteProjetEntity(
134 + serverId = "n1",
135 + projetServerId = "p1",
136 + titre = titreOriginal,
137 + texte = "Contenu original",
138 + auteur = "alice",
139 + createdAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"),
140 + updatedAt = parseIsoToEpochMs("2026-09-15T09:00:00Z"), // local plus récent
141 + ),
142 + )
143 +
144 + api.pullResult = AilianceApiClient.ApiResult.Ok(
145 + SyncPullResponse(
146 + serverTime = "2026-09-15T10:00:00Z",
147 + notes = listOf(
148 + NoteProjetDto(
149 + id = "n1",
150 + projetId = "p1",
151 + titre = "Titre distant obsolète",
152 + contenu = "Contenu distant obsolète",
153 + auteur = "alice",
154 + creeLe = "2026-09-14T08:00:00Z",
155 + majLe = "2026-09-14T10:00:00Z", // plus ancien que updatedAt local
156 + ),
157 + ),
158 + ),
159 + )
160 +
161 + engine.syncNow()
162 +
163 + val unchanged = db.noteProjetDao().getByServerId("n1")
164 + assertEquals(titreOriginal, unchanged?.titre)
165 + }
166 +
167 + // ---- Tâche 4 : tombstone note → suppression ----
168 +
169 + @Test
170 + fun pull_tombstoneNote_suppression() = runBlocking {
171 + db.noteProjetDao().upsert(
172 + NoteProjetEntity(
173 + serverId = "n-delete",
174 + projetServerId = "p1",
175 + titre = "À supprimer",
176 + texte = "Sera supprimée.",
177 + auteur = "alice",
178 + createdAt = parseIsoToEpochMs("2026-09-10T08:00:00Z"),
179 + ),
180 + )
181 + api.pullResult = AilianceApiClient.ApiResult.Ok(
182 + SyncPullResponse(
183 + serverTime = "2026-09-15T10:00:00Z",
184 + tombstones = listOf(
185 + TombstoneDto(
186 + entityType = "note",
187 + id = "n-delete",
188 + supprimeLe = "2026-09-15T09:00:00Z",
189 + ),
190 + ),
191 + ),
192 + )
193 +
194 + engine.syncNow()
195 +
196 + assertNull(db.noteProjetDao().getByServerId("n-delete"))
197 + }
198 +
199 + // ---- Tâche 5 : pull sans section notes (ancien serveur) → aucune erreur ----
200 +
201 + @Test
202 + fun pull_sansSectionNotes_aucuneErreur() = runBlocking {
203 + // SyncPullResponse sans `notes` explicite → default emptyList() (compat anciens serveurs)
204 + api.pullResult = AilianceApiClient.ApiResult.Ok(
205 + SyncPullResponse(serverTime = "2026-09-15T10:00:00Z"),
206 + )
207 +
208 + val result = engine.syncNow()
209 +
210 + assertEquals(true, result.success)
211 + assertEquals(0, db.noteProjetDao().listAll().size)
212 + }
213 +}
A android/app/src/test/java/fr/ebii/card2vcf/sync/NoteVocaleSuppressionRelanceTest.kt
+269 -0
@@ -0,0 +1,269 @@
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.InteractionEntity
8 +import fr.ebii.card2vcf.data.NoteProjetEntity
9 +import kotlinx.coroutines.runBlocking
10 +import org.junit.After
11 +import org.junit.Assert.assertEquals
12 +import org.junit.Assert.assertNotNull
13 +import org.junit.Assert.assertNull
14 +import org.junit.Before
15 +import org.junit.Test
16 +import org.junit.runner.RunWith
17 +import org.robolectric.RobolectricTestRunner
18 +import org.robolectric.annotation.Config
19 +
20 +@RunWith(RobolectricTestRunner::class)
21 +@Config(sdk = [31])
22 +class NoteVocaleSuppressionRelanceTest {
23 +
24 + private lateinit var db: CrmDatabase
25 + private lateinit var api: FakeAilianceApi
26 + private lateinit var engine: SyncEngine
27 +
28 + @Before
29 + fun setUp() {
30 + val ctx = ApplicationProvider.getApplicationContext<Context>()
31 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
32 + .allowMainThreadQueries()
33 + .build()
34 + api = FakeAilianceApi()
35 + engine = SyncEngine(api, db)
36 + }
37 +
38 + @After
39 + fun tearDown() = db.close()
40 +
41 + // ---- Push interaction delete ----
42 +
43 + @Test
44 + fun push_interactionDelete_appelApiEtOpConsommee() = runBlocking {
45 + db.interactionDao().upsert(
46 + InteractionEntity(
47 + serverId = "iid-srv-1",
48 + contactServerId = "contact-srv-1",
49 + type = "note_vocale",
50 + sujet = "Note à supprimer",
51 + createdAt = 1L,
52 + ),
53 + )
54 + db.syncOpDao().insert(
55 + SyncOpEntity(
56 + entityType = "interaction",
57 + op = "delete",
58 + payloadJson = "{}",
59 + serverId = "iid-srv-1",
60 + createdAt = 1L,
61 + ),
62 + )
63 +
64 + engine.syncNow()
65 +
66 + assertEquals(1, api.deleteInteractionCalls.size)
67 + assertEquals("iid-srv-1", api.deleteInteractionCalls[0])
68 + assertEquals(0, db.syncOpDao().listAll().size)
69 + }
70 +
71 + @Test
72 + fun push_interactionDelete_sansSrvId_opConservee() = runBlocking {
73 + db.syncOpDao().insert(
74 + SyncOpEntity(
75 + entityType = "interaction",
76 + op = "delete",
77 + payloadJson = "{}",
78 + serverId = null,
79 + createdAt = 1L,
80 + ),
81 + )
82 +
83 + engine.syncNow()
84 +
85 + assertEquals(0, api.deleteInteractionCalls.size)
86 + // op sans serverId : abandon sans consommation
87 + assertEquals(1, db.syncOpDao().listAll().size)
88 + }
89 +
90 + @Test
91 + fun push_interactionDelete_echecApi_opConservee() = runBlocking {
92 + api.deleteInteractionResult = AilianceApiClient.ApiResult.Err(503, "Service indisponible")
93 + db.syncOpDao().insert(
94 + SyncOpEntity(
95 + entityType = "interaction",
96 + op = "delete",
97 + payloadJson = "{}",
98 + serverId = "iid-srv-2",
99 + createdAt = 1L,
100 + ),
101 + )
102 +
103 + engine.syncNow()
104 +
105 + assertEquals(1, api.deleteInteractionCalls.size)
106 + assertEquals(1, db.syncOpDao().listAll().size)
107 + }
108 +
109 + // ---- Pull interaction avec transcriptionErreur ----
110 +
111 + @Test
112 + fun pull_interaction_avecTranscriptionErreur_stockeChamp() = runBlocking {
113 + api.pullResult = AilianceApiClient.ApiResult.Ok(
114 + SyncPullResponse(
115 + serverTime = "2026-09-15T10:00:00Z",
116 + interactions = listOf(
117 + InteractionDto(
118 + id = "int-err-1",
119 + contactId = "contact-srv-1",
120 + typeInteraction = "note_vocale",
121 + sujet = "Note en échec",
122 + creeLe = "2026-09-14T08:00:00Z",
123 + transcription = "echec",
124 + transcriptionErreur = "délai de 30 s dépassé",
125 + ),
126 + ),
127 + ),
128 + )
129 +
130 + engine.syncNow()
131 +
132 + val stored = db.interactionDao().getByServerId("int-err-1")
133 + assertNotNull(stored)
134 + assertEquals("echec", stored?.transcriptionStatut)
135 + assertEquals("délai de 30 s dépassé", stored?.transcriptionErreur)
136 + }
137 +
138 + @Test
139 + fun pull_interaction_transcriptionErreur_misAJourSurExistant() = runBlocking {
140 + db.interactionDao().upsert(
141 + InteractionEntity(
142 + serverId = "int-err-2",
143 + contactServerId = "contact-srv-1",
144 + type = "note_vocale",
145 + sujet = "Note",
146 + transcriptionStatut = "en_attente",
147 + transcriptionErreur = null,
148 + createdAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"),
149 + updatedAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"),
150 + ),
151 + )
152 +
153 + api.pullResult = AilianceApiClient.ApiResult.Ok(
154 + SyncPullResponse(
155 + serverTime = "2026-09-15T10:00:00Z",
156 + interactions = listOf(
157 + InteractionDto(
158 + id = "int-err-2",
159 + contactId = "contact-srv-1",
160 + typeInteraction = "note_vocale",
161 + sujet = "Note",
162 + creeLe = "2026-09-14T08:00:00Z",
163 + misAJourLe = "2026-09-15T09:00:00Z",
164 + transcription = "echec",
165 + transcriptionErreur = "modèle introuvable",
166 + ),
167 + ),
168 + ),
169 + )
170 +
171 + engine.syncNow()
172 +
173 + val updated = db.interactionDao().getByServerId("int-err-2")
174 + assertEquals("echec", updated?.transcriptionStatut)
175 + assertEquals("modèle introuvable", updated?.transcriptionErreur)
176 + }
177 +
178 + // ---- Pull note_projet avec transcriptionErreur ----
179 +
180 + @Test
181 + fun pull_noteProjet_avecTranscriptionErreur_stockeChamp() = runBlocking {
182 + api.pullResult = AilianceApiClient.ApiResult.Ok(
183 + SyncPullResponse(
184 + serverTime = "2026-09-15T10:00:00Z",
185 + notes = listOf(
186 + NoteProjetDto(
187 + id = "note-err-1",
188 + projetId = "projet-srv-1",
189 + titre = "Note en échec",
190 + contenu = "",
191 + auteur = "alice",
192 + creeLe = "2026-09-14T08:00:00Z",
193 + transcription = "echec",
194 + transcriptionErreur = "audio corrompu",
195 + ),
196 + ),
197 + ),
198 + )
199 +
200 + engine.syncNow()
201 +
202 + val stored = db.noteProjetDao().getByServerId("note-err-1")
203 + assertNotNull(stored)
204 + assertEquals("echec", stored?.transcriptionStatut)
205 + assertEquals("audio corrompu", stored?.transcriptionErreur)
206 + }
207 +
208 + @Test
209 + fun pull_noteProjet_transcriptionTerminee_erreurEffacee() = runBlocking {
210 + db.noteProjetDao().upsert(
211 + NoteProjetEntity(
212 + serverId = "note-err-2",
213 + projetServerId = "projet-srv-1",
214 + titre = "Note",
215 + texte = "",
216 + auteur = "alice",
217 + transcriptionStatut = "echec",
218 + transcriptionErreur = "ancienne erreur",
219 + createdAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"),
220 + updatedAt = parseIsoToEpochMs("2026-09-14T08:00:00Z"),
221 + ),
222 + )
223 +
224 + api.pullResult = AilianceApiClient.ApiResult.Ok(
225 + SyncPullResponse(
226 + serverTime = "2026-09-15T10:00:00Z",
227 + notes = listOf(
228 + NoteProjetDto(
229 + id = "note-err-2",
230 + projetId = "projet-srv-1",
231 + titre = "Note",
232 + contenu = "Transcription OK.",
233 + auteur = "alice",
234 + creeLe = "2026-09-14T08:00:00Z",
235 + majLe = "2026-09-15T09:00:00Z",
236 + transcription = "terminee",
237 + transcriptionErreur = null,
238 + ),
239 + ),
240 + ),
241 + )
242 +
243 + engine.syncNow()
244 +
245 + val updated = db.noteProjetDao().getByServerId("note-err-2")
246 + assertEquals("terminee", updated?.transcriptionStatut)
247 + assertNull(updated?.transcriptionErreur)
248 + }
249 +
250 + // ---- FakeAilianceApi relance (smoke tests) ----
251 +
252 + @Test
253 + fun fake_relancerTranscriptionInteraction_enregistreAppel() {
254 + val result = api.relancerTranscriptionInteraction("contact-srv-1", "iid-srv-1")
255 +
256 + assertEquals(AilianceApiClient.ApiResult.Ok(Unit), result)
257 + assertEquals(1, api.relancerTranscriptionInteractionCalls.size)
258 + assertEquals("contact-srv-1" to "iid-srv-1", api.relancerTranscriptionInteractionCalls[0])
259 + }
260 +
261 + @Test
262 + fun fake_relancerTranscriptionNote_enregistreAppel() {
263 + val result = api.relancerTranscriptionNote("projet-srv-1", "note-srv-1")
264 +
265 + assertEquals(AilianceApiClient.ApiResult.Ok(Unit), result)
266 + assertEquals(1, api.relancerTranscriptionNoteCalls.size)
267 + assertEquals("projet-srv-1" to "note-srv-1", api.relancerTranscriptionNoteCalls[0])
268 + }
269 +}
A android/app/src/test/java/fr/ebii/card2vcf/ui/audio/FormaterDureeTest.kt
+45 -0
@@ -0,0 +1,45 @@
1 +package fr.ebii.card2vcf.ui.audio
2 +
3 +import org.junit.Assert.assertEquals
4 +import org.junit.Test
5 +
6 +class FormaterDureeTest {
7 +
8 + @Test
9 + fun zero_ms_retourne_00_00() {
10 + assertEquals("00:00", formaterDuree(0L))
11 + }
12 +
13 + @Test
14 + fun moins_dune_minute() {
15 + assertEquals("00:07", formaterDuree(7_000L))
16 + assertEquals("00:59", formaterDuree(59_000L))
17 + }
18 +
19 + @Test
20 + fun exactement_une_minute() {
21 + assertEquals("01:00", formaterDuree(60_000L))
22 + }
23 +
24 + @Test
25 + fun une_minute_trente() {
26 + assertEquals("01:30", formaterDuree(90_000L))
27 + }
28 +
29 + @Test
30 + fun arrondi_vers_le_bas_les_ms_residuelles() {
31 + // 61 999 ms → 1 min 1 sec
32 + assertEquals("01:01", formaterDuree(61_999L))
33 + }
34 +
35 + @Test
36 + fun dix_minutes() {
37 + assertEquals("10:00", formaterDuree(600_000L))
38 + }
39 +
40 + @Test
41 + fun plus_dune_heure_affiche_minutes_totales() {
42 + // 65 min 3 sec
43 + assertEquals("65:03", formaterDuree(3_903_000L))
44 + }
45 +}
A android/app/src/test/java/fr/ebii/card2vcf/ui/contact/ContactPagerViewModelTest.kt
+163 -0
@@ -0,0 +1,163 @@
1 +package fr.ebii.card2vcf.ui.contact
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import fr.ebii.card2vcf.crm.ContactSort
7 +import fr.ebii.card2vcf.data.AudioNoteStore
8 +import fr.ebii.card2vcf.data.ContactImageStore
9 +import fr.ebii.card2vcf.data.ContactRepository
10 +import fr.ebii.card2vcf.data.CrmDatabase
11 +import fr.ebii.card2vcf.sync.AilianceApiClient
12 +import fr.ebii.card2vcf.sync.FakeAilianceApi
13 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
14 +import fr.ebii.card2vcf.sync.SyncEngine
15 +import fr.ebii.card2vcf.ui.audio.ConfirmationNote
16 +import kotlinx.coroutines.Dispatchers
17 +import kotlinx.coroutines.ExperimentalCoroutinesApi
18 +import kotlinx.coroutines.flow.first
19 +import kotlinx.coroutines.runBlocking
20 +import kotlinx.coroutines.test.resetMain
21 +import kotlinx.coroutines.test.setMain
22 +import org.junit.After
23 +import org.junit.Assert.assertEquals
24 +import org.junit.Assert.assertNull
25 +import org.junit.Before
26 +import org.junit.Test
27 +import org.junit.runner.RunWith
28 +import org.robolectric.RobolectricTestRunner
29 +import org.robolectric.annotation.Config
30 +
31 +@OptIn(ExperimentalCoroutinesApi::class)
32 +@RunWith(RobolectricTestRunner::class)
33 +@Config(sdk = [31])
34 +class ContactPagerViewModelTest {
35 +
36 + private lateinit var db: CrmDatabase
37 + private lateinit var repository: ContactRepository
38 + private lateinit var credentialsStore: SyncCredentialsStore
39 + private lateinit var audioStore: AudioNoteStore
40 + private val contactServerId = "contact-srv-1"
41 +
42 + @Before
43 + fun setUp() {
44 + Dispatchers.setMain(Dispatchers.Unconfined)
45 + val ctx = ApplicationProvider.getApplicationContext<Context>()
46 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
47 + .allowMainThreadQueries()
48 + .setQueryExecutor { it.run() }
49 + .setTransactionExecutor { it.run() }
50 + .build()
51 + repository = ContactRepository(
52 + dao = db.crmContactDao(),
53 + images = ContactImageStore(ctx),
54 + syncOpDao = db.syncOpDao(),
55 + )
56 + credentialsStore = SyncCredentialsStore(ctx.getSharedPreferences("test_creds_pager", Context.MODE_PRIVATE))
57 + audioStore = AudioNoteStore(ctx)
58 + }
59 +
60 + @After
61 + fun tearDown() {
62 + db.close()
63 + Dispatchers.resetMain()
64 + }
65 +
66 + private fun viewModel(engineFabrique: () -> SyncEngine? = { null }) = ContactPagerViewModel(
67 + repository = repository,
68 + database = db,
69 + credentialsStore = credentialsStore,
70 + audioStore = audioStore,
71 + initialContactId = 0L,
72 + sort = ContactSort.LAST_NAME,
73 + engineFabrique = engineFabrique,
74 + )
75 +
76 + @Test
77 + fun ajouterNoteVocale_insereInteractionDansDB() = runBlocking {
78 + val vm = viewModel()
79 + vm.ajouterNoteVocale(contactServerId, "Appel client", "Résumé de l'appel", audioPath = null, modeServeur = false)
80 +
81 + val interactions = db.interactionDao()
82 + .observeByContactServerId(contactServerId)
83 + .first { it.isNotEmpty() }
84 + assertEquals(1, interactions.size)
85 + val note = interactions[0]
86 + assertEquals("note_vocale", note.type)
87 + assertEquals("Appel client", note.sujet)
88 + assertEquals("Résumé de l'appel", note.description)
89 + assertEquals(contactServerId, note.contactServerId)
90 + assertNull("Pas de statut sans audio en mode local", note.transcriptionStatut)
91 + }
92 +
93 + @Test
94 + fun ajouterNoteVocale_modeServeurAvecAudio_statut_en_attente() = runBlocking {
95 + val vm = viewModel()
96 + vm.ajouterNoteVocale(contactServerId, "Note audio", "", audioPath = "/tmp/note.wav", modeServeur = true)
97 +
98 + val interactions = db.interactionDao()
99 + .observeByContactServerId(contactServerId)
100 + .first { it.isNotEmpty() }
101 + assertEquals("en_attente", interactions[0].transcriptionStatut)
102 + }
103 +
104 + @Test
105 + fun ajouterNoteVocale_insereOpDeSyncCreate() = runBlocking {
106 + viewModel().ajouterNoteVocale(contactServerId, "Sujet", "Corps", audioPath = null, modeServeur = false)
107 +
108 + val ops = db.syncOpDao().listAll()
109 + assertEquals(1, ops.size)
110 + val op = ops[0]
111 + assertEquals("interaction", op.entityType)
112 + assertEquals("create", op.op)
113 + assertEquals(contactServerId, op.serverId)
114 + }
115 +
116 + @Test
117 + fun ajouterNoteVocale_sujetVide_nInsereRien() = runBlocking {
118 + viewModel().ajouterNoteVocale(contactServerId, " ", "Corps", audioPath = null, modeServeur = false)
119 +
120 + val ops = db.syncOpDao().listAll()
121 + assertEquals(0, ops.size)
122 + }
123 +
124 + @Test
125 + fun ajouterNoteVocale_modeServeurEnLigne_pushImmediatEtOpConsommee() = runBlocking {
126 + val fakeApi = FakeAilianceApi()
127 + val vm = viewModel { SyncEngine(fakeApi, db) }
128 + vm.ajouterNoteVocale(contactServerId, "Note audio", "", audioPath = null, modeServeur = true)
129 +
130 + vm.confirmationNote.first { it != null }
131 +
132 + assertEquals(1, fakeApi.createInteractionCalls.size)
133 + assertEquals(0, db.syncOpDao().listAll().size)
134 + assertEquals(ConfirmationNote.SERVEUR_OK, vm.confirmationNote.value)
135 + }
136 +
137 + @Test
138 + fun ajouterNoteVocale_modeServeurEchecReseau_opConserveeEtMessageRepli() = runBlocking {
139 + val fakeApi = FakeAilianceApi().also {
140 + it.createInteractionResult = AilianceApiClient.ApiResult.Err(503, "KO")
141 + }
142 + val vm = viewModel { SyncEngine(fakeApi, db) }
143 + vm.ajouterNoteVocale(contactServerId, "Note réseau KO", "", audioPath = null, modeServeur = true)
144 +
145 + vm.confirmationNote.first { it != null }
146 +
147 + assertEquals(1, db.syncOpDao().listAll().size)
148 + assertEquals(ConfirmationNote.SERVEUR_REPLI, vm.confirmationNote.value)
149 + }
150 +
151 + @Test
152 + fun ajouterNoteVocale_modeAppareil_aucunPush() = runBlocking {
153 + val fakeApi = FakeAilianceApi()
154 + val vm = viewModel { SyncEngine(fakeApi, db) }
155 + vm.ajouterNoteVocale(contactServerId, "Note locale", "", audioPath = null, modeServeur = false)
156 +
157 + vm.confirmationNote.first { it != null }
158 +
159 + assertEquals(0, fakeApi.createInteractionCalls.size)
160 + assertEquals(1, db.syncOpDao().listAll().size)
161 + assertEquals(ConfirmationNote.APPAREIL, vm.confirmationNote.value)
162 + }
163 +}
A android/app/src/test/java/fr/ebii/card2vcf/ui/projets/ProjetDetailViewModelTest.kt
+183 -0
@@ -0,0 +1,183 @@
1 +package fr.ebii.card2vcf.ui.projets
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import fr.ebii.card2vcf.data.AudioNoteStore
7 +import fr.ebii.card2vcf.data.CrmDatabase
8 +import fr.ebii.card2vcf.data.NoteProjetEntity
9 +import fr.ebii.card2vcf.sync.AilianceApiClient
10 +import fr.ebii.card2vcf.sync.FakeAilianceApi
11 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
12 +import fr.ebii.card2vcf.sync.SyncEngine
13 +import fr.ebii.card2vcf.ui.audio.ConfirmationNote
14 +import kotlinx.coroutines.Dispatchers
15 +import kotlinx.coroutines.ExperimentalCoroutinesApi
16 +import kotlinx.coroutines.flow.first
17 +import kotlinx.coroutines.runBlocking
18 +import kotlinx.coroutines.test.resetMain
19 +import kotlinx.coroutines.test.setMain
20 +import org.junit.After
21 +import org.junit.Assert.assertEquals
22 +import org.junit.Assert.assertNull
23 +import org.junit.Before
24 +import org.junit.Test
25 +import org.junit.runner.RunWith
26 +import org.robolectric.RobolectricTestRunner
27 +import org.robolectric.annotation.Config
28 +
29 +@OptIn(ExperimentalCoroutinesApi::class)
30 +@RunWith(RobolectricTestRunner::class)
31 +@Config(sdk = [31])
32 +class ProjetDetailViewModelTest {
33 +
34 + private lateinit var db: CrmDatabase
35 + private lateinit var credentialsStore: SyncCredentialsStore
36 + private lateinit var audioStore: AudioNoteStore
37 + private val projetServerId = "proj-1"
38 +
39 + @Before
40 + fun setUp() {
41 + Dispatchers.setMain(Dispatchers.Unconfined)
42 + val ctx = ApplicationProvider.getApplicationContext<Context>()
43 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
44 + .allowMainThreadQueries()
45 + .setQueryExecutor { it.run() }
46 + .setTransactionExecutor { it.run() }
47 + .build()
48 + // Utilise le constructeur interne pour éviter EncryptedSharedPreferences en test.
49 + credentialsStore = SyncCredentialsStore(ctx.getSharedPreferences("test_creds", Context.MODE_PRIVATE))
50 + audioStore = AudioNoteStore(ctx)
51 + }
52 +
53 + @After
54 + fun tearDown() {
55 + db.close()
56 + Dispatchers.resetMain()
57 + }
58 +
59 + private fun viewModel(engineFabrique: () -> SyncEngine? = { null }) =
60 + ProjetDetailViewModel(db, credentialsStore, audioStore, projetServerId, engineFabrique)
61 +
62 + @Test
63 + fun notesExposeesPourLeProjetUniquement() = runBlocking {
64 + db.noteProjetDao().upsert(
65 + NoteProjetEntity(projetServerId = projetServerId, titre = "Ma note", texte = "contenu", auteur = "alice", createdAt = 1000L),
66 + )
67 + db.noteProjetDao().upsert(
68 + NoteProjetEntity(projetServerId = "autre-projet", titre = "Autre", texte = "x", auteur = "bob", createdAt = 2000L),
69 + )
70 +
71 + val notes = viewModel().notes.first { it.isNotEmpty() }
72 +
73 + assertEquals(1, notes.size)
74 + assertEquals("Ma note", notes[0].titre)
75 + }
76 +
77 + @Test
78 + fun notesTrieesParUpdatedAtOuCreatedAtDecroissant() = runBlocking {
79 + val base = 1_000_000L
80 + // updatedAt = base+300 → clé de tri la plus haute
81 + db.noteProjetDao().upsert(
82 + NoteProjetEntity(projetServerId = projetServerId, titre = "AvecUpdate", texte = "", auteur = "a", createdAt = base, updatedAt = base + 300),
83 + )
84 + // pas d'updatedAt → clé = createdAt = base+200
85 + db.noteProjetDao().upsert(
86 + NoteProjetEntity(projetServerId = projetServerId, titre = "SansUpdate", texte = "", auteur = "b", createdAt = base + 200),
87 + )
88 + // updatedAt = base+100 → clé la plus basse
89 + db.noteProjetDao().upsert(
90 + NoteProjetEntity(projetServerId = projetServerId, titre = "UpdateAncien", texte = "", auteur = "c", createdAt = base + 500, updatedAt = base + 100),
91 + )
92 +
93 + val notes = viewModel().notes.first { it.size == 3 }
94 +
95 + // tri attendu DESC : base+300, base+200, base+100
96 + assertEquals("AvecUpdate", notes[0].titre)
97 + assertEquals("SansUpdate", notes[1].titre)
98 + assertEquals("UpdateAncien", notes[2].titre)
99 + }
100 +
101 + @Test
102 + fun ajouterNoteVocale_insereEntityDansDB() = runBlocking {
103 + val vm = viewModel()
104 + vm.ajouterNoteVocale("Réunion client", "Résumé de la réunion", audioPath = null, modeServeur = false)
105 +
106 + val notes = vm.notes.first { it.isNotEmpty() }
107 + assertEquals(1, notes.size)
108 + assertEquals("Réunion client", notes[0].titre)
109 + assertEquals("Résumé de la réunion", notes[0].texte)
110 + assertEquals(projetServerId, notes[0].projetServerId)
111 + assertNull("Pas de transcription en mode local sans audio", notes[0].transcriptionStatut)
112 + }
113 +
114 + @Test
115 + fun ajouterNoteVocale_modeServeurAvecAudio_statut_en_attente() = runBlocking {
116 + val vm = viewModel()
117 + vm.ajouterNoteVocale("Note audio", "", audioPath = "/tmp/note.wav", modeServeur = true)
118 +
119 + val notes = vm.notes.first { it.isNotEmpty() }
120 + assertEquals("en_attente", notes[0].transcriptionStatut)
121 + }
122 +
123 + @Test
124 + fun ajouterNoteVocale_modeServeurSansAudio_statut_null() = runBlocking {
125 + val vm = viewModel()
126 + vm.ajouterNoteVocale("Note sans audio", "", audioPath = null, modeServeur = true)
127 +
128 + val notes = vm.notes.first { it.isNotEmpty() }
129 + assertNull(notes[0].transcriptionStatut)
130 + }
131 +
132 + @Test
133 + fun ajouterNoteVocale_insereOpDeSyncCreate() = runBlocking {
134 + viewModel().ajouterNoteVocale("Sujet", "Corps", audioPath = "/tmp/a.wav", modeServeur = true)
135 +
136 + val ops = db.syncOpDao().listAll()
137 + assertEquals(1, ops.size)
138 + val op = ops[0]
139 + assertEquals("note_projet", op.entityType)
140 + assertEquals("create", op.op)
141 + assertEquals(projetServerId, op.serverId)
142 + }
143 +
144 + @Test
145 + fun ajouterNoteVocale_modeServeurEnLigne_pushImmediatEtOpConsommee() = runBlocking {
146 + val fakeApi = FakeAilianceApi()
147 + val vm = viewModel { SyncEngine(fakeApi, db) }
148 + vm.ajouterNoteVocale("Note audio", "", audioPath = null, modeServeur = true)
149 +
150 + vm.confirmationNote.first { it != null }
151 +
152 + assertEquals(1, fakeApi.createNoteProjetCalls.size)
153 + assertEquals(0, db.syncOpDao().listAll().size)
154 + assertEquals(ConfirmationNote.SERVEUR_OK, vm.confirmationNote.value)
155 + }
156 +
157 + @Test
158 + fun ajouterNoteVocale_modeServeurEchecReseau_opConserveeEtMessageRepli() = runBlocking {
159 + val fakeApi = FakeAilianceApi().also {
160 + it.createNoteProjetResult = AilianceApiClient.ApiResult.Err(503, "KO")
161 + }
162 + val vm = viewModel { SyncEngine(fakeApi, db) }
163 + vm.ajouterNoteVocale("Note réseau KO", "", audioPath = null, modeServeur = true)
164 +
165 + vm.confirmationNote.first { it != null }
166 +
167 + assertEquals(1, db.syncOpDao().listAll().size)
168 + assertEquals(ConfirmationNote.SERVEUR_REPLI, vm.confirmationNote.value)
169 + }
170 +
171 + @Test
172 + fun ajouterNoteVocale_modeAppareil_aucunPush() = runBlocking {
173 + val fakeApi = FakeAilianceApi()
174 + val vm = viewModel { SyncEngine(fakeApi, db) }
175 + vm.ajouterNoteVocale("Note locale", "", audioPath = null, modeServeur = false)
176 +
177 + vm.confirmationNote.first { it != null }
178 +
179 + assertEquals(0, fakeApi.createNoteProjetCalls.size)
180 + assertEquals(1, db.syncOpDao().listAll().size)
181 + assertEquals(ConfirmationNote.APPAREIL, vm.confirmationNote.value)
182 + }
183 +}
M android/app/src/test/java/fr/ebii/card2vcf/ui/settings/SettingsViewModelTest.kt
+10 -0
@@ -53,6 +53,9 @@ private class FakeAilianceApi(
53 53 override fun deleteTache(projetId: String, tacheId: String) = error("not used")
54 54 override fun moveTache(projetId: String, tacheId: String, jsonBody: String) = error("not used")
55 55 override fun createInteraction(contactId: String, jsonBody: String) = error("not used")
56 + override fun deleteInteraction(iid: String) = error("not used")
57 + override fun relancerTranscriptionInteraction(contactServerId: String, iid: String) = error("not used")
58 + override fun relancerTranscriptionNote(projetServerId: String, nid: String) = error("not used")
56 59 override fun listRdv() = error("not used")
57 60 override fun createRdv(jsonBody: String) = error("not used")
58 61 override fun getRdv(id: String) = error("not used")
@@ -68,6 +71,13 @@ private class FakeAilianceApi(
68 71 override fun uploadContactPhoto(id: String, bytes: ByteArray, filename: String, contentType: String) = error("not used")
69 72 override fun downloadContactCarte(id: String) = error("not used")
70 73 override fun downloadContactPhoto(id: String) = error("not used")
74 + override fun uploadInteractionAudio(contactId: String, iid: String, bytes: ByteArray, filename: String, contentType: String) = error("not used")
75 + override fun downloadInteractionAudio(contactId: String, iid: String) = error("not used")
76 + override fun createNoteProjet(projetId: String, jsonBody: String) = error("not used")
77 + override fun updateNoteProjet(projetId: String, nid: String, jsonBody: String) = error("not used")
78 + override fun deleteNoteProjet(projetId: String, nid: String) = error("not used")
79 + override fun uploadNoteAudio(projetId: String, nid: String, bytes: ByteArray, filename: String, contentType: String) = error("not used")
80 + override fun downloadNoteAudio(projetId: String, nid: String) = error("not used")
71 81 }
72 82
73 83 @OptIn(ExperimentalCoroutinesApi::class)
M android/settings.gradle.kts
+1 -0
@@ -11,6 +11,7 @@ dependencyResolutionManagement {
11 11 google()
12 12 mavenCentral()
13 13 maven { url = uri("https://jitpack.io") }
14 + maven { url = uri("https://alphacephei.com/maven") }
14 15 }
15 16 }
16 17 rootProject.name = "card2vcf"
A android/vosk-cache/vosk-model-small-fr-0.22.zip
+0 -0