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 = """