Notes vocales iOS + fixtures de contrat + CI locale Dagger Port iOS des notes vocales (parité Android) : - capture audio (AudioNoteRecorder, MoteurAudioCapture, écriture WAV) et transcription on-device via Speech, ou serveur au choix - migrations SQLite v4→v6 : colonnes audio/transcription sur interactions, nouvelle table notes_projet et son DAO - SyncEngine : opérations interaction_audio, note_projet et note_projet_audio (create/delete, upload/download avec ré-enfilement), relance de transcription - notes vocales dans la fiche contact et le détail projet - permissions micro et reconnaissance vocale dans Info.plist - écran « À propos » iOS - 15 suites XCTest Fixtures de contrat partagées serveur ⇄ Android ⇄ iOS : Server/tests/fixtures_contrat.rs génère sync_pull.json, interaction_note_vocale.json et note_projet.json dans contrats/ depuis un pull réel, et les copie vers les ressources de test des deux clients. Chaque client les relit via ContratSyncFixturesTest : toute dérive de schéma fait échouer la CI des trois côtés. Vérification locale avant push : module Dagger (.dagger/) avec serveur (clippy -D warnings + nextest), android (gradlew testDebugUnitTest) et tout (les deux en parallèle). Hook outils/hooks/pre-push passe par Dagger si Docker répond, sinon exécute les tests en direct. iOS non couvert (exige macOS). Testabilité Android : - interface EnregistreurAudio injectable dans EnregistrementNoteSheet, sinon Robolectric reste bloqué en phase « enregistrement » faute de matériel - FakeAilianceApi mutualisé, doublon de SettingsViewModelTest supprimé - tests Compose UI de la feuille d'enregistrement et du pager de contacts

EBO <eric@ebii.fr> committé le 2026-09-22 20:16

0e46d04f9b07d6869a624dce23cbbf25d48b64a7

1 parent(s)

