Upgrade client & fix front

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

d1caf6015952f5b8b7c0d771191e447dbc3cd16b

1 parent(s)

9 fichiers modifiés +339 -8
M android/app/build.gradle.kts
+3 -2
@@ -47,8 +47,8 @@ android {
47 47 applicationId = "fr.ebii.piclead"
48 48 minSdk = 30
49 49 targetSdk = 35
50 - versionCode = 1
51 - versionName = "0.4.3"
50 + versionCode = 3
51 + versionName = "0.4.4"
52 52 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
53 53 ndk {
54 54 abiFilters += listOf("arm64-v8a")
@@ -56,6 +56,7 @@ android {
56 56 }
57 57 buildFeatures {
58 58 compose = true
59 + buildConfig = true
59 60 }
60 61 compileOptions {
61 62 sourceCompatibility = JavaVersion.VERSION_21
A android/app/src/main/java/fr/ebii/card2vcf/ui/apropos/AProposScreen.kt
+143 -0
@@ -0,0 +1,143 @@
1 +package fr.ebii.card2vcf.ui.apropos
2 +
3 +import android.content.Intent
4 +import android.net.Uri
5 +import androidx.compose.foundation.background
6 +import androidx.compose.foundation.layout.Arrangement
7 +import androidx.compose.foundation.layout.Box
8 +import androidx.compose.foundation.layout.Column
9 +import androidx.compose.foundation.layout.PaddingValues
10 +import androidx.compose.foundation.layout.fillMaxSize
11 +import androidx.compose.foundation.layout.fillMaxWidth
12 +import androidx.compose.foundation.layout.padding
13 +import androidx.compose.foundation.rememberScrollState
14 +import androidx.compose.foundation.verticalScroll
15 +import androidx.compose.material3.HorizontalDivider
16 +import androidx.compose.material3.MaterialTheme
17 +import androidx.compose.material3.Text
18 +import androidx.compose.material3.TextButton
19 +import androidx.compose.runtime.Composable
20 +import androidx.compose.ui.Modifier
21 +import androidx.compose.ui.platform.LocalContext
22 +import androidx.compose.ui.res.stringResource
23 +import androidx.compose.ui.unit.dp
24 +import fr.ebii.card2vcf.BuildConfig
25 +import fr.ebii.card2vcf.R
26 +import fr.ebii.card2vcf.ui.composants.AppTopBar
27 +import fr.ebii.card2vcf.ui.theme.Bordure
28 +import fr.ebii.card2vcf.ui.theme.Fond
29 +import fr.ebii.card2vcf.ui.theme.Ink
30 +import fr.ebii.card2vcf.ui.theme.Link
31 +import fr.ebii.card2vcf.ui.theme.Surface
32 +import fr.ebii.card2vcf.ui.theme.TexteFaible
33 +
34 +@Composable
35 +fun AProposScreen(
36 + onBack: () -> Unit,
37 + modifier: Modifier = Modifier,
38 +) {
39 + val context = LocalContext.current
40 +
41 + Column(modifier.fillMaxSize().background(Fond)) {
42 + Box(Modifier.fillMaxWidth().background(Surface)) {
43 + AppTopBar(
44 + titre = stringResource(R.string.apropos_titre),
45 + onBack = onBack,
46 + modifier = Modifier.padding(horizontal = 8.dp),
47 + )
48 + }
49 +
50 + Column(
51 + Modifier
52 + .fillMaxSize()
53 + .verticalScroll(rememberScrollState())
54 + .padding(horizontal = 24.dp, vertical = 20.dp),
55 + verticalArrangement = Arrangement.spacedBy(6.dp),
56 + ) {
57 + // Nom de l'application et version
58 + Text(
59 + stringResource(R.string.app_name),
60 + style = MaterialTheme.typography.headlineSmall,
61 + color = Ink,
62 + )
63 + Text(
64 + stringResource(R.string.apropos_version, BuildConfig.VERSION_NAME),
65 + style = MaterialTheme.typography.bodyMedium,
66 + color = TexteFaible,
67 + )
68 +
69 + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = Bordure, thickness = 1.dp)
70 +
71 + // Éditeur
72 + Text(
73 + stringResource(R.string.apropos_editeur_titre),
74 + style = MaterialTheme.typography.titleSmall,
75 + color = Ink,
76 + )
77 + Text(
78 + stringResource(R.string.apropos_editeur_nom),
79 + style = MaterialTheme.typography.bodyMedium,
80 + color = Ink,
81 + )
82 + Text(
83 + stringResource(R.string.apropos_editeur_description),
84 + style = MaterialTheme.typography.bodySmall,
85 + color = TexteFaible,
86 + )
87 +
88 + val urlProduit = stringResource(R.string.apropos_lien_produit_url)
89 + TextButton(
90 + onClick = {
91 + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(urlProduit)))
92 + },
93 + contentPadding = PaddingValues(0.dp),
94 + ) {
95 + Text(
96 + stringResource(R.string.apropos_lien_produit_label),
97 + color = Link,
98 + style = MaterialTheme.typography.bodyMedium,
99 + )
100 + }
101 +
102 + val email = stringResource(R.string.apropos_contact_email)
103 + TextButton(
104 + onClick = {
105 + context.startActivity(Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$email")))
106 + },
107 + contentPadding = PaddingValues(0.dp),
108 + ) {
109 + Text(
110 + email,
111 + color = Link,
112 + style = MaterialTheme.typography.bodyMedium,
113 + )
114 + }
115 +
116 + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = Bordure, thickness = 1.dp)
117 +
118 + // Licence
119 + Text(
120 + stringResource(R.string.apropos_licence_titre),
121 + style = MaterialTheme.typography.titleSmall,
122 + color = Ink,
123 + )
124 + Text(
125 + stringResource(R.string.apropos_licence),
126 + style = MaterialTheme.typography.bodyMedium,
127 + color = Ink,
128 + )
129 +
130 + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = Bordure, thickness = 1.dp)
131 +
132 + // Composants tiers
133 + Text(
134 + stringResource(R.string.apropos_composants_titre),
135 + style = MaterialTheme.typography.titleSmall,
136 + color = TexteFaible,
137 + )
138 + Text(stringResource(R.string.apropos_composant_tesseract), style = MaterialTheme.typography.bodySmall, color = TexteFaible)
139 + Text(stringResource(R.string.apropos_composant_vosk), style = MaterialTheme.typography.bodySmall, color = TexteFaible)
140 + Text(stringResource(R.string.apropos_composant_markwon), style = MaterialTheme.typography.bodySmall, color = TexteFaible)
141 + }
142 + }
143 +}
M android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactPagerScreen.kt
+15 -6
@@ -46,6 +46,7 @@ import androidx.compose.runtime.collectAsState
46 46 import androidx.compose.runtime.getValue
47 47 import androidx.compose.runtime.mutableStateOf
48 48 import androidx.compose.runtime.remember
49 +import androidx.compose.runtime.saveable.rememberSaveable
49 50 import androidx.compose.runtime.setValue
50 51 import androidx.compose.ui.Alignment
51 52 import androidx.compose.ui.platform.LocalContext
@@ -109,12 +110,20 @@ fun ContactPagerScreen(
109 110 viewModel.consommerMessageErreur()
110 111 }
111 112
112 - // rememberPagerState doit être appelé inconditionnellement (règle Compose).
113 - val initialPage = if (contacts.isNotEmpty()) viewModel.initialPageIndex(contacts) else 0
114 - val pagerState = rememberPagerState(
115 - initialPage = initialPage,
116 - pageCount = { contacts.size },
117 - )
113 + // rememberPagerState est appelé inconditionnellement (règle Compose).
114 + // Le positionnement initial est différé dans un LaunchedEffect pour éviter de se bloquer
115 + // sur page 0 lorsque la liste Room est encore vide au premier rendu.
116 + val pagerState = rememberPagerState(initialPage = 0, pageCount = { contacts.size })
117 + var positionne by rememberSaveable { mutableStateOf(false) }
118 + LaunchedEffect(contacts) {
119 + if (!positionne) {
120 + val page = pageCible(contacts, viewModel.initialContactId)
121 + if (page != null) {
122 + pagerState.scrollToPage(page)
123 + positionne = true
124 + }
125 + }
126 + }
118 127 val current: CrmContactEntity? = contacts.getOrNull(pagerState.currentPage)
119 128 val dupCount = current?.let { duplicateCountById[it.id] } ?: 0
120 129
M android/app/src/main/java/fr/ebii/card2vcf/ui/contact/ContactPagerViewModel.kt
+11 -0
@@ -238,3 +238,14 @@ class ContactPagerViewModel(
238 238 }
239 239 }
240 240 }
241 +
242 +/**
243 + * Retourne null tant que la liste est vide (premier rendu avant chargement Room),
244 + * puis l'index du contact cible — ou 0 si introuvable.
245 + * Utilisée par le LaunchedEffect de positionnement dans ContactPagerScreen.
246 + */
247 +internal fun pageCible(contacts: List<CrmContactEntity>, contactId: Long): Int? {
248 + if (contacts.isEmpty()) return null
249 + val idx = contacts.indexOfFirst { it.id == contactId }
250 + return if (idx >= 0) idx else 0
251 +}
M android/app/src/main/java/fr/ebii/card2vcf/ui/nav/Card2vcfNavHost.kt
+6 -0
@@ -46,6 +46,7 @@ import fr.ebii.card2vcf.ui.projets.ProjetDetailScreen
46 46 import fr.ebii.card2vcf.ui.projets.ProjetDetailViewModel
47 47 import fr.ebii.card2vcf.ui.projets.ProjetsListScreen
48 48 import fr.ebii.card2vcf.ui.projets.ProjetsViewModel
49 +import fr.ebii.card2vcf.ui.apropos.AProposScreen
49 50 import fr.ebii.card2vcf.ui.settings.SettingsScreen
50 51 import fr.ebii.card2vcf.ui.settings.SettingsViewModel
51 52 import fr.ebii.card2vcf.ui.sync.SyncChromeViewModel
@@ -61,6 +62,7 @@ object Routes {
61 62 const val Draft = "draft"
62 63 const val ImportVcf = "import_vcf"
63 64 const val Settings = "settings"
65 + const val APropos = "apropos"
64 66 const val Projets = "projets"
65 67 const val ProjetDetail = "projet/{serverId}"
66 68
@@ -307,8 +309,12 @@ fun Card2vcfNavHost(
307 309 SettingsScreen(
308 310 viewModel = settingsVm,
309 311 onBack = { navController.popBackStack() },
312 + onAPropos = { navController.navigate(Routes.APropos) },
310 313 )
311 314 }
315 + composable(Routes.APropos) {
316 + AProposScreen(onBack = { navController.popBackStack() })
317 + }
312 318 }
313 319 }
314 320
M android/app/src/main/java/fr/ebii/card2vcf/ui/settings/SettingsScreen.kt
+25 -0
@@ -66,6 +66,7 @@ private fun hasCalendarPermissions(context: Context): Boolean =
66 66 fun SettingsScreen(
67 67 viewModel: SettingsViewModel,
68 68 onBack: () -> Unit,
69 + onAPropos: () -> Unit,
69 70 modifier: Modifier = Modifier,
70 71 ) {
71 72 val state by viewModel.state.collectAsState()
@@ -105,10 +106,12 @@ fun SettingsScreen(
105 106 viewModel = viewModel,
106 107 hasCalendarPermission = hasCalendarPermission,
107 108 onRequestCalendarPermission = { calendarPermissionLauncher.launch(CalendarPermissions) },
109 + onAPropos = onAPropos,
108 110 )
109 111 is SettingsUiState.LoggedOut -> LoggedOutContent(
110 112 state = state as SettingsUiState.LoggedOut,
111 113 viewModel = viewModel,
114 + onAPropos = onAPropos,
112 115 )
113 116 }
114 117 }
@@ -120,6 +123,7 @@ private fun LoggedInContent(
120 123 viewModel: SettingsViewModel,
121 124 hasCalendarPermission: Boolean,
122 125 onRequestCalendarPermission: () -> Unit,
126 + onAPropos: () -> Unit,
123 127 ) {
124 128 Column(
125 129 Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(18.dp),
@@ -149,6 +153,16 @@ private fun LoggedInContent(
149 153 HorizontalDivider(color = Bordure, thickness = 1.dp)
150 154
151 155 TranscriptionSection(state = state, viewModel = viewModel)
156 +
157 + HorizontalDivider(color = Bordure, thickness = 1.dp)
158 +
159 + TextButton(onClick = onAPropos, modifier = Modifier.fillMaxWidth()) {
160 + Text(
161 + stringResource(R.string.apropos_titre),
162 + color = TexteFaible,
163 + modifier = Modifier.weight(1f),
164 + )
165 + }
152 166 }
153 167
154 168 if (state.pendingRemoval != null) {
@@ -309,6 +323,7 @@ private fun TranscriptionSection(
309 323 private fun LoggedOutContent(
310 324 state: SettingsUiState.LoggedOut,
311 325 viewModel: SettingsViewModel,
326 + onAPropos: () -> Unit,
312 327 ) {
313 328 Column(
314 329 Modifier.fillMaxSize().padding(18.dp),
@@ -354,6 +369,16 @@ private fun LoggedOutContent(
354 369 ) {
355 370 Text(stringResource(R.string.settings_obtenir_cle))
356 371 }
372 +
373 + HorizontalDivider(color = Bordure, thickness = 1.dp)
374 +
375 + TextButton(onClick = onAPropos, modifier = Modifier.fillMaxWidth()) {
376 + Text(
377 + stringResource(R.string.apropos_titre),
378 + color = TexteFaible,
379 + modifier = Modifier.weight(1f),
380 + )
381 + }
357 382 }
358 383
359 384 if (state.pendingAuth != null) {
M android/app/src/main/res/values-en/strings.xml
+17 -0
@@ -182,6 +182,23 @@
182 182 <string name="lecteur_audio_telecharger">Download audio</string>
183 183 <string name="lecteur_audio_en_chargement">Loading…</string>
184 184 <string name="lecteur_audio_erreur">Unable to play audio</string>
185 + <!-- About -->
186 + <string name="apropos_titre">About</string>
187 + <string name="apropos_version">Version %1$s</string>
188 + <string name="apropos_editeur_titre">Publisher</string>
189 + <string name="apropos_editeur_nom">Eric Bouhana Ingénierie Informatique (ebii)</string>
190 + <string name="apropos_editeur_description">Open source digital services</string>
191 + <string name="apropos_lien_produit_label">Product page</string>
192 + <string name="apropos_lien_produit_url" translatable="false">https://www.ebii.fr/fr/editions/piclead</string>
193 + <string name="apropos_contact_label">Contact</string>
194 + <string name="apropos_contact_email" translatable="false">contact@ebii.fr</string>
195 + <string name="apropos_licence_titre">License</string>
196 + <string name="apropos_licence">GNU AGPL v3</string>
197 + <string name="apropos_composants_titre">Third-party components</string>
198 + <string name="apropos_composant_tesseract">Tesseract4Android — Apache 2.0</string>
199 + <string name="apropos_composant_vosk">Vosk — Apache 2.0</string>
200 + <string name="apropos_composant_markwon">Markwon — Apache 2.0</string>
201 +
185 202 <!-- Settings — transcription -->
186 203 <string name="settings_transcription_titre">Voice note transcription</string>
187 204 <string name="settings_transcription_local">On device</string>
M android/app/src/main/res/values/strings.xml
+17 -0
@@ -197,6 +197,23 @@
197 197 <string name="lecteur_audio_telecharger">Télécharger l\'audio</string>
198 198 <string name="lecteur_audio_en_chargement">Chargement…</string>
199 199 <string name="lecteur_audio_erreur">Impossible de lire l\'audio</string>
200 + <!-- À propos -->
201 + <string name="apropos_titre">À propos</string>
202 + <string name="apropos_version">Version %1$s</string>
203 + <string name="apropos_editeur_titre">Éditeur</string>
204 + <string name="apropos_editeur_nom">Eric Bouhana Ingénierie Informatique (ebii)</string>
205 + <string name="apropos_editeur_description">Services numériques open source</string>
206 + <string name="apropos_lien_produit_label">Page produit</string>
207 + <string name="apropos_lien_produit_url" translatable="false">https://www.ebii.fr/fr/editions/piclead</string>
208 + <string name="apropos_contact_label">Contact</string>
209 + <string name="apropos_contact_email" translatable="false">contact@ebii.fr</string>
210 + <string name="apropos_licence_titre">Licence</string>
211 + <string name="apropos_licence">GNU AGPL v3</string>
212 + <string name="apropos_composants_titre">Composants tiers</string>
213 + <string name="apropos_composant_tesseract">Tesseract4Android — Apache 2.0</string>
214 + <string name="apropos_composant_vosk">Vosk — Apache 2.0</string>
215 + <string name="apropos_composant_markwon">Markwon — Apache 2.0</string>
216 +
200 217 <!-- Paramètres — transcription -->
201 218 <string name="settings_transcription_titre">Transcription des notes vocales</string>
202 219 <string name="settings_transcription_local">Sur l\'appareil</string>
A android/app/src/test/java/fr/ebii/card2vcf/ui/contact/ContactPagerPositionnementTest.kt
+102 -0
@@ -0,0 +1,102 @@
1 +package fr.ebii.card2vcf.ui.contact
2 +
3 +import fr.ebii.card2vcf.data.CrmContactEntity
4 +import org.junit.Assert.assertEquals
5 +import org.junit.Assert.assertNull
6 +import org.junit.Test
7 +
8 +/**
9 + * Teste la logique de positionnement initial du pager de fiches contact.
10 + *
11 + * Régression corrigée (commit 068f21d) : rememberPagerState ne relit pas initialPage après
12 + * le premier rendu. Quand Room renvoie une liste vide au premier rendu, le pager se bloque
13 + * sur la page 0. La correction différe le positionnement via pageCible(), qui retourne null
14 + * tant que la liste est vide, puis l'index cible dès que les données sont disponibles.
15 + */
16 +class ContactPagerPositionnementTest {
17 +
18 + private fun contact(id: Long) = CrmContactEntity(id = id, fullName = "Contact $id")
19 +
20 + // --- pageCible : comportements de base ---
21 +
22 + @Test
23 + fun `liste vide retourne null`() {
24 + assertNull(pageCible(emptyList(), contactId = 42L))
25 + }
26 +
27 + @Test
28 + fun `contact cible en deuxieme position retourne index 1`() {
29 + val contacts = listOf(contact(1L), contact(2L), contact(3L))
30 + assertEquals(1, pageCible(contacts, contactId = 2L))
31 + }
32 +
33 + @Test
34 + fun `contact cible en premiere position retourne index 0`() {
35 + val contacts = listOf(contact(10L), contact(20L))
36 + assertEquals(0, pageCible(contacts, contactId = 10L))
37 + }
38 +
39 + @Test
40 + fun `contact cible en derniere position retourne dernier index`() {
41 + val contacts = listOf(contact(1L), contact(2L), contact(3L))
42 + assertEquals(2, pageCible(contacts, contactId = 3L))
43 + }
44 +
45 + @Test
46 + fun `contact introuvable retourne 0 (repli)`() {
47 + val contacts = listOf(contact(1L), contact(2L))
48 + assertEquals(0, pageCible(contacts, contactId = 999L))
49 + }
50 +
51 + // --- Séquence qui reproduit la régression ---
52 + //
53 + // Ancien comportement : initialPage calculé depuis une liste vide → toujours 0.
54 + // rememberPagerState ne relit pas initialPage : la page cible (≠ 0) n'était jamais atteinte.
55 + //
56 + // Nouveau comportement : pageCible retourne null tant que la liste est vide (pas de scroll),
57 + // puis retourne l'index exact quand les données arrivent (scroll unique, bon index).
58 +
59 + @Test
60 + fun `sequence chargement - null puis index correct - attrape la regression`() {
61 + val contactId = 3L
62 + val contacts = listOf(contact(1L), contact(2L), contact(3L), contact(4L))
63 +
64 + // Premier rendu : Room n'a pas encore émis → liste vide → pas de scroll (null).
65 + val avantChargement = pageCible(emptyList(), contactId)
66 + assertNull(
67 + "La page ne doit pas être fixée avant le chargement (sinon blocage sur page 0)",
68 + avantChargement,
69 + )
70 +
71 + // Après chargement : la liste arrive → scroll vers le bon index.
72 + val apresChargement = pageCible(contacts, contactId)
73 + assertEquals(
74 + "Après chargement, doit pointer sur le contact demandé (index 2, pas 0)",
75 + 2,
76 + apresChargement,
77 + )
78 + }
79 +
80 + @Test
81 + fun `ancien code aurait retourne 0 sur liste vide - preuve que le test attrape la regression`() {
82 + // Simule l'ancien calcul : indexOfFirst sur liste vide = -1, repli sur 0.
83 + // Cela provoquait le blocage du pager sur la page 0 même quand la cible était ailleurs.
84 + val ancienneLogique: (List<CrmContactEntity>, Long) -> Int = { list, id ->
85 + val idx = list.indexOfFirst { it.id == id }
86 + if (idx >= 0) idx else 0
87 + }
88 +
89 + val contactId = 3L
90 + val contacts = listOf(contact(1L), contact(2L), contact(3L))
91 +
92 + // Ancienne logique : 0 sur liste vide → pager bloqué sur la mauvaise page.
93 + assertEquals(0, ancienneLogique(emptyList(), contactId))
94 +
95 + // Nouvelle logique : null sur liste vide → pas de positionnement prématuré.
96 + assertNull(pageCible(emptyList(), contactId))
97 +
98 + // Après chargement, les deux convergent sur l'index correct (2).
99 + assertEquals(2, ancienneLogique(contacts, contactId))
100 + assertEquals(2, pageCible(contacts, contactId))
101 + }
102 +}