From 2a467c5780521dd3509ecce34814ae48cc0ab7d6 Mon Sep 17 00:00:00 2001 From: Abdel-Hamid Makarimi Date: Sun, 1 Nov 2026 16:58:39 +0100 Subject: [PATCH] Permet d'entrer le code-barres au clavier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La caméra ne lit pas toujours l'étiquette : emballage abîmé, reflet, manque de lumière, produit déjà jeté. Le scan était alors une impasse, qui renvoyait vers la saisie complète de l'aliment et de ses macros. - Bouton « Entrer le code à la main » présent en permanence pendant que la caméra cherche, et proposé en premier quand la caméra est refusée ou que le réseau manque. - La clé de contrôle EAN-13, EAN-8 et UPC-A est vérifiée avant d'interroger Open Food Facts : une faute de frappe est signalée dans le champ plutôt que de partir en requête pour rien. Le message n'apparaît qu'à partir de huit chiffres, pour ne pas crier pendant la frappe. - Un produit absent de la base continue de renvoyer vers la saisie complète : réentrer le code n'y changerait rien. Au passage, le nom affiché ne répète plus la marque quand elle est déjà dans le nom du produit — Open Food Facts donne « Nutella » pour les deux champs, ce qui affichait « Nutella — Nutella ». Vérifié sur émulateur : code erroné refusé sans appel réseau, code corrigé trouvé chez Open Food Facts avec ses valeurs, puis ajouté au journal. --- .../com/kaz229/fittrack/data/Nutrition.kt | 36 +++++++++++++- .../kaz229/fittrack/ui/screens/AddDialogs.kt | 49 +++++++++++++++++++ .../kaz229/fittrack/ui/screens/ScanScreen.kt | 41 ++++++++++++++-- .../java/com/kaz229/fittrack/NutritionTest.kt | 33 +++++++++++++ 4 files changed, 153 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/kaz229/fittrack/data/Nutrition.kt b/app/src/main/java/com/kaz229/fittrack/data/Nutrition.kt index 0d5aff6..64012f5 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/Nutrition.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/Nutrition.kt @@ -25,8 +25,16 @@ data class FoodProduct( val servingSizeG: Double? = null, val fetchedAtEpochDay: Long = 0, ) { - /** « Nutella — Ferrero », ou juste le nom quand la marque est inconnue. */ - val displayName: String get() = if (brands.isBlank()) name else "$name — $brands" + /** + * « Nutella — Ferrero », ou juste le nom quand la marque est inconnue — ou déjà + * contenue dans le nom, ce qui donnerait « Nutella — Nutella ». + */ + val displayName: String + get() = if (brands.isBlank() || name.contains(brands, ignoreCase = true)) { + name + } else { + "$name — $brands" + } } /** Les valeurs d'une portion : ce qui finit réellement enregistré dans le journal. */ @@ -88,4 +96,28 @@ fun parseServingGrams(raw: String?): Double? { return value.takeIf { it > 0 } } +/** + * Vérifie qu'une suite de chiffres est un code-barres alimentaire plausible. + * + * Les formats EAN-13, EAN-8, UPC-A et UPC-E portent une clé de contrôle en dernier + * chiffre : la valider évite d'interroger Open Food Facts pour une faute de frappe, + * et distingue un vrai code d'un numéro saisi de travers. + */ +fun isValidBarcode(raw: String): Boolean { + val digits = raw.trim() + if (digits.length !in VALID_BARCODE_LENGTHS) return false + if (!digits.all { it.isDigit() }) return false + + // Somme pondérée 3/1 en partant de l'avant-dernier chiffre, quelle que soit la longueur. + val body = digits.dropLast(1) + val sum = body.reversed().mapIndexed { index, char -> + val value = Character.getNumericValue(char) + if (index % 2 == 0) value * 3 else value + }.sum() + val expected = (10 - sum % 10) % 10 + return expected == Character.getNumericValue(digits.last()) +} + +private val VALID_BARCODE_LENGTHS = setOf(8, 12, 13) + private fun round1(value: Double): Double = (value * 10).roundToInt() / 10.0 diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/AddDialogs.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/AddDialogs.kt index a496d06..3c6b321 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/AddDialogs.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/AddDialogs.kt @@ -32,6 +32,7 @@ import com.kaz229.fittrack.data.Exercise import com.kaz229.fittrack.data.ExerciseSet import com.kaz229.fittrack.data.FoodProduct import com.kaz229.fittrack.data.MealType +import com.kaz229.fittrack.data.isValidBarcode import com.kaz229.fittrack.data.portion /** @@ -186,6 +187,54 @@ fun AddCardioDialog( ) } +/** + * Saisie du code-barres au clavier, quand la caméra ne peut pas lire l'étiquette : + * emballage abîmé, reflet, produit déjà jeté, ou simple manque de lumière. + */ +@Composable +fun ManualBarcodeDialog( + onDismiss: () -> Unit, + onConfirm: (barcode: String) -> Unit, +) { + var code by remember { mutableStateOf("") } + val trimmed = code.trim() + val valid = isValidBarcode(trimmed) + // On ne crie pas à l'erreur pendant la frappe des premiers chiffres. + val showError = trimmed.length >= 8 && !valid + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Entrer le code-barres") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Les chiffres imprimés sous le code-barres, sans espace " + + "(8, 12 ou 13 chiffres).", + style = MaterialTheme.typography.bodySmall, + ) + OutlinedTextField( + value = code, + onValueChange = { entry -> code = entry.filter { it.isDigit() } }, + label = { Text("Code-barres") }, + singleLine = true, + isError = showError, + supportingText = if (showError) { + { Text("Ce code semble incorrect : vérifie les chiffres.") } + } else { + null + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(trimmed) }, enabled = valid) { Text("Chercher") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } }, + ) +} + /** * Quantité d'un produit scanné et type de repas, avec l'apport recalculé en direct. * diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/ScanScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/ScanScreen.kt index dd3137c..65cb0d2 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/ScanScreen.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/ScanScreen.kt @@ -79,6 +79,17 @@ fun ScanScreen( ) } var permissionRefused by remember { mutableStateOf(false) } + var showManualEntry by remember { mutableStateOf(false) } + + if (showManualEntry) { + ManualBarcodeDialog( + onDismiss = { showManualEntry = false }, + onConfirm = { barcode -> + showManualEntry = false + onBarcode(barcode) + }, + ) + } val launcher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> @@ -99,9 +110,11 @@ fun ScanScreen( } else { "FitTrack a besoin de la caméra pour lire le code-barres des produits." }, - actionLabel = "Saisir à la main", - onAction = onManualEntry, + actionLabel = "Entrer le code-barres", + onAction = { showManualEntry = true }, modifier = modifier, + secondaryLabel = "Saisir l'aliment à la main", + onSecondary = onManualEntry, ) return } @@ -109,6 +122,18 @@ fun ScanScreen( Box(modifier = modifier.fillMaxSize()) { if (state.cameraActive) { CameraPreview(onBarcode = onBarcode, modifier = Modifier.fillMaxSize()) + TextButton( + onClick = { showManualEntry = true }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(32.dp) + .background( + color = Color.Black.copy(alpha = 0.6f), + shape = RoundedCornerShape(24.dp), + ), + ) { + Text(text = "Entrer le code à la main", color = Color.White) + } Text( text = "Cadre le code-barres du produit", style = MaterialTheme.typography.bodyMedium, @@ -159,8 +184,16 @@ fun ScanScreen( "Impossible d'interroger Open Food Facts. Vérifie ta connexion, " + "ou saisis l'aliment à la main." }, - actionLabel = "Saisir à la main", - onAction = onManualEntry, + actionLabel = if (state.error == ScanError.NOT_FOUND) { + "Saisir l'aliment à la main" + } else { + "Entrer le code-barres" + }, + onAction = if (state.error == ScanError.NOT_FOUND) { + onManualEntry + } else { + { showManualEntry = true } + }, secondaryLabel = "Réessayer", onSecondary = onRetry, modifier = Modifier.align(Alignment.Center), diff --git a/app/src/test/java/com/kaz229/fittrack/NutritionTest.kt b/app/src/test/java/com/kaz229/fittrack/NutritionTest.kt index 54c97f0..40d47f4 100644 --- a/app/src/test/java/com/kaz229/fittrack/NutritionTest.kt +++ b/app/src/test/java/com/kaz229/fittrack/NutritionTest.kt @@ -4,9 +4,11 @@ import com.kaz229.fittrack.data.FoodLookup import com.kaz229.fittrack.data.FoodProduct import com.kaz229.fittrack.data.MacroTotals import com.kaz229.fittrack.data.OpenFoodFactsClient +import com.kaz229.fittrack.data.isValidBarcode import com.kaz229.fittrack.data.parseServingGrams import com.kaz229.fittrack.data.portion import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -133,6 +135,37 @@ class NutritionTest { assertEquals(FoodLookup.NoNutrition, OpenFoodFactsClient.parseProduct("123", json)) } + @Test + fun `le nom affiche ne repete pas la marque`() { + assertEquals("Nutella", nutella.copy(brands = "Nutella").displayName) + assertEquals("Nutella — Ferrero", nutella.displayName) + assertEquals("Nutella", nutella.copy(brands = "").displayName) + } + + @Test + fun `les vrais codes barres sont acceptes`() { + assertTrue(isValidBarcode("3017620422003")) // Nutella, EAN-13 + assertTrue(isValidBarcode("5449000000996")) // Coca-Cola, EAN-13 + assertTrue(isValidBarcode("96385074")) // EAN-8 + assertTrue(isValidBarcode("036000291452")) // UPC-A + assertTrue(isValidBarcode(" 3017620422003 ")) // espaces tolérés + } + + @Test + fun `un chiffre faux est detecte par la cle de controle`() { + // Un seul chiffre modifié : la saisie doit être refusée avant tout appel réseau. + assertFalse(isValidBarcode("3017620422004")) + assertFalse(isValidBarcode("3017620422013")) + } + + @Test + fun `une saisie mal formee est refusee`() { + assertFalse(isValidBarcode("")) + assertFalse(isValidBarcode("123")) + assertFalse(isValidBarcode("30176204220031")) // 14 chiffres + assertFalse(isValidBarcode("30176204220O3")) // lettre O au lieu d'un zéro + } + @Test fun `les macros absentes valent zero et non NaN`() { val json = """