Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions app/src/main/java/com/kaz229/fittrack/data/Nutrition.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
49 changes: 49 additions & 0 deletions app/src/main/java/com/kaz229/fittrack/ui/screens/AddDialogs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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.
*
Expand Down
41 changes: 37 additions & 4 deletions app/src/main/java/com/kaz229/fittrack/ui/screens/ScanScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand All @@ -99,16 +110,30 @@ 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
}

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,
Expand Down Expand Up @@ -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),
Expand Down
33 changes: 33 additions & 0 deletions app/src/test/java/com/kaz229/fittrack/NutritionTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = """
Expand Down
Loading