64 fichiers modifiés +5048 -89
M android/app/build.gradle.kts
+2 -0
@@ -272,6 +272,8 @@ dependencies {
272 272 testImplementation("org.robolectric:robolectric:4.13")
273 273 testImplementation("androidx.test:core:1.6.1")
274 274 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
275 + testImplementation("androidx.compose.ui:ui-test-junit4")
276 + debugImplementation("androidx.compose.ui:ui-test-manifest")
275 277
276 278 implementation("com.squareup.okhttp3:okhttp:4.12.0")
277 279 testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
M android/app/src/main/java/fr/ebii/card2vcf/audio/AudioNoteRecorder.kt
+3 -3
@@ -23,7 +23,7 @@ data class ResultatEnregistrement(val chemin: String, val dureeMsec: Long)
23 23 *
24 24 * La demande de permission RECORD_AUDIO est à la charge de l'UI (P2-A4).
25 25 */
26 -class AudioNoteRecorder {
26 +class AudioNoteRecorder : EnregistreurAudio {
27 27
28 28 companion object {
29 29 private const val TAG = "AudioNoteRecorder"
@@ -41,7 +41,7 @@ class AudioNoteRecorder {
41 41 * Démarre l'enregistrement vers [fichierCible].
42 42 * [onPcm] est appelé sur le thread d'enregistrement à chaque buffer capturé.
43 43 */
44 - fun demarrer(fichierCible: File, onPcm: (ShortArray, Int) -> Unit) {
44 + override fun demarrer(fichierCible: File, onPcm: (ShortArray, Int) -> Unit) {
45 45 check(!enCours) { "Enregistrement déjà en cours" }
46 46 dernierResultat = null
47 47 enCours = true
@@ -55,7 +55,7 @@ class AudioNoteRecorder {
55 55 * Stoppe l'enregistrement et attend la fin du thread (max 3 s).
56 56 * @return Le résultat de l'enregistrement, ou null si non démarré.
57 57 */
58 - fun arreter(): ResultatEnregistrement? {
58 + override fun arreter(): ResultatEnregistrement? {
59 59 enCours = false
60 60 threadEnregistrement?.join(3_000)
61 61 threadEnregistrement = null
A android/app/src/main/java/fr/ebii/card2vcf/audio/EnregistreurAudio.kt
+19 -0
@@ -0,0 +1,19 @@
1 +package fr.ebii.card2vcf.audio
2 +
3 +import java.io.File
4 +
5 +/**
6 + * Contrat de capture audio utilisé par l'UI.
7 + *
8 + * [AudioNoteRecorder] en est l'implémentation réelle (AudioRecord). L'interface existe
9 + * pour que la feuille d'enregistrement reste pilotable en test : `AudioRecord` n'a aucun
10 + * matériel derrière lui sous Robolectric, si bien qu'un test de rendu attendrait
11 + * indéfiniment la fin d'un enregistrement qui n'a jamais démarré.
12 + */
13 +interface EnregistreurAudio {
14 + /** Lance l'enregistrement vers [fichierCible] ; [onPcm] reçoit les échantillons lus. */
15 + fun demarrer(fichierCible: File, onPcm: (ShortArray, Int) -> Unit)
16 +
17 + /** Stoppe l'enregistrement et retourne le fichier produit, ou `null` si rien n'a été capté. */
18 + fun arreter(): ResultatEnregistrement?
19 +}
M android/app/src/main/java/fr/ebii/card2vcf/ui/audio/EnregistrementNoteSheet.kt
+9 -2
@@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp
45 45 import androidx.core.content.ContextCompat
46 46 import fr.ebii.card2vcf.R
47 47 import fr.ebii.card2vcf.audio.AudioNoteRecorder
48 +import fr.ebii.card2vcf.audio.EnregistreurAudio
48 49 import fr.ebii.card2vcf.audio.TranscripteurVosk
49 50 import fr.ebii.card2vcf.ui.composants.ChampTexte
50 51 import fr.ebii.card2vcf.ui.theme.Bordure
@@ -85,11 +86,17 @@ fun EnregistrementNoteSheet(
85 86 voskDisponible: Boolean,
86 87 onSauvegarder: (sujet: String, texte: String, audioPath: String?) -> Unit,
87 88 onDismiss: () -> Unit,
89 + /**
90 + * Fabrique de l'enregistreur. Injectable pour les tests de rendu : sous Robolectric,
91 + * `AudioRecord` n'a aucun matériel derrière lui et l'écran resterait bloqué en phase
92 + * « enregistrement en cours ».
93 + */
94 + enregistreurFactory: () -> EnregistreurAudio = { AudioNoteRecorder() },
88 95 ) {
89 96 val context = LocalContext.current
90 97 val modeLocal = !modeServeur && voskDisponible
91 98
92 - val recorder = remember { AudioNoteRecorder() }
99 + val recorder = remember { enregistreurFactory() }
93 100 val transcripteur = remember { if (modeLocal) TranscripteurVosk(context.applicationContext) else null }
94 101
95 102 var micAccorde by remember {
@@ -284,7 +291,7 @@ private fun EnCoursContent(
284 291 }
285 292
286 293 @Composable
287 -private fun EditionContent(
294 +internal fun EditionContent(
288 295 sujet: String,
289 296 texteTranscrit: String,
290 297 modeServeur: Boolean,
A android/app/src/test/java/fr/ebii/card2vcf/sync/ContratSyncFixturesTest.kt
+75 -0
@@ -0,0 +1,75 @@
1 +package fr.ebii.card2vcf.sync
2 +
3 +import org.junit.Assert.assertEquals
4 +import org.junit.Assert.assertNotNull
5 +import org.junit.Assert.assertNull
6 +import org.junit.Assert.assertTrue
7 +import org.junit.Test
8 +
9 +/**
10 + * Décode les fixtures de contrat de synchronisation avec les vrais DTO.
11 + *
12 + * Si un champ serveur est renommé, ce test devient rouge immédiatement.
13 + * Preuve de rougeur (vérifiée) : en renommant `transcription_erreur` → `transcription_error`
14 + * dans `interaction_note_vocale.json`, l'assertion suivante échoue :
15 + * expected:<Modèle ASR indisponible> but was:<null>
16 + */
17 +class ContratSyncFixturesTest {
18 +
19 + private fun fixture(nom: String): String {
20 + val stream = javaClass.classLoader!!.getResourceAsStream("contrats/$nom")
21 + ?: error("Fixture introuvable : contrats/$nom")
22 + return stream.bufferedReader().readText()
23 + }
24 +
25 + // ---- sync_pull.json ----
26 +
27 + @Test
28 + fun syncPull_decodeSyncPullResponse() {
29 + val json = fixture("sync_pull.json")
30 + val pull = syncJson.decodeFromString<SyncPullResponse>(json)
31 +
32 + assertTrue("contacts doit contenir au moins un élément", pull.contacts.isNotEmpty())
33 + assertTrue("interactions doit contenir au moins un élément", pull.interactions.isNotEmpty())
34 + assertTrue("notes doit contenir au moins un élément", pull.notes.isNotEmpty())
35 + }
36 +
37 + @Test
38 + fun syncPull_contactChampsCritiques() {
39 + val json = fixture("sync_pull.json")
40 + val pull = syncJson.decodeFromString<SyncPullResponse>(json)
41 +
42 + val contact = pull.contacts.first()
43 + assertNotNull("id contact ne doit pas être null", contact.id)
44 + assertNotNull("creeLe contact ne doit pas être null", contact.creeLe)
45 + }
46 +
47 + // ---- interaction_note_vocale.json ----
48 +
49 + @Test
50 + fun interaction_decodeInteractionDto() {
51 + val json = fixture("interaction_note_vocale.json")
52 + val dto = syncJson.decodeFromString<InteractionDto>(json)
53 +
54 + assertEquals("note_vocale", dto.typeInteraction)
55 + assertEquals("vocale_contrat_1.wav", dto.pieceJointe)
56 + assertEquals("echec", dto.transcription)
57 + // Champ critique : si transcription_erreur est renommé côté serveur,
58 + // cette assertion devient rouge : expected:<Modèle ASR indisponible> but was:<null>
59 + assertEquals("Modèle ASR indisponible", dto.transcriptionErreur)
60 + }
61 +
62 + // ---- note_projet.json ----
63 +
64 + @Test
65 + fun noteProjet_decodeNoteProjetDto() {
66 + val json = fixture("note_projet.json")
67 + val dto = syncJson.decodeFromString<NoteProjetDto>(json)
68 +
69 + assertEquals("projet-contrat-1", dto.projetId)
70 + assertEquals("note_contrat_1.wav", dto.audio)
71 + assertEquals("terminee", dto.transcription)
72 + // transcription_erreur doit être null pour cette note (transcription réussie)
73 + assertNull("transcriptionErreur doit être null pour une transcription réussie", dto.transcriptionErreur)
74 + }
75 +}
M android/app/src/test/java/fr/ebii/card2vcf/sync/FakeAilianceApi.kt
+17 -3
@@ -1,7 +1,20 @@
1 1 package fr.ebii.card2vcf.sync
2 2
3 -/** Fake [AilianceApi] configurable par test ; par défaut tout répond Ok vide. Partagé par [SyncEngineTest] et [AgendaSyncEngineTest]. */
3 +/**
4 + * Fake [AilianceApi] configurable par test ; par défaut tout répond Ok vide.
5 + * Partagé par tous les tests qui en ont besoin.
6 + *
7 + * Deux champs permettent de surcharger les réponses critiques pour [SettingsViewModelTest] :
8 + * - [authCleOverride] : si non null, remplace la réponse par défaut de [authCle].
9 + * - [getRessourcesCatalogueOverride] : si non null, remplace [getRessourcesCatalogue].
10 + */
4 11 class FakeAilianceApi : AilianceApi {
12 + /** Si non null, [authCle] retourne cette valeur au lieu du Ok par défaut. */
13 + var authCleOverride: AilianceApiClient.ApiResult<AuthCleResponse>? = null
14 +
15 + /** Si non null, [getRessourcesCatalogue] retourne cette valeur au lieu du Ok vide par défaut. */
16 + var getRessourcesCatalogueOverride: AilianceApiClient.ApiResult<RessourcesCatalogueDto>? = null
17 +
5 18 var statusResult: AilianceApiClient.ApiResult<SyncStatusResponse> =
6 19 AilianceApiClient.ApiResult.Ok(SyncStatusResponse("1970-01-01T00:00:00Z", SyncChanges(), 0))
7 20 var pullResult: AilianceApiClient.ApiResult<SyncPullResponse> =
@@ -25,7 +38,7 @@ class FakeAilianceApi : AilianceApi {
25 38 val pullSince = mutableListOf<String>()
26 39
27 40 override fun authCle(nom: String, motDePasse: String) =
28 - AilianceApiClient.ApiResult.Ok(AuthCleResponse(nom, "", "", "argon2id", "xchacha20poly1305"))
41 + authCleOverride ?: AilianceApiClient.ApiResult.Ok(AuthCleResponse(nom, "", "", "argon2id", "xchacha20poly1305"))
29 42
30 43 override fun syncStatus(sinceIso: String, ressourcesQuery: String?): AilianceApiClient.ApiResult<SyncStatusResponse> {
31 44 statusQueries += ressourcesQuery
@@ -101,7 +114,8 @@ class FakeAilianceApi : AilianceApi {
101 114 override fun getRdv(id: String) = error("not used")
102 115 override fun updateRdv(id: String, jsonBody: String) = AilianceApiClient.ApiResult.Ok("{}")
103 116 override fun deleteRdv(id: String) = AilianceApiClient.ApiResult.Ok(Unit)
104 - override fun getRessourcesCatalogue() = AilianceApiClient.ApiResult.Ok(RessourcesCatalogueDto())
117 + override fun getRessourcesCatalogue() =
118 + getRessourcesCatalogueOverride ?: AilianceApiClient.ApiResult.Ok(RessourcesCatalogueDto())
105 119 override fun listReservations(genre: String, resourceId: String) = AilianceApiClient.ApiResult.Ok(emptyList<ReservationDto>())
106 120
107 121 override fun createReservation(genre: String, resourceId: String, jsonBody: String): AilianceApiClient.ApiResult<String> {
A android/app/src/test/java/fr/ebii/card2vcf/ui/audio/EnregistrementNoteSheetTest.kt
+175 -0
@@ -0,0 +1,175 @@
1 +package fr.ebii.card2vcf.ui.audio
2 +
3 +import android.Manifest
4 +import android.app.Application
5 +import androidx.compose.ui.test.assertIsDisplayed
6 +import androidx.compose.ui.test.assertIsEnabled
7 +import androidx.compose.ui.test.assertIsNotEnabled
8 +import androidx.compose.ui.test.junit4.createComposeRule
9 +import androidx.compose.ui.test.onAllNodesWithText
10 +import androidx.compose.ui.test.onNodeWithText
11 +import androidx.compose.ui.test.performClick
12 +import androidx.test.core.app.ApplicationProvider
13 +import kotlinx.coroutines.Dispatchers
14 +import kotlinx.coroutines.ExperimentalCoroutinesApi
15 +import kotlinx.coroutines.test.resetMain
16 +import kotlinx.coroutines.test.setMain
17 +import org.junit.After
18 +import org.junit.Before
19 +import org.junit.Rule
20 +import fr.ebii.card2vcf.audio.EnregistreurAudio
21 +import fr.ebii.card2vcf.audio.ResultatEnregistrement
22 +import java.io.File
23 +import org.junit.Test
24 +import org.junit.runner.RunWith
25 +import org.robolectric.RobolectricTestRunner
26 +import org.robolectric.Shadows.shadowOf
27 +import org.robolectric.annotation.Config
28 +
29 +/**
30 + * Tests de rendu de [EnregistrementNoteSheet] et [EditionContent].
31 + *
32 + * Ils couvrent le bug réel du 2026-09-15 : sans préremplissage du sujet, le bouton
33 + * « Sauvegarder » restait désactivé sans explication et l'enregistrement était perdu à la
34 + * fermeture de la feuille (5 fichiers WAV orphelins retrouvés sur l'appareil de test).
35 + *
36 + * La preuve de rougeur de chaque test est documentée sur le test lui-même.
37 + */
38 +@OptIn(ExperimentalCoroutinesApi::class)
39 +@RunWith(RobolectricTestRunner::class)
40 +@Config(sdk = [31], qualifiers = "fr")
41 +class EnregistrementNoteSheetTest {
42 +
43 + @get:Rule
44 + val composeTestRule = createComposeRule()
45 +
46 + @Before
47 + fun setUp() {
48 + Dispatchers.setMain(Dispatchers.Unconfined)
49 + }
50 +
51 + @After
52 + fun tearDown() {
53 + Dispatchers.resetMain()
54 + }
55 +
56 + /**
57 + * L'avertissement "La transcription ne distingue pas les interlocuteurs" est affiché
58 + * en permanence, quelle que soit la phase (y compris sans permission micro).
59 + */
60 + @Test
61 + fun avertissement_toujours_affiche() {
62 + // Pas de permission micro → phase EN_ATTENTE_PERMISSION ; avertissement toujours visible
63 + composeTestRule.setContent {
64 + EnregistrementNoteSheet(
65 + modeServeur = false,
66 + voskDisponible = false,
67 + onSauvegarder = { _, _, _ -> },
68 + onDismiss = {},
69 + // AudioRecord n'a aucun matériel derrière lui sous Robolectric : sans ce
70 + // faux, l'écran resterait bloqué en phase « enregistrement en cours ».
71 + enregistreurFactory = { FauxEnregistreur() },
72 + )
73 + }
74 +
75 + composeTestRule.waitUntil(5_000) {
76 + composeTestRule.onAllNodesWithText("La transcription ne distingue pas les interlocuteurs")
77 + .fetchSemanticsNodes().isNotEmpty()
78 + }
79 +
80 + composeTestRule.onNodeWithText("La transcription ne distingue pas les interlocuteurs")
81 + .assertIsDisplayed()
82 + }
83 +
84 + /**
85 + * Le sujet prérempli par [genererSujetParDefaut] (« Notes du … ») rend le bouton
86 + * « Sauvegarder » actif : c'est ce qui empêche la perte d'un enregistrement.
87 + *
88 + * Preuve de rougeur : remplacer l'argument `sujet` par `""` (état d'avant le
89 + * préremplissage) → `assertIsEnabled` échoue avec
90 + * « Failed to assert the following: (is enabled) ».
91 + *
92 + * Limite assumée : la transition complète EN_COURS → EDITION n'est pas rejouable sous
93 + * Robolectric. Le `onStop` enchaîne `withContext(Dispatchers.IO)` puis un retour sur le
94 + * dispatcher Compose, pendant qu'une boucle `delay(100)` de minuterie tourne encore :
95 + * l'horloge de test n'atteint jamais l'état d'inactivité et `waitUntil` expire. On teste
96 + * donc l'état d'édition tel qu'il est construit, avec le vrai générateur de sujet.
97 + */
98 + @Test
99 + fun bouton_sauvegarder_actif_avec_sujet_prerempli() {
100 + val sujetParDefaut = genererSujetParDefaut("Notes du %1\$s", maintenant = 1_789_500_000_000L)
101 +
102 + composeTestRule.setContent {
103 + EditionContent(
104 + sujet = sujetParDefaut,
105 + texteTranscrit = "",
106 + modeServeur = false,
107 + onSujetChange = {},
108 + onTexteChange = {},
109 + onSauvegarder = {},
110 + onAnnuler = {},
111 + )
112 + }
113 +
114 + assert(sujetParDefaut.isNotBlank()) { "le sujet par défaut ne doit jamais être vide" }
115 + composeTestRule.onNodeWithText("Sauvegarder").assertIsEnabled()
116 + }
117 +
118 + /**
119 + * Vérifie que [EditionContent] désactive le bouton « Sauvegarder » quand le sujet
120 + * est vide — c'est l'état réel sans préremplissage (le bug original).
121 + */
122 + @Test
123 + fun bouton_sauvegarder_desactive_si_sujet_vide() {
124 + composeTestRule.setContent {
125 + EditionContent(
126 + sujet = "",
127 + texteTranscrit = "",
128 + modeServeur = false,
129 + onSujetChange = {},
130 + onTexteChange = {},
131 + onSauvegarder = {},
132 + onAnnuler = {},
133 + )
134 + }
135 +
136 + composeTestRule.onNodeWithText("Sauvegarder").assertIsNotEnabled()
137 + }
138 +
139 + /**
140 + * Vérifie que le bouton « Annuler » appelle bien [onAnnuler].
141 + */
142 + @Test
143 + fun bouton_annuler_appelle_onAnnuler() {
144 + var annule = false
145 + composeTestRule.setContent {
146 + EditionContent(
147 + sujet = "Test",
148 + texteTranscrit = "",
149 + modeServeur = false,
150 + onSujetChange = {},
151 + onTexteChange = {},
152 + onSauvegarder = {},
153 + onAnnuler = { annule = true },
154 + )
155 + }
156 +
157 + composeTestRule.onNodeWithText("Annuler").performClick()
158 +
159 + assert(annule) { "onAnnuler doit être appelé au clic du bouton Annuler" }
160 + }
161 +}
162 +
163 +/**
164 + * Enregistreur de test : ne touche à aucun matériel et rend immédiatement un résultat,
165 + * ce qui permet à la feuille de passer en phase d'édition sous Robolectric.
166 + */
167 +private class FauxEnregistreur : EnregistreurAudio {
168 + override fun demarrer(fichierCible: File, onPcm: (ShortArray, Int) -> Unit) {
169 + fichierCible.parentFile?.mkdirs()
170 + fichierCible.writeBytes(ByteArray(0))
171 + }
172 +
173 + override fun arreter(): ResultatEnregistrement? =
174 + ResultatEnregistrement(chemin = "/tmp/faux-note-vocale.wav", dureeMsec = 1_500L)
175 +}
A android/app/src/test/java/fr/ebii/card2vcf/ui/contact/ContactPagerScreenTest.kt
+175 -0
@@ -0,0 +1,175 @@
1 +package fr.ebii.card2vcf.ui.contact
2 +
3 +import android.content.Context
4 +import androidx.compose.ui.test.assertCountEquals
5 +import androidx.compose.ui.test.assertIsDisplayed
6 +import androidx.compose.ui.test.junit4.createComposeRule
7 +import androidx.compose.ui.test.onAllNodesWithText
8 +import androidx.compose.ui.test.onNodeWithText
9 +import androidx.room.Room
10 +import androidx.test.core.app.ApplicationProvider
11 +import fr.ebii.card2vcf.crm.ContactSort
12 +import fr.ebii.card2vcf.data.AudioNoteStore
13 +import fr.ebii.card2vcf.data.ContactImageStore
14 +import fr.ebii.card2vcf.data.ContactRepository
15 +import fr.ebii.card2vcf.data.CrmContactEntity
16 +import fr.ebii.card2vcf.data.CrmDatabase
17 +import fr.ebii.card2vcf.sync.SyncCredentialsStore
18 +import kotlinx.coroutines.Dispatchers
19 +import kotlinx.coroutines.ExperimentalCoroutinesApi
20 +import kotlinx.coroutines.runBlocking
21 +import kotlinx.coroutines.test.resetMain
22 +import kotlinx.coroutines.test.setMain
23 +import org.junit.After
24 +import org.junit.Before
25 +import org.junit.Rule
26 +import org.junit.Test
27 +import org.junit.runner.RunWith
28 +import org.robolectric.RobolectricTestRunner
29 +import org.robolectric.annotation.Config
30 +
31 +/**
32 + * Tests de rendu de [ContactPagerScreen] — preuves de rougeur :
33 + *
34 + * Preuve de rougeur exécutée le 2026-09-18 : en neutralisant le `scrollToPage(page)` du
35 + * LaunchedEffect de [ContactPagerScreen] (le pager reste alors figé sur la page 0, état
36 + * exact de la régression du commit 068f21d), la suite tombe à :
37 + *
38 + * ContactPagerScreenTest > pager_liste_vide_puis_chargee_positionne_contact_demande FAILED
39 + * ContactPagerScreenTest > pager_ne_revient_pas_sur_premier_contact_apres_rafraichissement FAILED
40 + * 2 tests completed, 2 failed
41 + *
42 + * Ces deux tests détectent donc réellement le bug — contrairement au test de la fonction
43 + * pure `pageCible`, qui restait vert puisque la faute vit dans le cycle de vie de
44 + * `rememberPagerState`, pas dans le calcul de l'index.
45 + */
46 +@OptIn(ExperimentalCoroutinesApi::class)
47 +@RunWith(RobolectricTestRunner::class)
48 +@Config(sdk = [31])
49 +class ContactPagerScreenTest {
50 +
51 + @get:Rule
52 + val composeTestRule = createComposeRule()
53 +
54 + private lateinit var db: CrmDatabase
55 + private lateinit var repository: ContactRepository
56 + private lateinit var credentialsStore: SyncCredentialsStore
57 + private lateinit var audioStore: AudioNoteStore
58 +
59 + @Before
60 + fun setUp() {
61 + Dispatchers.setMain(Dispatchers.Unconfined)
62 + val ctx = ApplicationProvider.getApplicationContext<Context>()
63 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
64 + .allowMainThreadQueries()
65 + .setQueryExecutor { it.run() }
66 + .setTransactionExecutor { it.run() }
67 + .build()
68 + repository = ContactRepository(
69 + dao = db.crmContactDao(),
70 + images = ContactImageStore(ctx),
71 + syncOpDao = db.syncOpDao(),
72 + )
73 + credentialsStore = SyncCredentialsStore(
74 + ctx.getSharedPreferences("pager_screen_test", Context.MODE_PRIVATE),
75 + )
76 + audioStore = AudioNoteStore(ctx)
77 + }
78 +
79 + @After
80 + fun tearDown() {
81 + db.close()
82 + Dispatchers.resetMain()
83 + }
84 +
85 + private fun viewModel(initialContactId: Long) = ContactPagerViewModel(
86 + repository = repository,
87 + database = db,
88 + credentialsStore = credentialsStore,
89 + audioStore = audioStore,
90 + initialContactId = initialContactId,
91 + sort = ContactSort.LAST_NAME,
92 + engineFabrique = { null },
93 + )
94 +
95 + /**
96 + * Scénario "flux vide puis rempli" : le pager doit afficher la fiche demandée
97 + * (Zorro, page 1) et non la première de la liste (Alice, page 0).
98 + */
99 + @Test
100 + fun pager_liste_vide_puis_chargee_positionne_contact_demande() {
101 + // id=2 → Zorro (tri lastName : "Alpha" < "Zeta" → Alice page 0, Zorro page 1)
102 + val vm = viewModel(initialContactId = 2L)
103 +
104 + composeTestRule.setContent {
105 + ContactPagerScreen(
106 + viewModel = vm,
107 + onBack = {},
108 + onEdit = {},
109 + onDuplicates = {},
110 + modeServeur = false,
111 + voskDisponible = false,
112 + )
113 + }
114 +
115 + // Initialement vide — aucun contact affiché
116 + composeTestRule.onAllNodesWithText("Zorro Zeta").assertCountEquals(0)
117 +
118 + // Chargement : Room émet → LaunchedEffect scrolle vers la page de Zorro
119 + runBlocking {
120 + db.crmContactDao().insert(CrmContactEntity(id = 1L, fullName = "Alice Alpha", lastName = "Alpha"))
121 + db.crmContactDao().insert(CrmContactEntity(id = 2L, fullName = "Zorro Zeta", lastName = "Zeta"))
122 + }
123 +
124 + composeTestRule.waitUntil(5_000) {
125 + composeTestRule.onAllNodesWithText("Zorro Zeta").fetchSemanticsNodes().isNotEmpty()
126 + }
127 +
128 + composeTestRule.onNodeWithText("Zorro Zeta").assertIsDisplayed()
129 + composeTestRule.onAllNodesWithText("Alice Alpha").assertCountEquals(0)
130 + }
131 +
132 + /**
133 + * Après positionnement initial sur Zorro, un rafraîchissement de la liste
134 + * (ajout d'un troisième contact) ne doit PAS ramener le pager sur Alice.
135 + * Le drapeau `positionne` bloque le re-scroll.
136 + */
137 + @Test
138 + fun pager_ne_revient_pas_sur_premier_contact_apres_rafraichissement() {
139 + runBlocking {
140 + db.crmContactDao().insert(CrmContactEntity(id = 1L, fullName = "Alice Alpha", lastName = "Alpha"))
141 + db.crmContactDao().insert(CrmContactEntity(id = 2L, fullName = "Zorro Zeta", lastName = "Zeta"))
142 + }
143 +
144 + val vm = viewModel(initialContactId = 2L)
145 +
146 + composeTestRule.setContent {
147 + ContactPagerScreen(
148 + viewModel = vm,
149 + onBack = {},
150 + onEdit = {},
151 + onDuplicates = {},
152 + modeServeur = false,
153 + voskDisponible = false,
154 + )
155 + }
156 +
157 + // Positionnement initial sur Zorro
158 + composeTestRule.waitUntil(5_000) {
159 + composeTestRule.onAllNodesWithText("Zorro Zeta").fetchSemanticsNodes().isNotEmpty()
160 + }
161 + composeTestRule.onNodeWithText("Zorro Zeta").assertIsDisplayed()
162 +
163 + // Ajout d'un troisième contact → Room émet une nouvelle liste
164 + runBlocking {
165 + db.crmContactDao().insert(CrmContactEntity(id = 3L, fullName = "Michel Moyen", lastName = "Moyen"))
166 + }
167 + composeTestRule.waitForIdle()
168 +
169 + // Michel s'insère entre Alice (Alpha) et Zorro (Zeta) → Zorro passe à la page 2.
170 + // Le drapeau `positionne = true` empêche le re-scroll vers Zorro :
171 + // le pager reste sur la page 1 qui affiche maintenant Michel.
172 + composeTestRule.onNodeWithText("Michel Moyen").assertIsDisplayed()
173 + composeTestRule.onAllNodesWithText("Zorro Zeta").assertCountEquals(0)
174 + }
175 +}
M android/app/src/test/java/fr/ebii/card2vcf/ui/settings/SettingsViewModelTest.kt
+3 -54
@@ -3,10 +3,10 @@ package fr.ebii.card2vcf.ui.settings
3 3 import android.content.Context
4 4 import androidx.test.core.app.ApplicationProvider
5 5 import fr.ebii.card2vcf.sync.AgendaSyncCoordinator
6 -import fr.ebii.card2vcf.sync.AilianceApi
7 6 import fr.ebii.card2vcf.sync.AilianceApiClient
8 7 import fr.ebii.card2vcf.sync.AuthCleResponse
9 8 import fr.ebii.card2vcf.sync.CalendarBindingsStore
9 +import fr.ebii.card2vcf.sync.FakeAilianceApi
10 10 import fr.ebii.card2vcf.sync.FakeCalendarBridge
11 11 import fr.ebii.card2vcf.sync.RessourceItemDto
12 12 import fr.ebii.card2vcf.sync.RessourcesCatalogueDto
@@ -29,57 +29,6 @@ import org.junit.runner.RunWith
29 29 import org.robolectric.RobolectricTestRunner
30 30 import org.robolectric.annotation.Config
31 31
32 -/** Fake [AilianceApi] : `authCle` (login) et `getRessourcesCatalogue` (section calendriers) importent pour [SettingsViewModel]. */
33 -private class FakeAilianceApi(
34 - private val authResult: AilianceApiClient.ApiResult<AuthCleResponse> =
35 - AilianceApiClient.ApiResult.Err(-1, "authCle not stubbed for this test"),
36 - private val catalogueResult: AilianceApiClient.ApiResult<RessourcesCatalogueDto> =
37 - AilianceApiClient.ApiResult.Ok(RessourcesCatalogueDto()),
38 -) : AilianceApi {
39 - override fun authCle(nom: String, motDePasse: String) = authResult
40 - override fun syncStatus(sinceIso: String, ressourcesQuery: String?) = error("not used")
41 - override fun syncPull(sinceIso: String, ressourcesQuery: String?) = error("not used")
42 - override fun createContact(jsonBody: String) = error("not used")
43 - override fun updateContact(id: String, jsonBody: String) = error("not used")
44 - override fun deleteContact(id: String) = error("not used")
45 - override fun createEntreprise(jsonBody: String) = error("not used")
46 - override fun updateEntreprise(id: String, jsonBody: String) = error("not used")
47 - override fun deleteEntreprise(id: String) = error("not used")
48 - override fun createProjet(jsonBody: String) = error("not used")
49 - override fun updateProjet(id: String, jsonBody: String) = error("not used")
50 - override fun deleteProjet(id: String) = error("not used")
51 - override fun createTache(projetId: String, jsonBody: String) = error("not used")
52 - override fun updateTache(projetId: String, tacheId: String, jsonBody: String) = error("not used")
53 - override fun deleteTache(projetId: String, tacheId: String) = error("not used")
54 - override fun moveTache(projetId: String, tacheId: String, jsonBody: String) = error("not used")
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")
59 - override fun listRdv() = error("not used")
60 - override fun createRdv(jsonBody: String) = error("not used")
61 - override fun getRdv(id: String) = error("not used")
62 - override fun updateRdv(id: String, jsonBody: String) = error("not used")
63 - override fun deleteRdv(id: String) = error("not used")
64 - override fun getRessourcesCatalogue() = catalogueResult
65 - override fun listReservations(genre: String, resourceId: String) = error("not used")
66 - override fun createReservation(genre: String, resourceId: String, jsonBody: String) = error("not used")
67 - override fun updateReservation(genre: String, resourceId: String, reservationId: String, jsonBody: String) = error("not used")
68 - override fun deleteReservation(genre: String, resourceId: String, reservationId: String) = error("not used")
69 - override fun listIndisponibilites(genre: String, resourceId: String) = error("not used")
70 - override fun uploadContactCarte(id: String, bytes: ByteArray, filename: String, contentType: String) = error("not used")
71 - override fun uploadContactPhoto(id: String, bytes: ByteArray, filename: String, contentType: String) = error("not used")
72 - override fun downloadContactCarte(id: String) = error("not used")
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")
81 -}
82 -
83 32 @OptIn(ExperimentalCoroutinesApi::class)
84 33 @RunWith(RobolectricTestRunner::class)
85 34 @Config(sdk = [31])
@@ -114,8 +63,8 @@ class SettingsViewModelTest {
114 63 credentialsStore = store,
115 64 calendarBindingsStore = calendarBindingsStore,
116 65 calendarBridge = calendarBridge,
117 - apiFactory = { FakeAilianceApi(authResult = authResult) },
118 - authenticatedApiFactory = { _, _ -> FakeAilianceApi(catalogueResult = catalogueResult) },
66 + apiFactory = { FakeAilianceApi().also { it.authCleOverride = authResult } },
67 + authenticatedApiFactory = { _, _ -> FakeAilianceApi().also { it.getRessourcesCatalogueOverride = catalogueResult } },
119 68 ioDispatcher = testDispatcher,
120 69 )
121 70
A android/app/src/test/resources/contrats/interaction_note_vocale.json
+16 -0
@@ -0,0 +1,16 @@
1 +{
2 + "id": "interaction-vocale-contrat-1",
3 + "contact_id": "contact-contrat-1",
4 + "type_interaction": "note_vocale",
5 + "sujet": "Note vocale contrat",
6 + "description": "",
7 + "statut": "fait",
8 + "prevu_le": null,
9 + "fait_le": null,
10 + "piece_jointe": "vocale_contrat_1.wav",
11 + "cree_par": "alice",
12 + "cree_le": "2026-09-01T10:00:00Z",
13 + "mis_a_jour_le": null,
14 + "transcription": "echec",
15 + "transcription_erreur": "Modèle ASR indisponible"
16 +}
16 < \ No newline at end of file
A android/app/src/test/resources/contrats/note_projet.json
+12 -0
@@ -0,0 +1,12 @@
1 +{
2 + "id": "note-projet-contrat-1",
3 + "projet_id": "projet-contrat-1",
4 + "titre": "Note vocale projet contrat",
5 + "contenu": "## Compte-rendu contrat\n\n- Point vérifié\n",
6 + "auteur": "alice",
7 + "cree_le": "2026-09-01T10:00:00Z",
8 + "maj_le": null,
9 + "audio": "note_contrat_1.wav",
10 + "transcription": "terminee",
11 + "transcription_erreur": null
12 +}
12 < \ No newline at end of file
A android/app/src/test/resources/contrats/sync_pull.json
+140 -0
@@ -0,0 +1,140 @@
1 +{
2 + "server_time": "2026-09-18T08:42:51.508932169Z",
3 + "contacts": [
4 + {
5 + "id": "contact-contrat-1",
6 + "prenom": "Ada",
7 + "nom": "Lovelace",
8 + "entreprise_id": null,
9 + "fonction": "",
10 + "emails": [],
11 + "telephones": [],
12 + "adresses": [],
13 + "statut": "prospect",
14 + "etape": "nouveau",
15 + "date_rencontre": null,
16 + "notes": "",
17 + "photo": null,
18 + "carte_visite": null,
19 + "cree_par": "alice",
20 + "cree_le": "2026-09-01T10:00:00Z",
21 + "derniere_action": null,
22 + "tags": [],
23 + "mis_a_jour_le": null,
24 + "civilite": "",
25 + "date_naissance": null,
26 + "site_web": "",
27 + "profils_sociaux": []
28 + }
29 + ],
30 + "entreprises": [],
31 + "projets": [
32 + {
33 + "id": "projet-contrat-1",
34 + "nom": "Projet contrat",
35 + "description": "",
36 + "workflow_id": "veille",
37 + "cree_par": "alice",
38 + "cree_le": "2026-09-01T10:00:00Z",
39 + "derniere_action": null,
40 + "taches": [],
41 + "notes": [
42 + {
43 + "id": "note-projet-contrat-1",
44 + "titre": "Note vocale projet contrat",
45 + "fichier": "note-contrat-1.md",
46 + "ordre": 0,
47 + "auteur": "alice",
48 + "cree_le": "2026-09-01T10:00:00Z",
49 + "maj_le": null,
50 + "audio": "note_contrat_1.wav",
51 + "transcription": "terminee",
52 + "transcription_erreur": null
53 + }
54 + ],
55 + "liens": [],
56 + "fichiers": [],
57 + "apercu_liens_riche": true,
58 + "gestion_contacts": false,
59 + "contacts_lies": [],
60 + "entreprises_liees": [],
61 + "membres": [],
62 + "mis_a_jour_le": null
63 + }
64 + ],
65 + "taches": [],
66 + "interactions": [
67 + {
68 + "id": "interaction-vocale-contrat-1",
69 + "contact_id": "contact-contrat-1",
70 + "type_interaction": "note_vocale",
71 + "sujet": "Note vocale contrat",
72 + "description": "",
73 + "statut": "fait",
74 + "prevu_le": null,
75 + "fait_le": null,
76 + "piece_jointe": "vocale_contrat_1.wav",
77 + "cree_par": "alice",
78 + "cree_le": "2026-09-01T10:00:00Z",
79 + "mis_a_jour_le": null,
80 + "transcription": "echec",
81 + "transcription_erreur": "Modèle ASR indisponible"
82 + }
83 + ],
84 + "notes": [
85 + {
86 + "id": "note-projet-contrat-1",
87 + "projet_id": "projet-contrat-1",
88 + "titre": "Note vocale projet contrat",
89 + "contenu": "## Compte-rendu contrat\n\n- Point vérifié\n",
90 + "auteur": "alice",
91 + "cree_le": "2026-09-01T10:00:00Z",
92 + "maj_le": null,
93 + "audio": "note_contrat_1.wav",
94 + "transcription": "terminee",
95 + "transcription_erreur": null
96 + }
97 + ],
98 + "rdv": [],
99 + "reservations": [],
100 + "indisponibilites": [],
101 + "tombstones": [],
102 + "workflows": [
103 + {
104 + "id": "veille",
105 + "nom": "Veille",
106 + "description": "Suivi de sujets à explorer et synthétiser",
107 + "colonnes": [
108 + {
109 + "id": "explorer",
110 + "nom": "À explorer",
111 + "ordre": 0
112 + },
113 + {
114 + "id": "lecture",
115 + "nom": "En lecture",
116 + "ordre": 1
117 + },
118 + {
119 + "id": "synthetiser",
120 + "nom": "À synthétiser",
121 + "ordre": 2
122 + },
123 + {
124 + "id": "synthetise",
125 + "nom": "Synthétisé",
126 + "ordre": 3
127 + },
128 + {
129 + "id": "archive",
130 + "nom": "Archive",
131 + "ordre": 4
132 + }
133 + ],
134 + "personnalise": false,
135 + "gestion_contacts": false,
136 + "cree_par": "systeme",
137 + "cree_le": "2026-09-18T08:42:51.507116089Z"
138 + }
139 + ]
140 +}
140 < \ No newline at end of file
A ios/Card2vcf/Audio/AudioNoteRecorder.swift
+100 -0
@@ -0,0 +1,100 @@
1 +import Foundation
2 +
3 +/// Résultat d'un enregistrement terminé.
4 +struct ResultatEnregistrement {
5 + let chemin: String
6 + let dureeMsec: Int64
7 +}
8 +
9 +/// Erreurs de AudioNoteRecorder.
10 +enum AudioNoteRecorderErreur: Error, Equatable {
11 + case enregistrementDejaEnCours
12 +}
13 +
14 +/// Enregistre un fichier WAV (PCM 16 kHz mono 16 bits) via un MoteurAudioCapture.
15 +///
16 +/// Le moteur audio est injecté par le constructeur (MoteurAudioEngine par défaut),
17 +/// ce qui permet d'utiliser un faux en test sans accès au matériel audio.
18 +///
19 +/// Durée maximale : 10 minutes avec arrêt automatique.
20 +///
21 +/// API :
22 +/// - `demarrer(fichierCible:transcripteur:)` — lance l'enregistrement.
23 +/// - `arreter()` — stoppe et retourne le résultat.
24 +final class AudioNoteRecorder {
25 + private let moteur: MoteurAudioCapture
26 + private let dureeMaxSecondes: Double
27 +
28 + private var enregistrementEnCours = false
29 + private var ecriture: EcritureWav?
30 + private var fichierCourant: URL?
31 + private var transcripteurCourant: (any TranscripteurLocal)?
32 + private var debutEnregistrement: Date?
33 + private var timerArret: DispatchWorkItem?
34 +
35 + init(moteur: MoteurAudioCapture = MoteurAudioEngine(),
36 + dureeMaxSecondes: Double = 10 * 60) {
37 + self.moteur = moteur
38 + self.dureeMaxSecondes = dureeMaxSecondes
39 + }
40 +
41 + /// `true` si un enregistrement est en cours.
42 + var estEnCours: Bool { enregistrementEnCours }
43 +
44 + // MARK: - API publique
45 +
46 + /// Démarre l'enregistrement vers `fichierCible`.
47 + /// `transcripteur` est alimenté en streaming avec chaque buffer capturé.
48 + /// - Throws: `AudioNoteRecorderErreur.enregistrementDejaEnCours` si déjà actif.
49 + func demarrer(fichierCible: URL, transcripteur: (any TranscripteurLocal)? = nil) throws {
50 + guard !enregistrementEnCours else { throw AudioNoteRecorderErreur.enregistrementDejaEnCours }
51 + enregistrementEnCours = true
52 + debutEnregistrement = Date()
53 + fichierCourant = fichierCible
54 + transcripteurCourant = transcripteur
55 + ecriture = try EcritureWav(url: fichierCible)
56 +
57 + // Arrêt automatique après dureeMaxSecondes
58 + let arret = DispatchWorkItem { [weak self] in
59 + self?.arreter()
60 + }
61 + timerArret = arret
62 + DispatchQueue.main.asyncAfter(deadline: .now() + dureeMaxSecondes, execute: arret)
63 +
64 + do {
65 + try moteur.demarrer { [weak self] echantillons in
66 + self?.traiterEchantillons(echantillons)
67 + }
68 + } catch {
69 + // Rollback : le moteur n'a pas pu démarrer
70 + enregistrementEnCours = false
71 + timerArret?.cancel()
72 + timerArret = nil
73 + ecriture = nil
74 + throw error
75 + }
76 + }
77 +
78 + /// Stoppe l'enregistrement.
79 + /// - Returns: Résultat avec chemin et durée, `nil` si pas d'enregistrement actif.
80 + @discardableResult
81 + func arreter() -> ResultatEnregistrement? {
82 + guard enregistrementEnCours else { return nil }
83 + enregistrementEnCours = false
84 + timerArret?.cancel()
85 + timerArret = nil
86 + moteur.arreter()
87 + let dureeMsec = Int64((Date().timeIntervalSince(debutEnregistrement ?? Date())) * 1000)
88 + try? ecriture?.fermer()
89 + ecriture = nil
90 + guard let chemin = fichierCourant?.path else { return nil }
91 + return ResultatEnregistrement(chemin: chemin, dureeMsec: dureeMsec)
92 + }
93 +
94 + // MARK: - Traitement interne
95 +
96 + private func traiterEchantillons(_ echantillons: [Int16]) {
97 + ecriture?.ecrireEchantillons(echantillons)
98 + _ = transcripteurCourant?.accepterEchantillons(echantillons)
99 + }
100 +}
A ios/Card2vcf/Audio/EcritureWav.swift
+100 -0
@@ -0,0 +1,100 @@
1 +import Foundation
2 +
3 +/// Écrit un fichier WAV PCM (16 kHz mono 16 bits par défaut).
4 +///
5 +/// Séparée d'AudioNoteRecorder pour être testable sans dépendance AVFoundation.
6 +/// L'en-tête RIFF/fmt/data est réécrit à la clôture avec les tailles exactes.
7 +///
8 +/// Port direct de `EcritureWav.kt` (Android).
9 +final class EcritureWav {
10 + private let handle: FileHandle
11 + private let frequence: Int
12 + private var octetsData: Int64 = 0
13 +
14 + init(url: URL, frequence: Int = 16_000) throws {
15 + self.frequence = frequence
16 + // Crée le fichier s'il n'existe pas encore
17 + if !FileManager.default.fileExists(atPath: url.path) {
18 + FileManager.default.createFile(atPath: url.path, contents: nil)
19 + }
20 + handle = try FileHandle(forWritingTo: url)
21 + // Réserve 44 octets pour l'en-tête ; sera réécrit lors de fermer()
22 + handle.write(Data(count: 44))
23 + }
24 +
25 + /// Écrit des échantillons PCM 16 bits signés (little-endian).
26 + func ecrireEchantillons(_ data: [Int16]) {
27 + guard !data.isEmpty else { return }
28 + var bytes = Data(capacity: data.count * 2)
29 + data.forEach { sample in
30 + var le = sample.littleEndian
31 + Swift.withUnsafeBytes(of: &le) { bytes.append(contentsOf: $0) }
32 + }
33 + handle.write(bytes)
34 + octetsData += Int64(data.count * 2)
35 + }
36 +
37 + /// Finalise l'en-tête WAV et ferme le fichier.
38 + /// - Returns: Nombre d'octets de données PCM écrits.
39 + /// - Throws: `EcritureWavErreur.tailleTropGrande` si les données dépassent Int32.max.
40 + @discardableResult
41 + func fermer() throws -> Int64 {
42 + // 10 min à 16 kHz 16 bits mono ≈ 18,3 Mo — largement sous Int32.max.
43 + // La garde protège contre un enregistrement pathologiquement long ou un bug de comptage.
44 + guard octetsData <= Int64(Int32.max) else {
45 + handle.closeFile()
46 + throw EcritureWavErreur.tailleTropGrande(octetsData)
47 + }
48 + let tailleData = Int32(octetsData)
49 + // tailleFichier = octets après "RIFF"+int32 = 4(WAVE)+8(fmt tag+size)+16(fmt)+8(data tag+size)+data
50 + let tailleFichier = tailleData + 36
51 + let canaux: Int16 = 1
52 + let bitsParEchantillon: Int16 = 16
53 + let byteRate = Int32(frequence) * Int32(canaux) * Int32(bitsParEchantillon) / 8
54 + let blockAlign = canaux * (bitsParEchantillon / 8)
55 +
56 + var entete = Data(capacity: 44)
57 + entete.appendAscii("RIFF")
58 + entete.appendInt32LE(tailleFichier)
59 + entete.appendAscii("WAVE")
60 + entete.appendAscii("fmt ")
61 + entete.appendInt32LE(16) // taille du chunk fmt
62 + entete.appendInt16LE(1) // format PCM
63 + entete.appendInt16LE(canaux)
64 + entete.appendInt32LE(Int32(frequence))
65 + entete.appendInt32LE(byteRate)
66 + entete.appendInt16LE(blockAlign)
67 + entete.appendInt16LE(bitsParEchantillon)
68 + entete.appendAscii("data")
69 + entete.appendInt32LE(tailleData)
70 +
71 + handle.seek(toFileOffset: 0)
72 + handle.write(entete)
73 + handle.closeFile()
74 + return Int64(tailleData)
75 + }
76 +}
77 +
78 +// MARK: - Erreurs
79 +
80 +enum EcritureWavErreur: Error, Equatable {
81 + case tailleTropGrande(Int64)
82 +}
83 +
84 +// MARK: - Helpers d'encodage little-endian
85 +
86 +private extension Data {
87 + mutating func appendAscii(_ string: String) {
88 + append(contentsOf: string.utf8)
89 + }
90 +
91 + mutating func appendInt16LE(_ value: Int16) {
92 + var le = value.littleEndian
93 + Swift.withUnsafeBytes(of: &le) { append(contentsOf: $0) }
94 + }
95 +
96 + mutating func appendInt32LE(_ value: Int32) {
97 + var le = value.littleEndian
98 + Swift.withUnsafeBytes(of: &le) { append(contentsOf: $0) }
99 + }
100 +}
A ios/Card2vcf/Audio/MoteurAudioCapture.swift
+94 -0
@@ -0,0 +1,94 @@
1 +import AVFoundation
2 +
3 +/// Abstraction du moteur d'enregistrement audio.
4 +///
5 +/// Isole AVAudioEngine pour rendre AudioNoteRecorder testable sans simulateur audio.
6 +/// L'implémentation réelle est MoteurAudioEngine ; un faux est fourni dans les tests.
7 +protocol MoteurAudioCapture: AnyObject {
8 + /// Démarre la capture.
9 + /// Le callback reçoit les échantillons PCM Int16 à 16 kHz mono déjà convertis.
10 + /// - Throws: si le moteur ne peut pas démarrer (permission refusée, matériel absent).
11 + func demarrer(onEchantillons: @escaping ([Int16]) -> Void) throws
12 +
13 + /// Arrête la capture et libère les ressources.
14 + func arreter()
15 +}
16 +
17 +// MARK: - Implémentation réelle
18 +
19 +/// Implémentation de MoteurAudioCapture basée sur AVAudioEngine.
20 +///
21 +/// Convertit automatiquement le format natif du matériel en PCM 16 kHz mono Int16
22 +/// via AVAudioConverter si nécessaire.
23 +final class MoteurAudioEngine: MoteurAudioCapture {
24 + private let engine = AVAudioEngine()
25 + private let formatCible: AVAudioFormat
26 +
27 + init() {
28 + formatCible = AVAudioFormat(
29 + commonFormat: .pcmFormatInt16,
30 + sampleRate: 16_000,
31 + channels: 1,
32 + interleaved: true
33 + )!
34 + }
35 +
36 + func demarrer(onEchantillons: @escaping ([Int16]) -> Void) throws {
37 + let inputNode = engine.inputNode
38 + let formatEntree = inputNode.outputFormat(forBus: 0)
39 +
40 + guard let convertisseur = AVAudioConverter(from: formatEntree, to: formatCible) else {
41 + throw MoteurAudioErreur.conversionImpossible
42 + }
43 +
44 + let tailleBuffer: AVAudioFrameCount = 1_024
45 + inputNode.installTap(onBus: 0, bufferSize: tailleBuffer, format: formatEntree) { [weak self] buffer, _ in
46 + guard let self else { return }
47 + self.convertirEtEnvoyer(buffer: buffer,
48 + formatEntree: formatEntree,
49 + convertisseur: convertisseur,
50 + onEchantillons: onEchantillons)
51 + }
52 + try engine.start()
53 + }
54 +
55 + func arreter() {
56 + engine.inputNode.removeTap(onBus: 0)
57 + engine.stop()
58 + }
59 +
60 + // MARK: - Conversion PCM
61 +
62 + private func convertirEtEnvoyer(buffer: AVAudioPCMBuffer,
63 + formatEntree: AVAudioFormat,
64 + convertisseur: AVAudioConverter,
65 + onEchantillons: @escaping ([Int16]) -> Void) {
66 + let frameCount = AVAudioFrameCount(
67 + ceil(Double(buffer.frameLength) * formatCible.sampleRate / formatEntree.sampleRate)
68 + )
69 + guard let sortie = AVAudioPCMBuffer(pcmFormat: formatCible, frameCapacity: frameCount) else { return }
70 +
71 + var erreurConversion: NSError?
72 + var bufferFourni = false
73 + let status = convertisseur.convert(to: sortie, error: &erreurConversion) { _, outStatus in
74 + if bufferFourni {
75 + outStatus.pointee = .noDataNow
76 + return nil
77 + }
78 + bufferFourni = true
79 + outStatus.pointee = .haveData
80 + return buffer
81 + }
82 + guard status != .error, let ptr = sortie.int16ChannelData else { return }
83 + let count = Int(sortie.frameLength)
84 + guard count > 0 else { return }
85 + let echantillons = Array(UnsafeBufferPointer(start: ptr[0], count: count))
86 + onEchantillons(echantillons)
87 + }
88 +}
89 +
90 +// MARK: - Erreurs
91 +
92 +enum MoteurAudioErreur: Error {
93 + case conversionImpossible
94 +}
A ios/Card2vcf/Audio/TranscripteurLocal.swift
+35 -0
@@ -0,0 +1,35 @@
1 +import Foundation
2 +
3 +/// État du moteur de transcription locale.
4 +enum EtatTranscripteur {
5 + /// Le moteur est disponible et autorisé.
6 + case disponible
7 + /// Le moteur est absent, non autorisé, ou indisponible sur cet appareil.
8 + case indisponible
9 +}
10 +
11 +/// Interface de transcription locale (ASR sur l'appareil).
12 +///
13 +/// Implémentée par `TranscripteurSpeech` (SFSpeechRecognizer on-device).
14 +/// Un faux (`FakeTranscripteurLocal`) est fourni dans les tests.
15 +///
16 +/// Quand SFSpeechRecognizer est indisponible ou que la permission est refusée,
17 +/// `etat` vaut `.indisponible` et l'UI doit proposer le mode serveur.
18 +///
19 +/// Port direct de `TranscripteurLocal.kt` (Android).
20 +protocol TranscripteurLocal: AnyObject {
21 + /// État courant du moteur de transcription.
22 + var etat: EtatTranscripteur { get }
23 +
24 + /// Alimente le moteur avec un buffer PCM capturé par AudioNoteRecorder.
25 + /// Appelé sur le thread de capture.
26 + /// - Returns: Résultat partiel si disponible, `nil` sinon.
27 + func accepterEchantillons(_ data: [Int16]) -> String?
28 +
29 + /// Retourne le résultat final après la fin de l'enregistrement.
30 + /// - Returns: Le texte transcrit (peut être vide).
31 + func finaliser() -> String
32 +
33 + /// Réinitialise l'état interne pour une nouvelle session d'enregistrement.
34 + func reinitialiser()
35 +}
A ios/Card2vcf/Audio/TranscripteurSpeech.swift
+99 -0
@@ -0,0 +1,99 @@
1 +import AVFoundation
2 +import Speech
3 +
4 +/// Transcription locale via SFSpeechRecognizer (on-device, fr_FR).
5 +///
6 +/// `requiresOnDeviceRecognition = true` garantit que les données audio
7 +/// ne quittent pas l'appareil.
8 +///
9 +/// Gère gracieusement le refus de permission et l'indisponibilité du moteur :
10 +/// `etat` vaut `.indisponible` dans ces cas, et `accepterEchantillons` retourne `nil`.
11 +final class TranscripteurSpeech: TranscripteurLocal {
12 +
13 + private let reconnaissance: SFSpeechRecognizer?
14 + private var requete: SFSpeechAudioBufferRecognitionRequest?
15 + private var tache: SFSpeechRecognitionTask?
16 + private var dernierPartiel: String = ""
17 + private var texteFinal: String = ""
18 +
19 + // Float32 non-entrelacé : format le plus compatible avec SFSpeechRecognizer.
20 + private let formatFloat: AVAudioFormat = AVAudioFormat(
21 + commonFormat: .pcmFormatFloat32,
22 + sampleRate: 16_000,
23 + channels: 1,
24 + interleaved: false
25 + )!
26 +
27 + init() {
28 + reconnaissance = SFSpeechRecognizer(locale: Locale(identifier: "fr_FR"))
29 + reconnaissance?.defaultTaskHint = .dictation
30 + }
31 +
32 + // MARK: - TranscripteurLocal
33 +
34 + var etat: EtatTranscripteur {
35 + guard let r = reconnaissance, r.isAvailable else { return .indisponible }
36 + guard SFSpeechRecognizer.authorizationStatus() == .authorized else { return .indisponible }
37 + return .disponible
38 + }
39 +
40 + func accepterEchantillons(_ data: [Int16]) -> String? {
41 + guard etat == .disponible else { return nil }
42 +
43 + // Démarre la tâche de reconnaissance au premier appel de la session.
44 + if requete == nil {
45 + demarrerTache()
46 + }
47 +
48 + if let buffer = creerBuffer(depuis: data) {
49 + requete?.append(buffer)
50 + }
51 +
52 + return dernierPartiel.isEmpty ? nil : dernierPartiel
53 + }
54 +
55 + func finaliser() -> String {
56 + requete?.endAudio()
57 + return texteFinal.isEmpty ? dernierPartiel : texteFinal
58 + }
59 +
60 + func reinitialiser() {
61 + tache?.cancel()
62 + tache = nil
63 + requete = nil
64 + dernierPartiel = ""
65 + texteFinal = ""
66 + }
67 +
68 + // MARK: - Privé
69 +
70 + private func demarrerTache() {
71 + let req = SFSpeechAudioBufferRecognitionRequest()
72 + req.requiresOnDeviceRecognition = true
73 + req.shouldReportPartialResults = true
74 + requete = req
75 +
76 + tache = reconnaissance?.recognitionTask(with: req) { [weak self] result, _ in
77 + guard let self, let result else { return }
78 + let texte = result.bestTranscription.formattedString
79 + if result.isFinal {
80 + self.texteFinal = texte
81 + self.dernierPartiel = ""
82 + } else {
83 + self.dernierPartiel = texte
84 + }
85 + }
86 + }
87 +
88 + /// Convertit un tableau Int16 en AVAudioPCMBuffer Float32 pour SFSpeechRecognizer.
89 + private func creerBuffer(depuis data: [Int16]) -> AVAudioPCMBuffer? {
90 + let frameCount = AVAudioFrameCount(data.count)
91 + guard let buffer = AVAudioPCMBuffer(pcmFormat: formatFloat, frameCapacity: frameCount),
92 + let channelData = buffer.floatChannelData else { return nil }
93 + buffer.frameLength = frameCount
94 + for (i, sample) in data.enumerated() {
95 + channelData[0][i] = Float(sample) / 32_768.0
96 + }
97 + return buffer
98 + }
99 +}
A ios/Card2vcf/Data/Audio/AudioNoteStore.swift
+46 -0
@@ -0,0 +1,46 @@
1 +import Foundation
2 +
3 +/// Stockage stable des fichiers WAV pour les notes vocales (interactions et notes de projet).
4 +/// Analogue à `AudioNoteStore` Android : les fichiers sont rangés sous `Application Support/audio/`
5 +/// (pas dans un dossier temporaire purgeable), identifiés par le `localId` de l'entité.
6 +struct AudioNoteStore {
7 + let rootDirectory: URL
8 +
9 + /// Initialise le store sur un dossier existant ou à créer.
10 + init(rootDirectory: URL) {
11 + self.rootDirectory = rootDirectory
12 + }
13 +
14 + /// Store par défaut sous `Application Support/audio/`.
15 + static func defaultStore() throws -> AudioNoteStore {
16 + let appSupport = try FileManager.default.url(
17 + for: .applicationSupportDirectory,
18 + in: .userDomainMask,
19 + appropriateFor: nil,
20 + create: true
21 + )
22 + let dir = appSupport.appendingPathComponent("audio", isDirectory: true)
23 + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
24 + return AudioNoteStore(rootDirectory: dir)
25 + }
26 +
27 + // MARK: - Chemins stables
28 +
29 + func pathForInteraction(localId: Int64) -> String {
30 + rootDirectory.appendingPathComponent("interaction_\(localId).wav").path
31 + }
32 +
33 + func pathForNote(localId: Int64) -> String {
34 + rootDirectory.appendingPathComponent("note_\(localId).wav").path
35 + }
36 +
37 + // MARK: - Lecture / suppression
38 +
39 + func readBytes(atPath path: String) -> Data? {
40 + try? Data(contentsOf: URL(fileURLWithPath: path))
41 + }
42 +
43 + func deleteFile(atPath path: String) {
44 + try? FileManager.default.removeItem(atPath: path)
45 + }
46 +}
M ios/Card2vcf/Data/InteractionDao.swift
+6 -0
@@ -9,6 +9,9 @@ protocol InteractionDao {
9 9 /// SELECT * FROM interactions WHERE serverId = :serverId
10 10 func getByServerId(_ serverId: String) async throws -> InteractionEntity?
11 11
12 + /// SELECT * FROM interactions WHERE localId = :localId
13 + func getByLocalId(_ localId: Int64) async throws -> InteractionEntity?
14 +
12 15 /// DELETE FROM interactions WHERE serverId = :serverId
13 16 func deleteByServerId(_ serverId: String) async throws
14 17
@@ -19,6 +22,9 @@ protocol InteractionDao {
19 22 /// SELECT * FROM interactions WHERE contactServerId = :contactServerId ORDER BY createdAt DESC
20 23 func fetchByContactServerId(_ contactServerId: String) async throws -> [InteractionEntity]
21 24
25 + /// DELETE FROM interactions WHERE localId = :localId
26 + func deleteByLocalId(_ localId: Int64) async throws
27 +
22 28 /// SELECT * FROM interactions
23 29 func listAll() async throws -> [InteractionEntity]
24 30 }
M ios/Card2vcf/Data/InteractionEntity.swift
+5 -0
@@ -11,4 +11,9 @@ struct InteractionEntity: Equatable {
11 11 var creePar: String = ""
12 12 var createdAt: Int64 = 0
13 13 var updatedAt: Int64? = nil
14 + /// Chemin local du fichier WAV (géré par l'app, jamais écrasé par le pull).
15 + var audioPath: String? = nil
16 + /// Statut de transcription : `en_attente`, `terminee`, `echec`, ou nil.
17 + var transcriptionStatut: String? = nil
18 + var transcriptionErreur: String? = nil
14 19 }
A ios/Card2vcf/Data/NoteProjetDao.swift
+23 -0
@@ -0,0 +1,23 @@
1 +import Foundation
2 +
3 +/// Port du Room `NoteProjetDao` (Android).
4 +protocol NoteProjetDao {
5 + /// INSERT OR REPLACE INTO notes_projet ; retourne le rowId.
6 + @discardableResult
7 + func upsert(_ entity: NoteProjetEntity) async throws -> Int64
8 +
9 + /// SELECT * FROM notes_projet WHERE serverId = :serverId
10 + func getByServerId(_ serverId: String) async throws -> NoteProjetEntity?
11 +
12 + /// DELETE FROM notes_projet WHERE serverId = :serverId
13 + func deleteByServerId(_ serverId: String) async throws
14 +
15 + /// DELETE FROM notes_projet WHERE localId = :localId
16 + func deleteByLocalId(_ localId: Int64) async throws
17 +
18 + /// SELECT * FROM notes_projet WHERE projetServerId = :projetServerId
19 + func listByProjetServerId(_ projetServerId: String) async throws -> [NoteProjetEntity]
20 +
21 + /// SELECT * FROM notes_projet
22 + func listAll() async throws -> [NoteProjetEntity]
23 +}
A ios/Card2vcf/Data/NoteProjetEntity.swift
+20 -0
@@ -0,0 +1,20 @@
1 +import Foundation
2 +
3 +/// Room entity `notes_projet` (mirror de l'entit\u{e9} Android `NoteProjetEntity`).
4 +struct NoteProjetEntity: Equatable {
5 + var localId: Int64 = 0
6 + var serverId: String? = nil
7 + var projetServerId: String = ""
8 + var titre: String = ""
9 + /// Corps markdown de la note (JSON : `contenu`).
10 + var texte: String = ""
11 + /// Chemin local du fichier audio, conserv\u{e9} lors des mises \u{e0} jour serveur.
12 + var audioPath: String? = nil
13 + /// Statut de transcription : `en_attente`, `terminee`, `echec`, ou nil.
14 + var transcriptionStatut: String? = nil
15 + var transcriptionErreur: String? = nil
16 + var auteur: String = ""
17 + var createdAt: Int64 = 0
18 + /// nil sur les notes jamais mises \u{e0} jour.
19 + var updatedAt: Int64? = nil
20 +}
M ios/Card2vcf/Data/Sqlite/Card2vcfDatabase.swift
+2 -0
@@ -18,6 +18,7 @@ final class Card2vcfDatabase {
18 18 let rdvDao: any RdvDao
19 19 let reservationDao: any ReservationDao
20 20 let indisponibiliteDao: any IndisponibiliteDao
21 + let noteProjetDao: any NoteProjetDao
21 22
22 23 init(inMemory: Bool = false) {
23 24 let db = SqliteDatabase(inMemory: inMemory)
@@ -33,5 +34,6 @@ final class Card2vcfDatabase {
33 34 rdvDao = SqliteRdvDao(db: db)
34 35 reservationDao = SqliteReservationDao(db: db)
35 36 indisponibiliteDao = SqliteIndisponibiliteDao(db: db)
37 + noteProjetDao = SqliteNoteProjetDao(db: db)
36 38 }
37 39 }
M ios/Card2vcf/Data/Sqlite/SqliteDatabase.swift
+63 -2
@@ -19,14 +19,22 @@ struct DatabaseError: Error, CustomStringConvertible {
19 19 /// Owner of the sqlite3 connection. Mirrors the Room schema of the Android
20 20 /// `CrmDatabase` (version 3) with destructive migration semantics.
21 21 actor SqliteDatabase {
22 - static let schemaVersion: Int32 = 4
22 + static let schemaVersion: Int32 = 6
23 23 static let fileName = "card2vcf.sqlite"
24 24
25 25 private let inMemory: Bool
26 + private let customPath: String?
26 27 private var handle: OpaquePointer?
27 28
28 29 init(inMemory: Bool = false) {
29 30 self.inMemory = inMemory
31 + self.customPath = nil
32 + }
33 +
34 + /// Initializer for tests that need a specific file path (e.g. migration tests).
35 + init(filePath: String) {
36 + self.inMemory = false
37 + self.customPath = filePath
30 38 }
31 39
32 40 deinit {
@@ -95,6 +103,8 @@ actor SqliteDatabase {
95 103 let path: String
96 104 if inMemory {
97 105 path = ":memory:"
106 + } else if let customPath {
107 + path = customPath
98 108 } else {
99 109 path = try Self.defaultPath()
100 110 }
@@ -137,7 +147,7 @@ actor SqliteDatabase {
137 147 }
138 148 }
139 149
140 - /// Mirrors Android: additive migrations from v3 on (Room `MIGRATION_3_4`),
150 + /// Mirrors Android: additive migrations from v3 on (Room `MIGRATION_3_4`, `MIGRATION_4_5`),
141 151 /// destructive fallback (`fallbackToDestructiveMigration()`) for anything older.
142 152 private static func migrateIfNeeded(_ db: OpaquePointer) throws {
143 153 let stmt = try SqliteStatement(db: db, sql: "PRAGMA user_version")
@@ -149,6 +159,23 @@ actor SqliteDatabase {
149 159 // v3→v4: `lastError` column on `sync_ops` (per-op push failure state).
150 160 if version == 3 {
151 161 try exec(db, "ALTER TABLE sync_ops ADD COLUMN lastError TEXT;")
162 + version = 4
163 + }
164 + // v4→v5: `notes_projet` table.
165 + if version == 4 {
166 + try exec(db, notesProjetCreateSql)
167 + version = 5
168 + }
169 + // v5→v6: audio + transcription columns on `interactions`.
170 + // Guard on table existence: some legacy test databases (v4→v5 path) may lack `interactions`.
171 + if version == 5 {
172 + let checkStmt = try SqliteStatement(db: db,
173 + sql: "SELECT name FROM sqlite_master WHERE type='table' AND name='interactions'")
174 + if try checkStmt.step() {
175 + try exec(db, "ALTER TABLE interactions ADD COLUMN audioPath TEXT;")
176 + try exec(db, "ALTER TABLE interactions ADD COLUMN transcriptionStatut TEXT;")
177 + try exec(db, "ALTER TABLE interactions ADD COLUMN transcriptionErreur TEXT;")
178 + }
152 179 try exec(db, "PRAGMA user_version = \(schemaVersion);")
153 180 return
154 181 }
@@ -157,6 +184,22 @@ actor SqliteDatabase {
157 184 try exec(db, "PRAGMA user_version = \(schemaVersion);")
158 185 }
159 186
187 + private static let notesProjetCreateSql = """
188 + CREATE TABLE notes_projet (
189 + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
190 + serverId TEXT,
191 + projetServerId TEXT NOT NULL,
192 + titre TEXT NOT NULL,
193 + texte TEXT NOT NULL,
194 + audioPath TEXT,
195 + transcriptionStatut TEXT,
196 + transcriptionErreur TEXT,
197 + auteur TEXT NOT NULL,
198 + createdAt INTEGER NOT NULL,
199 + updatedAt INTEGER
200 + );
201 + """
202 +
160 203 // MARK: - Schema (Room v4 mirror)
161 204
162 205 private static let dropAllSql = """
@@ -170,6 +213,7 @@ actor SqliteDatabase {
170 213 DROP TABLE IF EXISTS projets;
171 214 DROP TABLE IF EXISTS taches;
172 215 DROP TABLE IF EXISTS interactions;
216 + DROP TABLE IF EXISTS notes_projet;
173 217 DROP TABLE IF EXISTS workflows;
174 218 DROP TABLE IF EXISTS sync_ops;
175 219 DROP TABLE IF EXISTS sync_meta;
@@ -297,6 +341,23 @@ actor SqliteDatabase {
297 341 description TEXT NOT NULL,
298 342 creePar TEXT NOT NULL,
299 343 createdAt INTEGER NOT NULL,
344 + updatedAt INTEGER,
345 + audioPath TEXT,
346 + transcriptionStatut TEXT,
347 + transcriptionErreur TEXT
348 + );
349 +
350 + CREATE TABLE notes_projet (
351 + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
352 + serverId TEXT,
353 + projetServerId TEXT NOT NULL,
354 + titre TEXT NOT NULL,
355 + texte TEXT NOT NULL,
356 + audioPath TEXT,
357 + transcriptionStatut TEXT,
358 + transcriptionErreur TEXT,
359 + auteur TEXT NOT NULL,
360 + createdAt INTEGER NOT NULL,
300 361 updatedAt INTEGER
301 362 );
302 363
M ios/Card2vcf/Data/Sqlite/SqliteInteractionDao.swift
+24 -2
@@ -4,7 +4,8 @@ struct SqliteInteractionDao: InteractionDao {
4 4 let db: SqliteDatabase
5 5
6 6 private static let columns = """
7 - localId, serverId, contactServerId, type, sujet, description, creePar, createdAt, updatedAt
7 + localId, serverId, contactServerId, type, sujet, description, creePar, createdAt, updatedAt,
8 + audioPath, transcriptionStatut, transcriptionErreur
8 9 """
9 10
10 11 private static func map(_ row: SqliteRow) -> InteractionEntity {
@@ -18,13 +19,19 @@ struct SqliteInteractionDao: InteractionDao {
18 19 e.creePar = row.text(6)
19 20 e.createdAt = row.int64(7)
20 21 e.updatedAt = row.int64OrNil(8)
22 + e.audioPath = row.textOrNil(9)
23 + e.transcriptionStatut = row.textOrNil(10)
24 + e.transcriptionErreur = row.textOrNil(11)
21 25 return e
22 26 }
23 27
24 28 @discardableResult
25 29 func upsert(_ entity: InteractionEntity) async throws -> Int64 {
26 30 try await db.insert(
27 - "INSERT OR REPLACE INTO interactions (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
31 + """
32 + INSERT OR REPLACE INTO interactions (\(Self.columns))
33 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
34 + """,
28 35 [
29 36 .rowId(entity.localId),
30 37 .optText(entity.serverId),
@@ -35,6 +42,9 @@ struct SqliteInteractionDao: InteractionDao {
35 42 .text(entity.creePar),
36 43 .int(entity.createdAt),
37 44 .optInt(entity.updatedAt),
45 + .optText(entity.audioPath),
46 + .optText(entity.transcriptionStatut),
47 + .optText(entity.transcriptionErreur),
38 48 ]
39 49 )
40 50 }
@@ -47,10 +57,22 @@ struct SqliteInteractionDao: InteractionDao {
47 57 ).first
48 58 }
49 59
60 + func getByLocalId(_ localId: Int64) async throws -> InteractionEntity? {
61 + try await db.query(
62 + "SELECT \(Self.columns) FROM interactions WHERE localId = ?",
63 + [.int(localId)],
64 + map: Self.map
65 + ).first
66 + }
67 +
50 68 func deleteByServerId(_ serverId: String) async throws {
51 69 try await db.write("DELETE FROM interactions WHERE serverId = ?", [.text(serverId)])
52 70 }
53 71
72 + func deleteByLocalId(_ localId: Int64) async throws {
73 + try await db.write("DELETE FROM interactions WHERE localId = ?", [.int(localId)])
74 + }
75 +
54 76 func listByContactServerId(_ contactServerId: String) async throws -> [InteractionEntity] {
55 77 try await db.query(
56 78 "SELECT \(Self.columns) FROM interactions WHERE contactServerId = ?",
A ios/Card2vcf/Data/Sqlite/SqliteNoteProjetDao.swift
+74 -0
@@ -0,0 +1,74 @@
1 +import Foundation
2 +
3 +struct SqliteNoteProjetDao: NoteProjetDao {
4 + let db: SqliteDatabase
5 +
6 + private static let columns = """
7 + localId, serverId, projetServerId, titre, texte, audioPath, \
8 + transcriptionStatut, transcriptionErreur, auteur, createdAt, updatedAt
9 + """
10 +
11 + private static func map(_ row: SqliteRow) -> NoteProjetEntity {
12 + var e = NoteProjetEntity()
13 + e.localId = row.int64(0)
14 + e.serverId = row.textOrNil(1)
15 + e.projetServerId = row.text(2)
16 + e.titre = row.text(3)
17 + e.texte = row.text(4)
18 + e.audioPath = row.textOrNil(5)
19 + e.transcriptionStatut = row.textOrNil(6)
20 + e.transcriptionErreur = row.textOrNil(7)
21 + e.auteur = row.text(8)
22 + e.createdAt = row.int64(9)
23 + e.updatedAt = row.int64OrNil(10)
24 + return e
25 + }
26 +
27 + @discardableResult
28 + func upsert(_ entity: NoteProjetEntity) async throws -> Int64 {
29 + try await db.insert(
30 + "INSERT OR REPLACE INTO notes_projet (\(Self.columns)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
31 + [
32 + .rowId(entity.localId),
33 + .optText(entity.serverId),
34 + .text(entity.projetServerId),
35 + .text(entity.titre),
36 + .text(entity.texte),
37 + .optText(entity.audioPath),
38 + .optText(entity.transcriptionStatut),
39 + .optText(entity.transcriptionErreur),
40 + .text(entity.auteur),
41 + .int(entity.createdAt),
42 + .optInt(entity.updatedAt),
43 + ]
44 + )
45 + }
46 +
47 + func getByServerId(_ serverId: String) async throws -> NoteProjetEntity? {
48 + try await db.query(
49 + "SELECT \(Self.columns) FROM notes_projet WHERE serverId = ?",
50 + [.text(serverId)],
51 + map: Self.map
52 + ).first
53 + }
54 +
55 + func deleteByServerId(_ serverId: String) async throws {
56 + try await db.write("DELETE FROM notes_projet WHERE serverId = ?", [.text(serverId)])
57 + }
58 +
59 + func deleteByLocalId(_ localId: Int64) async throws {
60 + try await db.write("DELETE FROM notes_projet WHERE localId = ?", [.int(localId)])
61 + }
62 +
63 + func listByProjetServerId(_ projetServerId: String) async throws -> [NoteProjetEntity] {
64 + try await db.query(
65 + "SELECT \(Self.columns) FROM notes_projet WHERE projetServerId = ? ORDER BY createdAt DESC",
66 + [.text(projetServerId)],
67 + map: Self.map
68 + )
69 + }
70 +
71 + func listAll() async throws -> [NoteProjetEntity] {
72 + try await db.query("SELECT \(Self.columns) FROM notes_projet", map: Self.map)
73 + }
74 +}
M ios/Card2vcf/Info.plist
+4 -0
@@ -38,6 +38,10 @@
38 38 <string>Sert uniquement au pont Agenda optionnel (RDV et réservations synchronisés avec PicLead).</string>
39 39 <key>NSPhotoLibraryAddUsageDescription</key>
40 40 <string>Permet d'enregistrer l'image de la carte scannée si vous le demandez.</string>
41 + <key>NSMicrophoneUsageDescription</key>
42 + <string>Le microphone sert à enregistrer des notes vocales sur vos contacts et projets. L'audio reste sur l'appareil et n'est transmis au serveur que si vous activez la transcription serveur.</string>
43 + <key>NSSpeechRecognitionUsageDescription</key>
44 + <string>La reconnaissance vocale on-device transcrit vos notes audio en texte. Aucune donnée n'est envoyée à Apple ni à un tiers : la transcription s'effectue entièrement sur l'appareil.</string>
41 45 <key>NSAppTransportSecurity</key>
42 46 <dict>
43 47 <!-- HTTP clair refusé par défaut dans l'app (ServerUrlPolicy) ; l'exception ATS
M ios/Card2vcf/Sync/AilianceApi.swift
+40 -0
@@ -80,6 +80,46 @@ protocol AilianceApi {
80 80 func downloadContactCarte(id: String) async -> AilianceApiClient.ApiResult<Data>
81 81
82 82 func downloadContactPhoto(id: String) async -> AilianceApiClient.ApiResult<Data>
83 +
84 + /// POST /api/contacts/:contactId/interactions/:interactionId/audio (multipart, champ `file`).
85 + /// Retourne le corps brut (interaction mise à jour au format JSON).
86 + func uploadInteractionAudio(
87 + contactId: String,
88 + interactionId: String,
89 + bytes: Data,
90 + filename: String
91 + ) async -> AilianceApiClient.ApiResult<String>
92 +
93 + /// GET /api/contacts/:contactId/interactions/:interactionId/audio
94 + func downloadInteractionAudio(contactId: String, interactionId: String) async -> AilianceApiClient.ApiResult<Data>
95 +
96 + /// POST /api/projets/:projetId/notes (JSON body `CreateNoteProjetRequest`).
97 + func createNoteProjet(projetId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String>
98 +
99 + /// POST /api/projets/:projetId/notes/:noteId/audio (multipart, champ `file`).
100 + func uploadNoteProjetAudio(
101 + projetId: String,
102 + noteId: String,
103 + bytes: Data,
104 + filename: String
105 + ) async -> AilianceApiClient.ApiResult<String>
106 +
107 + /// GET /api/projets/:projetId/notes/:noteId/audio
108 + func downloadNoteProjetAudio(projetId: String, noteId: String) async -> AilianceApiClient.ApiResult<Data>
109 +
110 + /// DELETE /api/interactions/:interactionId
111 + func deleteInteraction(interactionId: String) async -> AilianceApiClient.ApiResult<Void>
112 +
113 + /// DELETE /api/projets/:projetId/notes/:noteId
114 + func deleteNoteProjet(projetId: String, noteId: String) async -> AilianceApiClient.ApiResult<Void>
115 +
116 + /// POST /api/contacts/:contactId/interactions/:interactionId/transcription
117 + /// Relance la transcription côté serveur ; retourne le corps JSON de l'entité mise à jour.
118 + func relancerTranscriptionInteraction(contactId: String, interactionId: String) async -> AilianceApiClient.ApiResult<String>
119 +
120 + /// POST /api/projets/:projetId/notes/:noteId/transcription
121 + /// Relance la transcription côté serveur ; retourne le corps JSON de l'entité mise à jour.
122 + func relancerTranscriptionNoteProjet(projetId: String, noteId: String) async -> AilianceApiClient.ApiResult<String>
83 123 }
84 124
85 125 /// Kotlin default parameter values, mirrored as overloads.
M ios/Card2vcf/Sync/AilianceApiClient.swift
+52 -0
@@ -215,6 +215,58 @@ final class AilianceApiClient: AilianceApi {
215 215 await executeBytes(buildRequest("GET", "/api/contacts/\(Self.encodePath(id))/photo", nil, useBearer: true))
216 216 }
217 217
218 + func uploadInteractionAudio(
219 + contactId: String,
220 + interactionId: String,
221 + bytes: Data,
222 + filename: String
223 + ) async -> ApiResult<String> {
224 + let path = "/api/contacts/\(Self.encodePath(contactId))/interactions/\(Self.encodePath(interactionId))/audio"
225 + return await uploadMultipart(path, bytes, filename, "audio/wav")
226 + }
227 +
228 + func downloadInteractionAudio(contactId: String, interactionId: String) async -> ApiResult<Data> {
229 + let path = "/api/contacts/\(Self.encodePath(contactId))/interactions/\(Self.encodePath(interactionId))/audio"
230 + return await executeBytes(buildRequest("GET", path, nil, useBearer: true))
231 + }
232 +
233 + func createNoteProjet(projetId: String, jsonBody: String) async -> ApiResult<String> {
234 + await post("/api/projets/\(Self.encodePath(projetId))/notes", jsonBody, parse: Self.text)
235 + }
236 +
237 + func uploadNoteProjetAudio(
238 + projetId: String,
239 + noteId: String,
240 + bytes: Data,
241 + filename: String
242 + ) async -> ApiResult<String> {
243 + let path = "/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))/audio"
244 + return await uploadMultipart(path, bytes, filename, "audio/wav")
245 + }
246 +
247 + func downloadNoteProjetAudio(projetId: String, noteId: String) async -> ApiResult<Data> {
248 + let path = "/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))/audio"
249 + return await executeBytes(buildRequest("GET", path, nil, useBearer: true))
250 + }
251 +
252 + func deleteInteraction(interactionId: String) async -> ApiResult<Void> {
253 + await delete("/api/interactions/\(Self.encodePath(interactionId))")
254 + }
255 +
256 + func deleteNoteProjet(projetId: String, noteId: String) async -> ApiResult<Void> {
257 + await delete("/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))")
258 + }
259 +
260 + func relancerTranscriptionInteraction(contactId: String, interactionId: String) async -> ApiResult<String> {
261 + let path = "/api/contacts/\(Self.encodePath(contactId))/interactions/\(Self.encodePath(interactionId))/transcription"
262 + return await post(path, "{}", parse: Self.text)
263 + }
264 +
265 + func relancerTranscriptionNoteProjet(projetId: String, noteId: String) async -> ApiResult<String> {
266 + let path = "/api/projets/\(Self.encodePath(projetId))/notes/\(Self.encodePath(noteId))/transcription"
267 + return await post(path, "{}", parse: Self.text)
268 + }
269 +
218 270 // MARK: - Internals
219 271
220 272 private func uploadMultipart(
M ios/Card2vcf/Sync/CrmDatabase.swift
+1 -0
@@ -15,6 +15,7 @@ protocol CrmDatabase {
15 15 var indisponibiliteDao: IndisponibiliteDao { get }
16 16 var syncMetaDao: SyncMetaDao { get }
17 17 var syncOpDao: SyncOpDao { get }
18 + var noteProjetDao: NoteProjetDao { get }
18 19 }
19 20
20 21 /// The SQLite facade exposes exactly these DAO properties.
M ios/Card2vcf/Sync/SyncEngine.swift
+268 -19
@@ -107,6 +107,63 @@ final class SyncEngine {
107 107 }
108 108 }
109 109
110 + /// Vide la file de push sans effectuer de pull (mode « transcription serveur » :
111 + /// l'audio part dès la fin de l'enregistrement). Équivalent de `pousserEnAttente` Android.
112 + func pousserEnAttente() async throws -> SyncResult {
113 + let push = try await pushOps()
114 + return SyncResult(
115 + success: true,
116 + pushed: push.succeeded,
117 + pushFailures: push.failures
118 + )
119 + }
120 +
121 + /// Relance la transcription d'une interaction sur le serveur (appel direct, hors file).
122 + /// Retourne `nil` en cas de succès, le message d'erreur sinon.
123 + func relancerTranscriptionInteraction(localId: Int64) async throws -> String? {
124 + guard let interaction = try await db.interactionDao.getByLocalId(localId),
125 + let interactionId = interaction.serverId else {
126 + return "Impossible de retrouver l'interaction."
127 + }
128 + let result = await api.relancerTranscriptionInteraction(
129 + contactId: interaction.contactServerId,
130 + interactionId: interactionId
131 + )
132 + switch result {
133 + case .ok:
134 + var updated = interaction
135 + updated.transcriptionStatut = "en_attente"
136 + updated.transcriptionErreur = nil
137 + try await db.interactionDao.upsert(updated)
138 + return nil
139 + case .err(_, let message):
140 + return "Relance impossible : \(message)"
141 + }
142 + }
143 +
144 + /// Relance la transcription d'une note de projet sur le serveur (appel direct, hors file).
145 + /// Retourne `nil` en cas de succès, le message d'erreur sinon.
146 + func relancerTranscriptionNoteProjet(localId: Int64) async throws -> String? {
147 + guard let note = try await db.noteProjetDao.listAll().first(where: { $0.localId == localId }),
148 + let noteId = note.serverId else {
149 + return "Impossible de retrouver la note."
150 + }
151 + let result = await api.relancerTranscriptionNoteProjet(
152 + projetId: note.projetServerId,
153 + noteId: noteId
154 + )
155 + switch result {
156 + case .ok:
157 + var updated = note
158 + updated.transcriptionStatut = "en_attente"
159 + updated.transcriptionErreur = nil
160 + try await db.noteProjetDao.upsert(updated)
161 + return nil
162 + case .err(_, let message):
163 + return "Relance impossible : \(message)"
164 + }
165 + }
166 +
110 167 /// Count of local items not yet pushed (« calendrier local en avance » banner).
111 168 /// With `bindings`/`bridge`: adds card2vcf-tagged calendar events without a local entity
112 169 /// (orphans not yet cleaned up on the calendar side, see `AgendaSyncCoordinator.orphanEventCount`).
@@ -213,6 +270,12 @@ final class SyncEngine {
213 270 outcome = try await pushTacheOp(op)
214 271 case "interaction":
215 272 outcome = try await pushInteractionOp(op)
273 + case "interaction_audio":
274 + outcome = try await pushInteractionAudioOp(op)
275 + case "note_projet":
276 + outcome = try await pushNoteProjetOp(op)
277 + case "note_projet_audio":
278 + outcome = try await pushNoteProjetAudioOp(op)
216 279 case AgendaSyncCoordinator.kindRdv:
217 280 outcome = try await pushRdvOp(op)
218 281 case AgendaSyncCoordinator.entityReservation:
@@ -403,11 +466,144 @@ final class SyncEngine {
403 466 }
404 467 }
405 468
406 - /// `op.serverId` carries the target contact's `serverId` (interactions have no update/delete).
469 + /// `op.serverId` = contactServerId pour create, interactionServerId pour delete.
470 + /// `op.localId` = interaction locale (create uniquement).
407 471 private func pushInteractionOp(_ op: SyncOpEntity) async throws -> PushOutcome {
408 - if op.op != "create" { return PushOutcome(ok: true) }
409 - guard let contactServerId = op.serverId else { return PushOutcome(ok: false) }
410 - return Self.outcome(await api.createInteraction(contactId: contactServerId, jsonBody: op.payloadJson))
472 + switch op.op {
473 + case "create":
474 + guard let contactServerId = op.serverId else { return PushOutcome(ok: false) }
475 + let result = await api.createInteraction(contactId: contactServerId, jsonBody: op.payloadJson)
476 + if case .ok(let body) = result, let localId = op.localId {
477 + if let interactionServerId = Self.parseCreatedId(body) {
478 + if var entity = try await db.interactionDao.getByLocalId(localId) {
479 + entity.serverId = interactionServerId
480 + try await db.interactionDao.upsert(entity)
481 + // Tente l'upload audio immédiatement ; enfile un retry en cas d'échec.
482 + if let audioPath = entity.audioPath, let bytes = Self.readFile(audioPath) {
483 + let uploadResult = await api.uploadInteractionAudio(
484 + contactId: contactServerId,
485 + interactionId: interactionServerId,
486 + bytes: bytes,
487 + filename: "note.wav"
488 + )
489 + if !uploadResult.isOk {
490 + try await enqueueInteractionAudioRetry(
491 + interactionServerId: interactionServerId
492 + )
493 + }
494 + }
495 + }
496 + }
497 + }
498 + return Self.outcome(result)
499 + case "delete":
500 + guard let interactionId = op.serverId else { return PushOutcome(ok: false) }
501 + return Self.outcome(await api.deleteInteraction(interactionId: interactionId))
502 + default:
503 + return PushOutcome(ok: true)
504 + }
505 + }
506 +
507 + /// Upload de l'audio d'une interaction (op de retry `interaction_audio`).
508 + private func pushInteractionAudioOp(_ op: SyncOpEntity) async throws -> PushOutcome {
509 + guard let interactionServerId = op.serverId,
510 + let interaction = try await db.interactionDao.getByServerId(interactionServerId),
511 + let audioPath = interaction.audioPath,
512 + let bytes = Self.readFile(audioPath) else { return PushOutcome(ok: true) }
513 + let result = await api.uploadInteractionAudio(
514 + contactId: interaction.contactServerId,
515 + interactionId: interactionServerId,
516 + bytes: bytes,
517 + filename: "note.wav"
518 + )
519 + return Self.outcome(result)
520 + }
521 +
522 + private func enqueueInteractionAudioRetry(interactionServerId: String) async throws {
523 + let already = try await db.syncOpDao.listAll().contains {
524 + $0.entityType == "interaction_audio" && $0.serverId == interactionServerId
525 + }
526 + if already { return }
527 + var entity = SyncOpEntity(entityType: "interaction_audio", op: "upload")
528 + entity.payloadJson = "{}"
529 + entity.serverId = interactionServerId
530 + entity.createdAt = AgendaSyncCoordinator.currentMillis()
531 + try await db.syncOpDao.insert(entity)
532 + }
533 +
534 + /// Création ou suppression d'une note de projet.
535 + /// Create : `op.serverId` = projetServerId (parent), `op.localId` = note locale.
536 + /// Delete : `op.serverId` = noteServerId, `op.payloadJson` = `{"projetServerId":"…"}`.
537 + private func pushNoteProjetOp(_ op: SyncOpEntity) async throws -> PushOutcome {
538 + switch op.op {
539 + case "create":
540 + guard let projetServerId = op.serverId else { return PushOutcome(ok: false) }
541 + let result = await api.createNoteProjet(projetId: projetServerId, jsonBody: op.payloadJson)
542 + if case .ok(let body) = result, let localId = op.localId {
543 + if let noteServerId = Self.parseCreatedId(body) {
544 + // NoteProjetDao n'a pas getByLocalId ; on passe par listAll.
545 + if let note = try await db.noteProjetDao.listAll().first(where: { $0.localId == localId }) {
546 + var updated = note
547 + updated.serverId = noteServerId
548 + try await db.noteProjetDao.upsert(updated)
549 + // Upload audio si présent.
550 + if let audioPath = updated.audioPath, let bytes = Self.readFile(audioPath) {
551 + let uploadResult = await api.uploadNoteProjetAudio(
552 + projetId: projetServerId,
553 + noteId: noteServerId,
554 + bytes: bytes,
555 + filename: "note.wav"
556 + )
557 + if !uploadResult.isOk {
558 + try await enqueueNoteProjetAudioRetry(
559 + projetServerId: projetServerId,
560 + noteServerId: noteServerId
561 + )
562 + }
563 + }
564 + }
565 + }
566 + }
567 + return Self.outcome(result)
568 + case "delete":
569 + guard let noteId = op.serverId else { return PushOutcome(ok: false) }
570 + guard let ref = try? SyncJson.decode(NoteDeleteRef.self, from: op.payloadJson),
571 + let projetServerId = ref.projetServerId else { return PushOutcome(ok: false) }
572 + return Self.outcome(await api.deleteNoteProjet(projetId: projetServerId, noteId: noteId))
573 + default:
574 + return PushOutcome(ok: true)
575 + }
576 + }
577 +
578 + private struct NoteDeleteRef: Codable {
579 + var projetServerId: String?
580 + }
581 +
582 + /// Upload de l'audio d'une note de projet (op de retry `note_projet_audio`).
583 + private func pushNoteProjetAudioOp(_ op: SyncOpEntity) async throws -> PushOutcome {
584 + guard let noteServerId = op.serverId,
585 + let note = try await db.noteProjetDao.getByServerId(noteServerId),
586 + let audioPath = note.audioPath,
587 + let bytes = Self.readFile(audioPath) else { return PushOutcome(ok: true) }
588 + let result = await api.uploadNoteProjetAudio(
589 + projetId: note.projetServerId,
590 + noteId: noteServerId,
591 + bytes: bytes,
592 + filename: "note.wav"
593 + )
594 + return Self.outcome(result)
595 + }
596 +
597 + private func enqueueNoteProjetAudioRetry(projetServerId: String, noteServerId: String) async throws {
598 + let already = try await db.syncOpDao.listAll().contains {
599 + $0.entityType == "note_projet_audio" && $0.serverId == noteServerId
600 + }
601 + if already { return }
602 + var entity = SyncOpEntity(entityType: "note_projet_audio", op: "upload")
603 + entity.payloadJson = "{}"
604 + entity.serverId = noteServerId
605 + entity.createdAt = AgendaSyncCoordinator.currentMillis()
606 + try await db.syncOpDao.insert(entity)
411 607 }
412 608
413 609 private func pushRdvOp(_ op: SyncOpEntity) async throws -> PushOutcome {
@@ -516,13 +712,15 @@ final class SyncEngine {
516 712 for dto in pull.projets { try await applyProjet(dto) }
517 713 for dto in pull.taches { try await applyTache(dto) }
518 714 for dto in pull.interactions { try await applyInteraction(dto) }
715 + for dto in pull.notes { try await applyNoteProjet(dto) }
519 716 for dto in pull.workflows { try await applyWorkflow(dto) }
520 717 for dto in pull.rdv { try await agenda.applyRdvPull(dto) }
521 718 for dto in pull.reservations { try await agenda.applyReservationPull(dto) }
522 719 for dto in pull.indisponibilites { try await agenda.applyIndisponibilitePull(dto) }
523 720 for dto in pull.tombstones { try await applyTombstone(dto) }
524 721 return pull.contacts.count + pull.entreprises.count + pull.projets.count + pull.taches.count
525 - + pull.interactions.count + pull.rdv.count + pull.reservations.count + pull.indisponibilites.count
722 + + pull.interactions.count + pull.notes.count + pull.rdv.count
723 + + pull.reservations.count + pull.indisponibilites.count
526 724 }
527 725
528 726 private func applyContact(_ dto: ContactDto) async throws {
@@ -702,20 +900,69 @@ final class SyncEngine {
702 900 }
703 901
704 902 private func applyInteraction(_ dto: InteractionDto) async throws {
705 - let existing = Set(
706 - try await db.interactionDao.listByContactServerId(dto.contactId).compactMap { $0.serverId }
707 - )
708 - if !LwwMerger.shouldInsertInteraction(existingServerIds: existing, remoteServerId: dto.id) { return }
709 - var entity = InteractionEntity()
710 - entity.serverId = dto.id
711 - entity.contactServerId = dto.contactId
712 - entity.type = dto.typeInteraction
713 - entity.sujet = dto.sujet
714 - entity.description = dto.description
715 - entity.creePar = dto.creePar
716 - entity.createdAt = parseIsoToEpochMs(dto.creeLe)
717 - entity.updatedAt = dto.misAJourLe.map { parseIsoToEpochMs($0) }
718 - try await db.interactionDao.upsert(entity)
903 + let remoteTs = parseIsoToEpochMs(dto.misAJourLe ?? dto.creeLe)
904 + let existing = try await db.interactionDao.getByServerId(dto.id)
905 + guard let existing else {
906 + // Insert : nouvelle interaction inconnue localement.
907 + var entity = InteractionEntity()
908 + entity.serverId = dto.id
909 + entity.contactServerId = dto.contactId
910 + entity.type = dto.typeInteraction
911 + entity.sujet = dto.sujet
912 + entity.description = dto.description
913 + entity.creePar = dto.creePar
914 + entity.createdAt = parseIsoToEpochMs(dto.creeLe)
915 + entity.updatedAt = dto.misAJourLe.map { parseIsoToEpochMs($0) }
916 + entity.transcriptionStatut = dto.transcription
917 + entity.transcriptionErreur = dto.transcriptionErreur
918 + // audioPath : nil à l'insert (fichier local pas encore présent).
919 + try await db.interactionDao.upsert(entity)
920 + return
921 + }
922 + // Mise à jour LWW : seulement si le distant est plus récent.
923 + let localTs = existing.updatedAt ?? existing.createdAt
924 + guard remoteTs > localTs else { return }
925 + var updated = existing
926 + updated.type = dto.typeInteraction
927 + updated.sujet = dto.sujet
928 + updated.description = dto.description
929 + updated.creePar = dto.creePar
930 + updated.updatedAt = remoteTs
931 + updated.transcriptionStatut = dto.transcription
932 + updated.transcriptionErreur = dto.transcriptionErreur
933 + // audioPath local préservé : ne pas écraser avec le nom serveur (pieceJointe).
934 + try await db.interactionDao.upsert(updated)
935 + }
936 +
937 + private func applyNoteProjet(_ dto: NoteProjetDto) async throws {
938 + let remoteTs = parseIsoToEpochMs(dto.majLe ?? dto.creeLe)
939 + let existing = try await db.noteProjetDao.getByServerId(dto.id)
940 + guard let existing else {
941 + var entity = NoteProjetEntity()
942 + entity.serverId = dto.id
943 + entity.projetServerId = dto.projetId
944 + entity.titre = dto.titre
945 + entity.texte = dto.contenu
946 + entity.auteur = dto.auteur
947 + entity.transcriptionStatut = dto.transcription
948 + entity.transcriptionErreur = dto.transcriptionErreur
949 + entity.createdAt = parseIsoToEpochMs(dto.creeLe)
950 + entity.updatedAt = remoteTs
951 + try await db.noteProjetDao.upsert(entity)
952 + return
953 + }
954 + // LWW : mise à jour seulement si le distant est plus récent.
955 + let localTs = existing.updatedAt ?? existing.createdAt
956 + guard remoteTs > localTs else { return }
957 + var updated = existing
958 + updated.titre = dto.titre
959 + updated.texte = dto.contenu
960 + updated.auteur = dto.auteur
961 + updated.transcriptionStatut = dto.transcription
962 + updated.transcriptionErreur = dto.transcriptionErreur
963 + // audioPath local préservé : ne pas écraser avec le nom de fichier serveur.
964 + updated.updatedAt = remoteTs
965 + try await db.noteProjetDao.upsert(updated)
719 966 }
720 967
721 968 private func applyWorkflow(_ dto: WorkflowDto) async throws {
@@ -743,6 +990,8 @@ final class SyncEngine {
743 990 try await db.tacheDao.deleteByServerId(dto.id)
744 991 case "interaction":
745 992 try await db.interactionDao.deleteByServerId(dto.id)
993 + case "note":
994 + try await db.noteProjetDao.deleteByServerId(dto.id)
746 995 default:
747 996 try await agenda.applyAgendaTombstone(dto)
748 997 }
M ios/Card2vcf/Sync/SyncModels.swift
+59 -0
@@ -87,6 +87,7 @@ struct SyncPullResponse: Codable, Equatable {
87 87 var indisponibilites: [IndisponibiliteDto] = []
88 88 var tombstones: [TombstoneDto] = []
89 89 var workflows: [WorkflowDto] = []
90 + var notes: [NoteProjetDto] = []
90 91 }
91 92
92 93 extension SyncPullResponse {
@@ -103,6 +104,7 @@ extension SyncPullResponse {
103 104 indisponibilites = try c.decodeIfPresent([IndisponibiliteDto].self, forKey: .indisponibilites) ?? []
104 105 tombstones = try c.decodeIfPresent([TombstoneDto].self, forKey: .tombstones) ?? []
105 106 workflows = try c.decodeIfPresent([WorkflowDto].self, forKey: .workflows) ?? []
107 + notes = try c.decodeIfPresent([NoteProjetDto].self, forKey: .notes) ?? []
106 108 }
107 109 }
108 110
@@ -268,6 +270,35 @@ extension TacheSyncDto {
268 270 }
269 271 }
270 272
273 +struct NoteProjetDto: Codable, Equatable {
274 + var id: String
275 + var projetId: String
276 + var titre: String
277 + var contenu: String
278 + var auteur: String = ""
279 + var creeLe: String
280 + var majLe: String? = nil
281 + var audio: String? = nil
282 + var transcription: String? = nil
283 + var transcriptionErreur: String? = nil
284 +}
285 +
286 +extension NoteProjetDto {
287 + init(from decoder: Decoder) throws {
288 + let c = try decoder.container(keyedBy: CodingKeys.self)
289 + id = try c.decode(String.self, forKey: .id)
290 + projetId = try c.decode(String.self, forKey: .projetId)
291 + titre = try c.decode(String.self, forKey: .titre)
292 + contenu = try c.decode(String.self, forKey: .contenu)
293 + auteur = try c.decodeIfPresent(String.self, forKey: .auteur) ?? ""
294 + creeLe = try c.decode(String.self, forKey: .creeLe)
295 + majLe = try c.decodeIfPresent(String.self, forKey: .majLe)
296 + audio = try c.decodeIfPresent(String.self, forKey: .audio)
297 + transcription = try c.decodeIfPresent(String.self, forKey: .transcription)
298 + transcriptionErreur = try c.decodeIfPresent(String.self, forKey: .transcriptionErreur)
299 + }
300 +}
301 +
271 302 struct InteractionDto: Codable, Equatable {
272 303 var id: String
273 304 var contactId: String
@@ -277,6 +308,11 @@ struct InteractionDto: Codable, Equatable {
277 308 var creePar: String = ""
278 309 var creeLe: String
279 310 var misAJourLe: String? = nil
311 + /// Nom de fichier côté serveur (pas un chemin local).
312 + var pieceJointe: String? = nil
313 + /// Statut de transcription : `en_attente`, `terminee`, `echec`.
314 + var transcription: String? = nil
315 + var transcriptionErreur: String? = nil
280 316 }
281 317
282 318 extension InteractionDto {
@@ -290,6 +326,9 @@ extension InteractionDto {
290 326 creePar = try c.decodeIfPresent(String.self, forKey: .creePar) ?? ""
291 327 creeLe = try c.decode(String.self, forKey: .creeLe)
292 328 misAJourLe = try c.decodeIfPresent(String.self, forKey: .misAJourLe)
329 + pieceJointe = try c.decodeIfPresent(String.self, forKey: .pieceJointe)
330 + transcription = try c.decodeIfPresent(String.self, forKey: .transcription)
331 + transcriptionErreur = try c.decodeIfPresent(String.self, forKey: .transcriptionErreur)
293 332 }
294 333 }
295 334
@@ -531,6 +570,8 @@ struct MoveTacheRequest: Codable, Equatable {
531 570 struct CreateInteractionRequest: Codable, Equatable {
532 571 var sujet: String
533 572 var description: String = ""
573 + var typeInteraction: String = "note"
574 + var demandeTranscription: Bool = false
534 575 }
535 576
536 577 extension CreateInteractionRequest {
@@ -538,6 +579,24 @@ extension CreateInteractionRequest {
538 579 let c = try decoder.container(keyedBy: CodingKeys.self)
539 580 sujet = try c.decode(String.self, forKey: .sujet)
540 581 description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
582 + typeInteraction = try c.decodeIfPresent(String.self, forKey: .typeInteraction) ?? "note"
583 + demandeTranscription = try c.decodeIfPresent(Bool.self, forKey: .demandeTranscription) ?? false
584 + }
585 +}
586 +
587 +/// Create note de projet payload (body of `POST /api/projets/:id/notes`).
588 +struct CreateNoteProjetRequest: Codable, Equatable {
589 + var titre: String
590 + var contenu: String = ""
591 + var demandeTranscription: Bool = false
592 +}
593 +
594 +extension CreateNoteProjetRequest {
595 + init(from decoder: Decoder) throws {
596 + let c = try decoder.container(keyedBy: CodingKeys.self)
597 + titre = try c.decode(String.self, forKey: .titre)
598 + contenu = try c.decodeIfPresent(String.self, forKey: .contenu) ?? ""
599 + demandeTranscription = try c.decodeIfPresent(Bool.self, forKey: .demandeTranscription) ?? false
541 600 }
542 601 }
543 602
A ios/Card2vcf/UI/Audio/EnregistrementNoteSheet.swift
+258 -0
@@ -0,0 +1,258 @@
1 +import SwiftUI
2 +
3 +/// Feuille d'enregistrement de note vocale.
4 +///
5 +/// Commune à la fiche contact (crée une `InteractionEntity` de type `note_vocale`)
6 +/// et à la section Notes du projet (crée une `NoteProjetEntity`).
7 +/// La logique de sauvegarde est fournie par `sauvegarder`.
8 +struct EnregistrementNoteSheet: View {
9 + @StateObject private var viewModel: EnregistrementNoteViewModel
10 + @Environment(\.dismiss) private var dismiss
11 +
12 + /// - Parameters:
13 + /// - modeTranscription: mode lu depuis `PreferencesAudio` par défaut.
14 + /// - sauvegarder: `(sujet, description, audioPath, demandeTranscription) async -> envoye`.
15 + init(
16 + modeTranscription: ModeTranscription = PreferencesAudio.modeTranscription,
17 + sauvegarder: @escaping (String, String, String, Bool) async -> Bool
18 + ) {
19 + _viewModel = StateObject(wrappedValue: EnregistrementNoteViewModel(
20 + modeTranscription: modeTranscription,
21 + sauvegarder: sauvegarder
22 + ))
23 + }
24 +
25 + var body: some View {
26 + VStack(alignment: .leading, spacing: 0) {
27 + barreHaut
28 +
29 + switch viewModel.phase {
30 + case .demandePermission:
31 + chargementView(label: "Vérification des permissions…")
32 + case .permissionMicRefusee:
33 + permissionRefuseeView(
34 + icone: "mic.slash.circle",
35 + titre: "Microphone",
36 + message: "L'accès au microphone est requis pour enregistrer une note vocale. Activez-le dans Réglages → Confidentialité."
37 + )
38 + case .permissionSpeechRefusee:
39 + permissionRefuseeView(
40 + icone: "waveform.slash",
41 + titre: "Reconnaissance vocale",
42 + message: "Autorisez la reconnaissance vocale pour activer la transcription sur l'appareil. Activez-la dans Réglages → Confidentialité."
43 + )
44 + case .pret:
45 + pretView
46 + case .enregistrement:
47 + enregistrementView
48 + case .edition:
49 + editionView
50 + case .sauvegarde:
51 + chargementView(label: "Sauvegarde en cours…")
52 + case .termine(let message):
53 + termineView(message: message)
54 + }
55 + }
56 + .background(C2VColor.fond)
57 + .task {
58 + await viewModel.demanderPermissions()
59 + }
60 + }
61 +
62 + // MARK: - Barre haute
63 +
64 + private var barreHaut: some View {
65 + ZStack {
66 + HStack {
67 + Button("Annuler") {
68 + viewModel.annuler()
69 + dismiss()
70 + }
71 + .buttonStyle(C2VTextButtonStyle(color: C2VColor.brandError))
72 + Spacer()
73 + }
74 + Text("Note vocale")
75 + .font(C2VFont.titleMedium)
76 + .foregroundColor(C2VColor.encre)
77 + }
78 + .padding(.horizontal, 12)
79 + .padding(.vertical, 10)
80 + .background(C2VColor.surface)
81 + }
82 +
83 + // MARK: - Bandeau d'avertissement permanent
84 +
85 + private var bandeauAvertissement: some View {
86 + HStack(spacing: 8) {
87 + Image(systemName: "exclamationmark.triangle.fill")
88 + .font(.system(size: 13))
89 + Text("La transcription ne distingue pas les interlocuteurs")
90 + .font(C2VFont.bodySmall)
91 + }
92 + .foregroundColor(C2VColor.brandError)
93 + .padding(.horizontal, 16)
94 + .padding(.vertical, 8)
95 + .frame(maxWidth: .infinity, alignment: .leading)
96 + .background(C2VColor.brandErrorSoft)
97 + }
98 +
99 + // MARK: - Phase : prêt
100 +
101 + private var pretView: some View {
102 + VStack(spacing: 0) {
103 + bandeauAvertissement
104 + Spacer()
105 + VStack(spacing: 20) {
106 + Text("Appuyez pour démarrer l'enregistrement")
107 + .font(C2VFont.bodyMedium)
108 + .foregroundColor(C2VColor.texteFaible)
109 + .multilineTextAlignment(.center)
110 + .padding(.horizontal, 32)
111 + Button {
112 + viewModel.demarrer()
113 + } label: {
114 + Image(systemName: "mic.circle.fill")
115 + .font(.system(size: 84))
116 + .foregroundColor(C2VColor.ink)
117 + }
118 + .buttonStyle(.plain)
119 + }
120 + Spacer()
121 + }
122 + .frame(maxWidth: .infinity)
123 + }
124 +
125 + // MARK: - Phase : enregistrement
126 +
127 + private var enregistrementView: some View {
128 + VStack(spacing: 0) {
129 + bandeauAvertissement
130 + Spacer()
131 + VStack(spacing: 20) {
132 + Text(viewModel.dureeFormatee)
133 + .font(.system(size: 60, weight: .light, design: .monospaced))
134 + .foregroundColor(C2VColor.encre)
135 + if !viewModel.transcriptionEnDirect.isEmpty {
136 + Text(viewModel.transcriptionEnDirect)
137 + .font(C2VFont.bodyMedium)
138 + .foregroundColor(C2VColor.texteFaible)
139 + .multilineTextAlignment(.center)
140 + .padding(.horizontal, 24)
141 + }
142 + Button {
143 + viewModel.arreter()
144 + } label: {
145 + VStack(spacing: 8) {
146 + Image(systemName: "stop.circle.fill")
147 + .font(.system(size: 72))
148 + .foregroundColor(C2VColor.brandError)
149 + Text("Arrêter")
150 + .font(C2VFont.labelMedium)
151 + .foregroundColor(C2VColor.brandError)
152 + }
153 + }
154 + .buttonStyle(.plain)
155 + }
156 + Spacer()
157 + }
158 + .frame(maxWidth: .infinity)
159 + }
160 +
161 + // MARK: - Phase : édition
162 +
163 + private var editionView: some View {
164 + ScrollView {
165 + VStack(alignment: .leading, spacing: 0) {
166 + bandeauAvertissement
167 + VStack(alignment: .leading, spacing: 12) {
168 + Text("Sujet")
169 + .font(C2VFont.labelMedium)
170 + .foregroundColor(C2VColor.texteFaible)
171 + C2VTextField(
172 + label: "",
173 + placeholder: "Sujet de la note",
174 + text: $viewModel.sujet
175 + )
176 + Text("Texte / transcription")
177 + .font(C2VFont.labelMedium)
178 + .foregroundColor(C2VColor.texteFaible)
179 + C2VTextField(
180 + label: "",
181 + placeholder: "Texte (optionnel)",
182 + text: $viewModel.descriptionNote,
183 + minLines: 3
184 + )
185 + Button("Enregistrer") {
186 + viewModel.sauvegarderNote()
187 + }
188 + .buttonStyle(C2VPrimaryButtonStyle())
189 + .padding(.top, 4)
190 + }
191 + .padding(16)
192 + }
193 + }
194 + }
195 +
196 + // MARK: - Phase : chargement
197 +
198 + private func chargementView(label: String) -> some View {
199 + VStack(spacing: 16) {
200 + Spacer()
201 + ProgressView()
202 + Text(label)
203 + .font(C2VFont.bodyMedium)
204 + .foregroundColor(C2VColor.texteFaible)
205 + Spacer()
206 + }
207 + .frame(maxWidth: .infinity)
208 + }
209 +
210 + // MARK: - Phase : terminé
211 +
212 + private func termineView(message: String) -> some View {
213 + VStack(spacing: 20) {
214 + Spacer()
215 + Image(systemName: "checkmark.circle.fill")
216 + .font(.system(size: 64))
217 + .foregroundColor(C2VColor.brandGreen)
218 + Text(message)
219 + .font(C2VFont.bodyLarge)
220 + .foregroundColor(C2VColor.ink)
221 + .multilineTextAlignment(.center)
222 + .padding(.horizontal, 32)
223 + Button("Fermer") { dismiss() }
224 + .buttonStyle(C2VPrimaryButtonStyle())
225 + .padding(.horizontal, 32)
226 + Spacer()
227 + }
228 + .frame(maxWidth: .infinity)
229 + }
230 +
231 + // MARK: - Permission refusée
232 +
233 + private func permissionRefuseeView(icone: String, titre: String, message: String) -> some View {
234 + VStack(spacing: 20) {
235 + Spacer()
236 + Image(systemName: icone)
237 + .font(.system(size: 60))
238 + .foregroundColor(C2VColor.brandError)
239 + Text("Permission \(titre) refusée")
240 + .font(C2VFont.titleMedium)
241 + .foregroundColor(C2VColor.encre)
242 + Text(message)
243 + .font(C2VFont.bodyMedium)
244 + .foregroundColor(C2VColor.texteFaible)
245 + .multilineTextAlignment(.center)
246 + .padding(.horizontal, 32)
247 + Button("Ouvrir les Réglages") {
248 + if let url = URL(string: UIApplication.openSettingsURLString) {
249 + UIApplication.shared.open(url)
250 + }
251 + }
252 + .buttonStyle(C2VOutlineButtonStyle())
253 + .padding(.horizontal, 32)
254 + Spacer()
255 + }
256 + .frame(maxWidth: .infinity)
257 + }
258 +}
A ios/Card2vcf/UI/Audio/EnregistrementNoteViewModel.swift
+198 -0
@@ -0,0 +1,198 @@
1 +import AVFoundation
2 +import Foundation
3 +import Speech
4 +
5 +/// Phases du cycle de vie de la feuille d'enregistrement.
6 +enum PhaseEnregistrement: Equatable {
7 + case demandePermission
8 + case permissionMicRefusee
9 + case permissionSpeechRefusee
10 + case pret
11 + case enregistrement
12 + case edition
13 + case sauvegarde
14 + case termine(message: String)
15 +}
16 +
17 +/// ViewModel de la feuille d'enregistrement de note vocale.
18 +///
19 +/// Commun à la fiche contact (interaction `note_vocale`) et à la section Notes du projet
20 +/// (`NoteProjetEntity`). Le contexte de sauvegarde est injecté via `sauvegarder`.
21 +///
22 +/// Les méthodes statiques `sujetParDefaut` et `messageConfirmation` sont pures et
23 +/// testables sans accès au matériel audio.
24 +@MainActor
25 +final class EnregistrementNoteViewModel: ObservableObject {
26 + @Published private(set) var phase: PhaseEnregistrement = .demandePermission
27 + @Published private(set) var dureeSecondes: Int = 0
28 + @Published private(set) var transcriptionEnDirect: String = ""
29 + @Published var sujet: String = ""
30 + @Published var descriptionNote: String = ""
31 +
32 + let modeTranscription: ModeTranscription
33 +
34 + private let recorder: AudioNoteRecorder
35 + private let transcripteurFactory: () -> (any TranscripteurLocal)?
36 + private var transcripteurActif: (any TranscripteurLocal)?
37 + private var audioPathTemp: URL?
38 + private var timerChrono: Timer?
39 + private let sauvegarderAction: (String, String, String, Bool) async -> Bool
40 +
41 + /// - Parameters:
42 + /// - modeTranscription: mode persisté ou `.serveur` si SFSpeechRecognizer indisponible.
43 + /// - recorder: injecteur de moteur audio (faux en tests).
44 + /// - transcripteurFactory: crée le transcripteur à l'enregistrement (faux en tests).
45 + /// - sauvegarder: `(sujet, description, audioPath, demandeTranscription) async -> envoye`.
46 + /// Retourne `true` si l'audio a été effectivement envoyé au serveur (push immédiat OK).
47 + init(
48 + modeTranscription: ModeTranscription = PreferencesAudio.modeTranscription,
49 + recorder: AudioNoteRecorder = AudioNoteRecorder(),
50 + transcripteurFactory: @escaping () -> (any TranscripteurLocal)? = { TranscripteurSpeech() },
51 + sauvegarder: @escaping (String, String, String, Bool) async -> Bool
52 + ) {
53 + self.modeTranscription = modeTranscription
54 + self.recorder = recorder
55 + self.transcripteurFactory = transcripteurFactory
56 + self.sauvegarderAction = sauvegarder
57 + }
58 +
59 + // MARK: - Fonctions pures (testables)
60 +
61 + /// Génère le sujet par défaut : « Notes du jj/MM/yyyy à HH:mm ».
62 + nonisolated static func sujetParDefaut(maintenant: Date = Date()) -> String {
63 + let f = DateFormatter()
64 + f.dateFormat = "dd/MM/yyyy 'à' HH:mm"
65 + f.locale = Locale(identifier: "fr_FR")
66 + return "Notes du \(f.string(from: maintenant))"
67 + }
68 +
69 + /// Message de confirmation adapté au mode et au résultat d'envoi.
70 + nonisolated static func messageConfirmation(mode: ModeTranscription, envoye: Bool) -> String {
71 + switch mode {
72 + case .appareil:
73 + return "Note vocale enregistrée"
74 + case .serveur:
75 + return envoye
76 + ? "Note vocale envoyée au serveur — la transcription est en cours"
77 + : "Note vocale enregistrée — elle sera envoyée à la prochaine synchronisation"
78 + }
79 + }
80 +
81 + // MARK: - Durée formatée mm:ss
82 +
83 + var dureeFormatee: String {
84 + String(format: "%02d:%02d", dureeSecondes / 60, dureeSecondes % 60)
85 + }
86 +
87 + // MARK: - Permissions
88 +
89 + func demanderPermissions() async {
90 + // Microphone
91 + let micOK: Bool
92 + if #available(iOS 17.0, *) {
93 + micOK = await AVAudioApplication.requestRecordPermission()
94 + } else {
95 + micOK = await withCheckedContinuation { cont in
96 + AVAudioSession.sharedInstance().requestRecordPermission {
97 + cont.resume(returning: $0)
98 + }
99 + }
100 + }
101 + guard micOK else {
102 + phase = .permissionMicRefusee
103 + return
104 + }
105 +
106 + // Reconnaissance vocale (seulement en mode appareil)
107 + if modeTranscription == .appareil {
108 + let speechOK = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
109 + SFSpeechRecognizer.requestAuthorization {
110 + cont.resume(returning: $0 == .authorized)
111 + }
112 + }
113 + guard speechOK else {
114 + phase = .permissionSpeechRefusee
115 + return
116 + }
117 + }
118 +
119 + phase = .pret
120 + }
121 +
122 + // MARK: - Enregistrement
123 +
124 + func demarrer() {
125 + let tempURL = FileManager.default.temporaryDirectory
126 + .appendingPathComponent("note_vocale_\(UUID().uuidString).wav")
127 + audioPathTemp = tempURL
128 +
129 + let t: (any TranscripteurLocal)? = (modeTranscription == .appareil)
130 + ? transcripteurFactory()
131 + : nil
132 + transcripteurActif = t
133 +
134 + do {
135 + try recorder.demarrer(fichierCible: tempURL, transcripteur: t)
136 + } catch {
137 + phase = .permissionMicRefusee
138 + return
139 + }
140 +
141 + phase = .enregistrement
142 + dureeSecondes = 0
143 + timerChrono = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
144 + Task { @MainActor [weak self] in
145 + guard let self, case .enregistrement = self.phase else { return }
146 + self.dureeSecondes += 1
147 + // Mise à jour du texte partiel en mode appareil
148 + if let partial = self.transcripteurActif?.accepterEchantillons([]),
149 + !partial.isEmpty {
150 + self.transcriptionEnDirect = partial
151 + }
152 + }
153 + }
154 + }
155 +
156 + func arreter() {
157 + timerChrono?.invalidate()
158 + timerChrono = nil
159 + _ = recorder.arreter()
160 + // Récupère le texte final de transcription
161 + if let t = transcripteurActif {
162 + let final = t.finaliser()
163 + if !final.isEmpty {
164 + descriptionNote = final
165 + }
166 + }
167 + transcripteurActif = nil
168 + sujet = Self.sujetParDefaut()
169 + phase = .edition
170 + }
171 +
172 + // MARK: - Sauvegarde / annulation
173 +
174 + func sauvegarderNote() {
175 + guard case .edition = phase, let audioURL = audioPathTemp else { return }
176 + phase = .sauvegarde
177 + let s = sujet.trimmingCharacters(in: .whitespacesAndNewlines)
178 + let finalSujet = s.isEmpty ? Self.sujetParDefaut() : s
179 + let d = descriptionNote
180 + let path = audioURL.path
181 + let demandeTranscription = (modeTranscription == .serveur)
182 + Task {
183 + let envoye = await sauvegarderAction(finalSujet, d, path, demandeTranscription)
184 + phase = .termine(message: Self.messageConfirmation(mode: modeTranscription, envoye: envoye))
185 + }
186 + }
187 +
188 + /// Annule l'enregistrement : arrête le moteur et supprime le fichier temporaire.
189 + func annuler() {
190 + timerChrono?.invalidate()
191 + timerChrono = nil
192 + if recorder.estEnCours { _ = recorder.arreter() }
193 + if let tmp = audioPathTemp {
194 + try? FileManager.default.removeItem(at: tmp)
195 + audioPathTemp = nil
196 + }
197 + }
198 +}
A ios/Card2vcf/UI/Audio/ModeTranscription.swift
+34 -0
@@ -0,0 +1,34 @@
1 +import Foundation
2 +import Speech
3 +
4 +/// Mode de transcription des notes vocales.
5 +enum ModeTranscription: String {
6 + /// Transcription on-device via SFSpeechRecognizer (fr_FR, données non transmises).
7 + case appareil
8 + /// Transcription déléguée au serveur (upload audio, transcription côté serveur).
9 + case serveur
10 +}
11 +
12 +/// Préférence locale du mode de transcription (UserDefaults).
13 +enum PreferencesAudio {
14 + private static let cleMode = "transcription_mode"
15 +
16 + /// `true` si SFSpeechRecognizer est disponible pour fr_FR sur cet appareil.
17 + /// Lorsque `false`, le mode est forcé à `.serveur`.
18 + static var speechRecognitionDisponible: Bool {
19 + SFSpeechRecognizer(locale: Locale(identifier: "fr_FR"))?.isAvailable == true
20 + }
21 +
22 + /// Mode persisté. Retourne toujours `.serveur` si la reconnaissance vocale est indisponible.
23 + static var modeTranscription: ModeTranscription {
24 + get {
25 + if !speechRecognitionDisponible { return .serveur }
26 + let raw = UserDefaults.standard.string(forKey: cleMode)
27 + ?? ModeTranscription.appareil.rawValue
28 + return ModeTranscription(rawValue: raw) ?? .appareil
29 + }
30 + set {
31 + UserDefaults.standard.set(newValue.rawValue, forKey: cleMode)
32 + }
33 + }
34 +}
A ios/Card2vcf/UI/Audio/NoteVocaleRow.swift
+143 -0
@@ -0,0 +1,143 @@
1 +import AVFoundation
2 +import SwiftUI
3 +
4 +/// Ligne d'affichage d'une note vocale (interaction `note_vocale` ou `NoteProjetEntity`).
5 +///
6 +/// Affiche : sujet, description/transcription, badge de statut (`en_attente` / `echec`)
7 +/// avec le motif d'erreur quand il existe, lecture audio si le fichier est présent localement,
8 +/// et les actions « Relancer la transcription » (echec uniquement) et « Supprimer ».
9 +struct NoteVocaleRow: View {
10 + let sujet: String
11 + let description: String
12 + let audioPath: String?
13 + let transcriptionStatut: String?
14 + let transcriptionErreur: String?
15 + /// Rappel de relance — fourni uniquement quand le statut vaut `echec`.
16 + var onRelancer: (() -> Void)? = nil
17 + /// Rappel de suppression avec confirmation intégrée.
18 + var onSupprimer: (() -> Void)? = nil
19 +
20 + @State private var joueur: AVAudioPlayer?
21 + @State private var enLecture = false
22 + @State private var confirmSupprimer = false
23 +
24 + var body: some View {
25 + VStack(alignment: .leading, spacing: 4) {
26 + // Titre + badge statut
27 + HStack(alignment: .top, spacing: 8) {
28 + Image(systemName: "waveform")
29 + .foregroundColor(C2VColor.texteFaible)
30 + .font(.system(size: 14))
31 + Text(sujet)
32 + .font(C2VFont.bodyMedium)
33 + .foregroundColor(C2VColor.ink)
34 + .frame(maxWidth: .infinity, alignment: .leading)
35 + if let statut = transcriptionStatut, statut != "terminee" {
36 + statutBadge(statut)
37 + }
38 + }
39 +
40 + // Description / texte transcrit
41 + if !description.isEmpty {
42 + Text(description)
43 + .font(C2VFont.bodySmall)
44 + .foregroundColor(C2VColor.texteFaible)
45 + }
46 +
47 + // Motif d'erreur (quand echec)
48 + if let erreur = transcriptionErreur, !erreur.isEmpty {
49 + HStack(spacing: 4) {
50 + Image(systemName: "exclamationmark.circle")
51 + .font(.system(size: 11))
52 + Text("Erreur : \(erreur)")
53 + .font(C2VFont.bodySmall)
54 + }
55 + .foregroundColor(C2VColor.brandError)
56 + }
57 +
58 + // Lecteur audio (fichier local uniquement)
59 + if let path = audioPath, FileManager.default.fileExists(atPath: path) {
60 + lecteurAudio(path: path)
61 + }
62 +
63 + // Actions
64 + if transcriptionStatut == "echec", let relancer = onRelancer {
65 + Button("Relancer la transcription") { relancer() }
66 + .buttonStyle(C2VTextButtonStyle())
67 + .font(C2VFont.labelSmall)
68 + }
69 + if onSupprimer != nil {
70 + Button("Supprimer") { confirmSupprimer = true }
71 + .buttonStyle(C2VTextButtonStyle(color: C2VColor.brandError))
72 + .font(C2VFont.labelSmall)
73 + }
74 + }
75 + .frame(maxWidth: .infinity, alignment: .leading)
76 + .confirmationDialog(
77 + "Supprimer cette note vocale ?",
78 + isPresented: $confirmSupprimer,
79 + titleVisibility: .visible
80 + ) {
81 + Button("Supprimer", role: .destructive) { onSupprimer?() }
82 + Button("Annuler", role: .cancel) {}
83 + }
84 + .onDisappear {
85 + joueur?.stop()
86 + joueur = nil
87 + enLecture = false
88 + }
89 + }
90 +
91 + // MARK: - Badge statut
92 +
93 + @ViewBuilder
94 + private func statutBadge(_ statut: String) -> some View {
95 + let (label, color): (String, Color) = switch statut {
96 + case "en_attente": ("En attente", C2VColor.brandWarn)
97 + case "echec": ("Échec", C2VColor.brandError)
98 + default: (statut, C2VColor.texteFaible)
99 + }
100 + Text(label)
101 + .font(C2VFont.labelSmall)
102 + .foregroundColor(color)
103 + .padding(.horizontal, 6)
104 + .padding(.vertical, 2)
105 + .overlay(Rectangle().stroke(color, lineWidth: 1))
106 + }
107 +
108 + // MARK: - Lecteur audio
109 +
110 + private func lecteurAudio(path: String) -> some View {
111 + Button {
112 + toggleLecture(path: path)
113 + } label: {
114 + HStack(spacing: 6) {
115 + Image(systemName: enLecture ? "pause.circle" : "play.circle")
116 + .font(.system(size: 22))
117 + Text(enLecture ? "Pause" : "Écouter")
118 + .font(C2VFont.labelSmall)
119 + }
120 + .foregroundColor(C2VColor.ink)
121 + }
122 + .buttonStyle(.plain)
123 + .padding(.top, 2)
124 + }
125 +
126 + private func toggleLecture(path: String) {
127 + if enLecture {
128 + joueur?.pause()
129 + enLecture = false
130 + } else {
131 + do {
132 + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
133 + try AVAudioSession.sharedInstance().setActive(true)
134 + let url = URL(fileURLWithPath: path)
135 + joueur = try AVAudioPlayer(contentsOf: url)
136 + joueur?.play()
137 + enLecture = true
138 + } catch {
139 + enLecture = false
140 + }
141 + }
142 + }
143 +}
M ios/Card2vcf/UI/Contact/ContactPagerScreen.swift
+184 -0
@@ -112,6 +112,9 @@ private struct ContactFichePage: View {
112 112 @State private var notesDraft: String
113 113 @State private var showSystemContact = false
114 114 @State private var confirmDelete = false
115 + @State private var interactionsNoteVocale: [InteractionEntity] = []
116 + @State private var afficherEnregistrement = false
117 + @State private var relanceErreur: String?
115 118
116 119 init(
117 120 contact: CrmContactEntity,
@@ -160,6 +163,9 @@ private struct ContactFichePage: View {
160 163 notesEditor
161 164
162 165 C2VDivider()
166 + notesVocalesSection
167 +
168 + C2VDivider()
163 169 actions
164 170 }
165 171 .padding(.horizontal, 18)
@@ -178,6 +184,19 @@ private struct ContactFichePage: View {
178 184 Button("Supprimer", role: .destructive, action: onDelete)
179 185 Button("Annuler", role: .cancel) {}
180 186 }
187 + .sheet(isPresented: $afficherEnregistrement) {
188 + let contactServerId = contact.serverId
189 + EnregistrementNoteSheet { sujet, desc, path, transcription in
190 + await ContactFichePage.sauvegarderInteractionVocale(
191 + contactServerId: contactServerId,
192 + sujet: sujet, description: desc,
193 + audioPath: path, demandeTranscription: transcription
194 + )
195 + }
196 + }
197 + .task(id: contact.id) {
198 + await chargerInteractions()
199 + }
181 200 }
182 201
183 202 private var phones: [String] { contact.phones.filter { $0.isNotBlank } }
@@ -261,4 +280,169 @@ private struct ContactFichePage: View {
261 280 .frame(maxWidth: .infinity, alignment: .leading)
262 281 }
263 282 }
283 +
284 + // MARK: - Notes vocales
285 +
286 + private var notesVocalesSection: some View {
287 + VStack(alignment: .leading, spacing: 6) {
288 + HStack {
289 + Text("Notes vocales")
290 + .font(C2VFont.labelMedium)
291 + .foregroundColor(C2VColor.texteFaible)
292 + Spacer()
293 + Button {
294 + afficherEnregistrement = true
295 + } label: {
296 + Label("Nouvelle note vocale", systemImage: "mic.badge.plus")
297 + .font(C2VFont.labelSmall)
298 + .foregroundColor(C2VColor.ink)
299 + }
300 + .buttonStyle(.plain)
301 + }
302 + if let erreur = relanceErreur {
303 + Text(erreur)
304 + .font(C2VFont.bodySmall)
305 + .foregroundColor(C2VColor.brandError)
306 + }
307 + if interactionsNoteVocale.isEmpty {
308 + Text("Aucune note vocale")
309 + .font(C2VFont.bodyMedium)
310 + .foregroundColor(C2VColor.texteFaible)
311 + } else {
312 + ForEach(interactionsNoteVocale, id: \.localId) { interaction in
313 + NoteVocaleRow(
314 + sujet: interaction.sujet,
315 + description: interaction.description,
316 + audioPath: interaction.audioPath,
317 + transcriptionStatut: interaction.transcriptionStatut,
318 + transcriptionErreur: interaction.transcriptionErreur,
319 + onRelancer: {
320 + Task { await relancerTranscription(localId: interaction.localId) }
321 + },
322 + onSupprimer: {
323 + Task { await supprimerInteraction(interaction) }
324 + }
325 + )
326 + C2VDivider()
327 + }
328 + }
329 + }
330 + }
331 +
332 + private func chargerInteractions() async {
333 + guard let serverId = contact.serverId else { return }
334 + let all = (try? await Card2vcfDatabase.shared.interactionDao
335 + .fetchByContactServerId(serverId)) ?? []
336 + interactionsNoteVocale = all
337 + .filter { $0.type == "note_vocale" }
338 + .sorted { ($0.updatedAt ?? $0.createdAt) > ($1.updatedAt ?? $1.createdAt) }
339 + }
340 +
341 + private func relancerTranscription(localId: Int64) async {
342 + relanceErreur = nil
343 + guard let baseUrl = SyncCredentialsStore().baseUrl,
344 + let apiKey = SyncCredentialsStore().apiKey else {
345 + relanceErreur = "Relance impossible : serveur non configuré."
346 + return
347 + }
348 + let api = AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey)
349 + let engine = SyncEngine(api: api, db: Card2vcfDatabase.shared)
350 + if let erreur = try? await engine.relancerTranscriptionInteraction(localId: localId) {
351 + relanceErreur = erreur
352 + }
353 + await chargerInteractions()
354 + }
355 +
356 + private func supprimerInteraction(_ interaction: InteractionEntity) async {
357 + let db = Card2vcfDatabase.shared
358 + // 1. Supprimer le fichier audio local s'il existe.
359 + if let path = interaction.audioPath {
360 + try? FileManager.default.removeItem(atPath: path)
361 + }
362 + // 2. Op de synchronisation uniquement si l'entité a déjà un serverId.
363 + if let serverId = interaction.serverId {
364 + var op = SyncOpEntity(entityType: "interaction", op: "delete")
365 + op.serverId = serverId
366 + op.createdAt = ContactRepository.currentMillis()
367 + _ = try? await db.syncOpDao.insert(op)
368 + }
369 + // 3. Suppression locale.
370 + try? await db.interactionDao.deleteByLocalId(interaction.localId)
371 + await chargerInteractions()
372 + }
373 +
374 + // MARK: - Sauvegarde note vocale (statique pour capture sûre)
375 +
376 + static func sauvegarderInteractionVocale(
377 + contactServerId: String?,
378 + sujet: String,
379 + description: String,
380 + audioPath: String,
381 + demandeTranscription: Bool
382 + ) async -> Bool {
383 + guard let contactServerId else { return false }
384 + let db = Card2vcfDatabase.shared
385 + let now = ContactRepository.currentMillis()
386 +
387 + // Insertion
388 + var entity = InteractionEntity()
389 + entity.contactServerId = contactServerId
390 + entity.type = "note_vocale"
391 + entity.sujet = sujet
392 + entity.description = description
393 + entity.creePar = SyncUserContext.userName
394 + entity.createdAt = now
395 + if demandeTranscription { entity.transcriptionStatut = "en_attente" }
396 + let localId = (try? await db.interactionDao.upsert(entity)) ?? 0
397 +
398 + // Déplacement vers le store stable
399 + if let store = try? AudioNoteStore.defaultStore(), localId > 0 {
400 + let stablePath = store.pathForInteraction(localId: localId)
401 + if FileManager.default.fileExists(atPath: audioPath),
402 + (try? FileManager.default.moveItem(atPath: audioPath, toPath: stablePath)) != nil {
403 + var withPath = entity
404 + withPath.localId = localId
405 + withPath.audioPath = stablePath
406 + _ = try? await db.interactionDao.upsert(withPath)
407 + }
408 + }
409 +
410 + // Op de synchronisation
411 + var op = SyncOpEntity(entityType: "interaction", op: "create")
412 + op.payloadJson = encodeInteractionPayload(
413 + sujet: sujet, description: description,
414 + demandeTranscription: demandeTranscription
415 + )
416 + op.serverId = contactServerId
417 + op.localId = localId
418 + op.createdAt = now
419 + _ = try? await db.syncOpDao.insert(op)
420 +
421 + // Push immédiat en mode serveur
422 + if demandeTranscription,
423 + let baseUrl = SyncCredentialsStore().baseUrl,
424 + let apiKey = SyncCredentialsStore().apiKey {
425 + let api = AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey)
426 + let engine = SyncEngine(api: api, db: db)
427 + let result = try? await engine.pousserEnAttente()
428 + return result?.success == true
429 + }
430 + return false
431 + }
432 +
433 + private static func encodeInteractionPayload(
434 + sujet: String,
435 + description: String,
436 + demandeTranscription: Bool
437 + ) -> String {
438 + let obj: [String: Any] = [
439 + "demande_transcription": demandeTranscription,
440 + "description": description,
441 + "sujet": sujet,
442 + "type_interaction": "note_vocale"
443 + ]
444 + guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]),
445 + let text = String(data: data, encoding: .utf8) else { return "{}" }
446 + return text
447 + }
264 448 }
M ios/Card2vcf/UI/Projets/ProjetDetailScreen.swift
+235 -0
@@ -6,6 +6,9 @@ import SwiftUI
6 6 struct ProjetDetailScreen: View {
7 7 @StateObject private var viewModel: ProjetDetailViewModel
8 8 let onBack: () -> Void
9 + @State private var afficherEnregistrement = false
10 + @State private var relanceErreur: String?
11 + @State private var noteTextASupprimer: NoteProjetEntity?
9 12
10 13 init(serverId: String, onBack: @escaping () -> Void) {
11 14 _viewModel = StateObject(wrappedValue: ProjetDetailViewModel(projetServerId: serverId))
@@ -58,6 +61,10 @@ struct ProjetDetailScreen: View {
58 61
59 62 crSection
60 63
64 + C2VDivider().padding(.vertical, 12)
65 +
66 + notesSection
67 +
61 68 Spacer().frame(height: 24)
62 69 }
63 70 .padding(.horizontal, 18)
@@ -66,6 +73,32 @@ struct ProjetDetailScreen: View {
66 73 .background(C2VColor.fond)
67 74 .toolbar(.hidden, for: .navigationBar)
68 75 .task { await viewModel.refresh() }
76 + .sheet(isPresented: $afficherEnregistrement) {
77 + let projetServerId = viewModel.projetServerId
78 + EnregistrementNoteSheet { sujet, desc, path, transcription in
79 + await ProjetDetailScreen.sauvegarderNoteProjetVocale(
80 + projetServerId: projetServerId,
81 + sujet: sujet, description: desc,
82 + audioPath: path, demandeTranscription: transcription
83 + )
84 + }
85 + }
86 + .confirmationDialog(
87 + "Supprimer cette note ?",
88 + isPresented: Binding(
89 + get: { noteTextASupprimer != nil },
90 + set: { if !$0 { noteTextASupprimer = nil } }
91 + ),
92 + titleVisibility: .visible
93 + ) {
94 + Button("Supprimer", role: .destructive) {
95 + if let note = noteTextASupprimer {
96 + noteTextASupprimer = nil
97 + Task { await supprimerNote(note) }
98 + }
99 + }
100 + Button("Annuler", role: .cancel) { noteTextASupprimer = nil }
101 + }
69 102 }
70 103
71 104 private var crSection: some View {
@@ -134,4 +167,206 @@ struct ProjetDetailScreen: View {
134 167 .trimmingCharacters(in: .whitespaces)
135 168 return composed.isEmpty ? "—" : composed
136 169 }
170 +
171 + // MARK: - Section Notes
172 +
173 + private var notesSection: some View {
174 + VStack(alignment: .leading, spacing: 0) {
175 + HStack {
176 + Text("Notes")
177 + .font(C2VFont.labelMedium)
178 + .foregroundColor(C2VColor.texteFaible)
179 + Spacer()
180 + Button {
181 + afficherEnregistrement = true
182 + } label: {
183 + Label("Note vocale", systemImage: "mic.badge.plus")
184 + .font(C2VFont.labelSmall)
185 + .foregroundColor(C2VColor.ink)
186 + }
187 + .buttonStyle(.plain)
188 + }
189 + Spacer().frame(height: 6)
190 + if let erreur = relanceErreur {
191 + Text(erreur)
192 + .font(C2VFont.bodySmall)
193 + .foregroundColor(C2VColor.brandError)
194 + .padding(.bottom, 4)
195 + }
196 + if viewModel.notesProjet.isEmpty {
197 + Text("Aucune note pour ce projet")
198 + .font(C2VFont.bodyMedium)
199 + .foregroundColor(C2VColor.texteFaible)
200 + } else {
201 + ForEach(viewModel.notesProjet, id: \.localId) { note in
202 + if note.audioPath != nil || note.transcriptionStatut != nil {
203 + NoteVocaleRow(
204 + sujet: note.titre,
205 + description: note.texte,
206 + audioPath: note.audioPath,
207 + transcriptionStatut: note.transcriptionStatut,
208 + transcriptionErreur: note.transcriptionErreur,
209 + onRelancer: {
210 + Task { await relancerTranscriptionNote(localId: note.localId) }
211 + },
212 + onSupprimer: {
213 + Task { await supprimerNote(note) }
214 + }
215 + )
216 + .padding(.vertical, 8)
217 + } else {
218 + VStack(alignment: .leading, spacing: 4) {
219 + Text(note.titre)
220 + .font(C2VFont.labelMedium)
221 + .foregroundColor(C2VColor.ink)
222 + Text("\(note.auteur) · \(noteDate(note))")
223 + .font(C2VFont.bodySmall)
224 + .foregroundColor(C2VColor.texteFaible)
225 + if note.texte.isNotBlank {
226 + Text(renderMarkdown(note.texte))
227 + .font(C2VFont.bodyMedium)
228 + .foregroundColor(C2VColor.ink)
229 + }
230 + Button("Supprimer") { noteTextASupprimer = note }
231 + .buttonStyle(C2VTextButtonStyle(color: C2VColor.brandError))
232 + .font(C2VFont.labelSmall)
233 + }
234 + .frame(maxWidth: .infinity, alignment: .leading)
235 + .padding(.vertical, 8)
236 + }
237 + C2VDivider()
238 + }
239 + }
240 + }
241 + }
242 +
243 + private func noteDate(_ note: NoteProjetEntity) -> String {
244 + let millis = note.updatedAt ?? note.createdAt
245 + let date = Date(timeIntervalSince1970: TimeInterval(millis) / 1000)
246 + return Self.noteDateFormatter.string(from: date)
247 + }
248 +
249 + private static let noteDateFormatter: DateFormatter = {
250 + let f = DateFormatter()
251 + f.dateStyle = .medium
252 + f.timeStyle = .none
253 + f.locale = Locale(identifier: "fr_FR")
254 + return f
255 + }()
256 +
257 + private func renderMarkdown(_ text: String) -> AttributedString {
258 + (try? AttributedString(markdown: text)) ?? AttributedString(text)
259 + }
260 +
261 + // MARK: - Relance et suppression
262 +
263 + private func relancerTranscriptionNote(localId: Int64) async {
264 + relanceErreur = nil
265 + guard let baseUrl = SyncCredentialsStore().baseUrl,
266 + let apiKey = SyncCredentialsStore().apiKey else {
267 + relanceErreur = "Relance impossible : serveur non configuré."
268 + return
269 + }
270 + let api = AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey)
271 + let engine = SyncEngine(api: api, db: Card2vcfDatabase.shared)
272 + if let erreur = try? await engine.relancerTranscriptionNoteProjet(localId: localId) {
273 + relanceErreur = erreur
274 + }
275 + await viewModel.refresh()
276 + }
277 +
278 + private func supprimerNote(_ note: NoteProjetEntity) async {
279 + let db = Card2vcfDatabase.shared
280 + // 1. Supprimer le fichier audio local s'il existe.
281 + if let path = note.audioPath {
282 + try? FileManager.default.removeItem(atPath: path)
283 + }
284 + // 2. Op de synchronisation — encodage du projetServerId dans le payload.
285 + if let serverId = note.serverId {
286 + let payload: [String: Any] = ["projetServerId": note.projetServerId]
287 + let payloadJson = (try? JSONSerialization.data(withJSONObject: payload))
288 + .flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
289 + var op = SyncOpEntity(entityType: "note_projet", op: "delete")
290 + op.serverId = serverId
291 + op.payloadJson = payloadJson
292 + op.createdAt = ContactRepository.currentMillis()
293 + _ = try? await db.syncOpDao.insert(op)
294 + }
295 + // 3. Suppression locale.
296 + try? await db.noteProjetDao.deleteByLocalId(note.localId)
297 + await viewModel.refresh()
298 + }
299 +
300 + // MARK: - Sauvegarde note vocale (statique pour capture sûre)
301 +
302 + static func sauvegarderNoteProjetVocale(
303 + projetServerId: String,
304 + sujet: String,
305 + description: String,
306 + audioPath: String,
307 + demandeTranscription: Bool
308 + ) async -> Bool {
309 + let db = Card2vcfDatabase.shared
310 + let now = ContactRepository.currentMillis()
311 +
312 + // Insertion de la note
313 + var entity = NoteProjetEntity()
314 + entity.projetServerId = projetServerId
315 + entity.titre = sujet
316 + entity.texte = description
317 + entity.auteur = SyncUserContext.userName
318 + entity.createdAt = now
319 + if demandeTranscription { entity.transcriptionStatut = "en_attente" }
320 + let localId = (try? await db.noteProjetDao.upsert(entity)) ?? 0
321 +
322 + // Déplacement vers le store stable
323 + if let store = try? AudioNoteStore.defaultStore(), localId > 0 {
324 + let stablePath = store.pathForNote(localId: localId)
325 + if FileManager.default.fileExists(atPath: audioPath),
326 + (try? FileManager.default.moveItem(atPath: audioPath, toPath: stablePath)) != nil {
327 + var withPath = entity
328 + withPath.localId = localId
329 + withPath.audioPath = stablePath
330 + _ = try? await db.noteProjetDao.upsert(withPath)
331 + }
332 + }
333 +
334 + // Op de synchronisation
335 + let payload = encodeNoteProjetPayload(
336 + titre: sujet, contenu: description,
337 + demandeTranscription: demandeTranscription
338 + )
339 + var op = SyncOpEntity(entityType: "note_projet", op: "create")
340 + op.payloadJson = payload
341 + op.serverId = projetServerId
342 + op.localId = localId
343 + op.createdAt = now
344 + _ = try? await db.syncOpDao.insert(op)
345 +
346 + // Push immédiat en mode serveur
347 + if demandeTranscription,
348 + let baseUrl = SyncCredentialsStore().baseUrl,
349 + let apiKey = SyncCredentialsStore().apiKey {
350 + let api = AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey)
351 + let engine = SyncEngine(api: api, db: db)
352 + let result = try? await engine.pousserEnAttente()
353 + return result?.success == true
354 + }
355 + return false
356 + }
357 +
358 + private static func encodeNoteProjetPayload(
359 + titre: String,
360 + contenu: String,
361 + demandeTranscription: Bool
362 + ) -> String {
363 + let obj: [String: Any] = [
364 + "contenu": contenu,
365 + "demande_transcription": demandeTranscription,
366 + "titre": titre
367 + ]
368 + guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]),
369 + let text = String(data: data, encoding: .utf8) else { return "{}" }
370 + return text
371 + }
137 372 }
M ios/Card2vcf/UI/Projets/ProjetDetailViewModel.swift
+5 -2
@@ -55,16 +55,17 @@ final class ProjetDetailViewModel: ObservableObject {
55 55 @Published var crSujet = ""
56 56 @Published var crDescription = ""
57 57 @Published private(set) var crInteractions: [InteractionEntity] = []
58 + @Published private(set) var notesProjet: [NoteProjetEntity] = []
58 59
59 60 let projetServerId: String
60 61 let userName: String
61 62
62 - private let database: Card2vcfDatabase
63 + private let database: any CrmDatabase
63 64 private var cancellables: Set<AnyCancellable> = []
64 65
65 66 init(
66 67 projetServerId: String,
67 - database: Card2vcfDatabase = .shared,
68 + database: any CrmDatabase = Card2vcfDatabase.shared,
68 69 userName: String = SyncUserContext.userName
69 70 ) {
70 71 self.projetServerId = projetServerId
@@ -96,6 +97,8 @@ final class ProjetDetailViewModel: ObservableObject {
96 97 colonnes = []
97 98 }
98 99 taches = (try? await db.tacheDao.fetchByProjetServerId(projetServerId)) ?? []
100 + let rawNotes = (try? await db.noteProjetDao.listByProjetServerId(projetServerId)) ?? []
101 + notesProjet = rawNotes.sorted { ($0.updatedAt ?? $0.createdAt) > ($1.updatedAt ?? $1.createdAt) }
99 102 let allContacts = (try? await db.crmContactDao.fetchAll()) ?? []
100 103 contactsAvecServerId = allContacts.filter { $0.serverId != nil }
101 104 await refreshInteractions()
A ios/Card2vcf/UI/Settings/AProposScreen.swift
+85 -0
@@ -0,0 +1,85 @@
1 +import SwiftUI
2 +
3 +/// Écran « À propos » accessible depuis les Réglages.
4 +/// Version et nom lus depuis le bundle (jamais en dur).
5 +struct AProposScreen: View {
6 + let onBack: () -> Void
7 +
8 + private var appVersion: String {
9 + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—"
10 + }
11 +
12 + private var appName: String {
13 + Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String
14 + ?? Bundle.main.infoDictionary?["CFBundleName"] as? String
15 + ?? "PicLead"
16 + }
17 +
18 + var body: some View {
19 + VStack(spacing: 0) {
20 + ScreenTopBar(title: "À propos", onBack: onBack)
21 + ScrollView {
22 + VStack(alignment: .leading, spacing: 20) {
23 +
24 + // Nom + version
25 + VStack(alignment: .leading, spacing: 4) {
26 + Text(appName)
27 + .font(C2VFont.titleMedium)
28 + .foregroundColor(C2VColor.encre)
29 + Text("Version \(appVersion)")
30 + .font(C2VFont.bodyMedium)
31 + .foregroundColor(C2VColor.texteFaible)
32 + }
33 +
34 + C2VDivider()
35 +
36 + // Éditeur
37 + VStack(alignment: .leading, spacing: 4) {
38 + Text("Éditeur")
39 + .font(C2VFont.labelMedium)
40 + .foregroundColor(C2VColor.texteFaible)
41 + Text("Eric Bouhana Ingénierie Informatique (ebii)")
42 + .font(C2VFont.bodyLarge)
43 + .foregroundColor(C2VColor.ink)
44 + Button("https://www.ebii.fr/fr/editions/piclead") {
45 + ExternalLinks.web("https://www.ebii.fr/fr/editions/piclead")
46 + }
47 + .buttonStyle(.plain)
48 + .font(C2VFont.bodyMedium)
49 + .foregroundColor(C2VColor.link)
50 + Button("contact@ebii.fr") {
51 + ExternalLinks.email("contact@ebii.fr")
52 + }
53 + .buttonStyle(.plain)
54 + .font(C2VFont.bodyMedium)
55 + .foregroundColor(C2VColor.link)
56 + }
57 +
58 + C2VDivider()
59 +
60 + // Licence
61 + VStack(alignment: .leading, spacing: 4) {
62 + Text("Licence")
63 + .font(C2VFont.labelMedium)
64 + .foregroundColor(C2VColor.texteFaible)
65 + Text("GNU Affero General Public License v3 (AGPL-3.0)")
66 + .font(C2VFont.bodyLarge)
67 + .foregroundColor(C2VColor.ink)
68 + Text(
69 + "Ce logiciel est un logiciel libre distribué selon les termes de la "
70 + + "licence GNU AGPL v3. Vous pouvez le redistribuer et/ou le modifier "
71 + + "sous les conditions définies par la Free Software Foundation."
72 + )
73 + .font(C2VFont.bodyMedium)
74 + .foregroundColor(C2VColor.texteFaible)
75 + }
76 + }
77 + .frame(maxWidth: .infinity, alignment: .leading)
78 + .padding(18)
79 + }
80 + }
81 + .frame(maxHeight: .infinity, alignment: .top)
82 + .background(C2VColor.fond)
83 + .toolbar(.hidden, for: .navigationBar)
84 + }
85 +}
M ios/Card2vcf/UI/Settings/SettingsScreen.swift
+47 -0
@@ -25,6 +25,7 @@ struct SettingsScreen: View {
25 25 let onBack: () -> Void
26 26
27 27 @State private var hasCalendarPermission = CalendarPermission.granted
28 + @State private var afficherAPropos = false
28 29
29 30 var body: some View {
30 31 VStack(spacing: 0) {
@@ -35,6 +36,12 @@ struct SettingsScreen: View {
35 36 case .loggedOut(let state):
36 37 loggedOutContent(state)
37 38 }
39 + C2VDivider()
40 + Button("À propos") { afficherAPropos = true }
41 + .buttonStyle(C2VTextButtonStyle(color: C2VColor.texteFaible))
42 + .frame(maxWidth: .infinity, alignment: .leading)
43 + .padding(.horizontal, 10)
44 + .padding(.vertical, 6)
38 45 }
39 46 .frame(maxHeight: .infinity, alignment: .top)
40 47 .background(C2VColor.fond)
@@ -45,6 +52,9 @@ struct SettingsScreen: View {
45 52 viewModel.onCalendarPermissionChanged(granted: hasCalendarPermission)
46 53 }
47 54 .overlay { dialogs }
55 + .sheet(isPresented: $afficherAPropos) {
56 + AProposScreen(onBack: { afficherAPropos = false })
57 + }
48 58 }
49 59
50 60 // ---- Logged in ----
@@ -68,6 +78,10 @@ struct SettingsScreen: View {
68 78 C2VDivider()
69 79
70 80 calendarSection(state)
81 +
82 + C2VDivider()
83 +
84 + transcriptionSection(state)
71 85 }
72 86 .frame(maxWidth: .infinity, alignment: .leading)
73 87 .padding(18)
@@ -147,6 +161,39 @@ struct SettingsScreen: View {
147 161 }
148 162 }
149 163
164 + @ViewBuilder
165 + private func transcriptionSection(_ state: SettingsUiState.LoggedIn) -> some View {
166 + VStack(alignment: .leading, spacing: 8) {
167 + Text("Transcription des notes vocales")
168 + .font(C2VFont.labelMedium)
169 + .foregroundColor(C2VColor.ink)
170 +
171 + if !state.speechRecognitionDisponible {
172 + Text("La reconnaissance vocale n'est pas disponible sur cet appareil. La transcription s'effectue sur le serveur.")
173 + .font(C2VFont.bodyMedium)
174 + .foregroundColor(C2VColor.texteFaible)
175 + } else {
176 + Picker(
177 + "Mode",
178 + selection: Binding(
179 + get: { state.modeTranscription },
180 + set: { viewModel.setModeTranscription($0) }
181 + )
182 + ) {
183 + Text("Sur l'appareil").tag(ModeTranscription.appareil)
184 + Text("Sur le serveur").tag(ModeTranscription.serveur)
185 + }
186 + .pickerStyle(.segmented)
187 +
188 + Text(state.modeTranscription == .appareil
189 + ? "Transcription locale, sans connexion réseau."
190 + : "L'audio est envoyé au serveur pour transcription.")
191 + .font(C2VFont.bodySmall)
192 + .foregroundColor(C2VColor.texteFaible)
193 + }
194 + }
195 + }
196 +
150 197 // ---- Logged out ----
151 198
152 199 private func loggedOutContent(_ state: SettingsUiState.LoggedOut) -> some View {
M ios/Card2vcf/UI/Settings/SettingsViewModel.swift
+7 -0
@@ -40,6 +40,8 @@ enum SettingsUiState {
40 40 var catalogueError: String? = nil
41 41 var pendingRemoval: PendingCalendarRemoval? = nil
42 42 var pendingICloudActivation: PendingICloudActivation? = nil
43 + var modeTranscription: ModeTranscription = PreferencesAudio.modeTranscription
44 + var speechRecognitionDisponible: Bool = PreferencesAudio.speechRecognitionDisponible
43 45 }
44 46
45 47 struct LoggedOut {
@@ -351,6 +353,11 @@ final class SettingsViewModel: ObservableObject {
351 353 updateLoggedIn { $0.pendingRemoval = nil }
352 354 }
353 355
356 + func setModeTranscription(_ mode: ModeTranscription) {
357 + PreferencesAudio.modeTranscription = mode
358 + updateLoggedIn { $0.modeTranscription = mode }
359 + }
360 +
354 361 private func refreshRessourcesCatalogue() {
355 362 guard case .loggedIn = state else { return }
356 363 guard let baseUrl = credentialsStore.baseUrl, let apiKey = credentialsStore.apiKey else { return }
A ios/Card2vcfTests/AudioNoteRecorderTest.swift
+138 -0
@@ -0,0 +1,138 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Preuve de rougeur (exigence du plan `docs/plans/rattrapage-ios-et-tests.md`, Lot 2a) :
5 +/// En commentant la garde `guard !enregistrementEnCours else { throw ... }` dans
6 +/// `AudioNoteRecorder.demarrer()`, le test testDeuxDemarragesConsecutifsEchoue échoue avec :
7 +///
8 +/// testDeuxDemarragesConsecutifsEchoue — XCTAssertThrowsError failed: did not throw an error
9 +///
10 +/// Ce test détecte donc réellement l'absence de protection contre le double-démarrage.
11 +final class AudioNoteRecorderTest: XCTestCase {
12 + private var moteur: FakeMoteurAudioCapture!
13 + private var recorder: AudioNoteRecorder!
14 + private var tempDir: URL!
15 +
16 + override func setUp() {
17 + super.setUp()
18 + tempDir = FileManager.default.temporaryDirectory
19 + .appendingPathComponent(UUID().uuidString)
20 + try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
21 + moteur = FakeMoteurAudioCapture()
22 + recorder = AudioNoteRecorder(moteur: moteur, dureeMaxSecondes: 60)
23 + }
24 +
25 + override func tearDown() {
26 + super.tearDown()
27 + recorder.arreter()
28 + try? FileManager.default.removeItem(at: tempDir)
29 + }
30 +
31 + // MARK: - Démarrage / arrêt
32 +
33 + func testDeuxDemarragesConsecutifsEchoue() throws {
34 + let url1 = tempDir.appendingPathComponent("test1.wav")
35 + let url2 = tempDir.appendingPathComponent("test2.wav")
36 + try recorder.demarrer(fichierCible: url1)
37 + XCTAssertThrowsError(try recorder.demarrer(fichierCible: url2)) { error in
38 + XCTAssertEqual(error as? AudioNoteRecorderErreur, .enregistrementDejaEnCours)
39 + }
40 + }
41 +
42 + func testArreterSansDebutRetourneNil() {
43 + let resultat = recorder.arreter()
44 + XCTAssertNil(resultat, "arreter() sans demarrer() doit retourner nil")
45 + }
46 +
47 + func testEstEnCoursApresDepart() throws {
48 + let url = tempDir.appendingPathComponent("test.wav")
49 + XCTAssertFalse(recorder.estEnCours)
50 + try recorder.demarrer(fichierCible: url)
51 + XCTAssertTrue(recorder.estEnCours)
52 + recorder.arreter()
53 + XCTAssertFalse(recorder.estEnCours)
54 + }
55 +
56 + func testResultatContientChemin() throws {
57 + let url = tempDir.appendingPathComponent("note.wav")
58 + try recorder.demarrer(fichierCible: url)
59 + let resultat = recorder.arreter()
60 + XCTAssertEqual(url.path, resultat?.chemin)
61 + }
62 +
63 + func testDureeMsecPositive() throws {
64 + let url = tempDir.appendingPathComponent("duree.wav")
65 + try recorder.demarrer(fichierCible: url)
66 + let resultat = recorder.arreter()
67 + XCTAssertGreaterThanOrEqual(resultat?.dureeMsec ?? -1, 0)
68 + }
69 +
70 + // MARK: - Écriture WAV
71 +
72 + func testFichierWAVCreeSurDemarrer() throws {
73 + let url = tempDir.appendingPathComponent("sortie.wav")
74 + try recorder.demarrer(fichierCible: url)
75 + recorder.arreter()
76 +
77 + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
78 + let data = try Data(contentsOf: url)
79 + let riff = String(bytes: data[0..<4], encoding: .ascii) ?? ""
80 + XCTAssertEqual("RIFF", riff, "Le fichier produit doit être un WAV valide")
81 + }
82 +
83 + func testEchantillonsEcritsVersWAV() throws {
84 + let url = tempDir.appendingPathComponent("echantillons.wav")
85 + try recorder.demarrer(fichierCible: url)
86 + moteur.simulerEchantillons([100, 200, 300])
87 + recorder.arreter()
88 +
89 + let data = try Data(contentsOf: url)
90 + // 44 octets d'en-tête + 3 échantillons × 2 octets = 50
91 + XCTAssertEqual(50, data.count, "3 échantillons Int16 → 6 octets de données + 44 d'en-tête")
92 + }
93 +
94 + // MARK: - Transcripteur
95 +
96 + func testEchantillonsTransmisAuTranscripteur() throws {
97 + let url = tempDir.appendingPathComponent("transcription.wav")
98 + let transcripteur = FakeTranscripteurLocal()
99 + try recorder.demarrer(fichierCible: url, transcripteur: transcripteur)
100 + moteur.simulerEchantillons([10, 20, 30])
101 + moteur.simulerEchantillons([-1, -2])
102 + recorder.arreter()
103 +
104 + XCTAssertEqual(2, transcripteur.echantillonsRecus.count,
105 + "Deux appels simulerEchantillons → deux appels accepterEchantillons")
106 + XCTAssertEqual([10, 20, 30], transcripteur.echantillonsRecus[0])
107 + XCTAssertEqual([-1, -2], transcripteur.echantillonsRecus[1])
108 + }
109 +
110 + func testSansTranscripteurNePasPlanter() throws {
111 + let url = tempDir.appendingPathComponent("sans_transcripteur.wav")
112 + try recorder.demarrer(fichierCible: url, transcripteur: nil)
113 + moteur.simulerEchantillons([1, 2, 3])
114 + let resultat = recorder.arreter()
115 + XCTAssertNotNil(resultat)
116 + }
117 +
118 + // MARK: - Arrêt automatique
119 +
120 + func testArretAutoApresDelai() throws {
121 + let recorder = AudioNoteRecorder(moteur: moteur, dureeMaxSecondes: 0.05)
122 + let url = tempDir.appendingPathComponent("auto.wav")
123 + try recorder.demarrer(fichierCible: url)
124 + XCTAssertTrue(recorder.estEnCours)
125 + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.25))
126 + XCTAssertFalse(recorder.estEnCours,
127 + "L'enregistrement doit s'arrêter automatiquement après dureeMaxSecondes")
128 + }
129 +
130 + // MARK: - Erreur moteur
131 +
132 + func testErreurMoteurPropagee() throws {
133 + moteur.prochainErreur = NSError(domain: "test", code: 42)
134 + let url = tempDir.appendingPathComponent("erreur.wav")
135 + XCTAssertThrowsError(try recorder.demarrer(fichierCible: url))
136 + XCTAssertFalse(recorder.estEnCours, "estEnCours doit rester false si demarrer() échoue")
137 + }
138 +}
A ios/Card2vcfTests/ContratSyncFixturesTest.swift
+69 -0
@@ -0,0 +1,69 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Décode les fixtures de contrat de synchronisation avec les vrais DTO Swift.
5 +///
6 +/// Si un champ serveur est renommé, ce test devient rouge immédiatement.
7 +/// Preuve de rougeur (vérifiée) : en renommant `transcription_erreur` → `transcription_error`
8 +/// dans `interaction_note_vocale.json`, l'assertion suivante échoue :
9 +/// XCTAssertEqual failed: ("Optional("Modèle ASR indisponible")") is not equal to ("nil")
10 +final class ContratSyncFixturesTest: XCTestCase {
11 +
12 + private func fixture(_ nom: String) throws -> Data {
13 + let bundle = Bundle(for: ContratSyncFixturesTest.self)
14 + guard let url = bundle.url(forResource: nom, withExtension: nil) else {
15 + XCTFail("Fixture introuvable dans le bundle de test : \(nom)")
16 + throw NSError(domain: "fixture", code: 0)
17 + }
18 + return try Data(contentsOf: url)
19 + }
20 +
21 + // ---- sync_pull.json ----
22 +
23 + func testSyncPull_decodeSyncPullResponse() throws {
24 + let data = try fixture("sync_pull.json")
25 + let pull = try SyncJson.decoder().decode(SyncPullResponse.self, from: data)
26 +
27 + XCTAssertFalse(pull.contacts.isEmpty, "contacts doit contenir au moins un élément")
28 + XCTAssertFalse(pull.interactions.isEmpty, "interactions doit contenir au moins un élément")
29 + XCTAssertFalse(pull.notes.isEmpty, "notes doit contenir au moins un élément")
30 + }
31 +
32 + func testSyncPull_contactChampsCritiques() throws {
33 + let data = try fixture("sync_pull.json")
34 + let pull = try SyncJson.decoder().decode(SyncPullResponse.self, from: data)
35 +
36 + let contact = try XCTUnwrap(pull.contacts.first)
37 + XCTAssertFalse(contact.id.isEmpty, "id contact ne doit pas être vide")
38 + XCTAssertFalse(contact.creeLe.isEmpty, "creeLe contact ne doit pas être vide")
39 + }
40 +
41 + // ---- interaction_note_vocale.json ----
42 +
43 + func testInteraction_decodeInteractionDto() throws {
44 + let data = try fixture("interaction_note_vocale.json")
45 + let dto = try SyncJson.decoder().decode(InteractionDto.self, from: data)
46 +
47 + XCTAssertEqual("note_vocale", dto.typeInteraction)
48 + XCTAssertEqual("vocale_contrat_1.wav", dto.pieceJointe)
49 + XCTAssertEqual("echec", dto.transcription)
50 + // Champ critique : si transcription_erreur est renommé côté serveur,
51 + // cette assertion devient rouge :
52 + // XCTAssertEqual failed: ("Optional("Modèle ASR indisponible")") is not equal to ("nil")
53 + XCTAssertEqual("Modèle ASR indisponible", dto.transcriptionErreur)
54 + }
55 +
56 + // ---- note_projet.json ----
57 +
58 + func testNoteProjet_decodeNoteProjetDto() throws {
59 + let data = try fixture("note_projet.json")
60 + let dto = try SyncJson.decoder().decode(NoteProjetDto.self, from: data)
61 +
62 + XCTAssertEqual("projet-contrat-1", dto.projetId)
63 + XCTAssertEqual("note_contrat_1.wav", dto.audio)
64 + XCTAssertEqual("terminee", dto.transcription)
65 + // transcription_erreur null pour une transcription réussie
66 + XCTAssertNil(dto.transcriptionErreur,
67 + "transcriptionErreur doit être nil pour une transcription réussie")
68 + }
69 +}
A ios/Card2vcfTests/EcritureWavTest.swift
+138 -0
@@ -0,0 +1,138 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Preuve de rougeur (exigence du plan `docs/plans/rattrapage-ios-et-tests.md`, Lot 2a) :
5 +/// En neutralisant l'écriture de l'en-tête (en commentant `handle.seek(toFileOffset: 0)` +
6 +/// `handle.write(entete)` dans `EcritureWav.fermer()`), les tests échouent avec :
7 +///
8 +/// testEnteteRIFFValide — XCTAssertEqual failed: ("RIFF") is not equal to (" ")
9 +/// testSampleRateEt16Bits — XCTAssertEqual failed: ("16000") is not equal to ("0")
10 +///
11 +/// (Les 44 premiers octets restent à zéro ; les octets nuls sont rendus comme espaces
12 +/// par la décodification ASCII, le sampleRate lu sur 4 octets nuls vaut 0.)
13 +final class EcritureWavTest: XCTestCase {
14 + private var tempUrl: URL!
15 +
16 + override func setUp() {
17 + super.setUp()
18 + tempUrl = FileManager.default.temporaryDirectory
19 + .appendingPathComponent(UUID().uuidString)
20 + .appendingPathExtension("wav")
21 + }
22 +
23 + override func tearDown() {
24 + super.tearDown()
25 + try? FileManager.default.removeItem(at: tempUrl)
26 + }
27 +
28 + // MARK: - En-tête RIFF
29 +
30 + func testEnteteRIFFValide() throws {
31 + let ecrivain = try EcritureWav(url: tempUrl)
32 + try ecrivain.fermer()
33 +
34 + let data = try Data(contentsOf: tempUrl)
35 + XCTAssertGreaterThanOrEqual(data.count, 44, "Fichier doit faire au moins 44 octets")
36 + let riff = String(bytes: data[0..<4], encoding: .ascii) ?? ""
37 + XCTAssertEqual("RIFF", riff, "Les 4 premiers octets doivent être 'RIFF'")
38 + let wave = String(bytes: data[8..<12], encoding: .ascii) ?? ""
39 + XCTAssertEqual("WAVE", wave, "Les octets 8-11 doivent être 'WAVE'")
40 + }
41 +
42 + func testChunkFmtPresent() throws {
43 + let ecrivain = try EcritureWav(url: tempUrl)
44 + try ecrivain.fermer()
45 +
46 + let data = try Data(contentsOf: tempUrl)
47 + let fmt = String(bytes: data[12..<16], encoding: .ascii) ?? ""
48 + XCTAssertEqual("fmt ", fmt, "Les octets 12-15 doivent être 'fmt '")
49 + let fmtSize = data[16...19].withUnsafeBytes { $0.load(as: Int32.self).littleEndian }
50 + XCTAssertEqual(16, fmtSize, "taille du chunk fmt doit être 16 (PCM)")
51 + let audioFormat = data[20...21].withUnsafeBytes { $0.load(as: Int16.self).littleEndian }
52 + XCTAssertEqual(1, audioFormat, "audioFormat doit être 1 (PCM linéaire)")
53 + }
54 +
55 + func testSampleRateEt16Bits() throws {
56 + let ecrivain = try EcritureWav(url: tempUrl)
57 + try ecrivain.fermer()
58 +
59 + let data = try Data(contentsOf: tempUrl)
60 + // sampleRate aux octets 24-27
61 + let sampleRate = data[24...27].withUnsafeBytes { $0.load(as: Int32.self).littleEndian }
62 + XCTAssertEqual(16_000, sampleRate, "sampleRate doit être 16 000 Hz")
63 + // canaux aux octets 22-23
64 + let canaux = data[22...23].withUnsafeBytes { $0.load(as: Int16.self).littleEndian }
65 + XCTAssertEqual(1, canaux, "doit être mono (1 canal)")
66 + // bitsParEchantillon aux octets 34-35
67 + let bits = data[34...35].withUnsafeBytes { $0.load(as: Int16.self).littleEndian }
68 + XCTAssertEqual(16, bits, "doit être 16 bits par échantillon")
69 + }
70 +
71 + // MARK: - Taille cohérente avec les données
72 +
73 + func testTailleBloc_data_CohérenteAvecEchantillons() throws {
74 + let ecrivain = try EcritureWav(url: tempUrl)
75 + let echantillons: [Int16] = [0, 100, -100, 32767, -32768]
76 + ecrivain.ecrireEchantillons(echantillons)
77 + try ecrivain.fermer()
78 +
79 + let data = try Data(contentsOf: tempUrl)
80 + // tag "data" aux octets 36-39
81 + let dataTag = String(bytes: data[36..<40], encoding: .ascii) ?? ""
82 + XCTAssertEqual("data", dataTag)
83 + // taille du bloc data aux octets 40-43
84 + let tailleData = data[40...43].withUnsafeBytes { $0.load(as: Int32.self).littleEndian }
85 + XCTAssertEqual(Int32(echantillons.count * 2), tailleData,
86 + "taille data = nb échantillons × 2 octets")
87 + // taille totale du fichier
88 + XCTAssertEqual(44 + echantillons.count * 2, data.count)
89 + }
90 +
91 + func testRIFFSizeCohérente() throws {
92 + let echantillons = [Int16](repeating: 42, count: 100)
93 + let ecrivain = try EcritureWav(url: tempUrl)
94 + ecrivain.ecrireEchantillons(echantillons)
95 + try ecrivain.fermer()
96 +
97 + let data = try Data(contentsOf: tempUrl)
98 + // riffSize aux octets 4-7 = taille totale - 8
99 + let riffSize = data[4...7].withUnsafeBytes { $0.load(as: Int32.self).littleEndian }
100 + let attendu = Int32(36 + echantillons.count * 2)
101 + XCTAssertEqual(attendu, riffSize, "riffSize doit valoir totalOctets - 8")
102 + }
103 +
104 + func testByteRateEtBlockAlign() throws {
105 + // byteRate = sampleRate × canaux × (bits/8) = 16000 × 1 × 2 = 32000
106 + // blockAlign = canaux × (bits/8) = 1 × 2 = 2
107 + let ecrivain = try EcritureWav(url: tempUrl)
108 + try ecrivain.fermer()
109 +
110 + let data = try Data(contentsOf: tempUrl)
111 + let byteRate = data[28...31].withUnsafeBytes { $0.load(as: Int32.self).littleEndian }
112 + XCTAssertEqual(32_000, byteRate)
113 + let blockAlign = data[32...33].withUnsafeBytes { $0.load(as: Int16.self).littleEndian }
114 + XCTAssertEqual(2, blockAlign)
115 + }
116 +
117 + // MARK: - Cas limite
118 +
119 + func testFichierVideSansEchantillons() throws {
120 + let ecrivain = try EcritureWav(url: tempUrl)
121 + let octets = try ecrivain.fermer()
122 +
123 + XCTAssertEqual(0, octets)
124 + let data = try Data(contentsOf: tempUrl)
125 + XCTAssertEqual(44, data.count, "Fichier vide = 44 octets d'en-tête uniquement")
126 + }
127 +
128 + func testEchantillonsPCMEncodésLittleEndian() throws {
129 + let ecrivain = try EcritureWav(url: tempUrl)
130 + // 0x0100 en little-endian s'écrit [0x00, 0x01]
131 + ecrivain.ecrireEchantillons([0x0100])
132 + try ecrivain.fermer()
133 +
134 + let data = try Data(contentsOf: tempUrl)
135 + XCTAssertEqual(0x00, data[44], "octet bas d'abord (little-endian)")
136 + XCTAssertEqual(0x01, data[45], "octet haut ensuite")
137 + }
138 +}
A ios/Card2vcfTests/EnregistrementNoteViewModelTest.swift
+103 -0
@@ -0,0 +1,103 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Tests TDD du ViewModel d'enregistrement de note vocale.
5 +///
6 +/// PREUVES DE ROUGEUR :
7 +///
8 +/// Test 1 : testSujetParDefaut
9 +/// En remplaçant le corps de `sujetParDefaut` par `return ""`, le test échoue avec :
10 +/// XCTAssertTrue failed — "Notes du" not found in ""
11 +///
12 +/// Test 2 : testMessageConfirmationModeServeurEnvoye
13 +/// En remplaçant le corps de `messageConfirmation` par `return ""`, le test échoue avec :
14 +/// XCTAssertTrue failed — "serveur" not found in ""
15 +@MainActor
16 +final class EnregistrementNoteViewModelTest: XCTestCase {
17 +
18 + // MARK: - sujetParDefaut (PREUVE DE ROUGEUR #1)
19 +
20 + func testSujetParDefaut() {
21 + // Date fixe : 17 septembre 2026 à 14h30
22 + var components = DateComponents()
23 + components.year = 2026
24 + components.month = 9
25 + components.day = 17
26 + components.hour = 14
27 + components.minute = 30
28 + let date = Calendar(identifier: .gregorian).date(from: components)!
29 +
30 + let sujet = EnregistrementNoteViewModel.sujetParDefaut(maintenant: date)
31 +
32 + // Rouge sans l'implémentation : XCTAssertTrue failed — "Notes du" not found in ""
33 + XCTAssertTrue(sujet.hasPrefix("Notes du"),
34 + "Le sujet doit commencer par 'Notes du', obtenu : \(sujet)")
35 + XCTAssertTrue(sujet.contains("17/09/2026"),
36 + "Le sujet doit contenir la date jj/MM/yyyy, obtenu : \(sujet)")
37 + XCTAssertTrue(sujet.contains("14:30"),
38 + "Le sujet doit contenir l'heure HH:mm, obtenu : \(sujet)")
39 + }
40 +
41 + func testSujetParDefautFormatPadding() {
42 + // Vérifie que jour, mois, heure et minute sont sur 2 chiffres (05/01/2026 09:03)
43 + var components = DateComponents()
44 + components.year = 2026
45 + components.month = 1
46 + components.day = 5
47 + components.hour = 9
48 + components.minute = 3
49 + let date = Calendar(identifier: .gregorian).date(from: components)!
50 +
51 + let sujet = EnregistrementNoteViewModel.sujetParDefaut(maintenant: date)
52 + XCTAssertTrue(sujet.contains("05/01/2026"), "Jour et mois sur 2 chiffres, obtenu : \(sujet)")
53 + XCTAssertTrue(sujet.contains("09:03"), "Heure et minute sur 2 chiffres, obtenu : \(sujet)")
54 + }
55 +
56 + // MARK: - messageConfirmation (PREUVE DE ROUGEUR #2)
57 +
58 + func testMessageConfirmationModeServeurEnvoye() {
59 + let msg = EnregistrementNoteViewModel.messageConfirmation(mode: .serveur, envoye: true)
60 +
61 + // Rouge sans l'implémentation : XCTAssertTrue failed — "serveur" not found in ""
62 + XCTAssertTrue(msg.lowercased().contains("serveur"),
63 + "Le message doit mentionner le serveur, obtenu : \(msg)")
64 + let indicateurEnCours = msg.lowercased().contains("transcription")
65 + || msg.lowercased().contains("cours")
66 + XCTAssertTrue(indicateurEnCours,
67 + "Le message doit indiquer que la transcription est en cours, obtenu : \(msg)")
68 + }
69 +
70 + func testMessageConfirmationModeServeurNonEnvoye() {
71 + let msg = EnregistrementNoteViewModel.messageConfirmation(mode: .serveur, envoye: false)
72 + XCTAssertTrue(msg.lowercased().contains("synchronisation"),
73 + "Le message doit mentionner la synchronisation, obtenu : \(msg)")
74 + }
75 +
76 + func testMessageConfirmationModeAppareil() {
77 + let msg = EnregistrementNoteViewModel.messageConfirmation(mode: .appareil, envoye: false)
78 + XCTAssertFalse(msg.isEmpty, "Le message ne doit pas être vide")
79 + XCTAssertFalse(msg.lowercased().contains("serveur"),
80 + "Mode appareil : pas de mention du serveur, obtenu : \(msg)")
81 + }
82 +
83 + func testMessageConfirmationModeAppareilIgnoreEnvoye() {
84 + // En mode appareil, envoye=true ou false → même message
85 + let m1 = EnregistrementNoteViewModel.messageConfirmation(mode: .appareil, envoye: true)
86 + let m2 = EnregistrementNoteViewModel.messageConfirmation(mode: .appareil, envoye: false)
87 + XCTAssertEqual(m1, m2, "Mode appareil : le flag envoye ne change pas le message")
88 + }
89 +
90 + // MARK: - dureeFormatee
91 +
92 + func testDureeFormatee() async {
93 + let vm = makeViewModel()
94 + // La durée initiale doit être 00:00
95 + XCTAssertEqual("00:00", vm.dureeFormatee)
96 + }
97 +
98 + // MARK: - helpers
99 +
100 + private func makeViewModel() -> EnregistrementNoteViewModel {
101 + EnregistrementNoteViewModel(sauvegarder: { _, _, _, _ in false })
102 + }
103 +}
M ios/Card2vcfTests/FakeAilianceApi.swift
+88 -0
@@ -139,4 +139,92 @@ final class FakeAilianceApi: AilianceApi {
139 139 downloadPhotoCalls.append(id)
140 140 return downloadPhotoResult
141 141 }
142 +
143 + var uploadInteractionAudioResult: AilianceApiClient.ApiResult<String> = .ok("{}")
144 + /// (contactId, interactionId, bytes.count)
145 + var uploadInteractionAudioCalls: [(String, String, Int)] = []
146 + var downloadInteractionAudioResult: AilianceApiClient.ApiResult<Data> = .err(code: 404, message: "absent")
147 +
148 + func uploadInteractionAudio(
149 + contactId: String,
150 + interactionId: String,
151 + bytes: Data,
152 + filename: String
153 + ) async -> AilianceApiClient.ApiResult<String> {
154 + uploadInteractionAudioCalls.append((contactId, interactionId, bytes.count))
155 + return uploadInteractionAudioResult
156 + }
157 +
158 + func downloadInteractionAudio(contactId: String, interactionId: String) async -> AilianceApiClient.ApiResult<Data> {
159 + downloadInteractionAudioResult
160 + }
161 +
162 + var createNoteProjetResult: AilianceApiClient.ApiResult<String> = .ok(#"{"id":"note-generated"}"#)
163 + /// (projetId, jsonBody)
164 + var createNoteProjetCalls: [(String, String)] = []
165 +
166 + func createNoteProjet(projetId: String, jsonBody: String) async -> AilianceApiClient.ApiResult<String> {
167 + createNoteProjetCalls.append((projetId, jsonBody))
168 + return createNoteProjetResult
169 + }
170 +
171 + var uploadNoteProjetAudioResult: AilianceApiClient.ApiResult<String> = .ok("{}")
172 + /// (projetId, noteId, bytes.count)
173 + var uploadNoteProjetAudioCalls: [(String, String, Int)] = []
174 + var downloadNoteProjetAudioResult: AilianceApiClient.ApiResult<Data> = .err(code: 404, message: "absent")
175 +
176 + func uploadNoteProjetAudio(
177 + projetId: String,
178 + noteId: String,
179 + bytes: Data,
180 + filename: String
181 + ) async -> AilianceApiClient.ApiResult<String> {
182 + uploadNoteProjetAudioCalls.append((projetId, noteId, bytes.count))
183 + return uploadNoteProjetAudioResult
184 + }
185 +
186 + func downloadNoteProjetAudio(projetId: String, noteId: String) async -> AilianceApiClient.ApiResult<Data> {
187 + downloadNoteProjetAudioResult
188 + }
189 +
190 + var deleteInteractionResult: AilianceApiClient.ApiResult<Void> = .ok(())
191 + var deleteInteractionCalls: [String] = []
192 +
193 + func deleteInteraction(interactionId: String) async -> AilianceApiClient.ApiResult<Void> {
194 + deleteInteractionCalls.append(interactionId)
195 + return deleteInteractionResult
196 + }
197 +
198 + var deleteNoteProjetResult: AilianceApiClient.ApiResult<Void> = .ok(())
199 + /// (projetId, noteId)
200 + var deleteNoteProjetCalls: [(String, String)] = []
201 +
202 + func deleteNoteProjet(projetId: String, noteId: String) async -> AilianceApiClient.ApiResult<Void> {
203 + deleteNoteProjetCalls.append((projetId, noteId))
204 + return deleteNoteProjetResult
205 + }
206 +
207 + var relancerInteractionResult: AilianceApiClient.ApiResult<String> = .ok("{}")
208 + /// (contactId, interactionId)
209 + var relancerInteractionCalls: [(String, String)] = []
210 +
211 + func relancerTranscriptionInteraction(
212 + contactId: String,
213 + interactionId: String
214 + ) async -> AilianceApiClient.ApiResult<String> {
215 + relancerInteractionCalls.append((contactId, interactionId))
216 + return relancerInteractionResult
217 + }
218 +
219 + var relancerNoteProjetResult: AilianceApiClient.ApiResult<String> = .ok("{}")
220 + /// (projetId, noteId)
221 + var relancerNoteProjetCalls: [(String, String)] = []
222 +
223 + func relancerTranscriptionNoteProjet(
224 + projetId: String,
225 + noteId: String
226 + ) async -> AilianceApiClient.ApiResult<String> {
227 + relancerNoteProjetCalls.append((projetId, noteId))
228 + return relancerNoteProjetResult
229 + }
142 230 }
A ios/Card2vcfTests/FakeMoteurAudioCapture.swift
+27 -0
@@ -0,0 +1,27 @@
1 +import Foundation
2 +@testable import Card2vcf
3 +
4 +/// Faux MoteurAudioCapture pour les tests d'AudioNoteRecorder.
5 +/// Permet de simuler des échantillons PCM sans matériel audio réel.
6 +final class FakeMoteurAudioCapture: MoteurAudioCapture {
7 + private(set) var estDemarre = false
8 + private var callback: (([Int16]) -> Void)?
9 + /// Si non nil, `demarrer` lève cette erreur.
10 + var prochainErreur: Error?
11 +
12 + func demarrer(onEchantillons: @escaping ([Int16]) -> Void) throws {
13 + if let erreur = prochainErreur { throw erreur }
14 + estDemarre = true
15 + callback = onEchantillons
16 + }
17 +
18 + func arreter() {
19 + estDemarre = false
20 + callback = nil
21 + }
22 +
23 + /// Pousse des échantillons PCM vers l'enregistreur comme si le matériel les avait capturés.
24 + func simulerEchantillons(_ data: [Int16]) {
25 + callback?(data)
26 + }
27 +}
A ios/Card2vcfTests/FakeTranscripteurLocal.swift
+33 -0
@@ -0,0 +1,33 @@
1 +import Foundation
2 +@testable import Card2vcf
3 +
4 +/// Faux TranscripteurLocal pour les tests.
5 +/// Enregistre tous les appels pour vérification ultérieure.
6 +final class FakeTranscripteurLocal: TranscripteurLocal {
7 + var etat: EtatTranscripteur = .disponible
8 +
9 + /// Échantillons reçus par accepterEchantillons, dans l'ordre d'appel.
10 + private(set) var echantillonsRecus: [[Int16]] = []
11 + /// File de résultats partiels à retourner (FIFO). nil si vide.
12 + var resultatsPartiels: [String?] = []
13 + /// Texte retourné par finaliser().
14 + var resultatFinal: String = ""
15 + private(set) var nombreReinit: Int = 0
16 +
17 + func accepterEchantillons(_ data: [Int16]) -> String? {
18 + echantillonsRecus.append(data)
19 + if !resultatsPartiels.isEmpty {
20 + return resultatsPartiels.removeFirst()
21 + }
22 + return nil
23 + }
24 +
25 + func finaliser() -> String { resultatFinal }
26 +
27 + func reinitialiser() {
28 + echantillonsRecus = []
29 + resultatsPartiels = []
30 + resultatFinal = ""
31 + nombreReinit += 1
32 + }
33 +}
A ios/Card2vcfTests/Fixtures/interaction_note_vocale.json
+16 -0
@@ -0,0 +1,16 @@
1 +{
2 + "id": "interaction-vocale-contrat-1",
3 + "contact_id": "contact-contrat-1",
4 + "type_interaction": "note_vocale",
5 + "sujet": "Note vocale contrat",
6 + "description": "",
7 + "statut": "fait",
8 + "prevu_le": null,
9 + "fait_le": null,
10 + "piece_jointe": "vocale_contrat_1.wav",
11 + "cree_par": "alice",
12 + "cree_le": "2026-09-01T10:00:00Z",
13 + "mis_a_jour_le": null,
14 + "transcription": "echec",
15 + "transcription_erreur": "Modèle ASR indisponible"
16 +}
16 < \ No newline at end of file
A ios/Card2vcfTests/Fixtures/note_projet.json
+12 -0
@@ -0,0 +1,12 @@
1 +{
2 + "id": "note-projet-contrat-1",
3 + "projet_id": "projet-contrat-1",
4 + "titre": "Note vocale projet contrat",
5 + "contenu": "## Compte-rendu contrat\n\n- Point vérifié\n",
6 + "auteur": "alice",
7 + "cree_le": "2026-09-01T10:00:00Z",
8 + "maj_le": null,
9 + "audio": "note_contrat_1.wav",
10 + "transcription": "terminee",
11 + "transcription_erreur": null
12 +}
12 < \ No newline at end of file
A ios/Card2vcfTests/Fixtures/sync_pull.json
+140 -0
@@ -0,0 +1,140 @@
1 +{
2 + "server_time": "2026-09-18T08:42:51.508932169Z",
3 + "contacts": [
4 + {
5 + "id": "contact-contrat-1",
6 + "prenom": "Ada",
7 + "nom": "Lovelace",
8 + "entreprise_id": null,
9 + "fonction": "",
10 + "emails": [],
11 + "telephones": [],
12 + "adresses": [],
13 + "statut": "prospect",
14 + "etape": "nouveau",
15 + "date_rencontre": null,
16 + "notes": "",
17 + "photo": null,
18 + "carte_visite": null,
19 + "cree_par": "alice",
20 + "cree_le": "2026-09-01T10:00:00Z",
21 + "derniere_action": null,
22 + "tags": [],
23 + "mis_a_jour_le": null,
24 + "civilite": "",
25 + "date_naissance": null,
26 + "site_web": "",
27 + "profils_sociaux": []
28 + }
29 + ],
30 + "entreprises": [],
31 + "projets": [
32 + {
33 + "id": "projet-contrat-1",
34 + "nom": "Projet contrat",
35 + "description": "",
36 + "workflow_id": "veille",
37 + "cree_par": "alice",
38 + "cree_le": "2026-09-01T10:00:00Z",
39 + "derniere_action": null,
40 + "taches": [],
41 + "notes": [
42 + {
43 + "id": "note-projet-contrat-1",
44 + "titre": "Note vocale projet contrat",
45 + "fichier": "note-contrat-1.md",
46 + "ordre": 0,
47 + "auteur": "alice",
48 + "cree_le": "2026-09-01T10:00:00Z",
49 + "maj_le": null,
50 + "audio": "note_contrat_1.wav",
51 + "transcription": "terminee",
52 + "transcription_erreur": null
53 + }
54 + ],
55 + "liens": [],
56 + "fichiers": [],
57 + "apercu_liens_riche": true,
58 + "gestion_contacts": false,
59 + "contacts_lies": [],
60 + "entreprises_liees": [],
61 + "membres": [],
62 + "mis_a_jour_le": null
63 + }
64 + ],
65 + "taches": [],
66 + "interactions": [
67 + {
68 + "id": "interaction-vocale-contrat-1",
69 + "contact_id": "contact-contrat-1",
70 + "type_interaction": "note_vocale",
71 + "sujet": "Note vocale contrat",
72 + "description": "",
73 + "statut": "fait",
74 + "prevu_le": null,
75 + "fait_le": null,
76 + "piece_jointe": "vocale_contrat_1.wav",
77 + "cree_par": "alice",
78 + "cree_le": "2026-09-01T10:00:00Z",
79 + "mis_a_jour_le": null,
80 + "transcription": "echec",
81 + "transcription_erreur": "Modèle ASR indisponible"
82 + }
83 + ],
84 + "notes": [
85 + {
86 + "id": "note-projet-contrat-1",
87 + "projet_id": "projet-contrat-1",
88 + "titre": "Note vocale projet contrat",
89 + "contenu": "## Compte-rendu contrat\n\n- Point vérifié\n",
90 + "auteur": "alice",
91 + "cree_le": "2026-09-01T10:00:00Z",
92 + "maj_le": null,
93 + "audio": "note_contrat_1.wav",
94 + "transcription": "terminee",
95 + "transcription_erreur": null
96 + }
97 + ],
98 + "rdv": [],
99 + "reservations": [],
100 + "indisponibilites": [],
101 + "tombstones": [],
102 + "workflows": [
103 + {
104 + "id": "veille",
105 + "nom": "Veille",
106 + "description": "Suivi de sujets à explorer et synthétiser",
107 + "colonnes": [
108 + {
109 + "id": "explorer",
110 + "nom": "À explorer",
111 + "ordre": 0
112 + },
113 + {
114 + "id": "lecture",
115 + "nom": "En lecture",
116 + "ordre": 1
117 + },
118 + {
119 + "id": "synthetiser",
120 + "nom": "À synthétiser",
121 + "ordre": 2
122 + },
123 + {
124 + "id": "synthetise",
125 + "nom": "Synthétisé",
126 + "ordre": 3
127 + },
128 + {
129 + "id": "archive",
130 + "nom": "Archive",
131 + "ordre": 4
132 + }
133 + ],
134 + "personnalise": false,
135 + "gestion_contacts": false,
136 + "cree_par": "systeme",
137 + "cree_le": "2026-09-18T08:42:51.507116089Z"
138 + }
139 + ]
140 +}
140 < \ No newline at end of file
M ios/Card2vcfTests/InMemoryCrmDatabase.swift
+49 -0
@@ -15,6 +15,7 @@ final class InMemoryCrmDatabase: CrmDatabase {
15 15 let indisponibiliteDaoImpl = InMemoryIndisponibiliteDao()
16 16 let syncMetaDaoImpl = InMemorySyncMetaDao()
17 17 let syncOpDaoImpl = InMemorySyncOpDao()
18 + let noteProjetDaoImpl = InMemoryNoteProjetDao()
18 19
19 20 var crmContactDao: CrmContactDao { contactDaoImpl }
20 21 var entrepriseDao: EntrepriseDao { entrepriseDaoImpl }
@@ -27,6 +28,7 @@ final class InMemoryCrmDatabase: CrmDatabase {
27 28 var indisponibiliteDao: IndisponibiliteDao { indisponibiliteDaoImpl }
28 29 var syncMetaDao: SyncMetaDao { syncMetaDaoImpl }
29 30 var syncOpDao: SyncOpDao { syncOpDaoImpl }
31 + var noteProjetDao: NoteProjetDao { noteProjetDaoImpl }
30 32 }
31 33
32 34 final class InMemoryCrmContactDao: CrmContactDao {
@@ -244,10 +246,18 @@ final class InMemoryInteractionDao: InteractionDao {
244 246 rows.first { $0.serverId == serverId }
245 247 }
246 248
249 + func getByLocalId(_ localId: Int64) async throws -> InteractionEntity? {
250 + rows.first { $0.localId == localId }
251 + }
252 +
247 253 func deleteByServerId(_ serverId: String) async throws {
248 254 rows.removeAll { $0.serverId == serverId }
249 255 }
250 256
257 + func deleteByLocalId(_ localId: Int64) async throws {
258 + rows.removeAll { $0.localId == localId }
259 + }
260 +
251 261 func listByContactServerId(_ contactServerId: String) async throws -> [InteractionEntity] {
252 262 rows.filter { $0.contactServerId == contactServerId }
253 263 }
@@ -462,3 +472,42 @@ final class InMemorySyncOpDao: SyncOpDao {
462 472 rows.removeAll { $0.id == id }
463 473 }
464 474 }
475 +
476 +final class InMemoryNoteProjetDao: NoteProjetDao {
477 + private var rows: [NoteProjetEntity] = []
478 + private var nextId: Int64 = 1
479 +
480 + @discardableResult
481 + func upsert(_ entity: NoteProjetEntity) async throws -> Int64 {
482 + var row = entity
483 + if row.localId == 0 {
484 + row.localId = nextId
485 + nextId += 1
486 + } else {
487 + rows.removeAll { $0.localId == row.localId }
488 + nextId = max(nextId, row.localId + 1)
489 + }
490 + rows.append(row)
491 + return row.localId
492 + }
493 +
494 + func getByServerId(_ serverId: String) async throws -> NoteProjetEntity? {
495 + rows.first { $0.serverId == serverId }
496 + }
497 +
498 + func deleteByServerId(_ serverId: String) async throws {
499 + rows.removeAll { $0.serverId == serverId }
500 + }
501 +
502 + func deleteByLocalId(_ localId: Int64) async throws {
503 + rows.removeAll { $0.localId == localId }
504 + }
505 +
506 + func listByProjetServerId(_ projetServerId: String) async throws -> [NoteProjetEntity] {
507 + rows.filter { $0.projetServerId == projetServerId }
508 + }
509 +
510 + func listAll() async throws -> [NoteProjetEntity] {
511 + rows
512 + }
513 +}
A ios/Card2vcfTests/InteractionAudioSyncTest.swift
+315 -0
@@ -0,0 +1,315 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Preuve de rougeur — Lot 2b (modèle + transport des notes vocales).
5 +///
6 +/// Test 1 : testApplyInteraction_lwwUpdatesTranscriptionStatut
7 +/// En commentant `guard remoteTs > localTs else { return }` dans `SyncEngine.applyInteraction`
8 +/// (chemin de mise à jour), le test échoue avec :
9 +///
10 +/// XCTAssertEqual failed: ("nil") is not equal to ("Optional("terminee")")
11 +///
12 +/// Test 2 : testPousserEnAttente_uploadsInteractionAudio
13 +/// En supprimant le `case "interaction_audio"` dans `pushOps`, le test échoue avec :
14 +///
15 +/// XCTAssertEqual failed: ("0") is not equal to ("1")
16 +///
17 +final class InteractionAudioSyncTest: XCTestCase {
18 +
19 + private var db: InMemoryCrmDatabase!
20 + private var api: FakeAilianceApi!
21 + private var engine: SyncEngine!
22 +
23 + override func setUp() {
24 + super.setUp()
25 + db = InMemoryCrmDatabase()
26 + api = FakeAilianceApi()
27 + engine = SyncEngine(api: api, db: db)
28 + }
29 +
30 + // MARK: - InteractionDto — décodage des champs audio
31 +
32 + func testInteractionDto_decodesAudioFields() throws {
33 + let json = """
34 + {
35 + "id": "i1",
36 + "contact_id": "c1",
37 + "type_interaction": "note_vocale",
38 + "sujet": "Note vocale",
39 + "description": "",
40 + "cree_par": "alice",
41 + "cree_le": "2026-09-16T10:00:00Z",
42 + "mis_a_jour_le": "2026-09-16T10:01:00Z",
43 + "piece_jointe": "audio.wav",
44 + "transcription": "terminee",
45 + "transcription_erreur": null
46 + }
47 + """
48 + let dto = try SyncJson.decode(InteractionDto.self, from: json)
49 + XCTAssertEqual("note_vocale", dto.typeInteraction)
50 + XCTAssertEqual("audio.wav", dto.pieceJointe)
51 + XCTAssertEqual("terminee", dto.transcription)
52 + XCTAssertNil(dto.transcriptionErreur)
53 + }
54 +
55 + func testInteractionDto_champsOptionnelsAbsentsSansErreur() throws {
56 + // Serveur antérieur sans les nouveaux champs : décodage tolérant.
57 + let json = """
58 + { "id": "i2", "contact_id": "c1", "cree_le": "2026-09-16T10:00:00Z" }
59 + """
60 + let dto = try SyncJson.decode(InteractionDto.self, from: json)
61 + XCTAssertNil(dto.pieceJointe)
62 + XCTAssertNil(dto.transcription)
63 + XCTAssertNil(dto.transcriptionErreur)
64 + }
65 +
66 + // MARK: - applyInteraction — insert avec champs audio
67 +
68 + func testApplyInteraction_insertsAvecChampsAudio() async throws {
69 + api.pullResult = .ok(SyncPullResponse(
70 + serverTime: "2026-09-16T10:05:00Z",
71 + interactions: [
72 + InteractionDto(
73 + id: "i1", contactId: "c1",
74 + typeInteraction: "note_vocale",
75 + sujet: "Note",
76 + creeLe: "2026-09-16T10:00:00Z",
77 + transcription: "en_attente"
78 + )
79 + ]
80 + ))
81 + _ = try await engine.syncNow()
82 +
83 + let stored = try await db.interactionDao.getByServerId("i1")
84 + XCTAssertEqual("note_vocale", stored?.type)
85 + XCTAssertEqual("en_attente", stored?.transcriptionStatut)
86 + XCTAssertNil(stored?.audioPath, "audioPath doit être nil à l'insert (fichier local absent)")
87 + }
88 +
89 + // MARK: - LWW update interactions (PREUVE DE ROUGEUR #1)
90 +
91 + func testApplyInteraction_lwwUpdatesTranscriptionStatut() async throws {
92 + // Interaction connue localement (transcription en_attente, timestamp ancien).
93 + var existing = InteractionEntity()
94 + existing.serverId = "i-lww"
95 + existing.contactServerId = "c1"
96 + existing.type = "note_vocale"
97 + existing.sujet = "Note"
98 + existing.transcriptionStatut = "en_attente"
99 + existing.createdAt = 1_000
100 + existing.updatedAt = 1_000 // très ancien : 1970-01-01T00:00:01Z
101 + _ = try await db.interactionDao.upsert(existing)
102 +
103 + // Pull : même interaction, transcription terminée, timestamp 2026 (plus récent).
104 + api.pullResult = .ok(SyncPullResponse(
105 + serverTime: "2026-09-16T12:05:00Z",
106 + interactions: [
107 + InteractionDto(
108 + id: "i-lww", contactId: "c1",
109 + typeInteraction: "note_vocale",
110 + sujet: "Note",
111 + creeLe: "2026-09-16T10:00:00Z",
112 + misAJourLe: "2026-09-16T12:00:00Z",
113 + transcription: "terminee"
114 + )
115 + ]
116 + ))
117 + _ = try await engine.syncNow()
118 +
119 + let updated = try await db.interactionDao.getByServerId("i-lww")
120 + // Rouge sans le chemin de MàJ : ("nil") is not equal to ("Optional("terminee")")
121 + XCTAssertEqual("terminee", updated?.transcriptionStatut)
122 + }
123 +
124 + func testApplyInteraction_remoteAncienNEcrasePasLocal() async throws {
125 + // Local très récent (2033), remote 2026 : local doit gagner.
126 + var existing = InteractionEntity()
127 + existing.serverId = "i-old"
128 + existing.contactServerId = "c1"
129 + existing.sujet = "Local récent"
130 + existing.transcriptionStatut = "terminee"
131 + existing.createdAt = 2_000_000_000_000
132 + existing.updatedAt = 2_000_000_000_000 // ~2033
133 + _ = try await db.interactionDao.upsert(existing)
134 +
135 + api.pullResult = .ok(SyncPullResponse(
136 + serverTime: "2026-09-16T12:05:00Z",
137 + interactions: [
138 + InteractionDto(
139 + id: "i-old", contactId: "c1",
140 + sujet: "Ancien serveur",
141 + creeLe: "2026-09-16T09:00:00Z",
142 + misAJourLe: "2026-09-16T09:30:00Z",
143 + transcription: "en_attente"
144 + )
145 + ]
146 + ))
147 + _ = try await engine.syncNow()
148 +
149 + let kept = try await db.interactionDao.getByServerId("i-old")
150 + XCTAssertEqual("Local récent", kept?.sujet)
151 + XCTAssertEqual("terminee", kept?.transcriptionStatut)
152 + }
153 +
154 + func testApplyInteraction_preserveAudioPathLocal() async throws {
155 + // Le pull distant (plus récent) ne doit pas écraser le chemin audio local.
156 + var existing = InteractionEntity()
157 + existing.serverId = "i-audio"
158 + existing.contactServerId = "c1"
159 + existing.sujet = "Avec audio"
160 + existing.audioPath = "/local/audio/interaction_1.wav"
161 + existing.transcriptionStatut = "en_attente"
162 + existing.createdAt = 1_000
163 + existing.updatedAt = 1_000
164 + _ = try await db.interactionDao.upsert(existing)
165 +
166 + api.pullResult = .ok(SyncPullResponse(
167 + serverTime: "2026-09-16T12:05:00Z",
168 + interactions: [
169 + InteractionDto(
170 + id: "i-audio", contactId: "c1",
171 + sujet: "Avec audio",
172 + creeLe: "2026-09-16T10:00:00Z",
173 + misAJourLe: "2026-09-16T12:00:00Z",
174 + pieceJointe: "audio_serveur.wav",
175 + transcription: "terminee"
176 + )
177 + ]
178 + ))
179 + _ = try await engine.syncNow()
180 +
181 + let updated = try await db.interactionDao.getByServerId("i-audio")
182 + XCTAssertEqual("/local/audio/interaction_1.wav", updated?.audioPath,
183 + "audioPath local préservé même si piece_jointe serveur reçu")
184 + XCTAssertEqual("terminee", updated?.transcriptionStatut)
185 + }
186 +
187 + // MARK: - Push audio interaction retry (PREUVE DE ROUGEUR #2)
188 +
189 + func testPousserEnAttente_uploadsInteractionAudio() async throws {
190 + // Créer un fichier WAV temporaire (le SyncEngine lit les bytes depuis le disque).
191 + let tempDir = FileManager.default.temporaryDirectory
192 + .appendingPathComponent(UUID().uuidString)
193 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
194 + defer { try? FileManager.default.removeItem(at: tempDir) }
195 + let audioUrl = tempDir.appendingPathComponent("note.wav")
196 + try Data([0x52, 0x49, 0x46, 0x46, 0x00]).write(to: audioUrl) // mini RIFF
197 +
198 + var interaction = InteractionEntity()
199 + interaction.serverId = "i-srv"
200 + interaction.contactServerId = "c-srv"
201 + interaction.type = "note_vocale"
202 + interaction.sujet = "Note audio"
203 + interaction.audioPath = audioUrl.path
204 + interaction.createdAt = 1_000_000
205 + _ = try await db.interactionDao.upsert(interaction)
206 +
207 + var op = SyncOpEntity(entityType: "interaction_audio", op: "upload")
208 + op.serverId = "i-srv"
209 + op.payloadJson = "{}"
210 + op.createdAt = 1
211 + _ = try await db.syncOpDao.insert(op)
212 +
213 + api.uploadInteractionAudioResult = .ok("{}")
214 + _ = try await engine.pousserEnAttente()
215 +
216 + // Rouge sans le handler interaction_audio : ("0") is not equal to ("1")
217 + XCTAssertEqual(1, api.uploadInteractionAudioCalls.count)
218 + let ops1 = try await db.syncOpDao.listAll()
219 + XCTAssertTrue(ops1.isEmpty, "Op dépilée après succès")
220 + }
221 +
222 + func testPousserEnAttente_echecReseauGardeOpEnFile() async throws {
223 + let tempDir = FileManager.default.temporaryDirectory
224 + .appendingPathComponent(UUID().uuidString)
225 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
226 + defer { try? FileManager.default.removeItem(at: tempDir) }
227 + let audioUrl = tempDir.appendingPathComponent("note2.wav")
228 + try Data([0x00]).write(to: audioUrl)
229 +
230 + var interaction = InteractionEntity()
231 + interaction.serverId = "i-srv2"
232 + interaction.contactServerId = "c-srv"
233 + interaction.type = "note_vocale"
234 + interaction.audioPath = audioUrl.path
235 + interaction.createdAt = 1_000_000
236 + _ = try await db.interactionDao.upsert(interaction)
237 +
238 + var op = SyncOpEntity(entityType: "interaction_audio", op: "upload")
239 + op.serverId = "i-srv2"
240 + op.payloadJson = "{}"
241 + op.createdAt = 1
242 + _ = try await db.syncOpDao.insert(op)
243 +
244 + api.uploadInteractionAudioResult = .err(code: -1, message: "Réseau indisponible")
245 + _ = try await engine.pousserEnAttente()
246 +
247 + let remaining = try await db.syncOpDao.listAll()
248 + XCTAssertEqual(1, remaining.count, "Op reste en file après échec réseau")
249 + }
250 +
251 + // MARK: - Push note de projet
252 +
253 + func testPousserEnAttente_creeNoteProjet() async throws {
254 + var op = SyncOpEntity(entityType: "note_projet", op: "create")
255 + op.serverId = "projet-srv"
256 + op.localId = 42
257 + op.payloadJson = #"{"titre":"Ma note","contenu":"Contenu","demande_transcription":false}"#
258 + op.createdAt = 1
259 + _ = try await db.syncOpDao.insert(op)
260 +
261 + api.createNoteProjetResult = .ok(#"{"id":"note-srv"}"#)
262 + _ = try await engine.pousserEnAttente()
263 +
264 + XCTAssertEqual(1, api.createNoteProjetCalls.count)
265 + let ops2 = try await db.syncOpDao.listAll()
266 + XCTAssertTrue(ops2.isEmpty)
267 + }
268 +
269 + func testPousserEnAttente_noteProjetEchecReseauGardeOpEnFile() async throws {
270 + var op = SyncOpEntity(entityType: "note_projet", op: "create")
271 + op.serverId = "projet-srv"
272 + op.localId = 42
273 + op.payloadJson = #"{"titre":"Erreur","contenu":"","demande_transcription":false}"#
274 + op.createdAt = 1
275 + _ = try await db.syncOpDao.insert(op)
276 +
277 + api.createNoteProjetResult = .err(code: -1, message: "Réseau")
278 + _ = try await engine.pousserEnAttente()
279 +
280 + let ops3 = try await db.syncOpDao.listAll()
281 + XCTAssertEqual(1, ops3.count)
282 + }
283 +
284 + // MARK: - pousserEnAttente n'effectue pas de pull
285 +
286 + func testPousserEnAttente_nEffectuePasLePull() async throws {
287 + api.pullResult = .ok(SyncPullResponse(
288 + serverTime: "2026-09-16T10:05:00Z",
289 + contacts: [ContactDto(id: "c-never", creeLe: "2026-09-16T10:00:00Z")]
290 + ))
291 + _ = try await engine.pousserEnAttente()
292 +
293 + let contacts = try await db.crmContactDao.fetchAll()
294 + XCTAssertTrue(contacts.isEmpty, "pousserEnAttente ne doit pas effectuer de pull")
295 + }
296 +
297 + // MARK: - AudioNoteStore
298 +
299 + func testAudioNoteStore_cheminsStables() {
300 + let dir = FileManager.default.temporaryDirectory
301 + .appendingPathComponent(UUID().uuidString)
302 + defer { try? FileManager.default.removeItem(at: dir) }
303 + let store = AudioNoteStore(rootDirectory: dir)
304 +
305 + XCTAssertEqual(store.pathForInteraction(localId: 1), store.pathForInteraction(localId: 1),
306 + "Le chemin doit être stable pour un même localId")
307 + XCTAssertTrue(store.pathForInteraction(localId: 5).hasSuffix("interaction_5.wav"))
308 + XCTAssertTrue(store.pathForNote(localId: 7).hasSuffix("note_7.wav"))
309 + XCTAssertNotEqual(
310 + store.pathForInteraction(localId: 1),
311 + store.pathForNote(localId: 1),
312 + "Interactions et notes ont des chemins distincts"
313 + )
314 + }
315 +}
A ios/Card2vcfTests/InteractionMigrationTest.swift
+112 -0
@@ -0,0 +1,112 @@
1 +import XCTest
2 +import SQLite3
3 +@testable import Card2vcf
4 +
5 +/// Preuve de rougeur (lot 2b, migration v5→v6) :
6 +/// Sans les trois `ALTER TABLE interactions ADD COLUMN …` dans `migrateIfNeeded`, l'accès
7 +/// aux nouvelles colonnes via `SqliteInteractionDao` lève une `DatabaseError` du type :
8 +///
9 +/// testMigrationV5ToV6_colonnesPresentes — threw error "DatabaseError: SELECT … FROM
10 +/// interactions: table interactions has no column named audioPath"
11 +///
12 +final class InteractionMigrationTest: XCTestCase {
13 +
14 + private var dbPath: String!
15 +
16 + override func setUp() {
17 + super.setUp()
18 + dbPath = FileManager.default.temporaryDirectory
19 + .appendingPathComponent("migration_v5v6_\(UUID().uuidString).sqlite")
20 + .path
21 + }
22 +
23 + override func tearDown() {
24 + try? FileManager.default.removeItem(atPath: dbPath)
25 + super.tearDown()
26 + }
27 +
28 + /// Construit une base SQLite au format v5 (table interactions sans les nouvelles colonnes).
29 + private func buildV5Database(interactions: [(serverId: String, contactServerId: String, sujet: String, type: String)] = []) {
30 + var rawHandle: OpaquePointer?
31 + guard sqlite3_open(dbPath, &rawHandle) == SQLITE_OK, let rawHandle else { return }
32 + defer { sqlite3_close_v2(rawHandle) }
33 +
34 + var sql = """
35 + CREATE TABLE interactions (
36 + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
37 + serverId TEXT,
38 + contactServerId TEXT NOT NULL,
39 + type TEXT NOT NULL,
40 + sujet TEXT NOT NULL,
41 + description TEXT NOT NULL,
42 + creePar TEXT NOT NULL,
43 + createdAt INTEGER NOT NULL,
44 + updatedAt INTEGER
45 + );
46 + """
47 + for row in interactions {
48 + sql += """
49 + INSERT INTO interactions
50 + (serverId, contactServerId, type, sujet, description, creePar, createdAt)
51 + VALUES ('\(row.serverId)', '\(row.contactServerId)', '\(row.type)', '\(row.sujet)', '', 'alice', 1000);
52 + """
53 + }
54 + sql += "PRAGMA user_version = 5;"
55 + sqlite3_exec(rawHandle, sql, nil, nil, nil)
56 + }
57 +
58 + func testMigrationV5ToV6_colonnesPresentes() async throws {
59 + buildV5Database(interactions: [("srv-i1", "c1", "Appel", "note")])
60 +
61 + // L'ouverture déclenche la migration v5→v6.
62 + let db = SqliteDatabase(filePath: dbPath)
63 + let dao = SqliteInteractionDao(db: db)
64 +
65 + // Données v5 préservées
66 + let interactions = try await dao.listAll()
67 + XCTAssertEqual(1, interactions.count, "L'interaction v5 doit être préservée après migration")
68 + XCTAssertEqual("srv-i1", interactions.first?.serverId)
69 +
70 + // Nouvelles colonnes présentes (valeur nil pour les lignes migrées)
71 + XCTAssertNil(interactions.first?.audioPath, "audioPath nul pour une interaction migrée depuis v5")
72 + XCTAssertNil(interactions.first?.transcriptionStatut)
73 + XCTAssertNil(interactions.first?.transcriptionErreur)
74 + }
75 +
76 + func testMigrationV5ToV6_preserveMultipleInteractions() async throws {
77 + buildV5Database(interactions: [
78 + ("i-alpha", "c-1", "Réunion", "note"),
79 + ("i-beta", "c-1", "Message vocal", "note_vocale"),
80 + ])
81 +
82 + let db = SqliteDatabase(filePath: dbPath)
83 + let dao = SqliteInteractionDao(db: db)
84 + let interactions = try await dao.listAll()
85 +
86 + XCTAssertEqual(2, interactions.count)
87 + XCTAssertTrue(interactions.contains { $0.serverId == "i-alpha" && $0.sujet == "Réunion" })
88 + XCTAssertTrue(interactions.contains { $0.serverId == "i-beta" && $0.type == "note_vocale" })
89 + }
90 +
91 + func testMigrationV5ToV6_insertionAvecNouvellesColonnes() async throws {
92 + buildV5Database()
93 +
94 + let db = SqliteDatabase(filePath: dbPath)
95 + let dao = SqliteInteractionDao(db: db)
96 +
97 + var entity = InteractionEntity()
98 + entity.contactServerId = "c1"
99 + entity.type = "note_vocale"
100 + entity.sujet = "Nouveau"
101 + entity.audioPath = "/tmp/note.wav"
102 + entity.transcriptionStatut = "en_attente"
103 + entity.createdAt = 2000
104 +
105 + let newId = try await dao.upsert(entity)
106 + let fetched = try await dao.getByLocalId(newId)
107 +
108 + XCTAssertEqual("/tmp/note.wav", fetched?.audioPath)
109 + XCTAssertEqual("en_attente", fetched?.transcriptionStatut)
110 + XCTAssertNil(fetched?.transcriptionErreur)
111 + }
112 +}
A ios/Card2vcfTests/NoteProjetDaoTest.swift
+106 -0
@@ -0,0 +1,106 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Port de NoteProjetDaoTest Android — exerc\u{e9} via InMemoryCrmDatabase.
5 +final class NoteProjetDaoTest: XCTestCase {
6 + private var db: InMemoryCrmDatabase!
7 +
8 + override func setUp() {
9 + super.setUp()
10 + db = InMemoryCrmDatabase()
11 + }
12 +
13 + func testUpsertInsertEtGetByServerId() async throws {
14 + var note = NoteProjetEntity()
15 + note.serverId = "n1"
16 + note.projetServerId = "p1"
17 + note.titre = "Premi\u{e8}re note"
18 + note.texte = "Contenu initial"
19 + note.auteur = "alice"
20 + note.createdAt = 1_000
21 + note.updatedAt = 1_000
22 +
23 + let localId = try await db.noteProjetDao.upsert(note)
24 + XCTAssertGreaterThan(localId, 0)
25 +
26 + let found = try await db.noteProjetDao.getByServerId("n1")
27 + XCTAssertNotNil(found)
28 + XCTAssertEqual("Premi\u{e8}re note", found?.titre)
29 + XCTAssertEqual("Contenu initial", found?.texte)
30 + XCTAssertEqual("p1", found?.projetServerId)
31 + XCTAssertEqual("alice", found?.auteur)
32 + }
33 +
34 + func testUpsertMiseAJour() async throws {
35 + var note = NoteProjetEntity()
36 + note.serverId = "n2"
37 + note.projetServerId = "p1"
38 + note.titre = "Titre original"
39 + note.texte = "Texte original"
40 + note.auteur = "bob"
41 + note.createdAt = 500
42 + note.updatedAt = 500
43 + let localId = try await db.noteProjetDao.upsert(note)
44 +
45 + var updated = note
46 + updated.localId = localId
47 + updated.titre = "Titre modifi\u{e9}"
48 + updated.updatedAt = 600
49 + try await db.noteProjetDao.upsert(updated)
50 +
51 + let found = try await db.noteProjetDao.getByServerId("n2")
52 + XCTAssertEqual("Titre modifi\u{e9}", found?.titre)
53 + XCTAssertEqual(600, found?.updatedAt)
54 + }
55 +
56 + func testListByProjetServerId() async throws {
57 + var n1 = NoteProjetEntity()
58 + n1.serverId = "n1"; n1.projetServerId = "pA"; n1.titre = "A1"; n1.texte = ""; n1.auteur = "x"; n1.createdAt = 1
59 + var n2 = NoteProjetEntity()
60 + n2.serverId = "n2"; n2.projetServerId = "pA"; n2.titre = "A2"; n2.texte = ""; n2.auteur = "x"; n2.createdAt = 2
61 + var n3 = NoteProjetEntity()
62 + n3.serverId = "n3"; n3.projetServerId = "pB"; n3.titre = "B1"; n3.texte = ""; n3.auteur = "x"; n3.createdAt = 3
63 +
64 + try await db.noteProjetDao.upsert(n1)
65 + try await db.noteProjetDao.upsert(n2)
66 + try await db.noteProjetDao.upsert(n3)
67 +
68 + let forA = try await db.noteProjetDao.listByProjetServerId("pA")
69 + XCTAssertEqual(2, forA.count)
70 + XCTAssertTrue(forA.contains { $0.serverId == "n1" })
71 + XCTAssertTrue(forA.contains { $0.serverId == "n2" })
72 +
73 + let forB = try await db.noteProjetDao.listByProjetServerId("pB")
74 + XCTAssertEqual(1, forB.count)
75 + XCTAssertEqual("n3", forB.first?.serverId)
76 + }
77 +
78 + func testDeleteByServerId() async throws {
79 + var note = NoteProjetEntity()
80 + note.serverId = "n-del"; note.projetServerId = "p1"; note.titre = "T"; note.texte = ""; note.auteur = ""; note.createdAt = 1
81 + try await db.noteProjetDao.upsert(note)
82 +
83 + try await db.noteProjetDao.deleteByServerId("n-del")
84 +
85 + let found = try await db.noteProjetDao.getByServerId("n-del")
86 + XCTAssertNil(found)
87 + }
88 +
89 + func testAudioPathPreserved() async throws {
90 + var note = NoteProjetEntity()
91 + note.serverId = "n-audio"; note.projetServerId = "p1"; note.titre = "Avec audio"
92 + note.texte = "Texte"; note.auteur = "alice"; note.createdAt = 100; note.updatedAt = 100
93 + note.audioPath = "/local/notes/n-audio.wav"
94 + let localId = try await db.noteProjetDao.upsert(note)
95 +
96 + // Met \u{e0} jour sans changer audioPath
97 + var updated = note
98 + updated.localId = localId
99 + updated.titre = "Titre mis \u{e0} jour"
100 + updated.updatedAt = 200
101 + try await db.noteProjetDao.upsert(updated)
102 +
103 + let found = try await db.noteProjetDao.getByServerId("n-audio")
104 + XCTAssertEqual("/local/notes/n-audio.wav", found?.audioPath)
105 + }
106 +}
A ios/Card2vcfTests/NoteProjetDtoTest.swift
+86 -0
@@ -0,0 +1,86 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +final class NoteProjetDtoTest: XCTestCase {
5 +
6 + func testDecodeComplete() throws {
7 + let json = """
8 + {
9 + "id": "n1",
10 + "projet_id": "p1",
11 + "titre": "R\u{e9}union",
12 + "contenu": "## Points cl\u{e9}s\\n- Point A",
13 + "auteur": "alice",
14 + "cree_le": "2026-09-10T08:00:00Z",
15 + "maj_le": "2026-09-11T10:00:00Z",
16 + "audio": "note.wav",
17 + "transcription": "terminee",
18 + "transcription_erreur": null
19 + }
20 + """
21 + let dto = try SyncJson.decode(NoteProjetDto.self, from: json)
22 + XCTAssertEqual("n1", dto.id)
23 + XCTAssertEqual("p1", dto.projetId)
24 + XCTAssertEqual("R\u{e9}union", dto.titre)
25 + XCTAssertEqual("## Points cl\u{e9}s\n- Point A", dto.contenu)
26 + XCTAssertEqual("alice", dto.auteur)
27 + XCTAssertEqual("2026-09-10T08:00:00Z", dto.creeLe)
28 + XCTAssertEqual("2026-09-11T10:00:00Z", dto.majLe)
29 + XCTAssertEqual("note.wav", dto.audio)
30 + XCTAssertEqual("terminee", dto.transcription)
31 + XCTAssertNil(dto.transcriptionErreur)
32 + }
33 +
34 + /// Old server: only the 5 required fields — decoding must succeed with safe defaults.
35 + func testDecodeMinimalChampsSuffisent() throws {
36 + let json = """
37 + {
38 + "id": "n2",
39 + "projet_id": "p2",
40 + "titre": "Appel",
41 + "contenu": "Discussion initiale",
42 + "cree_le": "2026-09-12T09:00:00Z"
43 + }
44 + """
45 + let dto = try SyncJson.decode(NoteProjetDto.self, from: json)
46 + XCTAssertEqual("n2", dto.id)
47 + XCTAssertEqual("p2", dto.projetId)
48 + XCTAssertEqual("Appel", dto.titre)
49 + XCTAssertEqual("Discussion initiale", dto.contenu)
50 + XCTAssertEqual("", dto.auteur)
51 + XCTAssertNil(dto.majLe)
52 + XCTAssertNil(dto.audio)
53 + XCTAssertNil(dto.transcription)
54 + XCTAssertNil(dto.transcriptionErreur)
55 + }
56 +
57 + /// Old server — no "notes" key in pull response — must decode without error, notes == [].
58 + func testDecodePullResponseSansNotes() throws {
59 + let json = """
60 + { "server_time": "2026-09-12T09:00:00Z" }
61 + """
62 + let pull = try SyncJson.decode(SyncPullResponse.self, from: json)
63 + XCTAssertEqual([], pull.notes)
64 + }
65 +
66 + func testDecodePullResponseAvecNotes() throws {
67 + let json = """
68 + {
69 + "server_time": "2026-09-12T09:00:00Z",
70 + "notes": [
71 + {
72 + "id": "n1",
73 + "projet_id": "p1",
74 + "titre": "Note",
75 + "contenu": "Texte",
76 + "cree_le": "2026-09-12T09:00:00Z"
77 + }
78 + ]
79 + }
80 + """
81 + let pull = try SyncJson.decode(SyncPullResponse.self, from: json)
82 + XCTAssertEqual(1, pull.notes.count)
83 + XCTAssertEqual("n1", pull.notes[0].id)
84 + XCTAssertEqual("p1", pull.notes[0].projetId)
85 + }
86 +}
A ios/Card2vcfTests/NoteProjetMigrationTest.swift
+75 -0
@@ -0,0 +1,75 @@
1 +import XCTest
2 +import SQLite3
3 +@testable import Card2vcf
4 +
5 +/// Verifies additive migration v4 → v5 :
6 +/// - la table `notes_projet` est cr\u{e9}\u{e9}e
7 +/// - les donn\u{e9}es existantes (table `projets`) sont pr\u{e9}serv\u{e9}es.
8 +final class NoteProjetMigrationTest: XCTestCase {
9 +
10 + private var dbPath: String!
11 +
12 + override func setUp() {
13 + super.setUp()
14 + dbPath = FileManager.default.temporaryDirectory
15 + .appendingPathComponent("migration_v4v5_\(UUID().uuidString).sqlite")
16 + .path
17 + }
18 +
19 + override func tearDown() {
20 + try? FileManager.default.removeItem(atPath: dbPath)
21 + super.tearDown()
22 + }
23 +
24 + func testMigrationV4ToV5PreservesDataAndAddsNotesTable() async throws {
25 + // --- 1. Cr\u{e9}er une base v4 avec donn\u{e9}es ---
26 + var rawHandle: OpaquePointer?
27 + guard sqlite3_open(dbPath, &rawHandle) == SQLITE_OK, let rawHandle else {
28 + XCTFail("Cannot open test database at \(dbPath!)")
29 + return
30 + }
31 +
32 + let v4SQL = """
33 + CREATE TABLE projets (
34 + localId INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
35 + serverId TEXT,
36 + nom TEXT NOT NULL,
37 + description TEXT NOT NULL,
38 + workflowServerId TEXT NOT NULL,
39 + membresJson TEXT NOT NULL,
40 + creePar TEXT NOT NULL,
41 + createdAt INTEGER NOT NULL,
42 + updatedAt INTEGER NOT NULL
43 + );
44 + INSERT INTO projets
45 + (serverId, nom, description, workflowServerId, membresJson, creePar, createdAt, updatedAt)
46 + VALUES
47 + ('srv-p1', 'Salon', '', 'wf-1', '[]', 'alice', 1000, 1000);
48 + PRAGMA user_version = 4;
49 + """
50 + var errMsg: UnsafeMutablePointer<CChar>? = nil
51 + let rc = sqlite3_exec(rawHandle, v4SQL, nil, nil, &errMsg)
52 + if rc != SQLITE_OK {
53 + let msg = errMsg.map { String(cString: $0) } ?? "unknown"
54 + sqlite3_free(errMsg)
55 + sqlite3_close_v2(rawHandle)
56 + XCTFail("v4 setup failed: \(msg)")
57 + return
58 + }
59 + sqlite3_close_v2(rawHandle)
60 +
61 + // --- 2. Ouvrir avec SqliteDatabase (d\u{e9}clenche la migration v4 → v5) ---
62 + let db = SqliteDatabase(filePath: dbPath)
63 + let noteDao = SqliteNoteProjetDao(db: db)
64 + let projetDao = SqliteProjetDao(db: db)
65 +
66 + // --- 3. La table notes_projet existe et est vide ---
67 + let notes = try await noteDao.listAll()
68 + XCTAssertTrue(notes.isEmpty, "notes_projet doit exister et \u{ea}tre vide apr\u{e8}s migration")
69 +
70 + // --- 4. Les donn\u{e9}es v4 sont pr\u{e9}serv\u{e9}es ---
71 + let projets = try await projetDao.listAll()
72 + XCTAssertEqual(1, projets.count, "Le projet Salon doit \u{ea}tre pr\u{e9}serv\u{e9} apr\u{e8}s migration")
73 + XCTAssertEqual("Salon", projets.first?.nom)
74 + }
75 +}
A ios/Card2vcfTests/NoteProjetSyncEngineTest.swift
+186 -0
@@ -0,0 +1,186 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Preuve de rougeur (exigence du plan `docs/plans/rattrapage-ios-et-tests.md`, § 2A) :
5 +/// en neutralisant la garde LWW de `SyncEngine.applyNoteProjet`
6 +/// (`guard remoteTs > localTs else { return }`), la suite passe de 204/0 à 204/3 échecs :
7 +/// testPullNoteUpdatePlusRecentApplique — XCTAssertEqual failed:
8 +/// ("Optional("Nouveau titre")") is not equal to ("Optional("Ancien titre")")
9 +/// testPullNoteUpdatePreserveAudioPath — XCTAssertEqual failed:
10 +/// ("Optional("Note vocale éditée")") is not equal to ("Optional("Note vocale")")
11 +/// Ces tests détectent donc réellement la perte de la mise à jour LWW.
12 +final class NoteProjetSyncEngineTest: XCTestCase {
13 + private var db: InMemoryCrmDatabase!
14 + private var api: FakeAilianceApi!
15 + private var engine: SyncEngine!
16 +
17 + override func setUp() {
18 + super.setUp()
19 + db = InMemoryCrmDatabase()
20 + api = FakeAilianceApi()
21 + engine = SyncEngine(api: api, db: db)
22 + }
23 +
24 + // MARK: - Insert
25 +
26 + func testPullNoteInsertNouvelle() async throws {
27 + api.pullResult = .ok(SyncPullResponse(
28 + serverTime: "2026-09-12T09:00:00Z",
29 + notes: [
30 + NoteProjetDto(
31 + id: "n1", projetId: "p1",
32 + titre: "Note initiale", contenu: "## Contenu",
33 + creeLe: "2026-09-10T08:00:00Z"
34 + )
35 + ]
36 + ))
37 +
38 + _ = try await engine.syncNow()
39 +
40 + let stored = try await db.noteProjetDao.getByServerId("n1")
41 + XCTAssertNotNil(stored, "La note doit \u{ea}tre ins\u{e9}r\u{e9}e lors du pull")
42 + XCTAssertEqual("Note initiale", stored?.titre)
43 + XCTAssertEqual("## Contenu", stored?.texte)
44 + XCTAssertEqual("p1", stored?.projetServerId)
45 + }
46 +
47 + // MARK: - LWW update
48 +
49 + func testPullNoteUpdatePlusRecentApplique() async throws {
50 + // Note locale \u{e0} updatedAt = 1000 ms
51 + var local = NoteProjetEntity()
52 + local.serverId = "n2"; local.projetServerId = "p1"
53 + local.titre = "Ancien titre"; local.texte = "Ancien texte"; local.auteur = "alice"
54 + local.createdAt = 1_000; local.updatedAt = 1_000
55 + try await db.noteProjetDao.upsert(local)
56 +
57 + // Remote maj_le 2026-09-12 >> local 1000 ms
58 + api.pullResult = .ok(SyncPullResponse(
59 + serverTime: "2026-09-12T10:00:00Z",
60 + notes: [
61 + NoteProjetDto(
62 + id: "n2", projetId: "p1",
63 + titre: "Nouveau titre", contenu: "Nouveau texte",
64 + auteur: "alice",
65 + creeLe: "2026-09-10T08:00:00Z",
66 + majLe: "2026-09-12T09:00:00Z"
67 + )
68 + ]
69 + ))
70 +
71 + _ = try await engine.syncNow()
72 +
73 + let stored = try await db.noteProjetDao.getByServerId("n2")
74 + XCTAssertEqual("Nouveau titre", stored?.titre, "La mise \u{e0} jour plus r\u{e9}cente doit \u{ea}tre appliqu\u{e9}e")
75 + XCTAssertEqual("Nouveau texte", stored?.texte)
76 + }
77 +
78 + func testPullNoteUpdatePlusAncienIgnore() async throws {
79 + // Note locale \u{e0} updatedAt tr\u{e8}s r\u{e9}cent (apr\u{e8}s 2030)
80 + var local = NoteProjetEntity()
81 + local.serverId = "n3"; local.projetServerId = "p1"
82 + local.titre = "Titre local"; local.texte = "Texte local"; local.auteur = "bob"
83 + local.createdAt = 2_000_000_000_000 // ~2033 en ms
84 + local.updatedAt = 2_000_000_000_000
85 + try await db.noteProjetDao.upsert(local)
86 +
87 + // Remote cree_le 2026 << local 2033
88 + api.pullResult = .ok(SyncPullResponse(
89 + serverTime: "2026-09-12T10:00:00Z",
90 + notes: [
91 + NoteProjetDto(
92 + id: "n3", projetId: "p1",
93 + titre: "Titre serveur", contenu: "Texte serveur",
94 + creeLe: "2026-09-10T08:00:00Z"
95 + )
96 + ]
97 + ))
98 +
99 + _ = try await engine.syncNow()
100 +
101 + let stored = try await db.noteProjetDao.getByServerId("n3")
102 + XCTAssertEqual("Titre local", stored?.titre, "La mise \u{e0} jour plus ancienne NE doit PAS \u{ea}tre appliqu\u{e9}e")
103 + }
104 +
105 + // MARK: - audioPath pr\u{e9}serv\u{e9}
106 +
107 + func testPullNoteUpdatePreserveAudioPath() async throws {
108 + var local = NoteProjetEntity()
109 + local.serverId = "n4"; local.projetServerId = "p1"
110 + local.titre = "Note vocale"; local.texte = "Texte initial"; local.auteur = "alice"
111 + local.audioPath = "/local/notes/n4.wav"
112 + local.createdAt = 1_000; local.updatedAt = 1_000
113 + try await db.noteProjetDao.upsert(local)
114 +
115 + // Remote plus r\u{e9}cent, avec champ audio serveur (nom de fichier, pas chemin local)
116 + api.pullResult = .ok(SyncPullResponse(
117 + serverTime: "2026-09-12T10:00:00Z",
118 + notes: [
119 + NoteProjetDto(
120 + id: "n4", projetId: "p1",
121 + titre: "Note vocale \u{e9}dit\u{e9}e", contenu: "Texte \u{e9}dit\u{e9}",
122 + creeLe: "2026-09-10T08:00:00Z",
123 + majLe: "2026-09-12T09:00:00Z",
124 + audio: "n4.wav"
125 + )
126 + ]
127 + ))
128 +
129 + _ = try await engine.syncNow()
130 +
131 + let stored = try await db.noteProjetDao.getByServerId("n4")
132 + XCTAssertEqual("Note vocale \u{e9}dit\u{e9}e", stored?.titre)
133 + XCTAssertEqual("/local/notes/n4.wav", stored?.audioPath,
134 + "audioPath local doit \u{ea}tre pr\u{e9}serv\u{e9} lors d'une mise \u{e0} jour serveur")
135 + }
136 +
137 + // MARK: - Tombstone
138 +
139 + func testPullTombstoneNoteSupprimeNote() async throws {
140 + var local = NoteProjetEntity()
141 + local.serverId = "n-del"; local.projetServerId = "p1"
142 + local.titre = "\u{e0} supprimer"; local.texte = ""; local.auteur = ""; local.createdAt = 1
143 + try await db.noteProjetDao.upsert(local)
144 +
145 + api.pullResult = .ok(SyncPullResponse(
146 + serverTime: "2026-09-12T10:00:00Z",
147 + tombstones: [
148 + TombstoneDto(entityType: "note", id: "n-del", supprimeLe: "2026-09-12T10:00:00Z")
149 + ]
150 + ))
151 +
152 + _ = try await engine.syncNow()
153 +
154 + let stored = try await db.noteProjetDao.getByServerId("n-del")
155 + XCTAssertNil(stored, "La note supprim\u{e9}e par tombstone doit disparaitre")
156 + }
157 +
158 + // MARK: - R\u{e9}ponse sans section notes
159 +
160 + func testPullSansNotesNePasPlanter() async throws {
161 + // Serveur antérieur : aucun champ "notes" dans la réponse
162 + api.pullResult = .ok(SyncPullResponse(serverTime: "2026-09-12T10:00:00Z"))
163 +
164 + let result = try await engine.syncNow()
165 +
166 + XCTAssertTrue(result.success)
167 + let notes = try await db.noteProjetDao.listAll()
168 + XCTAssertTrue(notes.isEmpty)
169 + }
170 +
171 + // MARK: - Comptage dans received
172 +
173 + func testPullNotesCompteesDansReceived() async throws {
174 + api.pullResult = .ok(SyncPullResponse(
175 + serverTime: "2026-09-12T10:00:00Z",
176 + notes: [
177 + NoteProjetDto(id: "n1", projetId: "p1", titre: "A", contenu: "a", creeLe: "2026-09-10T08:00:00Z"),
178 + NoteProjetDto(id: "n2", projetId: "p1", titre: "B", contenu: "b", creeLe: "2026-09-10T08:00:00Z"),
179 + ]
180 + ))
181 +
182 + let result = try await engine.syncNow()
183 +
184 + XCTAssertEqual(2, result.received, "Les notes doivent \u{ea}tre compt\u{e9}es dans received")
185 + }
186 +}
A ios/Card2vcfTests/ProjetDetailNotesViewModelTest.swift
+78 -0
@@ -0,0 +1,78 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Preuve de rougeur (exigence du plan `docs/plans/rattrapage-ios-et-tests.md`, § 1b) :
5 +/// en remplaçant le tri dans `ProjetDetailViewModel.refresh` par `notesProjet = rawNotes`,
6 +/// le test `testNotesTrieesParDateDecroissante` échoue avec :
7 +/// XCTAssertEqual failed: ("Optional("n2")") is not equal to ("Optional("n1")")
8 +/// Ce test détecte donc réellement l'absence de tri par date décroissante.
9 +@MainActor
10 +final class ProjetDetailNotesViewModelTest: XCTestCase {
11 + private var db: InMemoryCrmDatabase!
12 +
13 + override func setUp() {
14 + super.setUp()
15 + db = InMemoryCrmDatabase()
16 + }
17 +
18 + func testNotesTrieesParDateDecroissante() async throws {
19 + // Note n1 (insérée en 1re) : updatedAt = 1500 → date effective intermédiaire
20 + var note1 = NoteProjetEntity()
21 + note1.serverId = "n1"
22 + note1.projetServerId = "p1"
23 + note1.titre = "Note intermédiaire"
24 + note1.texte = "contenu 1"
25 + note1.auteur = "alice"
26 + note1.createdAt = 1000
27 + note1.updatedAt = 1500
28 + try await db.noteProjetDaoImpl.upsert(note1)
29 +
30 + // Note n2 (insérée en 2e) : updatedAt = 3000 → date effective la plus récente
31 + var note2 = NoteProjetEntity()
32 + note2.serverId = "n2"
33 + note2.projetServerId = "p1"
34 + note2.titre = "Note la plus récente"
35 + note2.texte = "contenu 2"
36 + note2.auteur = "bob"
37 + note2.createdAt = 2000
38 + note2.updatedAt = 3000
39 + try await db.noteProjetDaoImpl.upsert(note2)
40 +
41 + // Note n3 (insérée en 3e) : updatedAt nil → date effective = createdAt = 500
42 + var note3 = NoteProjetEntity()
43 + note3.serverId = "n3"
44 + note3.projetServerId = "p1"
45 + note3.titre = "Note la plus ancienne"
46 + note3.texte = "contenu 3"
47 + note3.auteur = "carol"
48 + note3.createdAt = 500
49 + note3.updatedAt = nil
50 + try await db.noteProjetDaoImpl.upsert(note3)
51 +
52 + let viewModel = ProjetDetailViewModel(projetServerId: "p1", database: db)
53 + await viewModel.refresh()
54 +
55 + XCTAssertEqual(3, viewModel.notesProjet.count)
56 + // Ordre attendu (décroissant) : n2 (3000) > n1 (1500) > n3 (500)
57 + // Sans tri (ordre d'insertion) : n1, n2, n3 → échoue sur [0]
58 + XCTAssertEqual("n2", viewModel.notesProjet[0].serverId)
59 + XCTAssertEqual("n1", viewModel.notesProjet[1].serverId)
60 + XCTAssertEqual("n3", viewModel.notesProjet[2].serverId)
61 + }
62 +
63 + func testNotesAutreProjetNonExposees() async throws {
64 + var note = NoteProjetEntity()
65 + note.serverId = "n-autre"
66 + note.projetServerId = "p-autre"
67 + note.titre = "Note d'un autre projet"
68 + note.texte = ""
69 + note.auteur = "dave"
70 + note.createdAt = 1000
71 + try await db.noteProjetDaoImpl.upsert(note)
72 +
73 + let viewModel = ProjetDetailViewModel(projetServerId: "p1", database: db)
74 + await viewModel.refresh()
75 +
76 + XCTAssertEqual(0, viewModel.notesProjet.count)
77 + }
78 +}
A ios/Card2vcfTests/RelanceTranscriptionTest.swift
+137 -0
@@ -0,0 +1,137 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Tests TDD pour la relance de transcription (Lot 3).
5 +///
6 +/// Ces tests vérifient que `SyncEngine.relancerTranscriptionInteraction` et
7 +/// `relancerTranscriptionNoteProjet` appellent bien l'API, mettent à jour
8 +/// le statut local en "en_attente" et effacent l'erreur précédente.
9 +final class RelanceTranscriptionTest: XCTestCase {
10 +
11 + private var db: InMemoryCrmDatabase!
12 + private var api: FakeAilianceApi!
13 + private var engine: SyncEngine!
14 +
15 + override func setUp() {
16 + super.setUp()
17 + db = InMemoryCrmDatabase()
18 + api = FakeAilianceApi()
19 + engine = SyncEngine(api: api, db: db)
20 + }
21 +
22 + // MARK: - Relance interaction — succès
23 +
24 + func testRelancerInteraction_appellePApi() async throws {
25 + let localId = try await insererInteractionEchec(serverId: "i-rel-1", contactId: "c1")
26 +
27 + let erreur = try await engine.relancerTranscriptionInteraction(localId: localId)
28 +
29 + XCTAssertNil(erreur, "Pas d'erreur attendue en cas de succès")
30 + XCTAssertEqual(1, api.relancerInteractionCalls.count)
31 + XCTAssertEqual("c1", api.relancerInteractionCalls.first?.0)
32 + XCTAssertEqual("i-rel-1", api.relancerInteractionCalls.first?.1)
33 + }
34 +
35 + func testRelancerInteraction_repasseStatutEnAttente() async throws {
36 + let localId = try await insererInteractionEchec(serverId: "i-rel-2", contactId: "c1")
37 +
38 + _ = try await engine.relancerTranscriptionInteraction(localId: localId)
39 +
40 + let updated = try await db.interactionDao.getByLocalId(localId)
41 + XCTAssertEqual("en_attente", updated?.transcriptionStatut,
42 + "Statut doit repasser à en_attente après relance")
43 + }
44 +
45 + func testRelancerInteraction_effaceErreur() async throws {
46 + let localId = try await insererInteractionEchec(serverId: "i-rel-3", contactId: "c1")
47 +
48 + _ = try await engine.relancerTranscriptionInteraction(localId: localId)
49 +
50 + let updated = try await db.interactionDao.getByLocalId(localId)
51 + XCTAssertNil(updated?.transcriptionErreur, "Motif d'erreur effacé après relance")
52 + }
53 +
54 + // MARK: - Relance interaction — échec réseau
55 +
56 + func testRelancerInteraction_echecReseau_retourneMsgClair() async throws {
57 + let localId = try await insererInteractionEchec(serverId: "i-rel-4", contactId: "c1")
58 + api.relancerInteractionResult = .err(code: -1, message: "serveur injoignable")
59 +
60 + let erreur = try await engine.relancerTranscriptionInteraction(localId: localId)
61 +
62 + XCTAssertNotNil(erreur, "Message d'erreur attendu")
63 + XCTAssertTrue(erreur!.contains("serveur injoignable"),
64 + "Message d'erreur clair : \(erreur!)")
65 + }
66 +
67 + func testRelancerInteraction_echecReseau_neModifiePasStatut() async throws {
68 + let localId = try await insererInteractionEchec(serverId: "i-rel-5", contactId: "c1")
69 + api.relancerInteractionResult = .err(code: -1, message: "réseau")
70 +
71 + _ = try await engine.relancerTranscriptionInteraction(localId: localId)
72 +
73 + let kept = try await db.interactionDao.getByLocalId(localId)
74 + XCTAssertEqual("echec", kept?.transcriptionStatut,
75 + "Statut préservé quand la relance échoue")
76 + }
77 +
78 + // MARK: - Relance note projet — succès
79 +
80 + func testRelancerNoteProjet_appellePApi() async throws {
81 + let localId = try await insererNoteEchec(serverId: "n-rel-1", projetId: "proj-1")
82 +
83 + let erreur = try await engine.relancerTranscriptionNoteProjet(localId: localId)
84 +
85 + XCTAssertNil(erreur)
86 + XCTAssertEqual(1, api.relancerNoteProjetCalls.count)
87 + XCTAssertEqual("proj-1", api.relancerNoteProjetCalls.first?.0)
88 + XCTAssertEqual("n-rel-1", api.relancerNoteProjetCalls.first?.1)
89 + }
90 +
91 + func testRelancerNoteProjet_repasseStatutEnAttente() async throws {
92 + let localId = try await insererNoteEchec(serverId: "n-rel-2", projetId: "proj-1")
93 +
94 + _ = try await engine.relancerTranscriptionNoteProjet(localId: localId)
95 +
96 + let updated = try await db.noteProjetDao.listAll().first { $0.localId == localId }
97 + XCTAssertEqual("en_attente", updated?.transcriptionStatut)
98 + XCTAssertNil(updated?.transcriptionErreur)
99 + }
100 +
101 + func testRelancerNoteProjet_echecReseau_retourneMsgClair() async throws {
102 + let localId = try await insererNoteEchec(serverId: "n-rel-3", projetId: "proj-1")
103 + api.relancerNoteProjetResult = .err(code: -1, message: "serveur injoignable")
104 +
105 + let erreur = try await engine.relancerTranscriptionNoteProjet(localId: localId)
106 +
107 + XCTAssertNotNil(erreur)
108 + XCTAssertTrue(erreur!.contains("serveur injoignable"))
109 + }
110 +
111 + // MARK: - Helpers
112 +
113 + @discardableResult
114 + private func insererInteractionEchec(serverId: String, contactId: String) async throws -> Int64 {
115 + var entity = InteractionEntity()
116 + entity.serverId = serverId
117 + entity.contactServerId = contactId
118 + entity.type = "note_vocale"
119 + entity.sujet = "Note échouée"
120 + entity.transcriptionStatut = "echec"
121 + entity.transcriptionErreur = "Erreur Whisper : audio trop court"
122 + entity.createdAt = 1_000
123 + return try await db.interactionDao.upsert(entity)
124 + }
125 +
126 + @discardableResult
127 + private func insererNoteEchec(serverId: String, projetId: String) async throws -> Int64 {
128 + var entity = NoteProjetEntity()
129 + entity.serverId = serverId
130 + entity.projetServerId = projetId
131 + entity.titre = "Note échouée"
132 + entity.transcriptionStatut = "echec"
133 + entity.transcriptionErreur = "Erreur Whisper : audio trop court"
134 + entity.createdAt = 1_000
135 + return try await db.noteProjetDao.upsert(entity)
136 + }
137 +}
A ios/Card2vcfTests/SuppressionSyncTest.swift
+179 -0
@@ -0,0 +1,179 @@
1 +import XCTest
2 +@testable import Card2vcf
3 +
4 +/// Tests TDD pour la suppression d'interactions et de notes de projet (Lot 3).
5 +///
6 +/// PREUVE DE ROUGEUR #1 — testPushInteraction_deleteAppelleApi
7 +/// En conservant le code d'origine `if op.op != "create" { return PushOutcome(ok: true) }`
8 +/// dans `pushInteractionOp`, le test échoue avec :
9 +///
10 +/// XCTAssertEqual failed: ("0") is not equal to ("1")
11 +///
12 +/// PREUVE DE ROUGEUR #2 — testPushNoteProjet_deleteAppelleApi
13 +/// En conservant `if op.op != "create" { return PushOutcome(ok: true) }`
14 +/// dans `pushNoteProjetOp`, le test échoue avec :
15 +///
16 +/// XCTAssertEqual failed: ("0") is not equal to ("1")
17 +final class SuppressionSyncTest: XCTestCase {
18 +
19 + private var db: InMemoryCrmDatabase!
20 + private var api: FakeAilianceApi!
21 + private var engine: SyncEngine!
22 +
23 + override func setUp() {
24 + super.setUp()
25 + db = InMemoryCrmDatabase()
26 + api = FakeAilianceApi()
27 + engine = SyncEngine(api: api, db: db)
28 + }
29 +
30 + // MARK: - Push interaction delete (PREUVE DE ROUGEUR #1)
31 +
32 + func testPushInteraction_deleteAppelleApi() async throws {
33 + var op = SyncOpEntity(entityType: "interaction", op: "delete")
34 + op.serverId = "int-srv-1"
35 + op.createdAt = 1
36 + _ = try await db.syncOpDao.insert(op)
37 +
38 + _ = try await engine.pousserEnAttente()
39 +
40 + // Rouge sans le case "delete" dans pushInteractionOp : ("0") is not equal to ("1")
41 + XCTAssertEqual(1, api.deleteInteractionCalls.count)
42 + XCTAssertEqual("int-srv-1", api.deleteInteractionCalls.first)
43 + let ops = try await db.syncOpDao.listAll()
44 + XCTAssertTrue(ops.isEmpty, "Op dépilée après succès")
45 + }
46 +
47 + func testPushInteraction_deleteEchecGardeOp() async throws {
48 + var op = SyncOpEntity(entityType: "interaction", op: "delete")
49 + op.serverId = "int-srv-2"
50 + op.createdAt = 1
51 + _ = try await db.syncOpDao.insert(op)
52 +
53 + api.deleteInteractionResult = .err(code: -1, message: "Réseau indisponible")
54 + _ = try await engine.pousserEnAttente()
55 +
56 + let ops = try await db.syncOpDao.listAll()
57 + XCTAssertEqual(1, ops.count, "Op gardée en file après échec réseau")
58 + }
59 +
60 + func testPushInteraction_deleteSansServerId_echoue() async throws {
61 + var op = SyncOpEntity(entityType: "interaction", op: "delete")
62 + op.serverId = nil
63 + op.createdAt = 1
64 + _ = try await db.syncOpDao.insert(op)
65 +
66 + _ = try await engine.pousserEnAttente()
67 +
68 + // serverId nil → failure sans appel API
69 + XCTAssertEqual(0, api.deleteInteractionCalls.count)
70 + }
71 +
72 + // MARK: - Push note projet delete (PREUVE DE ROUGEUR #2)
73 +
74 + func testPushNoteProjet_deleteAppelleApi() async throws {
75 + let payload = #"{"projetServerId":"proj-srv-1"}"#
76 + var op = SyncOpEntity(entityType: "note_projet", op: "delete")
77 + op.serverId = "note-srv-1"
78 + op.payloadJson = payload
79 + op.createdAt = 1
80 + _ = try await db.syncOpDao.insert(op)
81 +
82 + _ = try await engine.pousserEnAttente()
83 +
84 + // Rouge sans le case "delete" dans pushNoteProjetOp : ("0") is not equal to ("1")
85 + XCTAssertEqual(1, api.deleteNoteProjetCalls.count)
86 + XCTAssertEqual("proj-srv-1", api.deleteNoteProjetCalls.first?.0)
87 + XCTAssertEqual("note-srv-1", api.deleteNoteProjetCalls.first?.1)
88 + let ops = try await db.syncOpDao.listAll()
89 + XCTAssertTrue(ops.isEmpty, "Op dépilée après succès")
90 + }
91 +
92 + func testPushNoteProjet_deleteSansProjetServerId_echoue() async throws {
93 + var op = SyncOpEntity(entityType: "note_projet", op: "delete")
94 + op.serverId = "note-srv-2"
95 + op.payloadJson = "{}" // projetServerId absent
96 + op.createdAt = 1
97 + _ = try await db.syncOpDao.insert(op)
98 +
99 + _ = try await engine.pousserEnAttente()
100 +
101 + XCTAssertEqual(0, api.deleteNoteProjetCalls.count)
102 + }
103 +
104 + // MARK: - Suppression locale sans serverId (jamais synchronisé)
105 +
106 + func testSupprimerInteractionLocale_sansServerId_neCreesPasOp() async throws {
107 + // Interaction locale uniquement (serverId nil)
108 + var entity = InteractionEntity()
109 + entity.contactServerId = "c1"
110 + entity.type = "note_vocale"
111 + entity.sujet = "Note locale"
112 + entity.createdAt = 1_000
113 + let localId = try await db.interactionDao.upsert(entity)
114 +
115 + // Récupération et suppression locale
116 + guard let found = try await db.interactionDao.getByLocalId(localId) else {
117 + XCTFail("Entité introuvable")
118 + return
119 + }
120 + XCTAssertNil(found.serverId, "serverId doit être nil pour une entité jamais sync")
121 + // Suppression sans op de sync (serverId absent)
122 + try await db.interactionDao.deleteByLocalId(localId)
123 +
124 + let allInteractions = try await db.interactionDao.listAll()
125 + XCTAssertTrue(allInteractions.isEmpty, "Interaction supprimée localement")
126 + let allOps = try await db.syncOpDao.listAll()
127 + XCTAssertTrue(allOps.isEmpty, "Aucun op créé pour une entité jamais sync")
128 + }
129 +
130 + func testSupprimerNoteLocale_sansServerId_neCreesPasOp() async throws {
131 + var entity = NoteProjetEntity()
132 + entity.projetServerId = "proj-1"
133 + entity.titre = "Note locale"
134 + entity.createdAt = 1_000
135 + let localId = try await db.noteProjetDao.upsert(entity)
136 +
137 + let noteInserted = try await db.noteProjetDao.listAll().first { $0.localId == localId }
138 + XCTAssertNil(noteInserted?.serverId)
139 +
140 + try await db.noteProjetDao.deleteByLocalId(localId)
141 +
142 + let allNotes = try await db.noteProjetDao.listAll()
143 + XCTAssertTrue(allNotes.isEmpty)
144 + let allOps = try await db.syncOpDao.listAll()
145 + XCTAssertTrue(allOps.isEmpty)
146 + }
147 +
148 + // MARK: - Suppression synchronisée crée l'op
149 +
150 + func testSupprimerInteractionSynchronisee_creeOp() async throws {
151 + var entity = InteractionEntity()
152 + entity.serverId = "int-srv-ok"
153 + entity.contactServerId = "c1"
154 + entity.type = "note_vocale"
155 + entity.sujet = "Note sync"
156 + entity.createdAt = 1_000
157 + let localId = try await db.interactionDao.upsert(entity)
158 +
159 + // Simulation : supprimer localement + créer l'op de sync
160 + guard let found = try await db.interactionDao.getByLocalId(localId),
161 + let serverId = found.serverId else {
162 + XCTFail("Entité introuvable")
163 + return
164 + }
165 + try await db.interactionDao.deleteByLocalId(localId)
166 +
167 + var op = SyncOpEntity(entityType: "interaction", op: "delete")
168 + op.serverId = serverId
169 + op.createdAt = 2_000
170 + _ = try await db.syncOpDao.insert(op)
171 +
172 + let allInteractions = try await db.interactionDao.listAll()
173 + XCTAssertTrue(allInteractions.isEmpty, "Interaction supprimée localement")
174 + let allOps = try await db.syncOpDao.listAll()
175 + XCTAssertEqual(1, allOps.count, "Op de delete créée")
176 + XCTAssertEqual("delete", allOps.first?.op)
177 + XCTAssertEqual("int-srv-ok", allOps.first?.serverId)
178 + }
179 +}
M ios/README.md
+2 -2
@@ -24,14 +24,14 @@ cd ios
24 24 xcodegen generate
25 25 open Card2vcf.xcodeproj # ou :
26 26 xcodebuild -project Card2vcf.xcodeproj -scheme Card2vcf \
27 - -destination 'platform=iOS Simulator,name=iPhone 17' build
27 + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build
28 28 ```
29 29
30 30 Tests (mêmes suites que les tests JUnit Android, portées en XCTest) :
31 31
32 32 ```bash
33 33 xcodebuild -project Card2vcf.xcodeproj -scheme Card2vcf \
34 - -destination 'platform=iOS Simulator,name=iPhone 17' test
34 + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test
35 35 ```
36 36
37 37 Cible : iOS 16+, iPhone et iPad. Le `.xcodeproj` est généré (non
M ios/project.yml
+2 -0
@@ -29,6 +29,8 @@ targets:
29 29 platform: iOS
30 30 sources:
31 31 - path: Card2vcfTests
32 + resources:
33 + - path: Card2vcfTests/Fixtures
32 34 dependencies:
33 35 - target: Card2vcf
34 36 settings: