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 | +} |
GitRust