diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c587ca9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+*.iml
+.gradle/
+local.properties
+.idea/
+.DS_Store
+build/
+captures/
+.externalNativeBuild
+.cxx
+*.apk
+*.aab
+*.keystore
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..088d61c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,133 @@
+# SafeBite
+
+SafeBite est une application Android native (Kotlin + Jetpack Compose) qui permet aux personnes souffrant d'allergies ou d'intolérances alimentaires de scanner un code-barres en épicerie et d'obtenir instantanément un verdict visuel (**SAFE / ATTENTION / DANGER**) selon leurs profils d'allergies configurés.
+
+---
+
+## Fonctionnalités
+
+- **Profils d'allergies multiples** : allergies sévères (DANGER), intolérances modérées (ATTENTION), restrictions alimentaires, plusieurs profils simultanés.
+- **Scanner de code-barres** via CameraX + ML Kit (EAN-13/8, UPC-A/E, QR).
+- **Open Food Facts API v2** pour les données produits + cache local hors-ligne (Room).
+- **OCR** pour lire les listes d'ingrédients quand le produit est introuvable (Google ML Kit Text Recognition, entièrement sur l'appareil).
+- **Moteur d'analyse 3 couches** : tags OFF → ingrédients → mentions "peut contenir / may contain" (FR + EN).
+- **Historique** filtrable et recherchable.
+- **Paramètres** : langue FR/EN, langue de détection, thème (clair/sombre/système), vibration, son.
+- **Onboarding** guidé au premier lancement.
+
+---
+
+## Stack technique
+
+| Domaine | Technologie |
+|---|---|
+| Langage / Build | Kotlin 2.0.20, AGP 8.5.2, Gradle 8.9, JDK 17 |
+| UI | Jetpack Compose (BOM 2024.09.02), Material 3 |
+| Architecture | MVVM + Clean Architecture (domain / data / presentation) |
+| DI | Hilt 2.52 |
+| Base locale | Room 2.6.1 + KSP |
+| Préférences | DataStore Preferences 1.1.1 |
+| Réseau | Retrofit 2.11 + OkHttp 4.12 + Moshi 1.15 |
+| Caméra | CameraX 1.3.4 |
+| ML Kit | Barcode Scanning 17.3.0, Text Recognition 16.0.1 |
+| Navigation | Navigation Compose 2.8.1 |
+| Images | Coil 2.7.0 |
+| Logs | Timber 5.0.1 |
+
+- **minSdk** 26 (Android 8.0), **targetSdk** 34 (Android 14).
+
+---
+
+## Structure du projet
+
+```
+com.safebite.app/
+├── di/ # Modules Hilt
+├── data/
+│ ├── local/ # Room database + DataStore
+│ ├── remote/ # OpenFoodFactsApi + DTO + mapper
+│ ├── repository/ # Implémentations des repositories
+│ └── util/ # ConnectivityObserver
+├── domain/
+│ ├── model/ # UserProfile, Product, AllergenType, ScanResult, etc.
+│ ├── engine/ # AllergenAnalysisEngine (moteur 3 couches)
+│ ├── repository/ # Interfaces
+│ └── usecase/ # Cas d'usage
+└── presentation/
+ ├── navigation/ # NavGraph + Screen
+ ├── theme/ # Color, Theme, Type, Shape
+ ├── common/ # UiState + composants partagés
+ ├── screen/ # onboarding, home, scanner, result, ocr, profile, history, settings
+ └── MainActivity.kt
+```
+
+---
+
+## Build & Run
+
+### Pré-requis
+
+- Android Studio Koala Feature Drop (2024.1.2) ou plus récent, avec **JDK 17**.
+- Un appareil physique Android 8.0+ avec caméra (l'émulateur avec caméra virtuelle fonctionne pour l'UI, pas pour le scan réel).
+
+### Compiler et installer
+
+Le projet inclut le Gradle Wrapper pour assurer la reproductibilité des builds.
+
+```powershell
+.\gradlew assembleDebug
+.\gradlew installDebug
+```
+
+### Tests unitaires
+
+```powershell
+.\gradlew test
+```
+
+Les tests du moteur d'analyse se trouvent dans `app/src/test/java/com/safebite/app/domain/engine/AllergenAnalysisEngineTest.kt`.
+
+### Générer les APKs
+
+Le script `build_apks.ps1` (PowerShell) permet de compiler les versions Debug et Release de l'application, et d'incrémenter automatiquement la version de l'application.
+
+**Utilisation :**
+
+```powershell
+.\build_apks.ps1 [-Major | -Minor | -Patch]
+```
+
+**Options :**
+- `-Major` : Incrémente la version majeure (X.0.0) et réinitialise les versions mineure et de patch.
+- `-Minor` : Incrémente la version mineure (x.Y.0) et réinitialise la version de patch.
+- `-Patch` : Incrémente la version de patch (x.y.Z).
+- Si aucune option n'est spécifiée, le script utilise la version actuelle pour la compilation.
+- `.\build_apks.ps1 -Help` : Affiche ce message d'aide.
+
+Les fichiers APK générés se trouvent dans les dossiers suivants :
+- Debug : `app\build\outputs\apk\debug\app-debug.apk`
+- Release : `app\build\outputs\apk\release\app-release.apk`
+
+---
+
+## Permissions
+
+| Permission | Usage |
+|---|---|
+| `CAMERA` | Scan de codes-barres et capture OCR (demandée à l'utilisateur). |
+| `INTERNET`, `ACCESS_NETWORK_STATE` | Requêtes Open Food Facts. |
+| `VIBRATE` | Feedback haptique au scan réussi. |
+
+Aucune image n'est jamais envoyée hors de l'appareil : le scan de code-barres et l'OCR tournent **intégralement** sur l'appareil via ML Kit.
+
+---
+
+## Attribution
+
+Les données produits proviennent de [Open Food Facts](https://world.openfoodfacts.org), une base de données collaborative ouverte.
+
+---
+
+## Avertissement important
+
+> SafeBite est un outil d'aide. Il ne remplace pas la lecture attentive de l'étiquette. Les données peuvent être incomplètes ou inexactes. En cas de doute, ne consommez pas le produit. En cas de réaction allergique, appelez le **911**.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..29221b2
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,146 @@
+import java.util.Properties
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.kotlin.parcelize)
+ alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt)
+}
+
+android {
+ namespace = "com.safebite.app"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.safebite.app"
+ minSdk = 26
+ targetSdk = 34
+
+ val versionProps = Properties()
+ val versionFile = file("../version.properties")
+ if (versionFile.exists()) {
+ versionProps.load(versionFile.inputStream())
+ }
+ versionCode = versionProps.getProperty("CODE", "1").toInt()
+ versionName = "${versionProps.getProperty("MAJOR", "1")}.${versionProps.getProperty("MINOR", "0")}.${versionProps.getProperty("PATCH", "0")}"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables { useSupportLibrary = true }
+ }
+
+ val localProperties = Properties()
+ val localPropertiesFile = rootProject.file("local.properties")
+ if (localPropertiesFile.exists()) {
+ localProperties.load(localPropertiesFile.inputStream())
+ }
+
+ signingConfigs {
+ create("release") {
+ val storeFileProp = localProperties.getProperty("signing.storeFile")
+ if (storeFileProp != null) {
+ storeFile = file(storeFileProp)
+ storePassword = localProperties.getProperty("signing.storePassword")
+ keyAlias = localProperties.getProperty("signing.keyAlias")
+ keyPassword = localProperties.getProperty("signing.keyPassword")
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ signingConfig = signingConfigs.getByName("release")
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ debug {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ excludes += "/META-INF/DEPENDENCIES"
+ excludes += "/META-INF/LICENSE*"
+ excludes += "/META-INF/NOTICE*"
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.activity.compose)
+
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.ui.graphics)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.material.icons.extended)
+ implementation(libs.androidx.compose.animation)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+
+ implementation(libs.androidx.navigation.compose)
+
+ implementation(libs.hilt.android)
+ implementation(libs.hilt.navigation.compose)
+ ksp(libs.hilt.compiler)
+
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.room.ktx)
+ ksp(libs.androidx.room.compiler)
+
+ implementation(libs.androidx.datastore.preferences)
+
+ implementation(libs.retrofit)
+ implementation(libs.retrofit.moshi)
+ implementation(libs.okhttp)
+ implementation(libs.okhttp.logging)
+ implementation(libs.moshi)
+ implementation(libs.moshi.kotlin)
+ ksp(libs.moshi.codegen)
+
+ implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.kotlinx.coroutines.play.services)
+
+ implementation(libs.androidx.camera.core)
+ implementation(libs.androidx.camera.camera2)
+ implementation(libs.androidx.camera.lifecycle)
+ implementation(libs.androidx.camera.view)
+
+ implementation(libs.mlkit.barcode)
+ implementation(libs.mlkit.text)
+
+ implementation(libs.coil.compose)
+ implementation(libs.timber)
+ implementation(libs.accompanist.permissions)
+
+ testImplementation(libs.junit)
+ testImplementation(libs.truth)
+ testImplementation(libs.mockk)
+ testImplementation(libs.turbine)
+ testImplementation(libs.kotlinx.coroutines.test)
+
+ androidTestImplementation(libs.androidx.test.ext.junit)
+ androidTestImplementation(libs.androidx.test.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..7160d4f
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,22 @@
+# SafeBite proguard rules
+-keepattributes *Annotation*, Signature, Exceptions, InnerClasses
+
+# Moshi
+-keep class com.squareup.moshi.** { *; }
+-keepclassmembers class ** {
+ @com.squareup.moshi.FromJson *;
+ @com.squareup.moshi.ToJson *;
+}
+
+# Retrofit
+-keep class retrofit2.** { *; }
+-keepattributes Exceptions
+
+# Kotlin metadata
+-keep class kotlin.Metadata { *; }
+
+# DTOs
+-keep class com.safebite.app.data.remote.dto.** { *; }
+
+# ML Kit
+-keep class com.google.mlkit.** { *; }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c0566a2
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/safebite/app/SafeBiteApplication.kt b/app/src/main/java/com/safebite/app/SafeBiteApplication.kt
new file mode 100644
index 0000000..2478e40
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/SafeBiteApplication.kt
@@ -0,0 +1,15 @@
+package com.safebite.app
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+import timber.log.Timber
+
+@HiltAndroidApp
+class SafeBiteApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ if (BuildConfig.DEBUG) {
+ Timber.plant(Timber.DebugTree())
+ }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/data/local/database/Converters.kt b/app/src/main/java/com/safebite/app/data/local/database/Converters.kt
new file mode 100644
index 0000000..f31b8d9
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/local/database/Converters.kt
@@ -0,0 +1,76 @@
+package com.safebite.app.data.local.database
+
+import androidx.room.TypeConverter
+import com.safebite.app.domain.model.AllergenType
+import com.safebite.app.domain.model.CustomDietItem
+import com.safebite.app.domain.model.CustomItemTag
+import com.safebite.app.domain.model.DataSource
+import com.safebite.app.domain.model.DietaryRestriction
+import com.safebite.app.domain.model.SafetyStatus
+
+class Converters {
+ @TypeConverter
+ fun allergenSetToString(set: Set?): String =
+ set.orEmpty().joinToString(",") { it.name }
+
+ @TypeConverter
+ fun stringToAllergenSet(raw: String?): Set =
+ raw.orEmpty().split(',').mapNotNull { AllergenType.fromName(it.trim()) }.toSet()
+
+ @TypeConverter
+ fun restrictionSetToString(set: Set?): String =
+ set.orEmpty().joinToString(",") { it.name }
+
+ @TypeConverter
+ fun stringToRestrictionSet(raw: String?): Set =
+ raw.orEmpty().split(',')
+ .filter { it.isNotBlank() }
+ .mapNotNull { runCatching { DietaryRestriction.valueOf(it.trim()) }.getOrNull() }
+ .toSet()
+
+ @TypeConverter
+ fun stringListToString(list: List?): String =
+ list.orEmpty().joinToString("\u0001")
+
+ @TypeConverter
+ fun stringToStringList(raw: String?): List =
+ if (raw.isNullOrEmpty()) emptyList() else raw.split('\u0001')
+
+ @TypeConverter
+ fun safetyStatusToString(status: SafetyStatus): String = status.name
+
+ @TypeConverter
+ fun stringToSafetyStatus(raw: String): SafetyStatus = SafetyStatus.valueOf(raw)
+
+ @TypeConverter
+ fun dataSourceToString(source: DataSource): String = source.name
+
+ @TypeConverter
+ fun stringToDataSource(raw: String): DataSource = DataSource.valueOf(raw)
+
+ // CustomDietItem encoded as: name|tag|kw1;kw2;... records, joined by \u0002
+ @TypeConverter
+ fun customItemsToString(items: List?): String =
+ items.orEmpty().joinToString("\u0002") { item ->
+ listOf(
+ item.name.replace('|', '/').replace('\u0002', ' '),
+ item.tag.name,
+ item.keywords.joinToString(";") { it.replace(';', ',').replace('|', '/') }
+ ).joinToString("|")
+ }
+
+ @TypeConverter
+ fun stringToCustomItems(raw: String?): List {
+ if (raw.isNullOrBlank()) return emptyList()
+ return raw.split('\u0002').mapNotNull { record ->
+ val parts = record.split('|')
+ if (parts.size < 2) return@mapNotNull null
+ val name = parts[0].trim()
+ val tag = runCatching { CustomItemTag.valueOf(parts[1].trim()) }.getOrNull() ?: return@mapNotNull null
+ val keywords = if (parts.size >= 3 && parts[2].isNotBlank())
+ parts[2].split(';').map { it.trim() }.filter { it.isNotBlank() }
+ else emptyList()
+ CustomDietItem(name = name, tag = tag, keywords = keywords)
+ }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt b/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt
new file mode 100644
index 0000000..0296756
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt
@@ -0,0 +1,27 @@
+package com.safebite.app.data.local.database
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import com.safebite.app.data.local.database.dao.ProductCacheDao
+import com.safebite.app.data.local.database.dao.ScanHistoryDao
+import com.safebite.app.data.local.database.dao.UserProfileDao
+import com.safebite.app.data.local.database.entity.ProductCacheEntity
+import com.safebite.app.data.local.database.entity.ScanHistoryEntity
+import com.safebite.app.data.local.database.entity.UserProfileEntity
+
+@Database(
+ entities = [UserProfileEntity::class, ProductCacheEntity::class, ScanHistoryEntity::class],
+ version = 2,
+ exportSchema = false
+)
+@TypeConverters(Converters::class)
+abstract class SafeBiteDatabase : RoomDatabase() {
+ abstract fun userProfileDao(): UserProfileDao
+ abstract fun productCacheDao(): ProductCacheDao
+ abstract fun scanHistoryDao(): ScanHistoryDao
+
+ companion object {
+ const val NAME = "safebite.db"
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/data/local/database/dao/ProductCacheDao.kt b/app/src/main/java/com/safebite/app/data/local/database/dao/ProductCacheDao.kt
new file mode 100644
index 0000000..2fd8b8b
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/local/database/dao/ProductCacheDao.kt
@@ -0,0 +1,19 @@
+package com.safebite.app.data.local.database.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.safebite.app.data.local.database.entity.ProductCacheEntity
+
+@Dao
+interface ProductCacheDao {
+ @Query("SELECT * FROM product_cache WHERE barcode = :barcode LIMIT 1")
+ suspend fun getByBarcode(barcode: String): ProductCacheEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(entity: ProductCacheEntity)
+
+ @Query("DELETE FROM product_cache")
+ suspend fun clear()
+}
diff --git a/app/src/main/java/com/safebite/app/data/local/database/dao/ScanHistoryDao.kt b/app/src/main/java/com/safebite/app/data/local/database/dao/ScanHistoryDao.kt
new file mode 100644
index 0000000..e9b2777
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/local/database/dao/ScanHistoryDao.kt
@@ -0,0 +1,26 @@
+package com.safebite.app.data.local.database.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.safebite.app.data.local.database.entity.ScanHistoryEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface ScanHistoryDao {
+ @Query("SELECT * FROM scan_history ORDER BY scannedAt DESC")
+ fun observeAll(): Flow>
+
+ @Query("SELECT * FROM scan_history WHERE id = :id LIMIT 1")
+ suspend fun getById(id: Long): ScanHistoryEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(entity: ScanHistoryEntity): Long
+
+ @Query("DELETE FROM scan_history WHERE id = :id")
+ suspend fun deleteById(id: Long)
+
+ @Query("DELETE FROM scan_history")
+ suspend fun clear()
+}
diff --git a/app/src/main/java/com/safebite/app/data/local/database/dao/UserProfileDao.kt b/app/src/main/java/com/safebite/app/data/local/database/dao/UserProfileDao.kt
new file mode 100644
index 0000000..5067954
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/local/database/dao/UserProfileDao.kt
@@ -0,0 +1,34 @@
+package com.safebite.app.data.local.database.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import com.safebite.app.data.local.database.entity.UserProfileEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface UserProfileDao {
+ @Query("SELECT * FROM user_profiles ORDER BY isDefault DESC, name ASC")
+ fun observeAll(): Flow>
+
+ @Query("SELECT * FROM user_profiles WHERE id = :id LIMIT 1")
+ suspend fun getById(id: Long): UserProfileEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(entity: UserProfileEntity): Long
+
+ @Update
+ suspend fun update(entity: UserProfileEntity)
+
+ @Delete
+ suspend fun delete(entity: UserProfileEntity)
+
+ @Query("UPDATE user_profiles SET isDefault = 0")
+ suspend fun clearDefault()
+
+ @Query("UPDATE user_profiles SET isDefault = 1 WHERE id = :id")
+ suspend fun markDefault(id: Long)
+}
diff --git a/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt b/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt
new file mode 100644
index 0000000..d09fdff
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt
@@ -0,0 +1,62 @@
+package com.safebite.app.data.local.database.entity
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.safebite.app.domain.model.AllergenType
+import com.safebite.app.domain.model.CustomDietItem
+import com.safebite.app.domain.model.DataSource
+import com.safebite.app.domain.model.DietaryRestriction
+import com.safebite.app.domain.model.SafetyStatus
+
+@Entity(tableName = "user_profiles")
+data class UserProfileEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0L,
+ val name: String,
+ val avatar: String,
+ val severeAllergens: Set,
+ val moderateIntolerances: Set,
+ val dietaryRestrictions: Set,
+ val customItems: List = emptyList(),
+ val isDefault: Boolean
+)
+
+@Entity(tableName = "product_cache")
+data class ProductCacheEntity(
+ @PrimaryKey val barcode: String,
+ val name: String?,
+ val brand: String?,
+ val imageUrl: String?,
+ val ingredientsText: String?,
+ val allergensTags: List,
+ val tracesTags: List,
+ val nutriScore: String?,
+ val novaGroup: Int?,
+ val ecoScore: String? = null,
+ val servingSize: String? = null,
+ val labels: List = emptyList(),
+ val categories: List = emptyList(),
+ val energyKcal100g: Double? = null,
+ val energyKcalServing: Double? = null,
+ val fat100g: Double? = null,
+ val saturatedFat100g: Double? = null,
+ val sugars100g: Double? = null,
+ val salt100g: Double? = null,
+ val sodium100g: Double? = null,
+ val fiber100g: Double? = null,
+ val proteins100g: Double? = null,
+ val carbohydrates100g: Double? = null,
+ val cachedAt: Long
+)
+
+@Entity(tableName = "scan_history")
+data class ScanHistoryEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0L,
+ val barcode: String,
+ val productName: String?,
+ val brand: String?,
+ val imageUrl: String?,
+ val safetyStatus: SafetyStatus,
+ val profileNames: List,
+ val scannedAt: Long,
+ val source: DataSource
+)
diff --git a/app/src/main/java/com/safebite/app/data/local/datastore/UserPreferences.kt b/app/src/main/java/com/safebite/app/data/local/datastore/UserPreferences.kt
new file mode 100644
index 0000000..6c0f13f
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/local/datastore/UserPreferences.kt
@@ -0,0 +1,97 @@
+package com.safebite.app.data.local.datastore
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.safebite.app.domain.model.AppLanguage
+import com.safebite.app.domain.model.DetectionLanguage
+import com.safebite.app.domain.model.HealthStrictness
+import com.safebite.app.domain.model.ThemePref
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+val Context.safeBiteDataStore: DataStore by preferencesDataStore(name = "safebite_prefs")
+
+object UserPreferencesKeys {
+ val APP_LANGUAGE = stringPreferencesKey("app_language")
+ val DETECTION_LANGUAGE = stringPreferencesKey("detection_language")
+ val HAPTICS = booleanPreferencesKey("haptics_enabled")
+ val SOUND = booleanPreferencesKey("sound_enabled")
+ val THEME = stringPreferencesKey("theme")
+ val ONBOARDING_DONE = booleanPreferencesKey("onboarding_done")
+ val ACTIVE_PROFILE_IDS = stringSetPreferencesKey("active_profile_ids")
+ val HEALTH_STRICTNESS = stringPreferencesKey("health_strictness")
+}
+
+class UserPreferences(private val dataStore: DataStore) {
+
+ val appLanguage: Flow = dataStore.data.map {
+ runCatching { AppLanguage.valueOf(it[UserPreferencesKeys.APP_LANGUAGE] ?: AppLanguage.FR.name) }
+ .getOrDefault(AppLanguage.FR)
+ }
+
+ val detectionLanguage: Flow = dataStore.data.map {
+ runCatching { DetectionLanguage.valueOf(it[UserPreferencesKeys.DETECTION_LANGUAGE] ?: DetectionLanguage.BOTH.name) }
+ .getOrDefault(DetectionLanguage.BOTH)
+ }
+
+ val haptics: Flow = dataStore.data.map { it[UserPreferencesKeys.HAPTICS] ?: true }
+ val sound: Flow = dataStore.data.map { it[UserPreferencesKeys.SOUND] ?: true }
+
+ val theme: Flow = dataStore.data.map {
+ runCatching { ThemePref.valueOf(it[UserPreferencesKeys.THEME] ?: ThemePref.SYSTEM.name) }
+ .getOrDefault(ThemePref.SYSTEM)
+ }
+
+ val onboardingCompleted: Flow = dataStore.data.map { it[UserPreferencesKeys.ONBOARDING_DONE] ?: false }
+
+ val activeProfileIds: Flow> = dataStore.data.map { prefs ->
+ prefs[UserPreferencesKeys.ACTIVE_PROFILE_IDS].orEmpty()
+ .mapNotNull { it.toLongOrNull() }
+ .toSet()
+ }
+
+ val healthStrictness: Flow = dataStore.data.map {
+ runCatching { HealthStrictness.valueOf(it[UserPreferencesKeys.HEALTH_STRICTNESS] ?: HealthStrictness.NORMAL.name) }
+ .getOrDefault(HealthStrictness.NORMAL)
+ }
+
+ suspend fun setAppLanguage(value: AppLanguage) {
+ dataStore.edit { it[UserPreferencesKeys.APP_LANGUAGE] = value.name }
+ }
+
+ suspend fun setDetectionLanguage(value: DetectionLanguage) {
+ dataStore.edit { it[UserPreferencesKeys.DETECTION_LANGUAGE] = value.name }
+ }
+
+ suspend fun setHaptics(value: Boolean) {
+ dataStore.edit { it[UserPreferencesKeys.HAPTICS] = value }
+ }
+
+ suspend fun setSound(value: Boolean) {
+ dataStore.edit { it[UserPreferencesKeys.SOUND] = value }
+ }
+
+ suspend fun setTheme(value: ThemePref) {
+ dataStore.edit { it[UserPreferencesKeys.THEME] = value.name }
+ }
+
+ suspend fun setOnboardingCompleted(value: Boolean) {
+ dataStore.edit { it[UserPreferencesKeys.ONBOARDING_DONE] = value }
+ }
+
+ suspend fun setActiveProfileIds(ids: Set) {
+ dataStore.edit { prefs ->
+ prefs[UserPreferencesKeys.ACTIVE_PROFILE_IDS] = ids.map { it.toString() }.toSet()
+ }
+ }
+
+ suspend fun setHealthStrictness(value: HealthStrictness) {
+ dataStore.edit { it[UserPreferencesKeys.HEALTH_STRICTNESS] = value.name }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/data/remote/api/OpenFoodFactsApi.kt b/app/src/main/java/com/safebite/app/data/remote/api/OpenFoodFactsApi.kt
new file mode 100644
index 0000000..567266d
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/remote/api/OpenFoodFactsApi.kt
@@ -0,0 +1,15 @@
+package com.safebite.app.data.remote.api
+
+import com.safebite.app.data.remote.dto.ProductResponse
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Path
+
+interface OpenFoodFactsApi {
+ @GET("api/v2/product/{barcode}.json")
+ suspend fun getProduct(@Path("barcode") barcode: String): Response
+
+ companion object {
+ const val BASE_URL = "https://world.openfoodfacts.org/"
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/data/remote/dto/ProductDtos.kt b/app/src/main/java/com/safebite/app/data/remote/dto/ProductDtos.kt
new file mode 100644
index 0000000..32737af
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/remote/dto/ProductDtos.kt
@@ -0,0 +1,48 @@
+package com.safebite.app.data.remote.dto
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class ProductResponse(
+ @Json(name = "code") val code: String? = null,
+ @Json(name = "status") val status: Int? = null,
+ @Json(name = "status_verbose") val statusVerbose: String? = null,
+ @Json(name = "product") val product: ProductDto? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class ProductDto(
+ @Json(name = "product_name") val productName: String? = null,
+ @Json(name = "product_name_fr") val productNameFr: String? = null,
+ @Json(name = "product_name_en") val productNameEn: String? = null,
+ @Json(name = "brands") val brands: String? = null,
+ @Json(name = "allergens_tags") val allergensTags: List? = null,
+ @Json(name = "traces_tags") val tracesTags: List? = null,
+ @Json(name = "ingredients_text_fr") val ingredientsTextFr: String? = null,
+ @Json(name = "ingredients_text_en") val ingredientsTextEn: String? = null,
+ @Json(name = "ingredients_text") val ingredientsText: String? = null,
+ @Json(name = "image_front_url") val imageFrontUrl: String? = null,
+ @Json(name = "image_url") val imageUrl: String? = null,
+ @Json(name = "nutriscore_grade") val nutriScoreGrade: String? = null,
+ @Json(name = "nova_group") val novaGroup: Int? = null,
+ @Json(name = "ecoscore_grade") val ecoScoreGrade: String? = null,
+ @Json(name = "serving_size") val servingSize: String? = null,
+ @Json(name = "labels_tags") val labelsTags: List? = null,
+ @Json(name = "categories_tags") val categoriesTags: List? = null,
+ @Json(name = "nutriments") val nutriments: NutrimentsDto? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class NutrimentsDto(
+ @Json(name = "energy-kcal_100g") val energyKcal100g: Double? = null,
+ @Json(name = "energy-kcal_serving") val energyKcalServing: Double? = null,
+ @Json(name = "fat_100g") val fat100g: Double? = null,
+ @Json(name = "saturated-fat_100g") val saturatedFat100g: Double? = null,
+ @Json(name = "sugars_100g") val sugars100g: Double? = null,
+ @Json(name = "salt_100g") val salt100g: Double? = null,
+ @Json(name = "sodium_100g") val sodium100g: Double? = null,
+ @Json(name = "fiber_100g") val fiber100g: Double? = null,
+ @Json(name = "proteins_100g") val proteins100g: Double? = null,
+ @Json(name = "carbohydrates_100g") val carbohydrates100g: Double? = null
+)
diff --git a/app/src/main/java/com/safebite/app/data/remote/mapper/ProductMapper.kt b/app/src/main/java/com/safebite/app/data/remote/mapper/ProductMapper.kt
new file mode 100644
index 0000000..098e4a3
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/remote/mapper/ProductMapper.kt
@@ -0,0 +1,96 @@
+package com.safebite.app.data.remote.mapper
+
+import com.safebite.app.data.local.database.entity.ProductCacheEntity
+import com.safebite.app.data.remote.dto.NutrimentsDto
+import com.safebite.app.data.remote.dto.ProductDto
+import com.safebite.app.domain.model.Nutriments
+import com.safebite.app.domain.model.Product
+
+fun ProductDto.toDomain(barcode: String): Product = Product(
+ barcode = barcode,
+ name = productNameFr?.takeIf { it.isNotBlank() }
+ ?: productNameEn?.takeIf { it.isNotBlank() }
+ ?: productName?.takeIf { it.isNotBlank() },
+ brand = brands?.takeIf { it.isNotBlank() },
+ imageUrl = imageFrontUrl ?: imageUrl,
+ ingredientsText = ingredientsTextFr?.takeIf { it.isNotBlank() }
+ ?: ingredientsTextEn?.takeIf { it.isNotBlank() }
+ ?: ingredientsText,
+ allergensTags = allergensTags.orEmpty(),
+ tracesTags = tracesTags.orEmpty(),
+ nutriScore = nutriScoreGrade,
+ novaGroup = novaGroup,
+ ecoScore = ecoScoreGrade,
+ servingSize = servingSize,
+ nutriments = nutriments?.toDomain() ?: Nutriments(),
+ labels = labelsTags.orEmpty(),
+ categories = categoriesTags.orEmpty()
+)
+
+fun NutrimentsDto.toDomain(): Nutriments = Nutriments(
+ energyKcal100g = energyKcal100g,
+ energyKcalServing = energyKcalServing,
+ fat100g = fat100g,
+ saturatedFat100g = saturatedFat100g,
+ sugars100g = sugars100g,
+ salt100g = salt100g,
+ sodium100g = sodium100g,
+ fiber100g = fiber100g,
+ proteins100g = proteins100g,
+ carbohydrates100g = carbohydrates100g
+)
+
+fun Product.toCacheEntity(): ProductCacheEntity = ProductCacheEntity(
+ barcode = barcode,
+ name = name,
+ brand = brand,
+ imageUrl = imageUrl,
+ ingredientsText = ingredientsText,
+ allergensTags = allergensTags,
+ tracesTags = tracesTags,
+ nutriScore = nutriScore,
+ novaGroup = novaGroup,
+ ecoScore = ecoScore,
+ servingSize = servingSize,
+ labels = labels,
+ categories = categories,
+ energyKcal100g = nutriments.energyKcal100g,
+ energyKcalServing = nutriments.energyKcalServing,
+ fat100g = nutriments.fat100g,
+ saturatedFat100g = nutriments.saturatedFat100g,
+ sugars100g = nutriments.sugars100g,
+ salt100g = nutriments.salt100g,
+ sodium100g = nutriments.sodium100g,
+ fiber100g = nutriments.fiber100g,
+ proteins100g = nutriments.proteins100g,
+ carbohydrates100g = nutriments.carbohydrates100g,
+ cachedAt = System.currentTimeMillis()
+)
+
+fun ProductCacheEntity.toDomain(): Product = Product(
+ barcode = barcode,
+ name = name,
+ brand = brand,
+ imageUrl = imageUrl,
+ ingredientsText = ingredientsText,
+ allergensTags = allergensTags,
+ tracesTags = tracesTags,
+ nutriScore = nutriScore,
+ novaGroup = novaGroup,
+ ecoScore = ecoScore,
+ servingSize = servingSize,
+ labels = labels,
+ categories = categories,
+ nutriments = Nutriments(
+ energyKcal100g = energyKcal100g,
+ energyKcalServing = energyKcalServing,
+ fat100g = fat100g,
+ saturatedFat100g = saturatedFat100g,
+ sugars100g = sugars100g,
+ salt100g = salt100g,
+ sodium100g = sodium100g,
+ fiber100g = fiber100g,
+ proteins100g = proteins100g,
+ carbohydrates100g = carbohydrates100g
+ )
+)
diff --git a/app/src/main/java/com/safebite/app/data/repository/ProductRepositoryImpl.kt b/app/src/main/java/com/safebite/app/data/repository/ProductRepositoryImpl.kt
new file mode 100644
index 0000000..bfb7f36
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/repository/ProductRepositoryImpl.kt
@@ -0,0 +1,73 @@
+package com.safebite.app.data.repository
+
+import com.safebite.app.data.local.database.dao.ProductCacheDao
+import com.safebite.app.data.remote.api.OpenFoodFactsApi
+import com.safebite.app.data.remote.mapper.toCacheEntity
+import com.safebite.app.data.remote.mapper.toDomain
+import com.safebite.app.data.util.ConnectivityObserver
+import com.safebite.app.domain.model.Product
+import com.safebite.app.domain.repository.ProductFetchResult
+import com.safebite.app.domain.repository.ProductRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ProductRepositoryImpl @Inject constructor(
+ private val api: OpenFoodFactsApi,
+ private val cacheDao: ProductCacheDao,
+ private val connectivity: ConnectivityObserver
+) : ProductRepository {
+
+ override suspend fun fetchProduct(barcode: String): ProductFetchResult = withContext(Dispatchers.IO) {
+ val cached = cacheDao.getByBarcode(barcode)?.toDomain()
+ val online = connectivity.isOnline()
+
+ if (!online) {
+ return@withContext if (cached != null) {
+ ProductFetchResult.Found(cached, fromCache = true)
+ } else {
+ ProductFetchResult.Error("offline", offline = true)
+ }
+ }
+
+ try {
+ val response = api.getProduct(barcode)
+ if (!response.isSuccessful) {
+ Timber.w("OFF returned HTTP ${response.code()} for $barcode")
+ return@withContext cached?.let { ProductFetchResult.Found(it, fromCache = true) }
+ ?: ProductFetchResult.Error("http_${response.code()}")
+ }
+ val body = response.body()
+ val status = body?.status ?: 0
+ val dto = body?.product
+ if (status != 1 || dto == null) {
+ return@withContext ProductFetchResult.NotFound
+ }
+ val product = dto.toDomain(barcode)
+ cacheDao.upsert(product.toCacheEntity())
+ ProductFetchResult.Found(product, fromCache = false)
+ } catch (io: IOException) {
+ Timber.w(io, "Network error fetching $barcode")
+ cached?.let { ProductFetchResult.Found(it, fromCache = true) }
+ ?: ProductFetchResult.Error(io.message ?: "network_error", offline = true)
+ } catch (t: Throwable) {
+ Timber.e(t, "Unexpected error fetching $barcode")
+ cached?.let { ProductFetchResult.Found(it, fromCache = true) }
+ ?: ProductFetchResult.Error(t.message ?: "unknown_error")
+ }
+ }
+
+ override suspend fun cacheProduct(product: Product) = withContext(Dispatchers.IO) {
+ cacheDao.upsert(product.toCacheEntity())
+ }
+
+ override suspend fun getCachedProduct(barcode: String): Product? = withContext(Dispatchers.IO) {
+ cacheDao.getByBarcode(barcode)?.toDomain()
+ }
+
+ override suspend fun clearCache() = withContext(Dispatchers.IO) { cacheDao.clear() }
+}
diff --git a/app/src/main/java/com/safebite/app/data/repository/ScanHistoryRepositoryImpl.kt b/app/src/main/java/com/safebite/app/data/repository/ScanHistoryRepositoryImpl.kt
new file mode 100644
index 0000000..c865944
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/repository/ScanHistoryRepositoryImpl.kt
@@ -0,0 +1,57 @@
+package com.safebite.app.data.repository
+
+import com.safebite.app.data.local.database.dao.ScanHistoryDao
+import com.safebite.app.data.local.database.entity.ScanHistoryEntity
+import com.safebite.app.domain.model.ScanHistoryItem
+import com.safebite.app.domain.model.ScanResult
+import com.safebite.app.domain.repository.ScanHistoryRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ScanHistoryRepositoryImpl @Inject constructor(
+ private val dao: ScanHistoryDao
+) : ScanHistoryRepository {
+
+ override fun observeHistory(): Flow> =
+ dao.observeAll().map { list -> list.map { it.toDomain() } }
+
+ override suspend fun save(result: ScanResult): Long = withContext(Dispatchers.IO) {
+ dao.insert(
+ ScanHistoryEntity(
+ barcode = result.product.barcode,
+ productName = result.product.name,
+ brand = result.product.brand,
+ imageUrl = result.product.imageUrl,
+ safetyStatus = result.safetyStatus,
+ profileNames = result.analyzedProfiles.map { it.name },
+ scannedAt = System.currentTimeMillis(),
+ source = result.source
+ )
+ )
+ }
+
+ override suspend fun delete(id: Long) = withContext(Dispatchers.IO) { dao.deleteById(id) }
+
+ override suspend fun clear() = withContext(Dispatchers.IO) { dao.clear() }
+
+ override suspend fun getById(id: Long): ScanHistoryItem? = withContext(Dispatchers.IO) {
+ dao.getById(id)?.toDomain()
+ }
+}
+
+private fun ScanHistoryEntity.toDomain() = ScanHistoryItem(
+ id = id,
+ barcode = barcode,
+ productName = productName,
+ brand = brand,
+ imageUrl = imageUrl,
+ safetyStatus = safetyStatus,
+ profileNames = profileNames,
+ scannedAt = scannedAt,
+ source = source
+)
diff --git a/app/src/main/java/com/safebite/app/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/safebite/app/data/repository/SettingsRepositoryImpl.kt
new file mode 100644
index 0000000..74b97c3
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/repository/SettingsRepositoryImpl.kt
@@ -0,0 +1,31 @@
+package com.safebite.app.data.repository
+
+import com.safebite.app.data.local.datastore.UserPreferences
+import com.safebite.app.domain.model.AppLanguage
+import com.safebite.app.domain.model.DetectionLanguage
+import com.safebite.app.domain.model.HealthStrictness
+import com.safebite.app.domain.model.ThemePref
+import com.safebite.app.domain.repository.SettingsRepository
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class SettingsRepositoryImpl @Inject constructor(
+ private val prefs: UserPreferences
+) : SettingsRepository {
+ override val appLanguage = prefs.appLanguage
+ override val detectionLanguage = prefs.detectionLanguage
+ override val hapticsEnabled = prefs.haptics
+ override val soundEnabled = prefs.sound
+ override val theme = prefs.theme
+ override val onboardingCompleted = prefs.onboardingCompleted
+ override val healthStrictness = prefs.healthStrictness
+
+ override suspend fun setAppLanguage(value: AppLanguage) = prefs.setAppLanguage(value)
+ override suspend fun setDetectionLanguage(value: DetectionLanguage) = prefs.setDetectionLanguage(value)
+ override suspend fun setHaptics(enabled: Boolean) = prefs.setHaptics(enabled)
+ override suspend fun setSound(enabled: Boolean) = prefs.setSound(enabled)
+ override suspend fun setTheme(value: ThemePref) = prefs.setTheme(value)
+ override suspend fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
+ override suspend fun setHealthStrictness(value: HealthStrictness) = prefs.setHealthStrictness(value)
+}
diff --git a/app/src/main/java/com/safebite/app/data/repository/UserProfileRepositoryImpl.kt b/app/src/main/java/com/safebite/app/data/repository/UserProfileRepositoryImpl.kt
new file mode 100644
index 0000000..b21291a
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/repository/UserProfileRepositoryImpl.kt
@@ -0,0 +1,70 @@
+package com.safebite.app.data.repository
+
+import com.safebite.app.data.local.database.dao.UserProfileDao
+import com.safebite.app.data.local.database.entity.UserProfileEntity
+import com.safebite.app.data.local.datastore.UserPreferences
+import com.safebite.app.domain.model.UserProfile
+import com.safebite.app.domain.repository.UserProfileRepository
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class UserProfileRepositoryImpl @Inject constructor(
+ private val dao: UserProfileDao,
+ private val prefs: UserPreferences
+) : UserProfileRepository {
+
+ override fun observeProfiles(): Flow> =
+ dao.observeAll().map { list -> list.map { it.toDomain() } }
+
+ override suspend fun getProfile(id: Long): UserProfile? = withContext(Dispatchers.IO) {
+ dao.getById(id)?.toDomain()
+ }
+
+ override suspend fun upsert(profile: UserProfile): Long = withContext(Dispatchers.IO) {
+ val entity = profile.toEntity()
+ if (profile.id == 0L) dao.insert(entity) else {
+ dao.update(entity)
+ profile.id
+ }
+ }
+
+ override suspend fun delete(profile: UserProfile) = withContext(Dispatchers.IO) {
+ dao.delete(profile.toEntity())
+ }
+
+ override suspend fun setDefault(id: Long) = withContext(Dispatchers.IO) {
+ dao.clearDefault()
+ dao.markDefault(id)
+ }
+
+ override fun observeActiveProfileIds(): Flow> = prefs.activeProfileIds
+
+ override suspend fun setActiveProfileIds(ids: Set) { prefs.setActiveProfileIds(ids) }
+}
+
+private fun UserProfileEntity.toDomain(): UserProfile = UserProfile(
+ id = id,
+ name = name,
+ avatar = avatar,
+ severeAllergens = severeAllergens,
+ moderateIntolerances = moderateIntolerances,
+ dietaryRestrictions = dietaryRestrictions,
+ customItems = customItems,
+ isDefault = isDefault
+)
+
+private fun UserProfile.toEntity(): UserProfileEntity = UserProfileEntity(
+ id = id,
+ name = name,
+ avatar = avatar,
+ severeAllergens = severeAllergens,
+ moderateIntolerances = moderateIntolerances,
+ dietaryRestrictions = dietaryRestrictions,
+ customItems = customItems,
+ isDefault = isDefault
+)
diff --git a/app/src/main/java/com/safebite/app/data/util/ConnectivityObserver.kt b/app/src/main/java/com/safebite/app/data/util/ConnectivityObserver.kt
new file mode 100644
index 0000000..158024a
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/data/util/ConnectivityObserver.kt
@@ -0,0 +1,36 @@
+package com.safebite.app.data.util
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+
+class ConnectivityObserver(private val context: Context) {
+
+ fun isOnline(): Boolean {
+ val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
+ val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
+ return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
+ caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+ }
+
+ fun observe(): Flow = callbackFlow {
+ val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val callback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) { trySend(true) }
+ override fun onLost(network: Network) { trySend(false) }
+ override fun onUnavailable() { trySend(false) }
+ }
+ val request = NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .build()
+ cm.registerNetworkCallback(request, callback)
+ trySend(isOnline())
+ awaitClose { cm.unregisterNetworkCallback(callback) }
+ }.distinctUntilChanged()
+}
diff --git a/app/src/main/java/com/safebite/app/di/AppModule.kt b/app/src/main/java/com/safebite/app/di/AppModule.kt
new file mode 100644
index 0000000..34fb7e0
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/di/AppModule.kt
@@ -0,0 +1,33 @@
+package com.safebite.app.di
+
+import android.content.Context
+import com.safebite.app.data.local.datastore.UserPreferences
+import com.safebite.app.data.local.datastore.safeBiteDataStore
+import com.safebite.app.data.util.ConnectivityObserver
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+
+ @Provides
+ @Singleton
+ fun provideMoshi(): Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
+
+ @Provides
+ @Singleton
+ fun provideUserPreferences(@ApplicationContext context: Context): UserPreferences =
+ UserPreferences(context.safeBiteDataStore)
+
+ @Provides
+ @Singleton
+ fun provideConnectivity(@ApplicationContext context: Context): ConnectivityObserver =
+ ConnectivityObserver(context)
+}
diff --git a/app/src/main/java/com/safebite/app/di/DatabaseModule.kt b/app/src/main/java/com/safebite/app/di/DatabaseModule.kt
new file mode 100644
index 0000000..245ea13
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/di/DatabaseModule.kt
@@ -0,0 +1,30 @@
+package com.safebite.app.di
+
+import android.content.Context
+import androidx.room.Room
+import com.safebite.app.data.local.database.SafeBiteDatabase
+import com.safebite.app.data.local.database.dao.ProductCacheDao
+import com.safebite.app.data.local.database.dao.ScanHistoryDao
+import com.safebite.app.data.local.database.dao.UserProfileDao
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DatabaseModule {
+
+ @Provides
+ @Singleton
+ fun provideDatabase(@ApplicationContext context: Context): SafeBiteDatabase =
+ Room.databaseBuilder(context, SafeBiteDatabase::class.java, SafeBiteDatabase.NAME)
+ .fallbackToDestructiveMigration()
+ .build()
+
+ @Provides fun provideUserProfileDao(db: SafeBiteDatabase): UserProfileDao = db.userProfileDao()
+ @Provides fun provideProductCacheDao(db: SafeBiteDatabase): ProductCacheDao = db.productCacheDao()
+ @Provides fun provideScanHistoryDao(db: SafeBiteDatabase): ScanHistoryDao = db.scanHistoryDao()
+}
diff --git a/app/src/main/java/com/safebite/app/di/NetworkModule.kt b/app/src/main/java/com/safebite/app/di/NetworkModule.kt
new file mode 100644
index 0000000..9bbb01b
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/di/NetworkModule.kt
@@ -0,0 +1,53 @@
+package com.safebite.app.di
+
+import com.safebite.app.data.remote.api.OpenFoodFactsApi
+import com.squareup.moshi.Moshi
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+
+ private const val USER_AGENT = "SafeBite/1.0 (Android; contact@safebite.app)"
+
+ @Provides
+ @Singleton
+ fun provideOkHttp(): OkHttpClient {
+ val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
+ val uaInterceptor = Interceptor { chain ->
+ val req = chain.request().newBuilder().header("User-Agent", USER_AGENT).build()
+ chain.proceed(req)
+ }
+ return OkHttpClient.Builder()
+ .addInterceptor(uaInterceptor)
+ .addInterceptor(logging)
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(20, TimeUnit.SECONDS)
+ .writeTimeout(20, TimeUnit.SECONDS)
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit =
+ Retrofit.Builder()
+ .baseUrl(OpenFoodFactsApi.BASE_URL)
+ .client(client)
+ .addConverterFactory(MoshiConverterFactory.create(moshi))
+ .build()
+
+ @Provides
+ @Singleton
+ fun provideOpenFoodFactsApi(retrofit: Retrofit): OpenFoodFactsApi =
+ retrofit.create(OpenFoodFactsApi::class.java)
+}
diff --git a/app/src/main/java/com/safebite/app/di/RepositoryModule.kt b/app/src/main/java/com/safebite/app/di/RepositoryModule.kt
new file mode 100644
index 0000000..2746a42
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/di/RepositoryModule.kt
@@ -0,0 +1,32 @@
+package com.safebite.app.di
+
+import com.safebite.app.data.repository.ProductRepositoryImpl
+import com.safebite.app.data.repository.ScanHistoryRepositoryImpl
+import com.safebite.app.data.repository.SettingsRepositoryImpl
+import com.safebite.app.data.repository.UserProfileRepositoryImpl
+import com.safebite.app.domain.repository.ProductRepository
+import com.safebite.app.domain.repository.ScanHistoryRepository
+import com.safebite.app.domain.repository.SettingsRepository
+import com.safebite.app.domain.repository.UserProfileRepository
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class RepositoryModule {
+
+ @Binds @Singleton
+ abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository
+
+ @Binds @Singleton
+ abstract fun bindUserProfileRepository(impl: UserProfileRepositoryImpl): UserProfileRepository
+
+ @Binds @Singleton
+ abstract fun bindScanHistoryRepository(impl: ScanHistoryRepositoryImpl): ScanHistoryRepository
+
+ @Binds @Singleton
+ abstract fun bindSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository
+}
diff --git a/app/src/main/java/com/safebite/app/domain/engine/AllergenAnalysisEngine.kt b/app/src/main/java/com/safebite/app/domain/engine/AllergenAnalysisEngine.kt
new file mode 100644
index 0000000..faf01aa
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/domain/engine/AllergenAnalysisEngine.kt
@@ -0,0 +1,336 @@
+package com.safebite.app.domain.engine
+
+import com.safebite.app.domain.model.AllergenType
+import com.safebite.app.domain.model.AnalysisConfidence
+import com.safebite.app.domain.model.CustomDietItem
+import com.safebite.app.domain.model.CustomItemTag
+import com.safebite.app.domain.model.DataSource
+import com.safebite.app.domain.model.DetectedAllergen
+import com.safebite.app.domain.model.DetectedCustomItem
+import com.safebite.app.domain.model.DetectionLanguage
+import com.safebite.app.domain.model.DetectionLevel
+import com.safebite.app.domain.model.HealthAssessment
+import com.safebite.app.domain.model.HealthRating
+import com.safebite.app.domain.model.HealthStrictness
+import com.safebite.app.domain.model.Product
+import com.safebite.app.domain.model.SafetyStatus
+import com.safebite.app.domain.model.ScanResult
+import com.safebite.app.domain.model.UserProfile
+import java.text.Normalizer
+
+/**
+ * Core allergen detection engine. Completely pure (no Android deps) so it is easy to unit-test.
+ *
+ * Three-layer analysis:
+ * 1. Open Food Facts tag match (highest confidence).
+ * 2. Ingredients text keyword match (safety net).
+ * 3. "May contain / traces" pattern extraction from the ingredients text.
+ */
+object AllergenAnalysisEngine {
+
+ /** Regexes for "may contain" disclosures, in French and English. */
+ private val MAY_CONTAIN_PATTERNS = listOf(
+ Regex("peut contenir(?:\\s+des\\s+traces\\s+de)?\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE),
+ Regex("traces?\\s+possibles?\\s+de\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE),
+ Regex("fabriqué\\s+dans\\s+un\\s+(?:atelier|environnement|établissement)\\s+(?:contenant|utilisant|qui\\s+utilise)[^.]{1,200}", RegexOption.IGNORE_CASE),
+ Regex("may\\s+contain\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE),
+ Regex("manufactured\\s+in\\s+a\\s+facility\\s+that\\s+(?:processes|also\\s+processes|handles)[^.]{1,200}", RegexOption.IGNORE_CASE),
+ Regex("produced\\s+in\\s+a\\s+plant\\s+that\\s+also\\s+handles[^.]{1,200}", RegexOption.IGNORE_CASE)
+ )
+
+ /**
+ * Analyze a product against the given profiles.
+ *
+ * @param language The detection language to use when searching ingredients text.
+ */
+ fun analyze(
+ product: Product,
+ profiles: List,
+ source: DataSource,
+ language: DetectionLanguage = DetectionLanguage.BOTH,
+ healthStrictness: HealthStrictness = HealthStrictness.NORMAL
+ ): ScanResult {
+ if (profiles.isEmpty()) {
+ return ScanResult(
+ product = product,
+ safetyStatus = SafetyStatus.SAFE,
+ detectedAllergens = emptyList(),
+ detectedCustomItems = emptyList(),
+ health = HealthClassifier.classify(product, emptyList(), healthStrictness),
+ analyzedProfiles = emptyList(),
+ confidence = AnalysisConfidence.LOW,
+ source = source
+ )
+ }
+
+ val watched = profiles.flatMap { it.allAllergens() }.toSet()
+ val severeSet = profiles.flatMap { it.severeAllergens }.toSet()
+ val normalizedIngredients = normalize(product.ingredientsText.orEmpty())
+
+ val detections = mutableMapOf()
+
+ // Layer 1 — OFF tags (confirmed).
+ for (allergen in watched) {
+ val tagHits = matchTags(product.allergensTags, allergen.openFoodFactsTags)
+ if (tagHits.isNotEmpty()) {
+ detections[allergen] = DetectedAllergen(
+ allergenType = allergen,
+ detectionLevel = DetectionLevel.CONFIRMED,
+ matchedKeywords = tagHits,
+ source = "API allergens_tags",
+ profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
+ severe = allergen in severeSet
+ )
+ }
+ val traceTagHits = matchTags(product.tracesTags, allergen.openFoodFactsTags)
+ if (traceTagHits.isNotEmpty()) {
+ detections.compute(allergen) { _, existing ->
+ val hit = DetectedAllergen(
+ allergenType = allergen,
+ detectionLevel = DetectionLevel.TRACE,
+ matchedKeywords = traceTagHits,
+ source = "API traces_tags",
+ profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
+ severe = allergen in severeSet
+ )
+ merge(existing, hit)
+ }
+ }
+ }
+
+ // Layer 3 — "May contain" text patterns (must run before Layer 2 so we don't mis-label
+ // a TRACE mention as CONFIRMED).
+ val traceRegions = extractTraceRegions(normalizedIngredients)
+ val ingredientsOnly = stripRegions(normalizedIngredients, traceRegions)
+
+ // Layer 2 — Keyword match in ingredients text.
+ if (normalizedIngredients.isNotBlank()) {
+ for (allergen in watched) {
+ val keywords = keywordsFor(allergen, language)
+
+ val ingMatches = findKeywordMatches(ingredientsOnly, keywords)
+ if (ingMatches.isNotEmpty()) {
+ val level = if (detections[allergen]?.detectionLevel == DetectionLevel.CONFIRMED) {
+ DetectionLevel.CONFIRMED
+ } else {
+ DetectionLevel.SUSPECTED
+ }
+ val hit = DetectedAllergen(
+ allergenType = allergen,
+ detectionLevel = level,
+ matchedKeywords = ingMatches,
+ source = "Ingredients text",
+ profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
+ severe = allergen in severeSet
+ )
+ detections.compute(allergen) { _, existing -> merge(existing, hit) }
+ }
+
+ val traceMatches = traceRegions.flatMap { region -> findKeywordMatches(region, keywords) }
+ if (traceMatches.isNotEmpty()) {
+ val hit = DetectedAllergen(
+ allergenType = allergen,
+ detectionLevel = DetectionLevel.TRACE,
+ matchedKeywords = traceMatches.distinct(),
+ source = "May-contain mention",
+ profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
+ severe = allergen in severeSet
+ )
+ detections.compute(allergen) { _, existing -> merge(existing, hit) }
+ }
+ }
+ }
+
+ val detected = detections.values.toList()
+
+ // Custom items: per profile, scan against ingredients text + OFF tags / labels / categories.
+ val searchable = buildString {
+ append(normalizedIngredients)
+ append(' ')
+ append(product.allergensTags.joinToString(" ") { normalize(it) })
+ append(' ')
+ append(product.tracesTags.joinToString(" ") { normalize(it) })
+ append(' ')
+ append(product.labels.joinToString(" ") { normalize(it) })
+ append(' ')
+ append(product.categories.joinToString(" ") { normalize(it) })
+ append(' ')
+ append(normalize(product.name.orEmpty()))
+ }
+ val customDetections = detectCustomItems(searchable, profiles)
+
+ val status = computeStatus(detected, severeSet, customDetections)
+ val confidence = computeConfidence(product, source, hasAnyData = detected.isNotEmpty() || normalizedIngredients.isNotBlank())
+
+ val unhealthyCustomNames = customDetections
+ .filter { it.item.tag == CustomItemTag.UNHEALTHY }
+ .map { it.item.name }
+ val health = HealthClassifier.classify(product, unhealthyCustomNames, healthStrictness)
+
+ return ScanResult(
+ product = product,
+ safetyStatus = status,
+ detectedAllergens = detected.sortedWith(
+ compareByDescending { it.detectionLevel.ordinal == DetectionLevel.CONFIRMED.ordinal }
+ .thenByDescending { it.severe }
+ ),
+ detectedCustomItems = customDetections,
+ health = health,
+ analyzedProfiles = profiles,
+ confidence = confidence,
+ source = source
+ )
+ }
+
+ /**
+ * Match each profile's custom items against the pre-normalized [searchable] text
+ * (ingredients + tags + labels + categories + product name).
+ */
+ private fun detectCustomItems(searchable: String, profiles: List): List {
+ if (searchable.isBlank()) return emptyList()
+ // Group by (name, tag) so the same item across two profiles becomes a single detection
+ // with the union of profile IDs.
+ val grouped = mutableMapOf, MutableList>>()
+ for (profile in profiles) {
+ for (item in profile.customItems) {
+ grouped.getOrPut(item.name.trim().lowercase() to item.tag) { mutableListOf() }
+ .add(profile to item)
+ }
+ }
+ val results = mutableListOf()
+ for ((_, pairs) in grouped) {
+ val first = pairs.first().second
+ val keywords = first.allKeywords().map { normalize(it) }.filter { it.isNotBlank() }.distinct()
+ val hits = findKeywordMatches(searchable, keywords)
+ if (hits.isNotEmpty()) {
+ results.add(
+ DetectedCustomItem(
+ item = first,
+ matchedKeywords = hits,
+ profileIds = pairs.map { it.first.id }.distinct()
+ )
+ )
+ }
+ }
+ return results
+ }
+
+ // region — text helpers
+
+ /** Lower-case, strip accents, expand common ligatures, collapse punctuation. */
+ fun normalize(raw: String): String {
+ if (raw.isBlank()) return ""
+ val lowered = raw.lowercase()
+ .replace("œ", "oe")
+ .replace("æ", "ae")
+ val decomposed = Normalizer.normalize(lowered, Normalizer.Form.NFD)
+ val withoutAccents = decomposed.replace(Regex("\\p{Mn}+"), "")
+ // Replace various apostrophe/quote styles with a space, normalize whitespace.
+ return withoutAccents
+ .replace(Regex("[\\u2018\\u2019\\u201A\\u201B'`]"), " ")
+ .replace(Regex("[()\\[\\]{},;:!?./\\\\\"]"), " ")
+ .replace(Regex("\\s+"), " ")
+ .trim()
+ }
+
+ private fun keywordsFor(allergen: AllergenType, language: DetectionLanguage): List =
+ when (language) {
+ DetectionLanguage.FR -> allergen.keywordsFr
+ DetectionLanguage.EN -> allergen.keywordsEn
+ DetectionLanguage.BOTH -> allergen.keywordsFr + allergen.keywordsEn
+ }.map { normalize(it) }.filter { it.isNotBlank() }.distinct()
+
+ private fun matchTags(productTags: List, allergenTags: List): List {
+ if (productTags.isEmpty()) return emptyList()
+ val lowered = productTags.map { it.lowercase() }
+ return allergenTags.filter { tag -> lowered.any { it == tag.lowercase() } }
+ }
+
+ /**
+ * Word-boundary aware keyword matcher. Handles plurals by also matching the keyword
+ * followed by an "s". Supports multi-word keywords.
+ */
+ private fun findKeywordMatches(normalized: String, keywords: List): List {
+ if (normalized.isBlank()) return emptyList()
+ val hits = mutableListOf()
+ for (kw in keywords) {
+ if (kw.isBlank()) continue
+ val escaped = Regex.escape(kw)
+ // Allow an optional trailing "s" for plural forms of single-word keywords.
+ val suffix = if (kw.contains(' ')) "" else "s?"
+ val regex = Regex("(? {
+ if (normalizedText.isBlank()) return emptyList()
+ return MAY_CONTAIN_PATTERNS.flatMap { pattern ->
+ pattern.findAll(normalizedText).map { it.value }.toList()
+ }
+ }
+
+ private fun stripRegions(text: String, regions: List): String {
+ var result = text
+ for (region in regions) {
+ result = result.replace(region, " ")
+ }
+ return result
+ }
+
+ // endregion
+
+ private fun merge(existing: DetectedAllergen?, incoming: DetectedAllergen): DetectedAllergen {
+ if (existing == null) return incoming
+ // Keep the most severe detection level: CONFIRMED > TRACE > SUSPECTED.
+ val priority: (DetectionLevel) -> Int = {
+ when (it) {
+ DetectionLevel.CONFIRMED -> 2
+ DetectionLevel.TRACE -> 1
+ DetectionLevel.SUSPECTED -> 0
+ }
+ }
+ val best = if (priority(incoming.detectionLevel) > priority(existing.detectionLevel)) incoming else existing
+ return best.copy(
+ matchedKeywords = (existing.matchedKeywords + incoming.matchedKeywords).distinct(),
+ source = if (best == existing) existing.source else incoming.source,
+ profileIds = (existing.profileIds + incoming.profileIds).distinct()
+ )
+ }
+
+ private fun computeStatus(
+ detected: List,
+ severeSet: Set,
+ customDetections: List
+ ): SafetyStatus {
+ val hasSevereConfirmed = detected.any {
+ it.detectionLevel != DetectionLevel.TRACE && it.allergenType in severeSet
+ }
+ val customHasAllergy = customDetections.any { it.item.tag == CustomItemTag.ALLERGY }
+ if (hasSevereConfirmed || customHasAllergy) return SafetyStatus.DANGER
+ val customTriggersWarning = customDetections.any {
+ it.item.tag == CustomItemTag.INTOLERANCE || it.item.tag == CustomItemTag.DIET
+ }
+ if (detected.isEmpty() && !customTriggersWarning) return SafetyStatus.SAFE
+ return SafetyStatus.WARNING
+ }
+
+ private fun computeConfidence(product: Product, source: DataSource, hasAnyData: Boolean): AnalysisConfidence {
+ return when (source) {
+ DataSource.OCR -> AnalysisConfidence.LOW
+ DataSource.API, DataSource.CACHE -> {
+ val hasTags = product.allergensTags.isNotEmpty() || product.tracesTags.isNotEmpty()
+ val hasIngredients = !product.ingredientsText.isNullOrBlank()
+ when {
+ hasTags && hasIngredients -> AnalysisConfidence.HIGH
+ hasTags || hasIngredients -> AnalysisConfidence.MEDIUM
+ !hasAnyData -> AnalysisConfidence.LOW
+ else -> AnalysisConfidence.LOW
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/domain/engine/HealthClassifier.kt b/app/src/main/java/com/safebite/app/domain/engine/HealthClassifier.kt
new file mode 100644
index 0000000..e2a375a
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/domain/engine/HealthClassifier.kt
@@ -0,0 +1,101 @@
+package com.safebite.app.domain.engine
+
+import com.safebite.app.domain.model.HealthAssessment
+import com.safebite.app.domain.model.HealthRating
+import com.safebite.app.domain.model.HealthStrictness
+import com.safebite.app.domain.model.Product
+
+/**
+ * Derives a high-level health verdict from Nutri-Score, Nova group, Eco-Score and
+ * user-defined "unhealthy" custom items. Pure / no Android deps.
+ *
+ * Strictness levels adjust the thresholds:
+ * - LENIENT: only D/E + Nova 4 → UNHEALTHY; everything else ≥ C → MODERATE; A/B → HEALTHY.
+ * - NORMAL: C/D/E → lean toward UNHEALTHY with Nova 3+; A → HEALTHY; B → HEALTHY if Nova ≤ 2.
+ * - STRICT: only A + Nova ≤ 2 → HEALTHY; B → MODERATE; C/D/E or Nova ≥ 3 → UNHEALTHY.
+ */
+object HealthClassifier {
+
+ fun classify(
+ product: Product,
+ unhealthyCustomHits: List,
+ strictness: HealthStrictness
+ ): HealthAssessment {
+ val nutri = product.nutriScore?.lowercase()?.takeIf { it in listOf("a", "b", "c", "d", "e") }
+ val nova = product.novaGroup?.takeIf { it in 1..4 }
+ val eco = product.ecoScore?.lowercase()?.takeIf { it in listOf("a", "b", "c", "d", "e") }
+
+ val reasons = mutableListOf()
+ var rating = baseRating(nutri, nova, strictness, reasons)
+
+ if (unhealthyCustomHits.isNotEmpty()) {
+ reasons += "Contient: ${unhealthyCustomHits.joinToString()}"
+ rating = when (strictness) {
+ HealthStrictness.LENIENT -> when (rating) {
+ HealthRating.HEALTHY, HealthRating.UNKNOWN -> HealthRating.MODERATE
+ else -> rating
+ }
+ HealthStrictness.NORMAL, HealthStrictness.STRICT -> HealthRating.UNHEALTHY
+ }
+ }
+
+ // If we truly have nothing to judge on, keep UNKNOWN.
+ if (nutri == null && nova == null && unhealthyCustomHits.isEmpty()) {
+ rating = HealthRating.UNKNOWN
+ }
+
+ return HealthAssessment(
+ rating = rating,
+ reasons = reasons,
+ nutriScore = nutri,
+ novaGroup = nova,
+ ecoScore = eco
+ )
+ }
+
+ private fun baseRating(
+ nutri: String?,
+ nova: Int?,
+ strictness: HealthStrictness,
+ reasons: MutableList
+ ): HealthRating {
+ // Score nutri (a=0 best, e=4 worst)
+ val nutriScoreValue = nutri?.let { it[0].code - 'a'.code }
+ val novaValue = nova // 1 best, 4 worst
+
+ if (nutri != null) reasons += "Nutri-Score ${nutri.uppercase()}"
+ if (nova != null) reasons += "NOVA $nova"
+
+ return when (strictness) {
+ HealthStrictness.LENIENT -> when {
+ nutriScoreValue != null && nutriScoreValue >= 3 && novaValue == 4 -> HealthRating.UNHEALTHY
+ nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.MODERATE
+ novaValue == 4 -> HealthRating.MODERATE
+ nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.HEALTHY
+ nutriScoreValue != null -> HealthRating.MODERATE
+ novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY
+ else -> HealthRating.UNKNOWN
+ }
+ HealthStrictness.NORMAL -> when {
+ nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.UNHEALTHY
+ novaValue != null && novaValue >= 4 -> HealthRating.UNHEALTHY
+ nutriScoreValue == 2 && (novaValue ?: 1) >= 3 -> HealthRating.UNHEALTHY
+ nutriScoreValue == 2 -> HealthRating.MODERATE
+ novaValue == 3 && nutriScoreValue == null -> HealthRating.MODERATE
+ nutriScoreValue != null && nutriScoreValue <= 1 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY
+ nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.MODERATE
+ novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY
+ else -> HealthRating.UNKNOWN
+ }
+ HealthStrictness.STRICT -> when {
+ nutriScoreValue != null && nutriScoreValue >= 2 -> HealthRating.UNHEALTHY
+ novaValue != null && novaValue >= 3 -> HealthRating.UNHEALTHY
+ nutriScoreValue == 1 -> HealthRating.MODERATE
+ nutriScoreValue == 0 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY
+ nutriScoreValue == 0 -> HealthRating.MODERATE
+ novaValue != null && novaValue <= 2 -> HealthRating.MODERATE
+ else -> HealthRating.UNKNOWN
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/domain/model/AllergenType.kt b/app/src/main/java/com/safebite/app/domain/model/AllergenType.kt
new file mode 100644
index 0000000..7598620
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/domain/model/AllergenType.kt
@@ -0,0 +1,202 @@
+package com.safebite.app.domain.model
+
+/**
+ * The 14 major allergens supported by SafeBite.
+ * Each entry provides display names, an icon emoji, Open Food Facts allergen tags,
+ * and keyword lists in French and English used by the text-based detection engine.
+ */
+enum class AllergenType(
+ val displayNameFr: String,
+ val displayNameEn: String,
+ val icon: String,
+ val openFoodFactsTags: List,
+ val keywordsFr: List,
+ val keywordsEn: List
+) {
+ GLUTEN(
+ "Gluten", "Gluten", "🌾",
+ listOf("en:gluten"),
+ listOf(
+ "gluten", "blé", "froment", "seigle", "orge", "avoine",
+ "épeautre", "kamut", "triticale", "malt", "amidon de blé",
+ "farine de blé", "farine d'orge", "farine de seigle",
+ "protéine de blé", "seitan"
+ ),
+ listOf(
+ "gluten", "wheat", "rye", "barley", "oats", "spelt",
+ "kamut", "triticale", "malt", "wheat starch", "wheat flour"
+ )
+ ),
+ PEANUTS(
+ "Arachides", "Peanuts", "🥜",
+ listOf("en:peanuts"),
+ listOf(
+ "arachide", "arachides", "cacahuète", "cacahuètes",
+ "beurre d'arachide", "huile d'arachide"
+ ),
+ listOf(
+ "peanut", "peanuts", "peanut butter", "peanut oil",
+ "groundnut", "groundnuts"
+ )
+ ),
+ TREE_NUTS(
+ "Noix", "Tree Nuts", "🌰",
+ listOf("en:nuts", "en:tree-nuts"),
+ listOf(
+ "noix", "amande", "amandes", "noisette", "noisettes",
+ "cajou", "noix de cajou", "pistache", "pistaches",
+ "noix de pécan", "pécan", "noix du brésil", "macadamia",
+ "noix de macadamia", "pralin", "praliné", "massepain",
+ "pâte d'amande", "poudre d'amande"
+ ),
+ listOf(
+ "nut", "nuts", "almond", "almonds", "hazelnut", "hazelnuts",
+ "cashew", "cashews", "pistachio", "pecan", "pecans",
+ "brazil nut", "macadamia", "walnut", "walnuts", "praline",
+ "marzipan", "almond paste"
+ )
+ ),
+ MILK(
+ "Lait", "Milk", "🥛",
+ listOf("en:milk"),
+ listOf(
+ "lait", "lactose", "caséine", "caséinate", "lactosérum",
+ "petit-lait", "beurre", "crème", "fromage", "yogourt",
+ "babeurre", "ghee", "lactalbumine", "lactoglobuline",
+ "protéine de lait", "poudre de lait", "lait écrémé",
+ "lait entier", "concentré de protéines de lait"
+ ),
+ listOf(
+ "milk", "lactose", "casein", "caseinate", "whey",
+ "butter", "cream", "cheese", "yogurt", "buttermilk",
+ "ghee", "lactalbumin", "lactoglobulin", "milk protein",
+ "milk powder", "skim milk", "whole milk"
+ )
+ ),
+ EGGS(
+ "Œufs", "Eggs", "🥚",
+ listOf("en:eggs"),
+ listOf(
+ "œuf", "oeuf", "œufs", "oeufs", "albumine", "ovomucine",
+ "ovomucoïde", "ovalbumine", "lécithine d'œuf",
+ "lysozyme", "jaune d'œuf", "blanc d'œuf", "poudre d'œuf",
+ "œuf entier"
+ ),
+ listOf(
+ "egg", "eggs", "albumin", "ovomucin", "ovomucoid",
+ "ovalbumin", "egg lecithin", "lysozyme", "egg yolk",
+ "egg white", "egg powder", "whole egg"
+ )
+ ),
+ SOY(
+ "Soja", "Soy", "🫘",
+ listOf("en:soybeans"),
+ listOf(
+ "soja", "soya", "lécithine de soja", "protéine de soja",
+ "tofu", "tempeh", "edamame", "fève de soja",
+ "huile de soja", "sauce soja", "miso"
+ ),
+ listOf(
+ "soy", "soya", "soybean", "soybeans", "soy lecithin",
+ "soy protein", "tofu", "tempeh", "edamame",
+ "soybean oil", "soy sauce", "miso"
+ )
+ ),
+ FISH(
+ "Poisson", "Fish", "🐟",
+ listOf("en:fish"),
+ listOf(
+ "poisson", "anchois", "bar", "cabillaud", "colin",
+ "dorade", "flétan", "hareng", "maquereau", "merlu",
+ "morue", "perche", "sardine", "saumon", "sole",
+ "thon", "truite", "huile de poisson", "sauce de poisson",
+ "surimi", "gélatine de poisson"
+ ),
+ listOf(
+ "fish", "anchovy", "anchovies", "bass", "cod", "haddock",
+ "halibut", "herring", "mackerel", "perch", "salmon",
+ "sardine", "sole", "trout", "tuna", "fish oil",
+ "fish sauce", "surimi", "fish gelatin"
+ )
+ ),
+ CRUSTACEANS(
+ "Crustacés", "Crustaceans", "🦐",
+ listOf("en:crustaceans"),
+ listOf(
+ "crustacé", "crustacés", "crevette", "crevettes",
+ "homard", "crabe", "langouste", "langoustine",
+ "écrevisse", "fruits de mer"
+ ),
+ listOf(
+ "crustacean", "crustaceans", "shrimp", "lobster",
+ "crab", "crayfish", "prawn", "langoustine", "seafood"
+ )
+ ),
+ SESAME(
+ "Sésame", "Sesame", "⚪",
+ listOf("en:sesame-seeds"),
+ listOf(
+ "sésame", "graines de sésame", "huile de sésame",
+ "tahini", "tahina", "halva"
+ ),
+ listOf(
+ "sesame", "sesame seeds", "sesame oil", "tahini",
+ "tahina", "halva"
+ )
+ ),
+ MUSTARD(
+ "Moutarde", "Mustard", "🟡",
+ listOf("en:mustard"),
+ listOf(
+ "moutarde", "graines de moutarde", "huile de moutarde",
+ "farine de moutarde"
+ ),
+ listOf("mustard", "mustard seeds", "mustard oil", "mustard flour")
+ ),
+ SULPHITES(
+ "Sulfites", "Sulphites", "🟣",
+ listOf("en:sulphur-dioxide-and-sulphites"),
+ listOf(
+ "sulfite", "sulfites", "dioxyde de soufre", "bisulfite",
+ "métabisulfite", "anhydride sulfureux",
+ "e220", "e221", "e222", "e223", "e224", "e225", "e226", "e228"
+ ),
+ listOf(
+ "sulphite", "sulphites", "sulfite", "sulfites",
+ "sulphur dioxide", "bisulphite", "metabisulphite"
+ )
+ ),
+ LUPIN(
+ "Lupin", "Lupin", "💐",
+ listOf("en:lupin"),
+ listOf("lupin", "lupins", "farine de lupin"),
+ listOf("lupin", "lupine", "lupin flour")
+ ),
+ MOLLUSCS(
+ "Mollusques", "Molluscs", "🐚",
+ listOf("en:molluscs"),
+ listOf(
+ "mollusque", "mollusques", "huître", "moule", "moules",
+ "palourde", "pétoncle", "calmar", "calamar", "pieuvre",
+ "poulpe", "escargot", "coquille saint-jacques"
+ ),
+ listOf(
+ "mollusc", "molluscs", "mollusk", "oyster", "mussel",
+ "clam", "scallop", "squid", "octopus", "snail"
+ )
+ ),
+ CELERY(
+ "Céleri", "Celery", "🥬",
+ listOf("en:celery"),
+ listOf(
+ "céleri", "celeri", "sel de céleri", "graines de céleri",
+ "celeriac", "céleri-rave"
+ ),
+ listOf("celery", "celeriac", "celery salt", "celery seed")
+ );
+
+ companion object {
+ fun fromName(name: String): AllergenType? =
+ values().firstOrNull { it.name.equals(name, ignoreCase = true) }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/domain/model/DomainModels.kt b/app/src/main/java/com/safebite/app/domain/model/DomainModels.kt
new file mode 100644
index 0000000..9fe7b79
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/domain/model/DomainModels.kt
@@ -0,0 +1,152 @@
+package com.safebite.app.domain.model
+
+enum class SafetyStatus { SAFE, WARNING, DANGER }
+
+enum class AnalysisConfidence { HIGH, MEDIUM, LOW }
+
+enum class DataSource { API, OCR, CACHE }
+
+enum class DetectionLevel { CONFIRMED, TRACE, SUSPECTED }
+
+enum class DietaryRestriction(val displayFr: String, val displayEn: String) {
+ VEGAN("Végane", "Vegan"),
+ VEGETARIAN("Végétarien", "Vegetarian"),
+ HALAL("Halal", "Halal"),
+ KOSHER("Casher", "Kosher"),
+ NO_PORK("Sans porc", "No pork")
+}
+
+enum class DetectionLanguage { FR, EN, BOTH }
+
+enum class ThemePref { LIGHT, DARK, SYSTEM }
+
+enum class AppLanguage { FR, EN }
+
+enum class HealthRating { HEALTHY, MODERATE, UNHEALTHY, UNKNOWN }
+
+enum class HealthStrictness { LENIENT, NORMAL, STRICT }
+
+/** Tag associated with a user-defined custom diet item. */
+enum class CustomItemTag(val displayFr: String, val displayEn: String) {
+ ALLERGY("Allergie", "Allergy"),
+ INTOLERANCE("Intolérance", "Intolerance"),
+ DIET("Diète", "Diet"),
+ UNHEALTHY("Non-santé", "Unhealthy")
+}
+
+/** A user-defined ingredient/substance to watch for (e.g. "huile de palme"). */
+data class CustomDietItem(
+ val name: String,
+ val tag: CustomItemTag,
+ /** Optional additional keywords; if empty, [name] is used. */
+ val keywords: List = emptyList()
+) {
+ fun allKeywords(): List =
+ (listOf(name) + keywords).filter { it.isNotBlank() }.distinct()
+}
+
+/** A user's allergy profile. */
+data class UserProfile(
+ val id: Long = 0L,
+ val name: String,
+ val avatar: String = "🙂",
+ val severeAllergens: Set = emptySet(),
+ val moderateIntolerances: Set = emptySet(),
+ val dietaryRestrictions: Set = emptySet(),
+ val customItems: List = emptyList(),
+ val isDefault: Boolean = false
+) {
+ /** Returns every allergen (severe + moderate) referenced by this profile. */
+ fun allAllergens(): Set = severeAllergens + moderateIntolerances
+}
+
+/** Nutritional facts reported (usually per 100g unless noted). */
+data class Nutriments(
+ val energyKcal100g: Double? = null,
+ val energyKcalServing: Double? = null,
+ val fat100g: Double? = null,
+ val saturatedFat100g: Double? = null,
+ val sugars100g: Double? = null,
+ val salt100g: Double? = null,
+ val sodium100g: Double? = null,
+ val fiber100g: Double? = null,
+ val proteins100g: Double? = null,
+ val carbohydrates100g: Double? = null
+) {
+ fun isEmpty(): Boolean = listOf(
+ energyKcal100g, energyKcalServing, fat100g, saturatedFat100g,
+ sugars100g, salt100g, sodium100g, fiber100g, proteins100g, carbohydrates100g
+ ).all { it == null }
+}
+
+/** A product fetched from Open Food Facts (or reconstructed from OCR). */
+data class Product(
+ val barcode: String,
+ val name: String?,
+ val brand: String?,
+ val imageUrl: String?,
+ val ingredientsText: String?,
+ val allergensTags: List = emptyList(),
+ val tracesTags: List = emptyList(),
+ val nutriScore: String? = null,
+ val novaGroup: Int? = null,
+ val ecoScore: String? = null,
+ val servingSize: String? = null,
+ val nutriments: Nutriments = Nutriments(),
+ val labels: List = emptyList(),
+ val categories: List = emptyList()
+) {
+ /** Public Open Food Facts product page URL. */
+ fun openFoodFactsUrl(): String = "https://world.openfoodfacts.org/product/$barcode"
+}
+
+/** A user's custom diet item that was matched in the product data. */
+data class DetectedCustomItem(
+ val item: CustomDietItem,
+ val matchedKeywords: List,
+ val profileIds: List = emptyList()
+)
+
+/** High-level health verdict computed from Nutri-Score, Nova, Eco-Score + custom rules. */
+data class HealthAssessment(
+ val rating: HealthRating = HealthRating.UNKNOWN,
+ val reasons: List = emptyList(),
+ val nutriScore: String? = null,
+ val novaGroup: Int? = null,
+ val ecoScore: String? = null
+)
+
+/** Describes a single allergen that was detected during analysis. */
+data class DetectedAllergen(
+ val allergenType: AllergenType,
+ val detectionLevel: DetectionLevel,
+ val matchedKeywords: List,
+ val source: String,
+ /** Which profiles this detection concerns (useful for multi-profile scans). */
+ val profileIds: List = emptyList(),
+ /** True when at least one profile lists this as a *severe* allergy. */
+ val severe: Boolean = true
+)
+
+data class ScanResult(
+ val product: Product,
+ val safetyStatus: SafetyStatus,
+ val detectedAllergens: List,
+ val detectedCustomItems: List = emptyList(),
+ val health: HealthAssessment = HealthAssessment(),
+ val analyzedProfiles: List,
+ val confidence: AnalysisConfidence,
+ val source: DataSource
+)
+
+data class ScanHistoryItem(
+ val id: Long = 0L,
+ val barcode: String,
+ val productName: String?,
+ val brand: String?,
+ val imageUrl: String?,
+ val safetyStatus: SafetyStatus,
+ val profileNames: List,
+ val scannedAt: Long,
+ val source: DataSource
+)
diff --git a/app/src/main/java/com/safebite/app/domain/repository/Repositories.kt b/app/src/main/java/com/safebite/app/domain/repository/Repositories.kt
new file mode 100644
index 0000000..3986603
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/domain/repository/Repositories.kt
@@ -0,0 +1,60 @@
+package com.safebite.app.domain.repository
+
+import com.safebite.app.domain.model.AppLanguage
+import com.safebite.app.domain.model.DetectionLanguage
+import com.safebite.app.domain.model.HealthStrictness
+import com.safebite.app.domain.model.Product
+import com.safebite.app.domain.model.ScanHistoryItem
+import com.safebite.app.domain.model.ScanResult
+import com.safebite.app.domain.model.ThemePref
+import com.safebite.app.domain.model.UserProfile
+import kotlinx.coroutines.flow.Flow
+
+sealed class ProductFetchResult {
+ data class Found(val product: Product, val fromCache: Boolean) : ProductFetchResult()
+ data object NotFound : ProductFetchResult()
+ data class Error(val message: String, val offline: Boolean = false) : ProductFetchResult()
+}
+
+interface ProductRepository {
+ suspend fun fetchProduct(barcode: String): ProductFetchResult
+ suspend fun cacheProduct(product: Product)
+ suspend fun getCachedProduct(barcode: String): Product?
+ suspend fun clearCache()
+}
+
+interface UserProfileRepository {
+ fun observeProfiles(): Flow>
+ suspend fun getProfile(id: Long): UserProfile?
+ suspend fun upsert(profile: UserProfile): Long
+ suspend fun delete(profile: UserProfile)
+ suspend fun setDefault(id: Long)
+ fun observeActiveProfileIds(): Flow>
+ suspend fun setActiveProfileIds(ids: Set)
+}
+
+interface ScanHistoryRepository {
+ fun observeHistory(): Flow>
+ suspend fun save(result: ScanResult): Long
+ suspend fun delete(id: Long)
+ suspend fun clear()
+ suspend fun getById(id: Long): ScanHistoryItem?
+}
+
+interface SettingsRepository {
+ val appLanguage: Flow
+ val detectionLanguage: Flow
+ val hapticsEnabled: Flow
+ val soundEnabled: Flow
+ val theme: Flow
+ val onboardingCompleted: Flow
+ val healthStrictness: Flow
+
+ suspend fun setAppLanguage(value: AppLanguage)
+ suspend fun setDetectionLanguage(value: DetectionLanguage)
+ suspend fun setHaptics(enabled: Boolean)
+ suspend fun setSound(enabled: Boolean)
+ suspend fun setTheme(value: ThemePref)
+ suspend fun setOnboardingCompleted(value: Boolean)
+ suspend fun setHealthStrictness(value: HealthStrictness)
+}
diff --git a/app/src/main/java/com/safebite/app/domain/usecase/UseCases.kt b/app/src/main/java/com/safebite/app/domain/usecase/UseCases.kt
new file mode 100644
index 0000000..ca1972f
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/domain/usecase/UseCases.kt
@@ -0,0 +1,89 @@
+package com.safebite.app.domain.usecase
+
+import com.safebite.app.domain.engine.AllergenAnalysisEngine
+import com.safebite.app.domain.model.DataSource
+import com.safebite.app.domain.model.DetectionLanguage
+import com.safebite.app.domain.model.Product
+import com.safebite.app.domain.model.ScanResult
+import com.safebite.app.domain.model.UserProfile
+import com.safebite.app.domain.repository.ProductFetchResult
+import com.safebite.app.domain.repository.ProductRepository
+import com.safebite.app.domain.repository.ScanHistoryRepository
+import com.safebite.app.domain.repository.SettingsRepository
+import com.safebite.app.domain.repository.UserProfileRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import javax.inject.Inject
+
+/** Fetch a product by barcode (remote or cache). */
+class FetchProductUseCase @Inject constructor(
+ private val productRepository: ProductRepository
+) {
+ suspend operator fun invoke(barcode: String): ProductFetchResult =
+ productRepository.fetchProduct(barcode)
+}
+
+/** Analyze a product against a list of profiles using the engine. */
+class AnalyzeProductUseCase @Inject constructor(
+ private val settingsRepository: SettingsRepository
+) {
+ suspend operator fun invoke(
+ product: Product,
+ profiles: List,
+ source: DataSource
+ ): ScanResult {
+ val lang = settingsRepository.detectionLanguage.first()
+ val strictness = settingsRepository.healthStrictness.first()
+ return AllergenAnalysisEngine.analyze(product, profiles, source, lang, strictness)
+ }
+}
+
+/** Analyze free-form ingredients text (OCR path). */
+class AnalyzeIngredientsTextUseCase @Inject constructor(
+ private val analyzeProductUseCase: AnalyzeProductUseCase
+) {
+ suspend operator fun invoke(
+ text: String,
+ profiles: List,
+ barcode: String? = null,
+ productName: String? = null
+ ): ScanResult {
+ val product = Product(
+ barcode = barcode ?: "ocr-${System.currentTimeMillis()}",
+ name = productName,
+ brand = null,
+ imageUrl = null,
+ ingredientsText = text,
+ allergensTags = emptyList(),
+ tracesTags = emptyList()
+ )
+ return analyzeProductUseCase(product, profiles, DataSource.OCR)
+ }
+}
+
+class ManageProfileUseCase @Inject constructor(
+ private val repo: UserProfileRepository
+) {
+ fun observe(): Flow> = repo.observeProfiles()
+ suspend fun get(id: Long) = repo.getProfile(id)
+ suspend fun save(profile: UserProfile): Long = repo.upsert(profile)
+ suspend fun delete(profile: UserProfile) = repo.delete(profile)
+ suspend fun setDefault(id: Long) = repo.setDefault(id)
+ fun observeActiveIds() = repo.observeActiveProfileIds()
+ suspend fun setActive(ids: Set) = repo.setActiveProfileIds(ids)
+}
+
+class GetScanHistoryUseCase @Inject constructor(
+ private val repo: ScanHistoryRepository
+) {
+ fun observe(): Flow> = repo.observeHistory()
+ suspend fun delete(id: Long) = repo.delete(id)
+ suspend fun clear() = repo.clear()
+ suspend fun get(id: Long) = repo.getById(id)
+}
+
+class SaveScanUseCase @Inject constructor(
+ private val repo: ScanHistoryRepository
+) {
+ suspend operator fun invoke(result: ScanResult): Long = repo.save(result)
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/MainActivity.kt b/app/src/main/java/com/safebite/app/presentation/MainActivity.kt
new file mode 100644
index 0000000..8c8da3e
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/MainActivity.kt
@@ -0,0 +1,57 @@
+package com.safebite.app.presentation
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.core.view.WindowCompat
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.compose.runtime.getValue
+import com.safebite.app.domain.model.ThemePref
+import com.safebite.app.domain.repository.SettingsRepository
+import com.safebite.app.presentation.navigation.SafeBiteNavGraph
+import com.safebite.app.presentation.theme.SafeBiteTheme
+import dagger.hilt.android.AndroidEntryPoint
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import javax.inject.Inject
+
+data class RootUi(val onboardingDone: Boolean = false, val theme: ThemePref = ThemePref.SYSTEM, val ready: Boolean = false)
+
+@HiltViewModel
+class RootViewModel @Inject constructor(
+ settings: SettingsRepository
+) : ViewModel() {
+ val state: StateFlow = combine(settings.onboardingCompleted, settings.theme) { done, theme ->
+ RootUi(onboardingDone = done, theme = theme, ready = true)
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, RootUi())
+}
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+
+ private val rootViewModel: RootViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ WindowCompat.setDecorFitsSystemWindows(window, true)
+ setContent {
+ val ui by rootViewModel.state.collectAsStateWithLifecycle()
+ val dark = when (ui.theme) {
+ ThemePref.LIGHT -> false
+ ThemePref.DARK -> true
+ ThemePref.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme()
+ }
+ SafeBiteTheme(darkTheme = dark) {
+ if (ui.ready) {
+ SafeBiteNavGraph(onboardingCompleted = ui.onboardingDone)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/AppBars.kt b/app/src/main/java/com/safebite/app/presentation/common/components/AppBars.kt
new file mode 100644
index 0000000..5ea5df6
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/common/components/AppBars.kt
@@ -0,0 +1,91 @@
+package com.safebite.app.presentation.common.components
+
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+
+enum class AppBarVariant { Small, CenterAligned, Large }
+
+/**
+ * TopAppBar standardisée. Couleurs = `surfaceContainer` (M3) — fini le primary
+ * systématique. Un seul point d'entrée pour toute l'app.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SafeBiteTopAppBar(
+ title: String,
+ modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier,
+ variant: AppBarVariant = AppBarVariant.Small,
+ onBack: (() -> Unit)? = null,
+ backContentDescription: String? = null,
+ actions: @Composable RowScope.() -> Unit = {},
+ scrollBehavior: TopAppBarScrollBehavior? = null,
+) {
+ val colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ titleContentColor = MaterialTheme.colorScheme.onSurface,
+ navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
+ actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ val titleComposable: @Composable () -> Unit = {
+ Text(
+ title,
+ style = when (variant) {
+ AppBarVariant.Large -> MaterialTheme.typography.headlineMedium
+ AppBarVariant.CenterAligned -> MaterialTheme.typography.titleLarge
+ AppBarVariant.Small -> MaterialTheme.typography.titleLarge
+ }
+ )
+ }
+ val navIcon: @Composable () -> Unit = {
+ if (onBack != null) {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = backContentDescription)
+ }
+ }
+ }
+ when (variant) {
+ AppBarVariant.Small -> TopAppBar(
+ title = titleComposable,
+ modifier = modifier,
+ navigationIcon = navIcon,
+ actions = actions,
+ colors = colors,
+ scrollBehavior = scrollBehavior,
+ )
+ AppBarVariant.CenterAligned -> CenterAlignedTopAppBar(
+ title = titleComposable,
+ modifier = modifier,
+ navigationIcon = navIcon,
+ actions = actions,
+ colors = colors,
+ scrollBehavior = scrollBehavior,
+ )
+ AppBarVariant.Large -> LargeTopAppBar(
+ title = titleComposable,
+ modifier = modifier,
+ navigationIcon = navIcon,
+ actions = actions,
+ colors = TopAppBarDefaults.largeTopAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ titleContentColor = MaterialTheme.colorScheme.onSurface,
+ navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
+ actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ scrollBehavior = scrollBehavior,
+ )
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/Buttons.kt b/app/src/main/java/com/safebite/app/presentation/common/components/Buttons.kt
new file mode 100644
index 0000000..7c54cd4
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/common/components/Buttons.kt
@@ -0,0 +1,228 @@
+package com.safebite.app.presentation.common.components
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import com.safebite.app.presentation.theme.LocalDimens
+
+/**
+ * Hauteur et padding standardisés pour tous les boutons SafeBite.
+ * Aligné sur la cible tactile d'accessibilité de 48 dp.
+ */
+private object ButtonTokens {
+ val MinHeight = 48.dp
+ val MinHeightLarge = 56.dp
+ val IconSize = 20.dp
+ val IconSpacer = 8.dp
+ val ProgressSize = 20.dp
+}
+
+/** Anime un léger scale (0.96) quand le bouton est pressé. */
+@Composable
+private fun pressedScale(interactionSource: MutableInteractionSource): Float {
+ val pressed by interactionSource.collectIsPressedAsState()
+ val scale by animateFloatAsState(
+ targetValue = if (pressed) 0.96f else 1f,
+ animationSpec = tween(durationMillis = 120),
+ label = "buttonPressScale"
+ )
+ return scale
+}
+
+/**
+ * Bouton principal (filled) — à utiliser pour l'action principale d'un écran.
+ * Supporte un état [loading] qui désactive l'input et affiche un indicateur.
+ */
+@Composable
+fun PrimaryButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ loading: Boolean = false,
+ large: Boolean = false,
+ icon: ImageVector? = null,
+) {
+ val interaction = remember { MutableInteractionSource() }
+ val scale = pressedScale(interaction)
+ val dimens = LocalDimens.current
+ Button(
+ onClick = { if (!loading) onClick() },
+ enabled = enabled && !loading,
+ modifier = modifier
+ .scale(scale)
+ .heightIn(min = if (large) ButtonTokens.MinHeightLarge else ButtonTokens.MinHeight)
+ .defaultMinSize(minHeight = if (large) ButtonTokens.MinHeightLarge else ButtonTokens.MinHeight),
+ shape = MaterialTheme.shapes.medium,
+ contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm),
+ interactionSource = interaction,
+ ) {
+ ButtonContent(text = text, icon = icon, loading = loading)
+ }
+}
+
+/** Bouton secondaire (filled tonal M3). */
+@Composable
+fun SecondaryButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ loading: Boolean = false,
+ icon: ImageVector? = null,
+) {
+ val interaction = remember { MutableInteractionSource() }
+ val scale = pressedScale(interaction)
+ val dimens = LocalDimens.current
+ FilledTonalButton(
+ onClick = { if (!loading) onClick() },
+ enabled = enabled && !loading,
+ modifier = modifier
+ .scale(scale)
+ .heightIn(min = ButtonTokens.MinHeight)
+ .defaultMinSize(minHeight = ButtonTokens.MinHeight),
+ shape = MaterialTheme.shapes.medium,
+ contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm),
+ interactionSource = interaction,
+ ) {
+ ButtonContent(text = text, icon = icon, loading = loading)
+ }
+}
+
+/** Bouton outlined — action secondaire discrète. */
+@Composable
+fun OutlinedActionButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ loading: Boolean = false,
+ icon: ImageVector? = null,
+) {
+ val interaction = remember { MutableInteractionSource() }
+ val scale = pressedScale(interaction)
+ val dimens = LocalDimens.current
+ OutlinedButton(
+ onClick = { if (!loading) onClick() },
+ enabled = enabled && !loading,
+ modifier = modifier
+ .scale(scale)
+ .heightIn(min = ButtonTokens.MinHeight)
+ .defaultMinSize(minHeight = ButtonTokens.MinHeight),
+ shape = MaterialTheme.shapes.medium,
+ contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm),
+ interactionSource = interaction,
+ ) {
+ ButtonContent(text = text, icon = icon, loading = loading)
+ }
+}
+
+/** Bouton tertiaire (text button) — actions tertiaires / navigation inline. */
+@Composable
+fun TertiaryButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ icon: ImageVector? = null,
+) {
+ val interaction = remember { MutableInteractionSource() }
+ val scale = pressedScale(interaction)
+ TextButton(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = modifier
+ .scale(scale)
+ .heightIn(min = ButtonTokens.MinHeight)
+ .defaultMinSize(minHeight = ButtonTokens.MinHeight),
+ shape = MaterialTheme.shapes.medium,
+ interactionSource = interaction,
+ ) {
+ ButtonContent(text = text, icon = icon, loading = false)
+ }
+}
+
+/** Bouton destructif (erreur) — pour les actions type "Vider l'historique". */
+@Composable
+fun DestructiveButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ loading: Boolean = false,
+ icon: ImageVector? = null,
+) {
+ val dimens = LocalDimens.current
+ val interaction = remember { MutableInteractionSource() }
+ val scale = pressedScale(interaction)
+ val colors: ButtonColors = ButtonDefaults.filledTonalButtonColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ )
+ FilledTonalButton(
+ onClick = { if (!loading) onClick() },
+ enabled = enabled && !loading,
+ modifier = modifier
+ .scale(scale)
+ .heightIn(min = ButtonTokens.MinHeight)
+ .defaultMinSize(minHeight = ButtonTokens.MinHeight),
+ shape = MaterialTheme.shapes.medium,
+ colors = colors,
+ contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm),
+ interactionSource = interaction,
+ ) {
+ ButtonContent(text = text, icon = icon, loading = loading)
+ }
+}
+
+@Composable
+private fun ButtonContent(text: String, icon: ImageVector?, loading: Boolean) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (loading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(ButtonTokens.ProgressSize),
+ strokeWidth = 2.dp,
+ color = LocalContentColor.current
+ )
+ Spacer(Modifier.width(ButtonTokens.IconSpacer))
+ } else if (icon != null) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(ButtonTokens.IconSize)
+ )
+ Spacer(Modifier.width(ButtonTokens.IconSpacer))
+ }
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/Cards.kt b/app/src/main/java/com/safebite/app/presentation/common/components/Cards.kt
new file mode 100644
index 0000000..50e174e
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/common/components/Cards.kt
@@ -0,0 +1,84 @@
+package com.safebite.app.presentation.common.components
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.safebite.app.presentation.theme.LocalDimens
+
+enum class CardVariant { Elevated, Filled, Outlined }
+
+/**
+ * Carte SafeBite standardisée. Élévation, corner radius (`shapes.large`) et
+ * padding interne cohérents. Laisser [onClick] = null pour une carte statique.
+ */
+@Composable
+fun StandardCard(
+ modifier: Modifier = Modifier,
+ variant: CardVariant = CardVariant.Filled,
+ onClick: (() -> Unit)? = null,
+ contentPadding: PaddingValues = PaddingValues(LocalDimens.current.spacingLg),
+ content: @Composable () -> Unit,
+) {
+ val shape = MaterialTheme.shapes.large
+ when (variant) {
+ CardVariant.Elevated -> {
+ if (onClick != null) {
+ ElevatedCard(
+ onClick = onClick,
+ modifier = modifier,
+ shape = shape,
+ ) { InnerPadding(contentPadding, content) }
+ } else {
+ ElevatedCard(modifier = modifier, shape = shape) {
+ InnerPadding(contentPadding, content)
+ }
+ }
+ }
+ CardVariant.Filled -> {
+ if (onClick != null) {
+ Card(
+ onClick = onClick,
+ modifier = modifier,
+ shape = shape,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ ) { InnerPadding(contentPadding, content) }
+ } else {
+ Card(
+ modifier = modifier,
+ shape = shape,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ ) { InnerPadding(contentPadding, content) }
+ }
+ }
+ CardVariant.Outlined -> {
+ if (onClick != null) {
+ OutlinedCard(
+ onClick = onClick,
+ modifier = modifier,
+ shape = shape,
+ ) { InnerPadding(contentPadding, content) }
+ } else {
+ OutlinedCard(modifier = modifier, shape = shape) {
+ InnerPadding(contentPadding, content)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun InnerPadding(pad: PaddingValues, content: @Composable () -> Unit) {
+ androidx.compose.foundation.layout.Box(Modifier.padding(pad)) { content() }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/Components.kt b/app/src/main/java/com/safebite/app/presentation/common/components/Components.kt
new file mode 100644
index 0000000..35cd8be
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/common/components/Components.kt
@@ -0,0 +1,188 @@
+package com.safebite.app.presentation.common.components
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import com.safebite.app.R
+import com.safebite.app.domain.model.AllergenType
+import com.safebite.app.domain.model.SafetyStatus
+import com.safebite.app.presentation.theme.LocalDimens
+import com.safebite.app.presentation.theme.LocalStatusColors
+
+/** Couleur du statut adaptée au thème courant (light / dark). */
+@Composable
+fun statusColor(status: SafetyStatus): Color {
+ val s = LocalStatusColors.current
+ return when (status) {
+ SafetyStatus.SAFE -> s.safe
+ SafetyStatus.WARNING -> s.warning
+ SafetyStatus.DANGER -> s.danger
+ }
+}
+
+/** Couleur `on` (texte/icône) adaptée au thème courant. */
+@Composable
+fun onStatusColor(status: SafetyStatus): Color {
+ val s = LocalStatusColors.current
+ return when (status) {
+ SafetyStatus.SAFE -> s.onSafe
+ SafetyStatus.WARNING -> s.onWarning
+ SafetyStatus.DANGER -> s.onDanger
+ }
+}
+
+@Composable
+fun AllergenChip(
+ allergen: AllergenType,
+ selected: Boolean,
+ onToggle: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val dimens = LocalDimens.current
+ val bg = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface
+ val fg = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
+ Surface(
+ modifier = modifier,
+ shape = RoundedCornerShape(dimens.radiusPill),
+ color = bg,
+ border = if (selected) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
+ onClick = onToggle
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = dimens.spacingMd, vertical = dimens.spacingSm),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(text = allergen.icon, color = fg)
+ Spacer(Modifier.width(dimens.spacingXs + 2.dp))
+ Text(
+ text = allergen.displayNameFr,
+ color = fg,
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+}
+
+@Composable
+fun SafetyStatusBanner(status: SafetyStatus, modifier: Modifier = Modifier) {
+ val (text, icon) = when (status) {
+ SafetyStatus.SAFE -> R.string.result_safe_headline to "✅"
+ SafetyStatus.WARNING -> R.string.result_warning_headline to "⚠️"
+ SafetyStatus.DANGER -> R.string.result_danger_headline to "⛔"
+ }
+ val dimens = LocalDimens.current
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ color = statusColor(status),
+ contentColor = onStatusColor(status)
+ ) {
+ Column(
+ modifier = Modifier.padding(dimens.spacingXl),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(text = icon, style = MaterialTheme.typography.displaySmall)
+ Spacer(Modifier.height(dimens.spacingSm))
+ Text(
+ text = stringResource(text),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+}
+
+@Composable
+fun ProductCard(
+ title: String,
+ subtitle: String?,
+ imageUrl: String?,
+ modifier: Modifier = Modifier
+) {
+ val dimens = LocalDimens.current
+ StandardCard(
+ modifier = modifier.fillMaxWidth(),
+ variant = CardVariant.Elevated,
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(dimens.spacingMd),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (!imageUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = imageUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .size(64.dp)
+ .background(
+ MaterialTheme.colorScheme.surfaceVariant,
+ RoundedCornerShape(dimens.radiusMd)
+ )
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .size(64.dp)
+ .background(
+ MaterialTheme.colorScheme.surfaceVariant,
+ RoundedCornerShape(dimens.radiusMd)
+ ),
+ contentAlignment = Alignment.Center
+ ) { Text("🛒") }
+ }
+ Spacer(Modifier.width(dimens.spacingMd))
+ Column(Modifier.weight(1f)) {
+ Text(
+ title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 2
+ )
+ if (!subtitle.isNullOrBlank()) {
+ Text(
+ subtitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun AvatarBubble(avatar: String, modifier: Modifier = Modifier, size: Dp = 40.dp) {
+ Box(
+ modifier = modifier
+ .size(size)
+ .background(MaterialTheme.colorScheme.primaryContainer, CircleShape)
+ .border(1.dp, MaterialTheme.colorScheme.primary, CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ avatar,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/Feedback.kt b/app/src/main/java/com/safebite/app/presentation/common/components/Feedback.kt
new file mode 100644
index 0000000..f5006b0
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/common/components/Feedback.kt
@@ -0,0 +1,202 @@
+package com.safebite.app.presentation.common.components
+
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CloudOff
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.safebite.app.R
+import com.safebite.app.presentation.theme.LocalDimens
+
+/**
+ * Rectangle animé (shimmer) — base réutilisable pour tous les skeletons.
+ * Utilise `surfaceVariant` → `surface` pour rester cohérent Light/Dark.
+ */
+@Composable
+fun ShimmerBox(
+ modifier: Modifier = Modifier,
+ cornerRadius: Dp = LocalDimens.current.radiusMd,
+) {
+ val transition = rememberInfiniteTransition(label = "shimmer")
+ val progress by transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 1200, easing = FastOutSlowInEasing),
+ repeatMode = RepeatMode.Restart,
+ ),
+ label = "shimmerProgress"
+ )
+ val base = MaterialTheme.colorScheme.surfaceVariant
+ val highlight = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
+ val colors = listOf(base, highlight, base)
+ val offset = 1000f * progress
+ val brush = Brush.linearGradient(
+ colors = colors,
+ start = Offset(offset - 500f, 0f),
+ end = Offset(offset, 0f),
+ )
+ Box(
+ modifier = modifier
+ .clip(RoundedCornerShape(cornerRadius))
+ .background(brush)
+ )
+}
+
+/** Skeleton d'un item de liste (avatar + 2 lignes). */
+@Composable
+fun ShimmerListItem(modifier: Modifier = Modifier) {
+ val dimens = LocalDimens.current
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ ShimmerBox(modifier = Modifier.size(64.dp), cornerRadius = dimens.radiusMd)
+ Spacer(Modifier.width(dimens.spacingMd))
+ Column(Modifier.weight(1f)) {
+ ShimmerBox(Modifier.fillMaxWidth(0.8f).height(14.dp))
+ Spacer(Modifier.height(dimens.spacingSm))
+ ShimmerBox(Modifier.fillMaxWidth(0.5f).height(12.dp))
+ }
+ }
+}
+
+/** État vide standardisé. */
+@Composable
+fun EmptyState(
+ title: String,
+ message: String? = null,
+ emoji: String = "✨",
+ modifier: Modifier = Modifier,
+ action: (@Composable () -> Unit)? = null,
+) {
+ val dimens = LocalDimens.current
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(dimens.spacingXl),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(emoji, style = MaterialTheme.typography.displaySmall)
+ Spacer(Modifier.height(dimens.spacingMd))
+ Text(
+ title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ if (message != null) {
+ Spacer(Modifier.height(dimens.spacingSm))
+ Text(
+ message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (action != null) {
+ Spacer(Modifier.height(dimens.spacingLg))
+ action()
+ }
+ }
+}
+
+/** Chargement centré pleine surface. */
+@Composable
+fun LoadingIndicator(modifier: Modifier = Modifier) {
+ Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
+ }
+}
+
+/** Badge compact "Hors ligne" à afficher en overlay d'une barre / carte. */
+@Composable
+fun OfflineIndicator(modifier: Modifier = Modifier) {
+ val dimens = LocalDimens.current
+ Row(
+ modifier = modifier
+ .clip(RoundedCornerShape(dimens.radiusLg))
+ .background(MaterialTheme.colorScheme.errorContainer)
+ .padding(horizontal = dimens.spacingMd, vertical = dimens.spacingXs),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Filled.CloudOff,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(Modifier.width(dimens.spacingXs))
+ Text(
+ text = stringResource(R.string.offline_indicator),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ )
+ }
+}
+
+/** Vue d'erreur plein écran avec icône, message et action "Réessayer" optionnelle. */
+@Composable
+fun ErrorView(
+ message: String,
+ onRetry: (() -> Unit)? = null,
+ modifier: Modifier = Modifier
+) {
+ val dimens = LocalDimens.current
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(dimens.spacingXl),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Warning,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(48.dp)
+ )
+ Spacer(Modifier.height(dimens.spacingMd))
+ Text(
+ message,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ if (onRetry != null) {
+ Spacer(Modifier.height(dimens.spacingLg))
+ OutlinedActionButton(
+ text = stringResource(R.string.action_retry),
+ onClick = onRetry,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/TextFields.kt b/app/src/main/java/com/safebite/app/presentation/common/components/TextFields.kt
new file mode 100644
index 0000000..5ef33fc
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/common/components/TextFields.kt
@@ -0,0 +1,83 @@
+package com.safebite.app.presentation.common.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.ui.unit.dp
+import com.safebite.app.presentation.theme.LocalDimens
+
+/**
+ * OutlinedTextField Material 3 standardisé : label / placeholder / helper / error.
+ * Gère automatiquement la couleur du helper text selon l'état erreur.
+ */
+@Composable
+fun StandardTextField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ label: String? = null,
+ placeholder: String? = null,
+ helperText: String? = null,
+ errorText: String? = null,
+ leadingIcon: @Composable (() -> Unit)? = null,
+ trailingIcon: @Composable (() -> Unit)? = null,
+ singleLine: Boolean = true,
+ maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
+ enabled: Boolean = true,
+ readOnly: Boolean = false,
+ capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
+ maxLength: Int? = null,
+ showCounter: Boolean = false,
+) {
+ val isError = !errorText.isNullOrBlank()
+ val dimens = LocalDimens.current
+ Column(modifier = modifier.fillMaxWidth()) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = { new ->
+ if (maxLength == null || new.length <= maxLength) onValueChange(new)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ label = label?.let { { Text(it) } },
+ placeholder = placeholder?.let { { Text(it) } },
+ leadingIcon = leadingIcon,
+ trailingIcon = trailingIcon,
+ isError = isError,
+ singleLine = singleLine,
+ maxLines = maxLines,
+ enabled = enabled,
+ readOnly = readOnly,
+ shape = MaterialTheme.shapes.medium,
+ keyboardOptions = KeyboardOptions(capitalization = capitalization),
+ )
+ if (!errorText.isNullOrBlank() || !helperText.isNullOrBlank() || showCounter) {
+ Row(Modifier.fillMaxWidth().padding(horizontal = dimens.spacingLg, vertical = dimens.spacingXs)) {
+ val msg = errorText ?: helperText
+ if (msg != null) {
+ Text(
+ msg,
+ style = MaterialTheme.typography.bodySmall,
+ color = if (isError) MaterialTheme.colorScheme.error
+ else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ if (showCounter && maxLength != null) {
+ androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
+ Text(
+ "${value.length}/$maxLength",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/common/util/UiState.kt b/app/src/main/java/com/safebite/app/presentation/common/util/UiState.kt
new file mode 100644
index 0000000..18a4237
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/common/util/UiState.kt
@@ -0,0 +1,8 @@
+package com.safebite.app.presentation.common.util
+
+sealed interface UiState {
+ data object Idle : UiState
+ data object Loading : UiState
+ data class Success(val data: T) : UiState
+ data class Error(val message: String, val offline: Boolean = false) : UiState
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt b/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt
new file mode 100644
index 0000000..90fbb7d
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt
@@ -0,0 +1,150 @@
+package com.safebite.app.presentation.navigation
+
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.runtime.Composable
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import com.safebite.app.presentation.screen.history.HistoryScreen
+import com.safebite.app.presentation.screen.home.HomeScreen
+import com.safebite.app.presentation.screen.ocr.OcrCaptureScreen
+import com.safebite.app.presentation.screen.ocr.OcrReviewScreen
+import com.safebite.app.presentation.screen.onboarding.OnboardingScreen
+import com.safebite.app.presentation.screen.profile.ProfileEditScreen
+import com.safebite.app.presentation.screen.profile.ProfileListScreen
+import com.safebite.app.presentation.screen.result.ResultScreen
+import com.safebite.app.presentation.screen.scanner.ScannerScreen
+import com.safebite.app.presentation.screen.settings.SettingsScreen
+
+@Composable
+fun SafeBiteNavGraph(onboardingCompleted: Boolean) {
+ val navController = rememberNavController()
+ val startDestination = if (onboardingCompleted) Screen.Home.route else Screen.Onboarding.route
+
+ val enterAnim = fadeIn(animationSpec = tween(250)) +
+ slideInHorizontally(animationSpec = tween(250)) { it / 24 }
+ val exitAnim = fadeOut(animationSpec = tween(200))
+ val popEnterAnim = fadeIn(animationSpec = tween(250))
+ val popExitAnim = fadeOut(animationSpec = tween(200)) +
+ slideOutHorizontally(animationSpec = tween(250)) { it / 24 }
+
+ NavHost(
+ navController = navController,
+ startDestination = startDestination,
+ enterTransition = { enterAnim },
+ exitTransition = { exitAnim },
+ popEnterTransition = { popEnterAnim },
+ popExitTransition = { popExitAnim },
+ ) {
+ composable(Screen.Onboarding.route) {
+ OnboardingScreen(onFinished = {
+ navController.navigate(Screen.Home.route) {
+ popUpTo(Screen.Onboarding.route) { inclusive = true }
+ }
+ })
+ }
+ composable(Screen.Home.route) {
+ HomeScreen(
+ onScan = { navController.navigate(Screen.Scanner.route) },
+ onOcr = { navController.navigate(Screen.OcrCapture.route) },
+ onProfiles = { navController.navigate(Screen.ProfileList.route) },
+ onCreateProfile = { navController.navigate(Screen.ProfileEdit.new()) },
+ onHistory = { navController.navigate(Screen.History.route) },
+ onSettings = { navController.navigate(Screen.Settings.route) },
+ onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) }
+ )
+ }
+ composable(Screen.Scanner.route) {
+ ScannerScreen(
+ onBack = { navController.popBackStack() },
+ onBarcode = { code ->
+ navController.navigate(Screen.Result.fromBarcode(code)) {
+ popUpTo(Screen.Home.route)
+ }
+ }
+ )
+ }
+ composable(Screen.OcrCapture.route) {
+ OcrCaptureScreen(
+ onBack = { navController.popBackStack() },
+ onCaptured = { text -> navController.navigate(Screen.OcrReview.build(text)) }
+ )
+ }
+ composable(
+ route = Screen.OcrReview.route,
+ arguments = listOf(navArgument("text") { type = NavType.StringType })
+ ) { entry ->
+ val text = entry.arguments?.getString("text").orEmpty()
+ OcrReviewScreen(
+ initialText = android.net.Uri.decode(text),
+ onBack = { navController.popBackStack() },
+ onAnalyze = { edited ->
+ navController.navigate(Screen.Result.fromOcr(edited)) {
+ popUpTo(Screen.Home.route)
+ }
+ }
+ )
+ }
+ composable(
+ route = Screen.Result.route,
+ arguments = listOf(
+ navArgument("barcode") { type = NavType.StringType },
+ navArgument("fromOcr") { type = NavType.BoolType; defaultValue = false },
+ navArgument("ocrText") { type = NavType.StringType; nullable = true; defaultValue = null }
+ )
+ ) { entry ->
+ val barcode = entry.arguments?.getString("barcode")
+ val fromOcr = entry.arguments?.getBoolean("fromOcr") == true
+ val ocrText = entry.arguments?.getString("ocrText")?.let { android.net.Uri.decode(it) }
+ ResultScreen(
+ barcode = barcode,
+ fromOcr = fromOcr,
+ ocrText = ocrText,
+ onBack = { navController.popBackStack() },
+ onScanAgain = {
+ navController.navigate(Screen.Scanner.route) {
+ popUpTo(Screen.Home.route)
+ }
+ },
+ onOcr = {
+ navController.navigate(Screen.OcrCapture.route) {
+ popUpTo(Screen.Home.route)
+ }
+ }
+ )
+ }
+ composable(Screen.ProfileList.route) {
+ ProfileListScreen(
+ onBack = { navController.popBackStack() },
+ onNew = { navController.navigate(Screen.ProfileEdit.new()) },
+ onEdit = { id -> navController.navigate(Screen.ProfileEdit.edit(id)) }
+ )
+ }
+ composable(
+ route = Screen.ProfileEdit.route,
+ arguments = listOf(navArgument("id") { type = NavType.LongType; defaultValue = 0L })
+ ) { entry ->
+ val id = entry.arguments?.getLong("id") ?: 0L
+ ProfileEditScreen(
+ id = id,
+ onBack = { navController.popBackStack() },
+ onSaved = { navController.popBackStack() }
+ )
+ }
+ composable(Screen.History.route) {
+ HistoryScreen(
+ onBack = { navController.popBackStack() },
+ onOpen = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) }
+ )
+ }
+ composable(Screen.Settings.route) {
+ SettingsScreen(onBack = { navController.popBackStack() })
+ }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt b/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt
new file mode 100644
index 0000000..49e4b39
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt
@@ -0,0 +1,23 @@
+package com.safebite.app.presentation.navigation
+
+sealed class Screen(val route: String) {
+ data object Onboarding : Screen("onboarding")
+ data object Home : Screen("home")
+ data object Scanner : Screen("scanner")
+ data object OcrCapture : Screen("ocr/capture")
+ data object OcrReview : Screen("ocr/review/{text}") {
+ fun build(text: String) = "ocr/review/${android.net.Uri.encode(text)}"
+ }
+ data object Result : Screen("result/{barcode}?fromOcr={fromOcr}&ocrText={ocrText}") {
+ fun fromBarcode(barcode: String) = "result/$barcode?fromOcr=false&ocrText="
+ fun fromOcr(text: String) = "result/ocr?fromOcr=true&ocrText=${android.net.Uri.encode(text)}"
+ fun fromHistory(barcode: String) = "result/$barcode?fromOcr=false&ocrText="
+ }
+ data object ProfileList : Screen("profiles")
+ data object ProfileEdit : Screen("profile/edit?id={id}") {
+ fun new() = "profile/edit?id=0"
+ fun edit(id: Long) = "profile/edit?id=$id"
+ }
+ data object History : Screen("history")
+ data object Settings : Screen("settings")
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/history/HistoryScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/history/HistoryScreen.kt
new file mode 100644
index 0000000..0777edf
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/history/HistoryScreen.kt
@@ -0,0 +1,154 @@
+package com.safebite.app.presentation.screen.history
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.DeleteSweep
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.safebite.app.R
+import com.safebite.app.domain.model.SafetyStatus
+import com.safebite.app.presentation.common.components.CardVariant
+import com.safebite.app.presentation.common.components.EmptyState
+import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
+import com.safebite.app.presentation.common.components.StandardCard
+import com.safebite.app.presentation.common.components.StandardTextField
+import com.safebite.app.presentation.common.components.statusColor
+import com.safebite.app.presentation.theme.LocalDimens
+import java.text.DateFormat
+import java.util.Date
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HistoryScreen(
+ onBack: () -> Unit,
+ onOpen: (String) -> Unit,
+ viewModel: HistoryViewModel = hiltViewModel()
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val query by viewModel.query.collectAsStateWithLifecycle()
+ val filter by viewModel.filter.collectAsStateWithLifecycle()
+
+ val dimens = LocalDimens.current
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.background,
+ topBar = {
+ SafeBiteTopAppBar(
+ title = stringResource(R.string.history_title),
+ onBack = onBack,
+ backContentDescription = stringResource(R.string.action_back),
+ actions = {
+ IconButton(onClick = viewModel::clearAll) {
+ Icon(
+ Icons.Filled.DeleteSweep,
+ contentDescription = stringResource(R.string.history_clear_all)
+ )
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
+ verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
+ ) {
+ StandardTextField(
+ value = query,
+ onValueChange = viewModel::setQuery,
+ placeholder = stringResource(R.string.history_search),
+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
+ FilterChip(selected = filter == null, onClick = { viewModel.setFilter(null) }, label = { Text(stringResource(R.string.history_filter_all)) })
+ FilterChip(selected = filter == SafetyStatus.DANGER, onClick = { viewModel.setFilter(SafetyStatus.DANGER) }, label = { Text(stringResource(R.string.history_filter_danger)) })
+ FilterChip(selected = filter == SafetyStatus.WARNING, onClick = { viewModel.setFilter(SafetyStatus.WARNING) }, label = { Text(stringResource(R.string.history_filter_warning)) })
+ FilterChip(selected = filter == SafetyStatus.SAFE, onClick = { viewModel.setFilter(SafetyStatus.SAFE) }, label = { Text(stringResource(R.string.history_filter_safe)) })
+ }
+
+ if (state.items.isEmpty()) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ EmptyState(
+ title = stringResource(R.string.history_empty),
+ emoji = "📂",
+ )
+ }
+ } else {
+ LazyColumn(verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
+ items(state.items, key = { it.id }) { item ->
+ StandardCard(
+ modifier = Modifier.fillMaxWidth(),
+ variant = CardVariant.Elevated,
+ onClick = { onOpen(item.barcode) },
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(dimens.spacingMd),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier
+ .size(12.dp)
+ .background(statusColor(item.safetyStatus), CircleShape)
+ )
+ Spacer(Modifier.size(dimens.spacingMd))
+ Column(Modifier.weight(1f)) {
+ Text(
+ item.productName ?: item.barcode,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(item.scannedAt)),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ if (item.profileNames.isNotEmpty()) {
+ Text(
+ item.profileNames.joinToString(),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ IconButton(onClick = { viewModel.delete(item.id) }) {
+ Icon(
+ Icons.Filled.Delete,
+ contentDescription = stringResource(R.string.action_delete),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/history/HistoryViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/history/HistoryViewModel.kt
new file mode 100644
index 0000000..228fc54
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/history/HistoryViewModel.kt
@@ -0,0 +1,45 @@
+package com.safebite.app.presentation.screen.history
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.safebite.app.domain.model.SafetyStatus
+import com.safebite.app.domain.model.ScanHistoryItem
+import com.safebite.app.domain.usecase.GetScanHistoryUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class HistoryUi(
+ val items: List = emptyList(),
+ val filter: SafetyStatus? = null,
+ val query: String = ""
+)
+
+@HiltViewModel
+class HistoryViewModel @Inject constructor(
+ private val useCase: GetScanHistoryUseCase
+) : ViewModel() {
+
+ private val _filter = MutableStateFlow(null)
+ private val _query = MutableStateFlow("")
+ val filter: StateFlow = _filter.asStateFlow()
+ val query: StateFlow = _query.asStateFlow()
+
+ val state: StateFlow = combine(useCase.observe(), _filter, _query) { items, f, q ->
+ val filtered = items
+ .filter { f == null || it.safetyStatus == f }
+ .filter { q.isBlank() || (it.productName?.contains(q, ignoreCase = true) == true) || it.barcode.contains(q) }
+ HistoryUi(items = filtered, filter = f, query = q)
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HistoryUi())
+
+ fun setFilter(status: SafetyStatus?) { _filter.value = status }
+ fun setQuery(q: String) { _query.value = q }
+ fun delete(id: Long) = viewModelScope.launch { useCase.delete(id) }
+ fun clearAll() = viewModelScope.launch { useCase.clear() }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/home/HomeScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/home/HomeScreen.kt
new file mode 100644
index 0000000..79a02df
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/home/HomeScreen.kt
@@ -0,0 +1,216 @@
+package com.safebite.app.presentation.screen.home
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.History
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.QrCodeScanner
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.TextFields
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.safebite.app.R
+import com.safebite.app.domain.model.SafetyStatus
+import com.safebite.app.domain.model.UserProfile
+import com.safebite.app.presentation.common.components.AvatarBubble
+import com.safebite.app.presentation.common.components.OutlinedActionButton
+import com.safebite.app.presentation.common.components.PrimaryButton
+import com.safebite.app.presentation.common.components.ProductCard
+import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
+import com.safebite.app.presentation.common.components.statusColor
+import com.safebite.app.presentation.theme.LocalDimens
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HomeScreen(
+ onScan: () -> Unit,
+ onOcr: () -> Unit,
+ onProfiles: () -> Unit,
+ onCreateProfile: () -> Unit,
+ onHistory: () -> Unit,
+ onSettings: () -> Unit,
+ onOpenHistoryItem: (String) -> Unit,
+ viewModel: HomeViewModel = hiltViewModel()
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+
+ val dimens = LocalDimens.current
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.background,
+ topBar = {
+ SafeBiteTopAppBar(
+ title = stringResource(R.string.app_name),
+ actions = {
+ IconButton(onClick = onProfiles) { Icon(Icons.Filled.Person, contentDescription = stringResource(R.string.nav_profiles)) }
+ IconButton(onClick = onHistory) { Icon(Icons.Filled.History, contentDescription = stringResource(R.string.nav_history)) }
+ IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.nav_settings)) }
+ },
+ )
+ }
+ ) { padding ->
+ if (state.profiles.isEmpty()) {
+ NoProfileBlock(modifier = Modifier.padding(padding), onCreate = onCreateProfile)
+ return@Scaffold
+ }
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
+ verticalArrangement = Arrangement.spacedBy(dimens.spacingLg)
+ ) {
+ ActiveProfilesRow(
+ profiles = state.profiles,
+ active = state.activeProfiles,
+ onToggle = viewModel::toggleActive,
+ onManage = onProfiles
+ )
+
+ ScanButton(onClick = onScan)
+
+ OutlinedActionButton(
+ text = stringResource(R.string.home_ocr_button),
+ onClick = onOcr,
+ icon = Icons.Filled.TextFields,
+ modifier = Modifier.fillMaxWidth().height(dimens.buttonHeightLg)
+ )
+
+ Text(
+ stringResource(R.string.home_recent_scans),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ if (state.recent.isEmpty()) {
+ Text(
+ stringResource(R.string.home_no_recent),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ } else {
+ state.recent.forEach { item ->
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onOpenHistoryItem(item.barcode) }
+ ) {
+ Box(
+ modifier = Modifier.size(12.dp).background(statusColor(item.safetyStatus), CircleShape)
+ )
+ Spacer(Modifier.size(8.dp))
+ ProductCard(
+ title = item.productName ?: item.barcode,
+ subtitle = item.brand,
+ imageUrl = item.imageUrl,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ActiveProfilesRow(
+ profiles: List,
+ active: List,
+ onToggle: (UserProfile) -> Unit,
+ onManage: () -> Unit
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(stringResource(R.string.home_active_profile), style = MaterialTheme.typography.labelLarge)
+ Spacer(Modifier.weight(1f))
+ androidx.compose.material3.TextButton(onClick = onManage) { Text(stringResource(R.string.home_change_profile)) }
+ }
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(profiles) { p ->
+ val selected = active.any { it.id == p.id }
+ Surface(
+ onClick = { onToggle(p) },
+ shape = MaterialTheme.shapes.medium,
+ color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
+ ) {
+ Row(modifier = Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
+ AvatarBubble(avatar = p.avatar, size = 32.dp)
+ Spacer(Modifier.size(8.dp))
+ Text(p.name, fontWeight = FontWeight.SemiBold)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ScanButton(onClick: () -> Unit) {
+ val dimens = LocalDimens.current
+ PrimaryButton(
+ text = stringResource(R.string.home_scan_button),
+ onClick = onClick,
+ icon = Icons.Filled.QrCodeScanner,
+ large = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(dimens.buttonHeightHero)
+ .semantics { contentDescription = "Scan a product" },
+ )
+}
+
+@Composable
+private fun NoProfileBlock(modifier: Modifier, onCreate: () -> Unit) {
+ val dimens = LocalDimens.current
+ Column(
+ modifier = modifier.fillMaxSize().padding(dimens.spacingXl),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ stringResource(R.string.home_no_profile_title),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ Spacer(Modifier.size(dimens.spacingSm))
+ Text(
+ stringResource(R.string.home_no_profile_body),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(Modifier.size(dimens.spacingLg))
+ PrimaryButton(
+ text = stringResource(R.string.home_create_profile),
+ onClick = onCreate
+ )
+ }
+}
+
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/home/HomeViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/home/HomeViewModel.kt
new file mode 100644
index 0000000..c3e2afa
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/home/HomeViewModel.kt
@@ -0,0 +1,50 @@
+package com.safebite.app.presentation.screen.home
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.safebite.app.domain.model.ScanHistoryItem
+import com.safebite.app.domain.model.UserProfile
+import com.safebite.app.domain.usecase.GetScanHistoryUseCase
+import com.safebite.app.domain.usecase.ManageProfileUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class HomeUi(
+ val profiles: List = emptyList(),
+ val activeProfiles: List = emptyList(),
+ val recent: List = emptyList()
+)
+
+@HiltViewModel
+class HomeViewModel @Inject constructor(
+ private val manageProfile: ManageProfileUseCase,
+ private val history: GetScanHistoryUseCase
+) : ViewModel() {
+
+ val state: StateFlow = combine(
+ manageProfile.observe(),
+ manageProfile.observeActiveIds(),
+ history.observe()
+ ) { profiles, activeIds, scans ->
+ val resolvedActive = when {
+ activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }
+ else -> profiles.filter { it.isDefault }.ifEmpty { profiles.take(1) }
+ }
+ HomeUi(profiles = profiles, activeProfiles = resolvedActive, recent = scans.take(3))
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeUi())
+
+ fun toggleActive(profile: UserProfile) = viewModelScope.launch {
+ val current = state.value.activeProfiles.map { it.id }.toMutableSet()
+ if (profile.id in current) current.remove(profile.id) else current.add(profile.id)
+ manageProfile.setActive(current)
+ }
+
+ fun setActiveOnly(profile: UserProfile) = viewModelScope.launch {
+ manageProfile.setActive(setOf(profile.id))
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrCaptureScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrCaptureScreen.kt
new file mode 100644
index 0000000..1983375
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrCaptureScreen.kt
@@ -0,0 +1,162 @@
+package com.safebite.app.presentation.screen.ocr
+
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageProxy
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.google.mlkit.vision.common.InputImage
+import com.google.mlkit.vision.text.TextRecognition
+import com.google.mlkit.vision.text.latin.TextRecognizerOptions
+import com.safebite.app.R
+import com.safebite.app.presentation.common.components.ErrorView
+import com.safebite.app.presentation.common.components.PrimaryButton
+import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
+import java.util.concurrent.Executors
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
+@Composable
+fun OcrCaptureScreen(
+ onBack: () -> Unit,
+ onCaptured: (String) -> Unit
+) {
+ val permission = rememberPermissionState(android.Manifest.permission.CAMERA)
+ LaunchedEffect(Unit) { if (!permission.status.isGranted) permission.launchPermissionRequest() }
+
+ var livePreviewText by remember { mutableStateOf("") }
+
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.background,
+ topBar = {
+ SafeBiteTopAppBar(
+ title = stringResource(R.string.ocr_capture_title),
+ onBack = onBack,
+ backContentDescription = stringResource(R.string.action_back),
+ )
+ }
+ ) { padding ->
+ Box(Modifier.fillMaxSize().padding(padding)) {
+ if (!permission.status.isGranted) {
+ ErrorView(
+ message = stringResource(R.string.scanner_camera_denied),
+ onRetry = { permission.launchPermissionRequest() }
+ )
+ } else {
+ OcrCameraView(
+ onTextUpdate = { livePreviewText = it },
+ onCapture = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) }
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.BottomCenter)
+ .padding(16.dp)
+ ) {
+ if (livePreviewText.isNotBlank()) {
+ Text(
+ livePreviewText.take(300),
+ color = Color.White,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Color(0xAA000000), RoundedCornerShape(8.dp))
+ .padding(8.dp)
+ )
+ } else {
+ Text(
+ stringResource(R.string.ocr_capture_hint),
+ color = Color.White,
+ modifier = Modifier
+ .background(Color(0xAA000000), RoundedCornerShape(8.dp))
+ .padding(8.dp)
+ )
+ }
+ Spacer(Modifier.height(12.dp))
+ PrimaryButton(
+ text = stringResource(R.string.ocr_capture_action),
+ onClick = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) },
+ enabled = livePreviewText.isNotBlank(),
+ large = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun OcrCameraView(onTextUpdate: (String) -> Unit, onCapture: () -> Unit) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val executor = remember { Executors.newSingleThreadExecutor() }
+ val recognizer = remember { TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) }
+ DisposableEffect(Unit) {
+ onDispose {
+ executor.shutdown()
+ recognizer.close()
+ }
+ }
+
+ androidx.compose.ui.viewinterop.AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { ctx ->
+ val previewView = PreviewView(ctx).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
+ val providerFuture = ProcessCameraProvider.getInstance(ctx)
+ providerFuture.addListener({
+ val provider = providerFuture.get()
+ val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) }
+ val analysis = ImageAnalysis.Builder()
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .build()
+ analysis.setAnalyzer(executor) { proxy: ImageProxy ->
+ @androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
+ val media = proxy.image
+ if (media == null) { proxy.close(); return@setAnalyzer }
+ val input = InputImage.fromMediaImage(media, proxy.imageInfo.rotationDegrees)
+ recognizer.process(input)
+ .addOnSuccessListener { result -> onTextUpdate(result.text) }
+ .addOnCompleteListener { proxy.close() }
+ }
+ try {
+ provider.unbindAll()
+ provider.bindToLifecycle(
+ lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
+ )
+ } catch (_: Throwable) {}
+ }, ContextCompat.getMainExecutor(ctx))
+ previewView
+ }
+ )
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrReviewScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrReviewScreen.kt
new file mode 100644
index 0000000..6a52946
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrReviewScreen.kt
@@ -0,0 +1,81 @@
+package com.safebite.app.presentation.screen.ocr
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.safebite.app.R
+import com.safebite.app.presentation.common.components.PrimaryButton
+import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
+import com.safebite.app.domain.engine.AllergenAnalysisEngine
+import com.safebite.app.domain.model.AllergenType
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OcrReviewScreen(
+ initialText: String,
+ onBack: () -> Unit,
+ onAnalyze: (String) -> Unit
+) {
+ var text by remember { mutableStateOf(initialText) }
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.background,
+ topBar = {
+ SafeBiteTopAppBar(
+ title = stringResource(R.string.ocr_review_title),
+ onBack = onBack,
+ backContentDescription = stringResource(R.string.action_back),
+ )
+ }
+ ) { padding ->
+ Column(
+ Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(stringResource(R.string.ocr_review_hint), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ OutlinedTextField(
+ value = text,
+ onValueChange = { text = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.result_ingredients)) },
+ minLines = 8
+ )
+ val highlights = remember(text) { findHighlights(text) }
+ if (highlights.isNotEmpty()) {
+ Text("Détectés : " + highlights.joinToString { "${it.icon} ${it.displayNameFr}" })
+ }
+ PrimaryButton(
+ text = stringResource(R.string.action_analyze),
+ onClick = { onAnalyze(text) },
+ enabled = text.isNotBlank(),
+ large = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+}
+
+private fun findHighlights(text: String): List {
+ val normalized = AllergenAnalysisEngine.normalize(text)
+ return AllergenType.values().filter { a ->
+ val keywords = (a.keywordsFr + a.keywordsEn).map { AllergenAnalysisEngine.normalize(it) }
+ keywords.any { kw -> kw.isNotBlank() && normalized.contains(kw) }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrViewModel.kt
new file mode 100644
index 0000000..a7b383c
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrViewModel.kt
@@ -0,0 +1,16 @@
+package com.safebite.app.presentation.screen.ocr
+
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import javax.inject.Inject
+
+@HiltViewModel
+class OcrViewModel @Inject constructor() : ViewModel() {
+ private val _capturedText = MutableStateFlow("")
+ val capturedText: StateFlow = _capturedText.asStateFlow()
+
+ fun setText(text: String) { _capturedText.value = text }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingScreen.kt
new file mode 100644
index 0000000..cbda61b
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingScreen.kt
@@ -0,0 +1,342 @@
+package com.safebite.app.presentation.screen.onboarding
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.safebite.app.R
+import com.safebite.app.domain.model.AllergenType
+import com.safebite.app.domain.model.CustomDietItem
+import com.safebite.app.domain.model.CustomItemTag
+import com.safebite.app.domain.model.DietaryRestriction
+import com.safebite.app.presentation.common.components.PrimaryButton
+import com.safebite.app.presentation.common.components.StandardTextField
+import com.safebite.app.presentation.common.components.TertiaryButton
+import com.safebite.app.presentation.screen.profile.AllergenGrid
+import com.safebite.app.presentation.screen.profile.CustomItemAdder
+import com.safebite.app.presentation.screen.profile.CustomItemsList
+import com.google.accompanist.permissions.shouldShowRationale
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.material3.FilterChip
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun OnboardingScreen(
+ onFinished: () -> Unit,
+ viewModel: OnboardingViewModel = hiltViewModel()
+) {
+ var step by rememberSaveable { mutableStateOf(0) }
+ var name by rememberSaveable { mutableStateOf("") }
+ var avatar by rememberSaveable { mutableStateOf("🙂") }
+ val severe = remember { mutableStateOf>(emptySet()) }
+ val moderate = remember { mutableStateOf>(emptySet()) }
+ val restrictions = remember { mutableStateOf>(emptySet()) }
+ val customItems = remember { mutableStateOf>(emptyList()) }
+
+ val cameraPermission = rememberPermissionState(android.Manifest.permission.CAMERA)
+
+ Box(Modifier.fillMaxSize().padding(24.dp)) {
+ when (step) {
+ 0 -> WelcomeStep(onNext = { step = 1 })
+ 1 -> HowStep(onNext = { step = 2 })
+ 2 -> CreateProfileStep(
+ name = name,
+ onNameChange = { name = it },
+ avatar = avatar,
+ onAvatarChange = { avatar = it },
+ severe = severe.value,
+ onToggleSevere = { a ->
+ severe.value = if (a in severe.value) severe.value - a else severe.value + a
+ },
+ moderate = moderate.value,
+ onToggleModerate = { a ->
+ moderate.value = if (a in moderate.value) moderate.value - a else moderate.value + a
+ },
+ restrictions = restrictions.value,
+ onToggleRestriction = { r ->
+ restrictions.value = if (r in restrictions.value) restrictions.value - r else restrictions.value + r
+ },
+ customItems = customItems.value,
+ onAddCustomItem = { n, t ->
+ customItems.value = customItems.value + CustomDietItem(name = n, tag = t)
+ },
+ onRemoveCustomItem = { item ->
+ customItems.value = customItems.value - item
+ },
+ onNext = {
+ viewModel.createProfile(name, avatar, severe.value, moderate.value, restrictions.value, customItems.value)
+ step = 3
+ }
+ )
+ 3 -> PermissionStep(
+ granted = cameraPermission.status.isGranted,
+ rationale = cameraPermission.status.shouldShowRationale,
+ onRequest = { cameraPermission.launchPermissionRequest() },
+ onNext = { step = 4 }
+ )
+ 4 -> ReadyStep(onFinish = {
+ viewModel.complete()
+ onFinished()
+ })
+ }
+ }
+}
+
+@Composable
+private fun WelcomeStep(onNext: () -> Unit) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text("🛡️", style = MaterialTheme.typography.displayLarge)
+ Spacer(Modifier.height(16.dp))
+ Text(
+ stringResource(R.string.onboarding_welcome_title),
+ style = MaterialTheme.typography.headlineLarge,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ stringResource(R.string.onboarding_welcome_subtitle),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(Modifier.height(32.dp))
+ PrimaryButton(
+ text = stringResource(R.string.action_continue),
+ onClick = onNext,
+ large = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
+
+@Composable
+private fun HowStep(onNext: () -> Unit) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ stringResource(R.string.onboarding_how_title),
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ Spacer(Modifier.height(8.dp))
+ Text("👤 " + stringResource(R.string.onboarding_how_step1), style = MaterialTheme.typography.bodyLarge)
+ Text("📷 " + stringResource(R.string.onboarding_how_step2), style = MaterialTheme.typography.bodyLarge)
+ Text("✅ " + stringResource(R.string.onboarding_how_step3), style = MaterialTheme.typography.bodyLarge)
+ Spacer(Modifier.weight(1f))
+ PrimaryButton(
+ text = stringResource(R.string.action_continue),
+ onClick = onNext,
+ large = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun CreateProfileStep(
+ name: String,
+ onNameChange: (String) -> Unit,
+ avatar: String,
+ onAvatarChange: (String) -> Unit,
+ severe: Set,
+ onToggleSevere: (AllergenType) -> Unit,
+ moderate: Set,
+ onToggleModerate: (AllergenType) -> Unit,
+ restrictions: Set,
+ onToggleRestriction: (DietaryRestriction) -> Unit,
+ customItems: List,
+ onAddCustomItem: (String, CustomItemTag) -> Unit,
+ onRemoveCustomItem: (CustomDietItem) -> Unit,
+ onNext: () -> Unit
+) {
+ val avatars = listOf("🙂", "😀", "👧", "👦", "👨", "👩", "👵", "👴", "🧑", "👶", "🧒", "🧓", "🍽️", "🛒", "🥗", "🍎")
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ item {
+ Text(
+ stringResource(R.string.onboarding_profile_title),
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ }
+ item {
+ StandardTextField(
+ value = name,
+ onValueChange = onNameChange,
+ label = stringResource(R.string.profile_name),
+ )
+ }
+ item {
+ Text(stringResource(R.string.profile_avatar), style = MaterialTheme.typography.titleMedium)
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ items(avatars) { a ->
+ val selected = a == avatar
+ val bg = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
+ androidx.compose.material3.Surface(
+ onClick = { onAvatarChange(a) },
+ shape = CircleShape,
+ color = bg,
+ border = if (selected) androidx.compose.foundation.BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
+ modifier = Modifier.size(72.dp)
+ ) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(a, fontSize = MaterialTheme.typography.displaySmall.fontSize)
+ }
+ }
+ }
+ }
+ }
+
+ item {
+ Text(stringResource(R.string.profile_allergies), style = MaterialTheme.typography.titleMedium)
+ Text(stringResource(R.string.profile_allergies_help), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ item { AllergenGrid(selected = severe, onToggle = onToggleSevere) }
+
+ item {
+ Text(stringResource(R.string.profile_intolerances), style = MaterialTheme.typography.titleMedium)
+ Text(stringResource(R.string.profile_intolerances_help), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ item { AllergenGrid(selected = moderate, onToggle = onToggleModerate) }
+
+ item {
+ Text(stringResource(R.string.profile_restrictions), style = MaterialTheme.typography.titleMedium)
+ FlowRow {
+ DietaryRestriction.entries.forEach { r ->
+ FilterChip(
+ selected = r in restrictions,
+ onClick = { onToggleRestriction(r) },
+ label = { Text(r.displayFr) },
+ modifier = Modifier.padding(4.dp)
+ )
+ }
+ }
+ }
+
+ item {
+ Text(stringResource(R.string.profile_custom_items), style = MaterialTheme.typography.titleMedium)
+ Text(stringResource(R.string.profile_custom_items_help), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ item {
+ CustomItemAdder(onAdd = onAddCustomItem)
+ }
+ item {
+ CustomItemsList(items = customItems, onRemove = onRemoveCustomItem)
+ }
+
+ item {
+ PrimaryButton(
+ text = stringResource(R.string.action_continue),
+ onClick = onNext,
+ enabled = name.isNotBlank(),
+ large = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+}
+
+@Composable
+private fun PermissionStep(granted: Boolean, rationale: Boolean, onRequest: () -> Unit, onNext: () -> Unit) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ stringResource(R.string.onboarding_permission_title),
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ Text(
+ stringResource(R.string.onboarding_permission_body),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(Modifier.weight(1f))
+ if (granted) {
+ PrimaryButton(
+ text = stringResource(R.string.action_continue),
+ onClick = onNext,
+ large = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ } else {
+ PrimaryButton(
+ text = stringResource(R.string.onboarding_permission_grant),
+ onClick = onRequest,
+ large = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ TertiaryButton(
+ text = stringResource(R.string.action_continue),
+ onClick = onNext,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ReadyStep(onFinish: () -> Unit) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text("🎉", style = MaterialTheme.typography.displayLarge)
+ Spacer(Modifier.height(16.dp))
+ Text(
+ stringResource(R.string.onboarding_ready_title),
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ stringResource(R.string.onboarding_ready_body),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(Modifier.height(32.dp))
+ PrimaryButton(
+ text = stringResource(R.string.onboarding_start),
+ onClick = onFinish,
+ large = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingViewModel.kt
new file mode 100644
index 0000000..4e1437f
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingViewModel.kt
@@ -0,0 +1,43 @@
+package com.safebite.app.presentation.screen.onboarding
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.safebite.app.domain.model.AllergenType
+import com.safebite.app.domain.model.CustomDietItem
+import com.safebite.app.domain.model.DietaryRestriction
+import com.safebite.app.domain.model.UserProfile
+import com.safebite.app.domain.repository.SettingsRepository
+import com.safebite.app.domain.usecase.ManageProfileUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class OnboardingViewModel @Inject constructor(
+ private val manageProfile: ManageProfileUseCase,
+ private val settings: SettingsRepository
+) : ViewModel() {
+ fun createProfile(
+ name: String,
+ avatar: String,
+ severe: Set,
+ moderate: Set,
+ restrictions: Set = emptySet(),
+ customItems: List = emptyList()
+ ) = viewModelScope.launch {
+ val id = manageProfile.save(
+ UserProfile(
+ name = name.ifBlank { "Moi" },
+ avatar = avatar,
+ severeAllergens = severe,
+ moderateIntolerances = moderate,
+ dietaryRestrictions = restrictions,
+ customItems = customItems,
+ isDefault = true
+ )
+ )
+ manageProfile.setActive(setOf(id))
+ }
+
+ fun complete() = viewModelScope.launch { settings.setOnboardingCompleted(true) }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileComponents.kt b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileComponents.kt
index 5258165..70b0e42 100644
--- a/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileComponents.kt
+++ b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileComponents.kt
@@ -42,7 +42,7 @@ import com.safebite.app.domain.model.CustomItemTag
@Composable
fun AllergenGrid(selected: Set, onToggle: (AllergenType) -> Unit) {
FlowRow {
- AllergenType.values().forEach { a ->
+ AllergenType.entries.forEach { a ->
FilterChip(
selected = a in selected,
onClick = { onToggle(a) },
@@ -70,7 +70,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
)
Text(stringResource(R.string.profile_custom_tag), style = MaterialTheme.typography.labelLarge)
FlowRow {
- CustomItemTag.values().forEach { t ->
+ CustomItemTag.entries.forEach { t ->
FilterChip(
selected = tag == t,
onClick = { tag = t },
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileEditScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileEditScreen.kt
new file mode 100644
index 0000000..94aa505
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileEditScreen.kt
@@ -0,0 +1,161 @@
+package com.safebite.app.presentation.screen.profile
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.safebite.app.R
+import com.safebite.app.presentation.common.components.PrimaryButton
+import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
+import com.safebite.app.presentation.common.components.StandardTextField
+import com.safebite.app.presentation.theme.LocalDimens
+import com.safebite.app.domain.model.AllergenType
+import com.safebite.app.domain.model.CustomDietItem
+import com.safebite.app.domain.model.CustomItemTag
+import com.safebite.app.domain.model.DietaryRestriction
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun ProfileEditScreen(
+ id: Long,
+ onBack: () -> Unit,
+ onSaved: () -> Unit,
+ viewModel: ProfileViewModel = hiltViewModel()
+) {
+ LaunchedEffect(id) { viewModel.load(id) }
+ val ui by viewModel.edit.collectAsStateWithLifecycle()
+ val avatars = listOf("🙂", "😀", "👧", "👦", "👨", "👩", "👵", "👴", "🧑", "👶", "🧒", "🧓", "🍽️", "🛒", "🥗", "🍎")
+
+ val dimens = LocalDimens.current
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.background,
+ topBar = {
+ SafeBiteTopAppBar(
+ title = stringResource(R.string.profile_edit_title),
+ onBack = onBack,
+ backContentDescription = stringResource(R.string.action_back),
+ )
+ }
+ ) { padding ->
+ if (!ui.loaded) return@Scaffold
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
+ verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
+ ) {
+ item {
+ StandardTextField(
+ value = ui.name,
+ onValueChange = viewModel::setName,
+ label = stringResource(R.string.profile_name),
+ )
+ }
+ item {
+ Text(stringResource(R.string.profile_avatar), style = MaterialTheme.typography.titleMedium)
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ items(avatars) { a ->
+ val selected = a == ui.avatar
+ val bg = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
+ androidx.compose.material3.Surface(
+ onClick = { viewModel.setAvatar(a) },
+ shape = CircleShape,
+ color = bg,
+ border = if (selected) androidx.compose.foundation.BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
+ modifier = Modifier.size(72.dp)
+ ) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(a, fontSize = MaterialTheme.typography.displaySmall.fontSize)
+ }
+ }
+ }
+ }
+ }
+ item {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Switch(checked = ui.isDefault, onCheckedChange = viewModel::setDefault)
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.profile_set_default))
+ }
+ }
+
+ item {
+ Text(stringResource(R.string.profile_allergies), style = MaterialTheme.typography.titleMedium)
+ Text(stringResource(R.string.profile_allergies_help), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ item { AllergenGrid(selected = ui.severe, onToggle = viewModel::toggleSevere) }
+
+ item {
+ Text(stringResource(R.string.profile_intolerances), style = MaterialTheme.typography.titleMedium)
+ Text(stringResource(R.string.profile_intolerances_help), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ item { AllergenGrid(selected = ui.moderate, onToggle = viewModel::toggleModerate) }
+
+ item {
+ Text(stringResource(R.string.profile_restrictions), style = MaterialTheme.typography.titleMedium)
+ FlowRow {
+ DietaryRestriction.entries.forEach { r ->
+ FilterChip(
+ selected = r in ui.restrictions,
+ onClick = { viewModel.toggleRestriction(r) },
+ label = { Text(r.displayFr) },
+ modifier = Modifier.padding(4.dp)
+ )
+ }
+ }
+ }
+
+ item {
+ Text(stringResource(R.string.profile_custom_items), style = MaterialTheme.typography.titleMedium)
+ Text(stringResource(R.string.profile_custom_items_help), color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ item {
+ CustomItemAdder(onAdd = viewModel::addCustomItem)
+ }
+ item {
+ CustomItemsList(items = ui.customItems, onRemove = viewModel::removeCustomItem)
+ }
+
+ item {
+ PrimaryButton(
+ text = stringResource(R.string.action_save),
+ onClick = { viewModel.save(onSaved) },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+ }
+}
+
+
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileListScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileListScreen.kt
new file mode 100644
index 0000000..0f0ef90
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileListScreen.kt
@@ -0,0 +1,125 @@
+package com.safebite.app.presentation.screen.profile
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.safebite.app.R
+import com.safebite.app.presentation.common.components.AvatarBubble
+import com.safebite.app.presentation.common.components.CardVariant
+import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
+import com.safebite.app.presentation.common.components.StandardCard
+import com.safebite.app.presentation.theme.LocalDimens
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ProfileListScreen(
+ onBack: () -> Unit,
+ onNew: () -> Unit,
+ onEdit: (Long) -> Unit,
+ viewModel: ProfileViewModel = hiltViewModel()
+) {
+ val profiles by viewModel.profiles.collectAsStateWithLifecycle()
+ val dimens = LocalDimens.current
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.background,
+ topBar = {
+ SafeBiteTopAppBar(
+ title = stringResource(R.string.profile_list_title),
+ onBack = onBack,
+ backContentDescription = stringResource(R.string.action_back),
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(
+ onClick = onNew,
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ ) { Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.action_save)) }
+ }
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
+ verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
+ ) {
+ items(profiles, key = { it.id }) { profile ->
+ StandardCard(
+ modifier = Modifier.fillMaxWidth(),
+ variant = CardVariant.Elevated,
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(dimens.spacingMd),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ AvatarBubble(avatar = profile.avatar)
+ Spacer(Modifier.size(dimens.spacingMd))
+ Column(Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ profile.name,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ if (profile.isDefault) {
+ Spacer(Modifier.size(dimens.spacingXs + 2.dp))
+ androidx.compose.material3.AssistChip(
+ onClick = {},
+ label = { Text(stringResource(R.string.profile_default_badge)) }
+ )
+ }
+ }
+ Text(
+ "${profile.severeAllergens.size + profile.moderateIntolerances.size} allergènes",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ IconButton(onClick = { onEdit(profile.id) }) {
+ Icon(
+ Icons.Filled.Edit,
+ contentDescription = stringResource(R.string.action_save),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ IconButton(onClick = { viewModel.delete(profile) }) {
+ Icon(
+ Icons.Filled.Delete,
+ contentDescription = stringResource(R.string.action_delete),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileViewModel.kt
new file mode 100644
index 0000000..d12fd69
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileViewModel.kt
@@ -0,0 +1,110 @@
+package com.safebite.app.presentation.screen.profile
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.safebite.app.domain.model.AllergenType
+import com.safebite.app.domain.model.CustomDietItem
+import com.safebite.app.domain.model.CustomItemTag
+import com.safebite.app.domain.model.DietaryRestriction
+import com.safebite.app.domain.model.UserProfile
+import com.safebite.app.domain.usecase.ManageProfileUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class ProfileEditUi(
+ val id: Long = 0L,
+ val name: String = "",
+ val avatar: String = "🙂",
+ val severe: Set = emptySet(),
+ val moderate: Set = emptySet(),
+ val restrictions: Set = emptySet(),
+ val customItems: List = emptyList(),
+ val isDefault: Boolean = false,
+ val loaded: Boolean = false
+)
+
+@HiltViewModel
+class ProfileViewModel @Inject constructor(
+ private val manage: ManageProfileUseCase
+) : ViewModel() {
+
+ val profiles: StateFlow> = manage.observe()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+ private val _edit = MutableStateFlow(ProfileEditUi())
+ val edit: StateFlow = _edit.asStateFlow()
+
+ fun load(id: Long) = viewModelScope.launch {
+ if (id == 0L) {
+ _edit.value = ProfileEditUi(loaded = true)
+ } else {
+ val p = manage.get(id)
+ if (p != null) {
+ _edit.value = ProfileEditUi(
+ id = p.id,
+ name = p.name,
+ avatar = p.avatar,
+ severe = p.severeAllergens,
+ moderate = p.moderateIntolerances,
+ restrictions = p.dietaryRestrictions,
+ customItems = p.customItems,
+ isDefault = p.isDefault,
+ loaded = true
+ )
+ }
+ }
+ }
+
+ fun setName(v: String) = _edit.update { it.copy(name = v) }
+ fun setAvatar(v: String) = _edit.update { it.copy(avatar = v) }
+ fun toggleSevere(a: AllergenType) = _edit.update { s ->
+ s.copy(severe = if (a in s.severe) s.severe - a else s.severe + a, moderate = s.moderate - a)
+ }
+ fun toggleModerate(a: AllergenType) = _edit.update { s ->
+ s.copy(moderate = if (a in s.moderate) s.moderate - a else s.moderate + a, severe = s.severe - a)
+ }
+ fun toggleRestriction(r: DietaryRestriction) = _edit.update { s ->
+ s.copy(restrictions = if (r in s.restrictions) s.restrictions - r else s.restrictions + r)
+ }
+ fun setDefault(v: Boolean) = _edit.update { it.copy(isDefault = v) }
+
+ fun addCustomItem(name: String, tag: CustomItemTag) {
+ val trimmed = name.trim()
+ if (trimmed.isBlank()) return
+ _edit.update { s ->
+ if (s.customItems.any { it.name.equals(trimmed, ignoreCase = true) && it.tag == tag }) s
+ else s.copy(customItems = s.customItems + CustomDietItem(trimmed, tag))
+ }
+ }
+
+ fun removeCustomItem(item: CustomDietItem) = _edit.update { s ->
+ s.copy(customItems = s.customItems.filterNot { it.name == item.name && it.tag == item.tag })
+ }
+
+ fun save(onDone: () -> Unit) = viewModelScope.launch {
+ val ui = _edit.value
+ val id = manage.save(
+ UserProfile(
+ id = ui.id,
+ name = ui.name.ifBlank { "Profil" },
+ avatar = ui.avatar,
+ severeAllergens = ui.severe,
+ moderateIntolerances = ui.moderate,
+ dietaryRestrictions = ui.restrictions,
+ customItems = ui.customItems,
+ isDefault = ui.isDefault
+ )
+ )
+ if (ui.isDefault) manage.setDefault(id)
+ onDone()
+ }
+
+ fun delete(profile: UserProfile) = viewModelScope.launch { manage.delete(profile) }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/result/ResultScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/result/ResultScreen.kt
new file mode 100644
index 0000000..fcfc58b
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/result/ResultScreen.kt
@@ -0,0 +1,577 @@
+package com.safebite.app.presentation.screen.result
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.OpenInNew
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.safebite.app.R
+import com.safebite.app.domain.model.AnalysisConfidence
+import com.safebite.app.domain.model.CustomItemTag
+import com.safebite.app.domain.model.DataSource
+import com.safebite.app.domain.model.DetectedAllergen
+import com.safebite.app.domain.model.DetectedCustomItem
+import com.safebite.app.domain.model.DetectionLevel
+import com.safebite.app.domain.model.HealthAssessment
+import com.safebite.app.domain.model.HealthRating
+import com.safebite.app.domain.model.Nutriments
+import com.safebite.app.domain.model.ScanResult
+import com.safebite.app.presentation.common.components.ErrorView
+import com.safebite.app.presentation.common.components.LoadingIndicator
+import com.safebite.app.presentation.common.components.OutlinedActionButton
+import com.safebite.app.presentation.common.components.PrimaryButton
+import com.safebite.app.presentation.common.components.ProductCard
+import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
+import com.safebite.app.presentation.common.components.SafetyStatusBanner
+import com.safebite.app.presentation.common.util.UiState
+import com.safebite.app.presentation.theme.LocalDimens
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ResultScreen(
+ barcode: String?,
+ fromOcr: Boolean,
+ ocrText: String?,
+ onBack: () -> Unit,
+ onScanAgain: () -> Unit,
+ onOcr: () -> Unit,
+ viewModel: ResultViewModel = hiltViewModel()
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ LaunchedEffect(barcode, fromOcr, ocrText) {
+ if (fromOcr && !ocrText.isNullOrBlank()) {
+ viewModel.analyzeOcrText(ocrText)
+ } else if (!barcode.isNullOrBlank()) {
+ viewModel.analyzeBarcode(barcode)
+ }
+ }
+
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.background,
+ topBar = {
+ SafeBiteTopAppBar(
+ title = stringResource(R.string.app_name),
+ onBack = onBack,
+ backContentDescription = stringResource(R.string.action_back),
+ )
+ }
+ ) { padding ->
+ Box(Modifier.fillMaxSize().padding(padding)) {
+ when (val s = state) {
+ UiState.Idle, UiState.Loading -> LoadingIndicator()
+ is UiState.Error -> {
+ val msg = when {
+ s.offline -> stringResource(R.string.error_no_connection)
+ s.message == "not_found" -> stringResource(R.string.result_product_not_found)
+ else -> stringResource(R.string.error_product_unavailable)
+ }
+ Column(
+ modifier = Modifier.fillMaxSize().padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ ErrorView(message = msg, modifier = Modifier.weight(1f))
+ OutlinedActionButton(
+ text = stringResource(R.string.action_read_ingredients),
+ onClick = onOcr,
+ modifier = Modifier.fillMaxWidth()
+ )
+ PrimaryButton(
+ text = stringResource(R.string.action_scan_again),
+ onClick = onScanAgain,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+ is UiState.Success -> ResultContent(
+ result = s.data,
+ onScanAgain = onScanAgain,
+ onOcr = onOcr
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
+@Composable
+private fun ResultContent(
+ result: ScanResult,
+ onScanAgain: () -> Unit,
+ onOcr: () -> Unit
+) {
+ var ingredientsExpanded by remember { mutableStateOf(false) }
+ val context = LocalContext.current
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ SafetyStatusBanner(status = result.safetyStatus)
+
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ ProductCard(
+ title = result.product.name ?: result.product.barcode,
+ subtitle = result.product.brand,
+ imageUrl = result.product.imageUrl
+ )
+
+ // Open on OFF (only when we have a real barcode, not an OCR synthetic one).
+ if (result.source != DataSource.OCR) {
+ OutlinedActionButton(
+ text = stringResource(R.string.result_open_in_off),
+ onClick = {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(result.product.openFoodFactsUrl()))
+ ContextCompat.startActivity(context, intent, null)
+ },
+ icon = Icons.AutoMirrored.Filled.OpenInNew,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ ConfidenceRow(result.confidence, result.source)
+
+ if (result.analyzedProfiles.isNotEmpty()) {
+ Text(
+ stringResource(R.string.result_profiles_checked) + ": " +
+ result.analyzedProfiles.joinToString { it.name },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Text(stringResource(R.string.result_detected_allergens), style = MaterialTheme.typography.titleMedium)
+ if (result.detectedAllergens.isEmpty()) {
+ Text(
+ stringResource(R.string.result_no_allergen_detected),
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ } else {
+ result.detectedAllergens.forEach { AllergenRow(it) }
+ }
+
+ if (result.detectedCustomItems.isNotEmpty()) {
+ Text(stringResource(R.string.result_custom_matches), style = MaterialTheme.typography.titleMedium)
+ result.detectedCustomItems.forEach { CustomItemRow(it) }
+ }
+
+ HealthSection(result.health)
+
+ NutritionSection(result.product.nutriments, result.product.servingSize)
+
+ ScoresSection(result.health)
+
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(12.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ stringResource(R.string.result_ingredients),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(onClick = { ingredientsExpanded = !ingredientsExpanded }) {
+ Icon(
+ if (ingredientsExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
+ null
+ )
+ }
+ }
+ AnimatedVisibility(visible = ingredientsExpanded) {
+ Text(
+ result.product.ingredientsText
+ ?: stringResource(R.string.result_ingredients_unavailable),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.height(4.dp))
+ Text(
+ stringResource(R.string.result_disclaimer),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedActionButton(
+ text = stringResource(R.string.action_read_ingredients),
+ onClick = onOcr,
+ modifier = Modifier.weight(1f)
+ )
+ PrimaryButton(
+ text = stringResource(R.string.action_scan_again),
+ onClick = onScanAgain,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConfidenceRow(confidence: AnalysisConfidence, source: DataSource) {
+ val label = when (confidence) {
+ AnalysisConfidence.HIGH -> R.string.result_confidence_high
+ AnalysisConfidence.MEDIUM -> R.string.result_confidence_medium
+ AnalysisConfidence.LOW -> R.string.result_confidence_low
+ }
+ val src = when (source) {
+ DataSource.API -> R.string.result_source_api
+ DataSource.CACHE -> R.string.result_source_cache
+ DataSource.OCR -> R.string.result_source_ocr
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ AssistChip(onClick = {}, label = {
+ Text(stringResource(R.string.result_confidence) + ": " + stringResource(label))
+ })
+ AssistChip(onClick = {}, label = { Text(stringResource(src)) })
+ }
+}
+
+@Composable
+private fun AllergenRow(d: DetectedAllergen) {
+ val levelText = when (d.detectionLevel) {
+ DetectionLevel.CONFIRMED -> stringResource(R.string.result_level_confirmed)
+ DetectionLevel.TRACE -> stringResource(R.string.result_level_trace)
+ DetectionLevel.SUSPECTED -> stringResource(R.string.result_level_suspected)
+ }
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = if (d.severe && d.detectionLevel == DetectionLevel.CONFIRMED)
+ MaterialTheme.colorScheme.errorContainer
+ else MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(d.allergenType.icon, style = MaterialTheme.typography.headlineMedium)
+ Spacer(Modifier.size(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ d.allergenType.displayNameFr,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text("$levelText · ${d.source}", style = MaterialTheme.typography.bodySmall)
+ if (d.matchedKeywords.isNotEmpty()) {
+ Text(
+ d.matchedKeywords.joinToString(),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CustomItemRow(d: DetectedCustomItem) {
+ val tagLabel = when (d.item.tag) {
+ CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy)
+ CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance)
+ CustomItemTag.DIET -> stringResource(R.string.profile_custom_tag_diet)
+ CustomItemTag.UNHEALTHY -> stringResource(R.string.profile_custom_tag_unhealthy)
+ }
+ val icon = when (d.item.tag) {
+ CustomItemTag.ALLERGY -> "⛔"
+ CustomItemTag.INTOLERANCE -> "⚠️"
+ CustomItemTag.DIET -> "🥗"
+ CustomItemTag.UNHEALTHY -> "🍩"
+ }
+ val bg = when (d.item.tag) {
+ CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.errorContainer
+ else -> MaterialTheme.colorScheme.surfaceVariant
+ }
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = bg)
+ ) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(icon, style = MaterialTheme.typography.headlineMedium)
+ Spacer(Modifier.size(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(d.item.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Text(tagLabel, style = MaterialTheme.typography.bodySmall)
+ if (d.matchedKeywords.isNotEmpty()) {
+ Text(
+ d.matchedKeywords.joinToString(),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun HealthSection(health: HealthAssessment) {
+ val (ratingText, ratingColor, emoji) = when (health.rating) {
+ HealthRating.HEALTHY -> Triple(stringResource(R.string.result_health_healthy), Color(0xFF2E7D32), "💪")
+ HealthRating.MODERATE -> Triple(stringResource(R.string.result_health_moderate), Color(0xFFF57C00), "🙂")
+ HealthRating.UNHEALTHY -> Triple(stringResource(R.string.result_health_unhealthy), Color(0xFFC62828), "🚫")
+ HealthRating.UNKNOWN -> Triple(stringResource(R.string.result_health_unknown), Color(0xFF757575), "❔")
+ }
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = ratingColor.copy(alpha = 0.12f))
+ ) {
+ Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(emoji, style = MaterialTheme.typography.displaySmall)
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ stringResource(R.string.result_health_verdict),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(ratingText, style = MaterialTheme.typography.titleLarge, color = ratingColor, fontWeight = FontWeight.Bold)
+ if (health.reasons.isNotEmpty()) {
+ Text(
+ health.reasons.joinToString(" · "),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun NutritionSection(n: Nutriments, servingSize: String?) {
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(12.dp)) {
+ Text(stringResource(R.string.result_nutrition), style = MaterialTheme.typography.titleMedium)
+ if (n.isEmpty()) {
+ Text(
+ stringResource(R.string.result_nutrition_unavailable),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ return@Card
+ }
+ if (!servingSize.isNullOrBlank()) {
+ Text(
+ stringResource(R.string.result_nutrition_serving_size, servingSize),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(Modifier.height(8.dp))
+ Row {
+ Text("", modifier = Modifier.weight(1f))
+ Text(stringResource(R.string.result_nutrition_per_100g), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(80.dp), textAlign = TextAlign.End)
+ if (n.energyKcalServing != null) {
+ Text(stringResource(R.string.result_nutrition_per_serving), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(80.dp), textAlign = TextAlign.End)
+ }
+ }
+ HorizontalDivider(
+ modifier = Modifier.padding(vertical = 4.dp),
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ NutritionRow(stringResource(R.string.result_nutrition_energy), n.energyKcal100g, n.energyKcalServing, unit = "kcal", emphasize = true)
+ NutritionRow(stringResource(R.string.result_nutrition_fat), n.fat100g, null, unit = "g")
+ NutritionRow(" ${stringResource(R.string.result_nutrition_saturated_fat)}", n.saturatedFat100g, null, unit = "g")
+ NutritionRow(stringResource(R.string.result_nutrition_carbs), n.carbohydrates100g, null, unit = "g")
+ NutritionRow(" ${stringResource(R.string.result_nutrition_sugars)}", n.sugars100g, null, unit = "g")
+ NutritionRow(stringResource(R.string.result_nutrition_fiber), n.fiber100g, null, unit = "g")
+ NutritionRow(stringResource(R.string.result_nutrition_proteins), n.proteins100g, null, unit = "g")
+ NutritionRow(stringResource(R.string.result_nutrition_salt), n.salt100g, null, unit = "g")
+ }
+ }
+}
+
+@Composable
+private fun NutritionRow(label: String, per100: Double?, perServing: Double?, unit: String, emphasize: Boolean = false) {
+ if (per100 == null && perServing == null) return
+ val style = if (emphasize) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium
+ Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(label, modifier = Modifier.weight(1f), style = style, fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal)
+ Text(
+ per100?.let { "${formatNumber(it)} $unit" } ?: "—",
+ modifier = Modifier.width(80.dp),
+ textAlign = TextAlign.End,
+ style = style,
+ fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal
+ )
+ if (perServing != null) {
+ Text(
+ "${formatNumber(perServing)} $unit",
+ modifier = Modifier.width(80.dp),
+ textAlign = TextAlign.End,
+ style = style,
+ fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal
+ )
+ }
+ }
+}
+
+private fun formatNumber(d: Double): String {
+ return if (d >= 100) d.toInt().toString()
+ else if (d >= 10) "%.1f".format(d)
+ else "%.2f".format(d).trimEnd('0').trimEnd('.', ',')
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun ScoresSection(health: HealthAssessment) {
+ if (health.nutriScore == null && health.novaGroup == null && health.ecoScore == null) return
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(stringResource(R.string.result_scores_section), style = MaterialTheme.typography.titleMedium)
+ if (health.nutriScore != null) {
+ ScoreRow(
+ title = stringResource(R.string.result_nutriscore),
+ details = stringResource(R.string.result_nutriscore_details),
+ badge = { NutriScoreBadge(health.nutriScore) }
+ )
+ }
+ if (health.novaGroup != null) {
+ val desc = when (health.novaGroup) {
+ 1 -> stringResource(R.string.result_nova_1)
+ 2 -> stringResource(R.string.result_nova_2)
+ 3 -> stringResource(R.string.result_nova_3)
+ 4 -> stringResource(R.string.result_nova_4)
+ else -> stringResource(R.string.result_nova_details)
+ }
+ ScoreRow(
+ title = stringResource(R.string.result_nova),
+ details = desc,
+ badge = { NovaBadge(health.novaGroup) }
+ )
+ }
+ if (health.ecoScore != null) {
+ ScoreRow(
+ title = stringResource(R.string.result_ecoscore),
+ details = stringResource(R.string.result_ecoscore_details),
+ badge = { EcoScoreBadge(health.ecoScore) }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ScoreRow(title: String, details: String, badge: @Composable () -> Unit) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ badge()
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
+ Text(details, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+}
+
+@Composable
+private fun NutriScoreBadge(grade: String) {
+ val upper = grade.uppercase()
+ val color = when (upper) {
+ "A" -> Color(0xFF1E8E3E)
+ "B" -> Color(0xFF7CB342)
+ "C" -> Color(0xFFFBC02D)
+ "D" -> Color(0xFFEF6C00)
+ "E" -> Color(0xFFC62828)
+ else -> Color(0xFF757575)
+ }
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .background(color, RoundedCornerShape(12.dp))
+ .border(2.dp, color.copy(alpha = 0.8f), RoundedCornerShape(12.dp)),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(upper, color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineMedium)
+ }
+}
+
+@Composable
+private fun NovaBadge(group: Int) {
+ val color = when (group) {
+ 1 -> Color(0xFF1E8E3E)
+ 2 -> Color(0xFF7CB342)
+ 3 -> Color(0xFFEF6C00)
+ 4 -> Color(0xFFC62828)
+ else -> Color(0xFF757575)
+ }
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .background(color, CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(group.toString(), color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineMedium)
+ }
+}
+
+@Composable
+private fun EcoScoreBadge(grade: String) {
+ val upper = grade.uppercase()
+ val color = when (upper) {
+ "A" -> Color(0xFF2E7D32)
+ "B" -> Color(0xFF558B2F)
+ "C" -> Color(0xFFFBC02D)
+ "D" -> Color(0xFFEF6C00)
+ "E" -> Color(0xFFC62828)
+ else -> Color(0xFF757575)
+ }
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .background(color, RoundedCornerShape(28.dp)),
+ contentAlignment = Alignment.Center
+ ) {
+ Text("🌿$upper", color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/result/ResultViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/result/ResultViewModel.kt
new file mode 100644
index 0000000..b7f54a2
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/result/ResultViewModel.kt
@@ -0,0 +1,74 @@
+package com.safebite.app.presentation.screen.result
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.safebite.app.domain.model.DataSource
+import com.safebite.app.domain.model.ScanResult
+import com.safebite.app.domain.model.UserProfile
+import com.safebite.app.domain.repository.ProductFetchResult
+import com.safebite.app.domain.usecase.AnalyzeIngredientsTextUseCase
+import com.safebite.app.domain.usecase.AnalyzeProductUseCase
+import com.safebite.app.domain.usecase.FetchProductUseCase
+import com.safebite.app.domain.usecase.ManageProfileUseCase
+import com.safebite.app.domain.usecase.SaveScanUseCase
+import com.safebite.app.presentation.common.util.UiState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ResultViewModel @Inject constructor(
+ private val fetchProduct: FetchProductUseCase,
+ private val analyzeProduct: AnalyzeProductUseCase,
+ private val analyzeText: AnalyzeIngredientsTextUseCase,
+ private val manageProfile: ManageProfileUseCase,
+ private val saveScan: SaveScanUseCase
+) : ViewModel() {
+
+ private val _state = MutableStateFlow>(UiState.Idle)
+ val state: StateFlow> = _state.asStateFlow()
+
+ fun analyzeBarcode(barcode: String) = viewModelScope.launch {
+ _state.value = UiState.Loading
+ val profiles = resolveProfiles()
+ if (profiles.isEmpty()) {
+ _state.value = UiState.Error("No profile configured")
+ return@launch
+ }
+ when (val fetched = fetchProduct(barcode)) {
+ is ProductFetchResult.Found -> {
+ val source = if (fetched.fromCache) DataSource.CACHE else DataSource.API
+ val result = analyzeProduct(fetched.product, profiles, source)
+ _state.value = UiState.Success(result)
+ saveScan(result)
+ }
+ ProductFetchResult.NotFound -> _state.value = UiState.Error("not_found")
+ is ProductFetchResult.Error -> _state.value = UiState.Error(fetched.message, offline = fetched.offline)
+ }
+ }
+
+ fun analyzeOcrText(text: String) = viewModelScope.launch {
+ _state.value = UiState.Loading
+ val profiles = resolveProfiles()
+ if (profiles.isEmpty()) {
+ _state.value = UiState.Error("No profile configured")
+ return@launch
+ }
+ val result = analyzeText(text, profiles)
+ _state.value = UiState.Success(result)
+ saveScan(result)
+ }
+
+ private suspend fun resolveProfiles(): List {
+ val all = manageProfile.observe().first()
+ val activeIds = manageProfile.observeActiveIds().first()
+ return when {
+ activeIds.isNotEmpty() -> all.filter { it.id in activeIds }
+ else -> all.filter { it.isDefault }.ifEmpty { all.take(1) }
+ }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/scanner/BarcodeAnalyzer.kt b/app/src/main/java/com/safebite/app/presentation/screen/scanner/BarcodeAnalyzer.kt
new file mode 100644
index 0000000..2fbba54
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/scanner/BarcodeAnalyzer.kt
@@ -0,0 +1,49 @@
+package com.safebite.app.presentation.screen.scanner
+
+import androidx.annotation.OptIn
+import androidx.camera.core.ExperimentalGetImage
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageProxy
+import com.google.mlkit.vision.barcode.BarcodeScanner
+import com.google.mlkit.vision.barcode.BarcodeScannerOptions
+import com.google.mlkit.vision.barcode.BarcodeScanning
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.google.mlkit.vision.common.InputImage
+import timber.log.Timber
+import java.util.concurrent.atomic.AtomicBoolean
+
+class BarcodeAnalyzer(
+ private val onBarcode: (String) -> Unit
+) : ImageAnalysis.Analyzer {
+
+ private val scanner: BarcodeScanner = BarcodeScanning.getClient(
+ BarcodeScannerOptions.Builder()
+ .setBarcodeFormats(
+ Barcode.FORMAT_EAN_13,
+ Barcode.FORMAT_EAN_8,
+ Barcode.FORMAT_UPC_A,
+ Barcode.FORMAT_UPC_E,
+ Barcode.FORMAT_QR_CODE
+ ).build()
+ )
+
+ private val consumed = AtomicBoolean(false)
+
+ @OptIn(ExperimentalGetImage::class)
+ override fun analyze(image: ImageProxy) {
+ if (consumed.get()) { image.close(); return }
+ val mediaImage = image.image
+ if (mediaImage == null) { image.close(); return }
+ val input = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees)
+ scanner.process(input)
+ .addOnSuccessListener { barcodes ->
+ val first = barcodes.firstOrNull { !it.rawValue.isNullOrBlank() }
+ val value = first?.rawValue
+ if (!value.isNullOrBlank() && consumed.compareAndSet(false, true)) {
+ onBarcode(value)
+ }
+ }
+ .addOnFailureListener { Timber.w(it, "Barcode scan failed") }
+ .addOnCompleteListener { image.close() }
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/scanner/ScannerScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/scanner/ScannerScreen.kt
new file mode 100644
index 0000000..606e21c
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/scanner/ScannerScreen.kt
@@ -0,0 +1,239 @@
+package com.safebite.app.presentation.screen.scanner
+
+import android.os.Build
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.FlashOff
+import androidx.compose.material.icons.filled.FlashOn
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.safebite.app.R
+import com.safebite.app.presentation.common.components.ErrorView
+import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
+import java.util.concurrent.Executors
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
+@Composable
+fun ScannerScreen(
+ onBack: () -> Unit,
+ onBarcode: (String) -> Unit
+) {
+ val permission = rememberPermissionState(android.Manifest.permission.CAMERA)
+ LaunchedEffect(Unit) { if (!permission.status.isGranted) permission.launchPermissionRequest() }
+
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.background,
+ topBar = {
+ SafeBiteTopAppBar(
+ title = stringResource(R.string.scanner_title),
+ onBack = onBack,
+ backContentDescription = stringResource(R.string.action_back),
+ )
+ }
+ ) { padding ->
+ Box(Modifier.fillMaxSize().padding(padding)) {
+ if (!permission.status.isGranted) {
+ ErrorView(
+ message = stringResource(R.string.scanner_camera_denied),
+ onRetry = { permission.launchPermissionRequest() }
+ )
+ } else {
+ CameraView(onBarcode = onBarcode)
+ }
+ }
+ }
+}
+
+@Composable
+private fun CameraView(onBarcode: (String) -> Unit) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ var torch by remember { mutableStateOf(false) }
+ var cameraControl by remember { mutableStateOf(null) }
+ var detected by remember { mutableStateOf(false) }
+
+ val executor = remember { Executors.newSingleThreadExecutor() }
+ DisposableEffect(Unit) { onDispose { executor.shutdown() } }
+
+ Box(Modifier.fillMaxSize()) {
+ androidx.compose.ui.viewinterop.AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { ctx ->
+ val previewView = PreviewView(ctx).apply {
+ scaleType = PreviewView.ScaleType.FILL_CENTER
+ }
+ val providerFuture = ProcessCameraProvider.getInstance(ctx)
+ providerFuture.addListener({
+ val provider = providerFuture.get()
+ val preview = Preview.Builder().build().also {
+ it.setSurfaceProvider(previewView.surfaceProvider)
+ }
+ val analysis = ImageAnalysis.Builder()
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .build()
+ .also { it.setAnalyzer(executor, BarcodeAnalyzer { code ->
+ if (!detected) {
+ detected = true
+ triggerHaptic(ctx)
+ onBarcode(code)
+ }
+ }) }
+ try {
+ provider.unbindAll()
+ val camera = provider.bindToLifecycle(
+ lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
+ )
+ cameraControl = camera.cameraControl
+ } catch (t: Throwable) { /* ignore */ }
+ }, ContextCompat.getMainExecutor(ctx))
+ previewView
+ }
+ )
+
+ ScanOverlay(modifier = Modifier.fillMaxSize())
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.BottomCenter)
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = stringResource(R.string.scanner_hint),
+ color = Color.White,
+ modifier = Modifier
+ .background(Color(0x99000000), RoundedCornerShape(12.dp))
+ .padding(horizontal = 12.dp, vertical = 6.dp)
+ )
+ Spacer(Modifier.size(12.dp))
+ IconButton(
+ onClick = {
+ torch = !torch
+ cameraControl?.enableTorch(torch)
+ }
+ ) {
+ Icon(
+ if (torch) Icons.Filled.FlashOn else Icons.Filled.FlashOff,
+ contentDescription = stringResource(R.string.scanner_torch),
+ tint = Color.White
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ScanOverlay(modifier: Modifier = Modifier) {
+ val transition = rememberInfiniteTransition(label = "scan")
+ val y by transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(1800, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "scanY"
+ )
+ Canvas(modifier = modifier) {
+ val w = size.width
+ val h = size.height
+ val boxSize = Size(w * 0.8f, h * 0.3f)
+ val topLeft = Offset((w - boxSize.width) / 2f, (h - boxSize.height) / 2f)
+ drawRect(
+ color = Color(0xB3000000),
+ size = Size(w, topLeft.y)
+ )
+ drawRect(
+ color = Color(0xB3000000),
+ topLeft = Offset(0f, topLeft.y + boxSize.height),
+ size = Size(w, h - topLeft.y - boxSize.height)
+ )
+ drawRect(
+ color = Color(0xB3000000),
+ topLeft = Offset(0f, topLeft.y),
+ size = Size(topLeft.x, boxSize.height)
+ )
+ drawRect(
+ color = Color(0xB3000000),
+ topLeft = Offset(topLeft.x + boxSize.width, topLeft.y),
+ size = Size(w - topLeft.x - boxSize.width, boxSize.height)
+ )
+ drawRect(
+ color = Color.White,
+ topLeft = topLeft,
+ size = boxSize,
+ style = Stroke(width = 4f)
+ )
+ val lineY = topLeft.y + boxSize.height * y
+ drawLine(
+ color = Color(0xFF00E676),
+ start = Offset(topLeft.x, lineY),
+ end = Offset(topLeft.x + boxSize.width, lineY),
+ strokeWidth = 4f
+ )
+ }
+}
+
+private fun triggerHaptic(context: android.content.Context) {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val vm = context.getSystemService() ?: return
+ vm.defaultVibrator.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE))
+ } else {
+ @Suppress("DEPRECATION")
+ val v = context.getSystemService(android.content.Context.VIBRATOR_SERVICE) as? Vibrator
+ v?.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE))
+ }
+ } catch (_: Throwable) { /* ignore */ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/settings/SettingsScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/settings/SettingsScreen.kt
new file mode 100644
index 0000000..01d0ebb
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/settings/SettingsScreen.kt
@@ -0,0 +1,157 @@
+package com.safebite.app.presentation.screen.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.safebite.app.BuildConfig
+import com.safebite.app.R
+import com.safebite.app.domain.model.AppLanguage
+import com.safebite.app.domain.model.DetectionLanguage
+import com.safebite.app.domain.model.HealthStrictness
+import com.safebite.app.domain.model.ThemePref
+import com.safebite.app.presentation.common.components.CardVariant
+import com.safebite.app.presentation.common.components.DestructiveButton
+import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
+import com.safebite.app.presentation.common.components.StandardCard
+import com.safebite.app.presentation.theme.LocalDimens
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(
+ onBack: () -> Unit,
+ viewModel: SettingsViewModel = hiltViewModel()
+) {
+ val ui by viewModel.state.collectAsStateWithLifecycle()
+
+ val dimens = LocalDimens.current
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.background,
+ topBar = {
+ SafeBiteTopAppBar(
+ title = stringResource(R.string.settings_title),
+ onBack = onBack,
+ backContentDescription = stringResource(R.string.action_back),
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
+ verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
+ ) {
+ Section(stringResource(R.string.settings_language)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
+ FilterChip(selected = ui.appLanguage == AppLanguage.FR, onClick = { viewModel.setAppLanguage(AppLanguage.FR) }, label = { Text("FR") })
+ FilterChip(selected = ui.appLanguage == AppLanguage.EN, onClick = { viewModel.setAppLanguage(AppLanguage.EN) }, label = { Text("EN") })
+ }
+ }
+ Section(stringResource(R.string.settings_detection_language)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
+ FilterChip(selected = ui.detectionLanguage == DetectionLanguage.FR, onClick = { viewModel.setDetectionLanguage(DetectionLanguage.FR) }, label = { Text(stringResource(R.string.settings_detection_fr)) })
+ FilterChip(selected = ui.detectionLanguage == DetectionLanguage.EN, onClick = { viewModel.setDetectionLanguage(DetectionLanguage.EN) }, label = { Text(stringResource(R.string.settings_detection_en)) })
+ FilterChip(selected = ui.detectionLanguage == DetectionLanguage.BOTH, onClick = { viewModel.setDetectionLanguage(DetectionLanguage.BOTH) }, label = { Text(stringResource(R.string.settings_detection_both)) })
+ }
+ }
+ StandardCard(variant = CardVariant.Filled) {
+ Column(verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
+ ToggleRow(stringResource(R.string.settings_haptics), ui.haptics, viewModel::setHaptics)
+ ToggleRow(stringResource(R.string.settings_sound), ui.sound, viewModel::setSound)
+ }
+ }
+
+ Section(stringResource(R.string.settings_health_strictness)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
+ FilterChip(selected = ui.healthStrictness == HealthStrictness.LENIENT, onClick = { viewModel.setHealthStrictness(HealthStrictness.LENIENT) }, label = { Text(stringResource(R.string.settings_health_lenient)) })
+ FilterChip(selected = ui.healthStrictness == HealthStrictness.NORMAL, onClick = { viewModel.setHealthStrictness(HealthStrictness.NORMAL) }, label = { Text(stringResource(R.string.settings_health_normal)) })
+ FilterChip(selected = ui.healthStrictness == HealthStrictness.STRICT, onClick = { viewModel.setHealthStrictness(HealthStrictness.STRICT) }, label = { Text(stringResource(R.string.settings_health_strict)) })
+ }
+ }
+
+ Section(stringResource(R.string.settings_theme)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
+ FilterChip(selected = ui.theme == ThemePref.LIGHT, onClick = { viewModel.setTheme(ThemePref.LIGHT) }, label = { Text(stringResource(R.string.settings_theme_light)) })
+ FilterChip(selected = ui.theme == ThemePref.DARK, onClick = { viewModel.setTheme(ThemePref.DARK) }, label = { Text(stringResource(R.string.settings_theme_dark)) })
+ FilterChip(selected = ui.theme == ThemePref.SYSTEM, onClick = { viewModel.setTheme(ThemePref.SYSTEM) }, label = { Text(stringResource(R.string.settings_theme_system)) })
+ }
+ }
+
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
+ DestructiveButton(
+ text = stringResource(R.string.settings_clear_cache),
+ onClick = viewModel::clearCache,
+ modifier = Modifier.fillMaxWidth()
+ )
+ DestructiveButton(
+ text = stringResource(R.string.settings_clear_history),
+ onClick = viewModel::clearHistory,
+ modifier = Modifier.fillMaxWidth()
+ )
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
+
+ Text(stringResource(R.string.settings_about), style = MaterialTheme.typography.titleMedium)
+ Text(
+ stringResource(R.string.settings_version, BuildConfig.VERSION_NAME),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ stringResource(R.string.settings_off_attribution),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ "https://world.openfoodfacts.org",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+}
+
+@Composable
+private fun Section(title: String, content: @Composable () -> Unit) {
+ val dimens = LocalDimens.current
+ Column(verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
+ Text(
+ title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ content()
+ }
+}
+
+@Composable
+private fun ToggleRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ label,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f)
+ )
+ Switch(checked = checked, onCheckedChange = onChange)
+ }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/screen/settings/SettingsViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..0ae6083
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/screen/settings/SettingsViewModel.kt
@@ -0,0 +1,58 @@
+package com.safebite.app.presentation.screen.settings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.safebite.app.domain.model.AppLanguage
+import com.safebite.app.domain.model.DetectionLanguage
+import com.safebite.app.domain.model.HealthStrictness
+import com.safebite.app.domain.model.ThemePref
+import com.safebite.app.domain.repository.ProductRepository
+import com.safebite.app.domain.repository.ScanHistoryRepository
+import com.safebite.app.domain.repository.SettingsRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class SettingsUi(
+ val appLanguage: AppLanguage = AppLanguage.FR,
+ val detectionLanguage: DetectionLanguage = DetectionLanguage.BOTH,
+ val haptics: Boolean = true,
+ val sound: Boolean = true,
+ val theme: ThemePref = ThemePref.SYSTEM,
+ val healthStrictness: HealthStrictness = HealthStrictness.NORMAL
+)
+
+@HiltViewModel
+class SettingsViewModel @Inject constructor(
+ private val settings: SettingsRepository,
+ private val productRepo: ProductRepository,
+ private val historyRepo: ScanHistoryRepository
+) : ViewModel() {
+
+ private val coreFlow = combine(
+ settings.appLanguage,
+ settings.detectionLanguage,
+ settings.hapticsEnabled,
+ settings.soundEnabled,
+ settings.theme
+ ) { lang, detection, haptics, sound, theme ->
+ SettingsUi(lang, detection, haptics, sound, theme)
+ }
+
+ val state: StateFlow = combine(coreFlow, settings.healthStrictness) { core, strict ->
+ core.copy(healthStrictness = strict)
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUi())
+
+ fun setAppLanguage(v: AppLanguage) = viewModelScope.launch { settings.setAppLanguage(v) }
+ fun setDetectionLanguage(v: DetectionLanguage) = viewModelScope.launch { settings.setDetectionLanguage(v) }
+ fun setHaptics(v: Boolean) = viewModelScope.launch { settings.setHaptics(v) }
+ fun setSound(v: Boolean) = viewModelScope.launch { settings.setSound(v) }
+ fun setTheme(v: ThemePref) = viewModelScope.launch { settings.setTheme(v) }
+ fun setHealthStrictness(v: HealthStrictness) = viewModelScope.launch { settings.setHealthStrictness(v) }
+ fun clearCache() = viewModelScope.launch { productRepo.clearCache() }
+ fun clearHistory() = viewModelScope.launch { historyRepo.clear() }
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/theme/Color.kt b/app/src/main/java/com/safebite/app/presentation/theme/Color.kt
new file mode 100644
index 0000000..b573529
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/theme/Color.kt
@@ -0,0 +1,111 @@
+package com.safebite.app.presentation.theme
+
+import androidx.compose.ui.graphics.Color
+
+// =============================================================================
+// SafeBite palette — Material 3 design tokens (Light + Dark)
+// Les couleurs 'status' (Safe / Warning / Danger) restent hors M3 et sont
+// utilisées par les badges et bannières de sécurité.
+// =============================================================================
+
+// ---- Brand anchors --------------------------------------------------------
+val BrandIndigo = Color(0xFF1A237E)
+val BrandIndigoLight = Color(0xFFBAC3FF)
+val BrandTeal = Color(0xFF00897B)
+val BrandTealLight = Color(0xFF4DB6AC)
+
+// ---- Light scheme ---------------------------------------------------------
+val LightPrimary = Color(0xFF1A237E)
+val LightOnPrimary = Color(0xFFFFFFFF)
+val LightPrimaryContainer = Color(0xFFDDE1FF)
+val LightOnPrimaryContainer = Color(0xFF001159)
+
+val LightSecondary = Color(0xFF00897B)
+val LightOnSecondary = Color(0xFFFFFFFF)
+val LightSecondaryContainer = Color(0xFFB2DFDB)
+val LightOnSecondaryContainer = Color(0xFF00251F)
+
+val LightTertiary = Color(0xFF7B4E9E)
+val LightOnTertiary = Color(0xFFFFFFFF)
+val LightTertiaryContainer = Color(0xFFF0DBFF)
+val LightOnTertiaryContainer = Color(0xFF2B0A45)
+
+val LightError = Color(0xFFBA1A1A)
+val LightOnError = Color(0xFFFFFFFF)
+val LightErrorContainer = Color(0xFFFFDAD6)
+val LightOnErrorContainer = Color(0xFF410002)
+
+val LightBackground = Color(0xFFFAFAFA)
+val LightOnBackground = Color(0xFF1A1A1A)
+val LightSurface = Color(0xFFFFFFFF)
+val LightOnSurface = Color(0xFF1A1A1A)
+val LightSurfaceVariant = Color(0xFFE3E2EC)
+val LightOnSurfaceVariant = Color(0xFF46464F)
+val LightSurfaceTint = LightPrimary
+
+val LightOutline = Color(0xFF767680)
+val LightOutlineVariant = Color(0xFFC7C6D0)
+
+val LightInverseSurface = Color(0xFF2F3033)
+val LightInverseOnSurface = Color(0xFFF1F0F4)
+val LightInversePrimary = Color(0xFFBAC3FF)
+
+val LightScrim = Color(0xFF000000)
+
+// ---- Dark scheme (surfaces élevées M3) ------------------------------------
+val DarkPrimary = Color(0xFFBAC3FF)
+val DarkOnPrimary = Color(0xFF0A1A6A)
+val DarkPrimaryContainer = Color(0xFF3241A0)
+val DarkOnPrimaryContainer = Color(0xFFDDE1FF)
+
+val DarkSecondary = Color(0xFF4DB6AC)
+val DarkOnSecondary = Color(0xFF00332C)
+val DarkSecondaryContainer = Color(0xFF00695C)
+val DarkOnSecondaryContainer = Color(0xFFB2DFDB)
+
+val DarkTertiary = Color(0xFFE0B6FF)
+val DarkOnTertiary = Color(0xFF451F6D)
+val DarkTertiaryContainer = Color(0xFF5D3785)
+val DarkOnTertiaryContainer = Color(0xFFF0DBFF)
+
+val DarkError = Color(0xFFFFB4AB)
+val DarkOnError = Color(0xFF690005)
+val DarkErrorContainer = Color(0xFF93000A)
+val DarkOnErrorContainer = Color(0xFFFFDAD6)
+
+val DarkBackground = Color(0xFF121212)
+val DarkOnBackground = Color(0xFFE6E1E5)
+val DarkSurface = Color(0xFF121212)
+val DarkOnSurface = Color(0xFFE6E1E5)
+val DarkSurfaceVariant = Color(0xFF46464F)
+val DarkOnSurfaceVariant = Color(0xFFC7C6D0)
+val DarkSurfaceTint = DarkPrimary
+
+val DarkOutline = Color(0xFF90909A)
+val DarkOutlineVariant = Color(0xFF46464F)
+
+val DarkInverseSurface = Color(0xFFE6E1E5)
+val DarkInverseOnSurface = Color(0xFF2F3033)
+val DarkInversePrimary = Color(0xFF1A237E)
+
+val DarkScrim = Color(0xFF000000)
+
+// ---- Status / safety (food domain, hors M3) -------------------------------
+val StatusSafe = Color(0xFF2E7D32)
+val StatusSafeContainer = Color(0xFFC8E6C9)
+val OnStatusSafe = Color(0xFFFFFFFF)
+
+val StatusWarning = Color(0xFFF57C00)
+val StatusWarningContainer = Color(0xFFFFE0B2)
+val OnStatusWarning = Color(0xFFFFFFFF)
+
+val StatusDanger = Color(0xFFC62828)
+val StatusDangerContainer = Color(0xFFFFCDD2)
+val OnStatusDanger = Color(0xFFFFFFFF)
+
+val StatusSafeDark = Color(0xFF81C784)
+val StatusSafeContainerDark = Color(0xFF1B5E20)
+val StatusWarningDark = Color(0xFFFFB74D)
+val StatusWarningContainerDark = Color(0xFF8A4B00)
+val StatusDangerDark = Color(0xFFEF9A9A)
+val StatusDangerContainerDark = Color(0xFF7F1D1D)
diff --git a/app/src/main/java/com/safebite/app/presentation/theme/Dimens.kt b/app/src/main/java/com/safebite/app/presentation/theme/Dimens.kt
new file mode 100644
index 0000000..f0abe6b
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/theme/Dimens.kt
@@ -0,0 +1,59 @@
+package com.safebite.app.presentation.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * SafeBite Design System — espacements, rayons, élévations et hauteurs standards.
+ *
+ * Exposé via [LocalDimens] (ou directement en objet [SafeBiteDimens]).
+ * Toutes les valeurs hardcodées des écrans doivent référencer ces tokens.
+ */
+@Immutable
+data class Dimens(
+ // Spacing scale
+ val spacingXxs: Dp = 2.dp,
+ val spacingXs: Dp = 4.dp,
+ val spacingSm: Dp = 8.dp,
+ val spacingMd: Dp = 12.dp,
+ val spacingLg: Dp = 16.dp,
+ val spacingXl: Dp = 24.dp,
+ val spacingXxl: Dp = 32.dp,
+ val spacingXxxl: Dp = 48.dp,
+
+ // Corner radius
+ val radiusSm: Dp = 4.dp,
+ val radiusMd: Dp = 8.dp,
+ val radiusLg: Dp = 12.dp,
+ val radiusXl: Dp = 16.dp,
+ val radiusXxl: Dp = 24.dp,
+ val radiusPill: Dp = 999.dp,
+
+ // Elevations
+ val elevationNone: Dp = 0.dp,
+ val elevationSm: Dp = 1.dp,
+ val elevationMd: Dp = 3.dp,
+ val elevationLg: Dp = 6.dp,
+ val elevationXl: Dp = 8.dp,
+
+ // Component heights
+ val buttonHeightSm: Dp = 40.dp,
+ val buttonHeight: Dp = 48.dp,
+ val buttonHeightLg: Dp = 56.dp,
+ val buttonHeightHero: Dp = 72.dp,
+ val touchTarget: Dp = 48.dp,
+ val appBarHeight: Dp = 64.dp,
+ val listItemMinHeight: Dp = 56.dp,
+ val iconSizeSm: Dp = 16.dp,
+ val iconSizeMd: Dp = 24.dp,
+ val iconSizeLg: Dp = 32.dp,
+ val avatarSm: Dp = 32.dp,
+ val avatarMd: Dp = 40.dp,
+ val avatarLg: Dp = 56.dp,
+)
+
+val SafeBiteDimens = Dimens()
+
+val LocalDimens = staticCompositionLocalOf { SafeBiteDimens }
diff --git a/app/src/main/java/com/safebite/app/presentation/theme/Shape.kt b/app/src/main/java/com/safebite/app/presentation/theme/Shape.kt
new file mode 100644
index 0000000..31d3a86
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/theme/Shape.kt
@@ -0,0 +1,13 @@
+package com.safebite.app.presentation.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Shapes
+import androidx.compose.ui.unit.dp
+
+val SafeBiteShapes = Shapes(
+ extraSmall = RoundedCornerShape(4.dp),
+ small = RoundedCornerShape(8.dp),
+ medium = RoundedCornerShape(12.dp),
+ large = RoundedCornerShape(16.dp),
+ extraLarge = RoundedCornerShape(24.dp)
+)
diff --git a/app/src/main/java/com/safebite/app/presentation/theme/StatusColors.kt b/app/src/main/java/com/safebite/app/presentation/theme/StatusColors.kt
new file mode 100644
index 0000000..af92703
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/theme/StatusColors.kt
@@ -0,0 +1,59 @@
+package com.safebite.app.presentation.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Palette de statuts de sécurité alimentaire (safe / warning / danger), adaptée
+ * au thème courant. Injectée dans la hiérarchie via [LocalStatusColors].
+ */
+@Immutable
+data class StatusColors(
+ val safe: Color,
+ val onSafe: Color,
+ val safeContainer: Color,
+ val onSafeContainer: Color,
+
+ val warning: Color,
+ val onWarning: Color,
+ val warningContainer: Color,
+ val onWarningContainer: Color,
+
+ val danger: Color,
+ val onDanger: Color,
+ val dangerContainer: Color,
+ val onDangerContainer: Color,
+)
+
+val LightStatusColors = StatusColors(
+ safe = StatusSafe,
+ onSafe = OnStatusSafe,
+ safeContainer = StatusSafeContainer,
+ onSafeContainer = Color(0xFF0F3A13),
+ warning = StatusWarning,
+ onWarning = OnStatusWarning,
+ warningContainer = StatusWarningContainer,
+ onWarningContainer = Color(0xFF4A2800),
+ danger = StatusDanger,
+ onDanger = OnStatusDanger,
+ dangerContainer = StatusDangerContainer,
+ onDangerContainer = Color(0xFF5C0B0B),
+)
+
+val DarkStatusColors = StatusColors(
+ safe = StatusSafeDark,
+ onSafe = Color(0xFF0F3A13),
+ safeContainer = StatusSafeContainerDark,
+ onSafeContainer = Color(0xFFC8E6C9),
+ warning = StatusWarningDark,
+ onWarning = Color(0xFF4A2800),
+ warningContainer = StatusWarningContainerDark,
+ onWarningContainer = Color(0xFFFFE0B2),
+ danger = StatusDangerDark,
+ onDanger = Color(0xFF5C0B0B),
+ dangerContainer = StatusDangerContainerDark,
+ onDangerContainer = Color(0xFFFFCDD2),
+)
+
+val LocalStatusColors = staticCompositionLocalOf { LightStatusColors }
diff --git a/app/src/main/java/com/safebite/app/presentation/theme/Theme.kt b/app/src/main/java/com/safebite/app/presentation/theme/Theme.kt
new file mode 100644
index 0000000..204ff38
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/theme/Theme.kt
@@ -0,0 +1,165 @@
+package com.safebite.app.presentation.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val LightColors = lightColorScheme(
+ primary = LightPrimary,
+ onPrimary = LightOnPrimary,
+ primaryContainer = LightPrimaryContainer,
+ onPrimaryContainer = LightOnPrimaryContainer,
+ secondary = LightSecondary,
+ onSecondary = LightOnSecondary,
+ secondaryContainer = LightSecondaryContainer,
+ onSecondaryContainer = LightOnSecondaryContainer,
+ tertiary = LightTertiary,
+ onTertiary = LightOnTertiary,
+ tertiaryContainer = LightTertiaryContainer,
+ onTertiaryContainer = LightOnTertiaryContainer,
+ error = LightError,
+ onError = LightOnError,
+ errorContainer = LightErrorContainer,
+ onErrorContainer = LightOnErrorContainer,
+ background = LightBackground,
+ onBackground = LightOnBackground,
+ surface = LightSurface,
+ onSurface = LightOnSurface,
+ surfaceVariant = LightSurfaceVariant,
+ onSurfaceVariant = LightOnSurfaceVariant,
+ surfaceTint = LightSurfaceTint,
+ outline = LightOutline,
+ outlineVariant = LightOutlineVariant,
+ inverseSurface = LightInverseSurface,
+ inverseOnSurface = LightInverseOnSurface,
+ inversePrimary = LightInversePrimary,
+ scrim = LightScrim,
+)
+
+private val DarkColors = darkColorScheme(
+ primary = DarkPrimary,
+ onPrimary = DarkOnPrimary,
+ primaryContainer = DarkPrimaryContainer,
+ onPrimaryContainer = DarkOnPrimaryContainer,
+ secondary = DarkSecondary,
+ onSecondary = DarkOnSecondary,
+ secondaryContainer = DarkSecondaryContainer,
+ onSecondaryContainer = DarkOnSecondaryContainer,
+ tertiary = DarkTertiary,
+ onTertiary = DarkOnTertiary,
+ tertiaryContainer = DarkTertiaryContainer,
+ onTertiaryContainer = DarkOnTertiaryContainer,
+ error = DarkError,
+ onError = DarkOnError,
+ errorContainer = DarkErrorContainer,
+ onErrorContainer = DarkOnErrorContainer,
+ background = DarkBackground,
+ onBackground = DarkOnBackground,
+ surface = DarkSurface,
+ onSurface = DarkOnSurface,
+ surfaceVariant = DarkSurfaceVariant,
+ onSurfaceVariant = DarkOnSurfaceVariant,
+ surfaceTint = DarkSurfaceTint,
+ outline = DarkOutline,
+ outlineVariant = DarkOutlineVariant,
+ inverseSurface = DarkInverseSurface,
+ inverseOnSurface = DarkInverseOnSurface,
+ inversePrimary = DarkInversePrimary,
+ scrim = DarkScrim,
+)
+
+/**
+ * Applique le thème SafeBite. La StatusBar et la NavigationBar adaptent leurs
+ * icônes automatiquement (claires en dark, sombres en light) et leur fond est
+ * transparent pour laisser voir le contenu edge-to-edge.
+ *
+ * La transition entre light et dark est animée (cross-fade 300 ms) pour éviter
+ * tout flash lors du basculement manuel dans Settings.
+ */
+@Composable
+fun SafeBiteTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ dynamicColor: Boolean = false,
+ content: @Composable () -> Unit
+) {
+ val targetScheme: ColorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+ darkTheme -> DarkColors
+ else -> LightColors
+ }
+
+ val colorScheme = targetScheme.animated()
+ val statusColors = if (darkTheme) DarkStatusColors else LightStatusColors
+
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = android.graphics.Color.TRANSPARENT
+ @Suppress("DEPRECATION")
+ window.navigationBarColor = android.graphics.Color.TRANSPARENT
+ val controller = WindowCompat.getInsetsController(window, view)
+ controller.isAppearanceLightStatusBars = !darkTheme
+ controller.isAppearanceLightNavigationBars = !darkTheme
+ }
+ }
+
+ CompositionLocalProvider(
+ LocalDimens provides SafeBiteDimens,
+ LocalStatusColors provides statusColors,
+ ) {
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = SafeBiteTypography,
+ shapes = SafeBiteShapes,
+ content = content
+ )
+ }
+}
+
+/** Anime chaque rôle de couleur du scheme pour éviter les flashes au toggle. */
+@Composable
+private fun ColorScheme.animated(): ColorScheme {
+ val spec = tween(durationMillis = 300)
+ return copy(
+ primary = animateColorAsState(primary, spec, label = "primary").value,
+ onPrimary = animateColorAsState(onPrimary, spec, label = "onPrimary").value,
+ primaryContainer = animateColorAsState(primaryContainer, spec, label = "primaryContainer").value,
+ onPrimaryContainer = animateColorAsState(onPrimaryContainer, spec, label = "onPrimaryContainer").value,
+ secondary = animateColorAsState(secondary, spec, label = "secondary").value,
+ onSecondary = animateColorAsState(onSecondary, spec, label = "onSecondary").value,
+ secondaryContainer = animateColorAsState(secondaryContainer, spec, label = "secondaryContainer").value,
+ onSecondaryContainer = animateColorAsState(onSecondaryContainer, spec, label = "onSecondaryContainer").value,
+ tertiary = animateColorAsState(tertiary, spec, label = "tertiary").value,
+ onTertiary = animateColorAsState(onTertiary, spec, label = "onTertiary").value,
+ tertiaryContainer = animateColorAsState(tertiaryContainer, spec, label = "tertiaryContainer").value,
+ onTertiaryContainer = animateColorAsState(onTertiaryContainer, spec, label = "onTertiaryContainer").value,
+ background = animateColorAsState(background, spec, label = "background").value,
+ onBackground = animateColorAsState(onBackground, spec, label = "onBackground").value,
+ surface = animateColorAsState(surface, spec, label = "surface").value,
+ onSurface = animateColorAsState(onSurface, spec, label = "onSurface").value,
+ surfaceVariant = animateColorAsState(surfaceVariant, spec, label = "surfaceVariant").value,
+ onSurfaceVariant = animateColorAsState(onSurfaceVariant, spec, label = "onSurfaceVariant").value,
+ outline = animateColorAsState(outline, spec, label = "outline").value,
+ outlineVariant = animateColorAsState(outlineVariant, spec, label = "outlineVariant").value,
+ errorContainer = animateColorAsState(errorContainer, spec, label = "errorContainer").value,
+ onErrorContainer = animateColorAsState(onErrorContainer, spec, label = "onErrorContainer").value,
+ )
+}
diff --git a/app/src/main/java/com/safebite/app/presentation/theme/Type.kt b/app/src/main/java/com/safebite/app/presentation/theme/Type.kt
new file mode 100644
index 0000000..32a0b91
--- /dev/null
+++ b/app/src/main/java/com/safebite/app/presentation/theme/Type.kt
@@ -0,0 +1,135 @@
+package com.safebite.app.presentation.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+/**
+ * Typographie Material 3 complète — 15 styles avec lineHeight et letterSpacing
+ * standardisés. Utilise la sans-serif système (Roboto sur Android) pour éviter
+ * toute dépendance réseau / asset.
+ */
+private val DisplayFamily = FontFamily.SansSerif
+private val HeadlineFamily = FontFamily.SansSerif
+private val TitleFamily = FontFamily.SansSerif
+private val BodyFamily = FontFamily.SansSerif
+private val LabelFamily = FontFamily.SansSerif
+
+val SafeBiteTypography = Typography(
+ // Display — pour les titres héros (onboarding, banner)
+ displayLarge = TextStyle(
+ fontFamily = DisplayFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = 57.sp,
+ lineHeight = 64.sp,
+ letterSpacing = (-0.25).sp
+ ),
+ displayMedium = TextStyle(
+ fontFamily = DisplayFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = 45.sp,
+ lineHeight = 52.sp,
+ letterSpacing = 0.sp
+ ),
+ displaySmall = TextStyle(
+ fontFamily = DisplayFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = 36.sp,
+ lineHeight = 44.sp,
+ letterSpacing = 0.sp
+ ),
+
+ // Headline — sections majeures
+ headlineLarge = TextStyle(
+ fontFamily = HeadlineFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = 32.sp,
+ lineHeight = 40.sp,
+ letterSpacing = 0.sp
+ ),
+ headlineMedium = TextStyle(
+ fontFamily = HeadlineFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = 28.sp,
+ lineHeight = 36.sp,
+ letterSpacing = 0.sp
+ ),
+ headlineSmall = TextStyle(
+ fontFamily = HeadlineFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 24.sp,
+ lineHeight = 32.sp,
+ letterSpacing = 0.sp
+ ),
+
+ // Title — titres d'écran, de cartes
+ titleLarge = TextStyle(
+ fontFamily = TitleFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ titleMedium = TextStyle(
+ fontFamily = TitleFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.15.sp
+ ),
+ titleSmall = TextStyle(
+ fontFamily = TitleFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp
+ ),
+
+ // Body — textes courants
+ bodyLarge = TextStyle(
+ fontFamily = BodyFamily,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ ),
+ bodyMedium = TextStyle(
+ fontFamily = BodyFamily,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.25.sp
+ ),
+ bodySmall = TextStyle(
+ fontFamily = BodyFamily,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.4.sp
+ ),
+
+ // Label — boutons, chips, tags
+ labelLarge = TextStyle(
+ fontFamily = LabelFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp
+ ),
+ labelMedium = TextStyle(
+ fontFamily = LabelFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = LabelFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+)
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..aa48669
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..3361676
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..3361676
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
new file mode 100644
index 0000000..a26de29
--- /dev/null
+++ b/app/src/main/res/values-en/strings.xml
@@ -0,0 +1,196 @@
+
+
+ SafeBite
+
+ Continue
+ Next
+ Back
+ Save
+ Cancel
+ Delete
+ Edit
+ Retry
+ Close
+ Done
+ Analyze
+ Scan another product
+ Read ingredients (OCR)
+
+ Welcome to SafeBite
+ Scan, verify, eat safely.
+ How it works
+ 1. Create an allergy profile
+ 2. Scan the product barcode
+ 3. Get an instant verdict
+ Create your first profile
+ Camera permission
+ SafeBite needs the camera to scan barcodes and read labels. No image ever leaves your device.
+ Grant camera access
+ You\'re ready!
+ Scan your first product now.
+ Start
+
+ Scan a product
+ Read ingredients (OCR)
+ Active profile
+ Change
+ Recent scans
+ No recent scans
+ No profile set up
+ Create an allergy profile to get started.
+ Create profile
+ History
+ Profiles
+ Settings
+
+ Scan a barcode
+ Place the barcode inside the frame
+ Torch
+ Camera access is required to scan.
+ Open settings
+
+ NO ALLERGEN DETECTED FOR YOUR PROFILE
+ THIS PRODUCT MAY CONTAIN TRACES OF ALLERGENS
+ THIS PRODUCT CONTAINS ALLERGENS FROM YOUR PROFILE
+ Detected allergens
+ No allergen detected
+ Ingredients
+ Ingredients unavailable
+ Analysis confidence
+ High
+ Medium
+ Low
+ Open Food Facts
+ Local cache
+ OCR (photo)
+ Profiles checked
+ Confirmed
+ Traces
+ Suspected
+ ⚠️ This app is an assistive tool. It does not replace careful reading of the label. Data may be incomplete or inaccurate. When in doubt, do not consume the product. In case of an allergic reaction, call 911.
+ Product not found in Open Food Facts
+ Would you like to take a photo of the ingredients?
+
+ Photograph the ingredients
+ Frame the ingredient list
+ Capture
+ Review text
+ Fix the text if needed before analysis.
+ No text detected. Try again.
+
+ Profiles
+ New profile
+ Edit profile
+ Profile name
+ Avatar
+ Allergies (severe)
+ Trigger a DANGER verdict
+ Intolerances (moderate)
+ Trigger a WARNING verdict
+ Dietary restrictions
+ Vegan
+ Vegetarian
+ Halal
+ Kosher
+ No pork
+ Set as default
+ Default
+ Delete this profile?
+ Use for scanning
+
+ History
+ No products scanned yet
+ All
+ Danger
+ Warning
+ Safe
+ Search
+ Clear all
+
+ Settings
+ App language
+ Ingredient detection language
+ French
+ English
+ Both
+ Vibration on scan
+ Sound on scan
+ Theme
+ Light
+ Dark
+ System
+ Clear product cache
+ Clear history
+ About
+ Version %1$s
+ Data provided by Open Food Facts
+
+ No Internet connection
+ An error occurred
+ Product unavailable. Try OCR.
+ Offline
+
+ Custom items
+ Add your own ingredients to watch for and assign them a tag.
+ Add item
+ Name (e.g. palm oil)
+ Tag
+ Allergy
+ Intolerance
+ Diet
+ Unhealthy
+ No custom items.
+
+ View on Open Food Facts
+ Nutrition facts
+ per 100 g
+ per serving
+ Serving size: %1$s
+ Energy
+ Fat
+ of which saturated
+ Carbohydrates
+ of which sugars
+ Fiber
+ Proteins
+ Salt
+ Sodium
+ Nutrition information unavailable.
+ Indicators
+ Nutri-Score
+ Nutritional quality (A = best, E = avoid).
+ NOVA
+ Processing level (1 = unprocessed, 4 = ultra-processed).
+ Unprocessed or minimally processed foods
+ Processed culinary ingredients
+ Processed foods
+ Ultra-processed foods
+ Eco-Score
+ Environmental impact (A = low, E = high).
+ Health verdict
+ Healthy
+ Consume in moderation
+ Not recommended
+ Not enough data
+ Your custom matches
+
+ Health verdict strictness
+ Lenient
+ Normal
+ Strict
+
+ Gluten
+ Peanuts
+ Tree Nuts
+ Milk
+ Eggs
+ Soy
+ Fish
+ Crustaceans
+ Sesame
+ Mustard
+ Sulphites
+ Lupin
+ Molluscs
+ Celery
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..29a403a
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,6 @@
+
+
+
+ #121212
+
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..c9471c7
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..7ac043b
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,8 @@
+
+
+
+ #FAFAFA
+
+ #1A237E
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..460f276
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,209 @@
+
+
+ SafeBite
+
+
+ Continuer
+ Suivant
+ Retour
+ Enregistrer
+ Annuler
+ Supprimer
+ Modifier
+ Réessayer
+ Fermer
+ Terminé
+ Analyser
+ Scanner un autre produit
+ Lire les ingrédients (OCR)
+
+
+ Bienvenue sur SafeBite
+ Scannez, vérifiez, mangez en toute sécurité.
+ Comment ça fonctionne
+ 1. Créez un profil d\'allergies
+ 2. Scannez le code-barres du produit
+ 3. Obtenez un verdict instantané
+ Créez votre premier profil
+ Autorisation caméra
+ SafeBite a besoin de la caméra pour scanner les codes-barres et lire les étiquettes. Aucune image n\'est envoyée sur Internet.
+ Autoriser la caméra
+ Vous êtes prêt !
+ Scannez votre premier produit dès maintenant.
+ Commencer
+
+
+ Scanner un produit
+ Lire les ingrédients (OCR)
+ Profil actif
+ Changer
+ Scans récents
+ Aucun scan récent
+ Aucun profil configuré
+ Créez un profil d\'allergies pour commencer.
+ Créer un profil
+ Historique
+ Profils
+ Paramètres
+
+
+ Scanner un code-barres
+ Placez le code-barres dans le cadre
+ Lampe
+ L\'accès à la caméra est nécessaire pour scanner.
+ Ouvrir les paramètres
+
+
+ AUCUN ALLERGÈNE DÉTECTÉ POUR VOTRE PROFIL
+ CE PRODUIT PEUT CONTENIR DES TRACES D\'ALLERGÈNES
+ CE PRODUIT CONTIENT DES ALLERGÈNES DE VOTRE PROFIL
+ Allergènes détectés
+ Aucun allergène détecté
+ Ingrédients
+ Ingrédients non disponibles
+ Confiance de l\'analyse
+ Élevée
+ Moyenne
+ Faible
+ Open Food Facts
+ Cache local
+ OCR (photo)
+ Profils vérifiés
+ Confirmé
+ Traces
+ Suspecté
+ ⚠️ Cette application est un outil d\'aide. Elle ne remplace pas la lecture attentive de l\'étiquette. Les données peuvent être incomplètes ou inexactes. En cas de doute, ne consommez pas le produit. En cas de réaction allergique, appelez le 911.
+ Produit introuvable dans Open Food Facts
+ Voulez-vous prendre une photo des ingrédients ?
+
+
+ Photographier les ingrédients
+ Cadrez la liste d\'ingrédients
+ Capturer
+ Vérifier le texte
+ Corrigez le texte si nécessaire avant l\'analyse.
+ Aucun texte détecté. Réessayez.
+
+
+ Profils
+ Nouveau profil
+ Modifier le profil
+ Nom du profil
+ Avatar
+ Allergies (sévères)
+ Déclenchent un DANGER
+ Intolérances (modérées)
+ Déclenchent un AVERTISSEMENT
+ Restrictions alimentaires
+ Végane
+ Végétarien
+ Halal
+ Casher
+ Sans porc
+ Définir par défaut
+ Par défaut
+ Supprimer ce profil ?
+ Utiliser pour le scan
+
+
+ Historique
+ Aucun produit scanné pour l\'instant
+ Tous
+ Danger
+ Attention
+ Safe
+ Rechercher
+ Tout effacer
+
+
+ Paramètres
+ Langue de l\'application
+ Langue de détection des ingrédients
+ Français
+ Anglais
+ Les deux
+ Vibration au scan
+ Son au scan
+ Thème
+ Clair
+ Sombre
+ Système
+ Vider le cache des produits
+ Vider l\'historique
+ À propos
+ Version %1$s
+ Données fournies par Open Food Facts
+
+
+ Pas de connexion Internet
+ Une erreur est survenue
+ Produit indisponible. Essayez l\'OCR.
+ Hors ligne
+
+
+ Éléments personnalisés
+ Ajoutez vos propres ingrédients à surveiller et attribuez-leur un tag.
+ Ajouter un élément
+ Nom (ex. huile de palme)
+ Tag
+ Allergie
+ Intolérance
+ Diète
+ Non-santé
+ Aucun élément personnalisé.
+
+
+ Voir sur Open Food Facts
+ Valeurs nutritionnelles
+ pour 100 g
+ par portion
+ Taille de portion : %1$s
+ Énergie
+ Matières grasses
+ dont saturées
+ Glucides
+ dont sucres
+ Fibres
+ Protéines
+ Sel
+ Sodium
+ Informations nutritionnelles indisponibles.
+ Indicateurs
+ Nutri-Score
+ Qualité nutritionnelle (A = meilleure, E = à éviter).
+ NOVA
+ Degré de transformation (1 = non transformé, 4 = ultra-transformé).
+ Aliments non transformés ou peu transformés
+ Ingrédients culinaires transformés
+ Aliments transformés
+ Aliments ultra-transformés
+ Éco-Score
+ Impact environnemental (A = faible, E = élevé).
+ Verdict santé
+ Plutôt sain
+ À consommer avec modération
+ Peu recommandable
+ Données insuffisantes
+ Vos éléments détectés
+
+ Sévérité du verdict santé
+ Permissif
+ Normal
+ Strict
+
+
+ Gluten
+ Arachides
+ Noix
+ Lait
+ Œufs
+ Soja
+ Poisson
+ Crustacés
+ Sésame
+ Moutarde
+ Sulfites
+ Lupin
+ Mollusques
+ Céleri
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..84f9107
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..d8c4e5b
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..b1f3fe5
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/test/java/com/safebite/app/domain/engine/AllergenAnalysisEngineTest.kt b/app/src/test/java/com/safebite/app/domain/engine/AllergenAnalysisEngineTest.kt
new file mode 100644
index 0000000..627060d
--- /dev/null
+++ b/app/src/test/java/com/safebite/app/domain/engine/AllergenAnalysisEngineTest.kt
@@ -0,0 +1,287 @@
+package com.safebite.app.domain.engine
+
+import com.google.common.truth.Truth.assertThat
+import com.safebite.app.domain.model.AllergenType
+import com.safebite.app.domain.model.AnalysisConfidence
+import com.safebite.app.domain.model.DataSource
+import com.safebite.app.domain.model.DetectionLanguage
+import com.safebite.app.domain.model.DetectionLevel
+import com.safebite.app.domain.model.Product
+import com.safebite.app.domain.model.SafetyStatus
+import com.safebite.app.domain.model.UserProfile
+import org.junit.Test
+
+class AllergenAnalysisEngineTest {
+
+ private val profile = UserProfile(
+ id = 1L,
+ name = "Test",
+ severeAllergens = setOf(AllergenType.PEANUTS, AllergenType.MILK),
+ moderateIntolerances = setOf(AllergenType.GLUTEN)
+ )
+
+ @Test
+ fun `OFF tag severe match to DANGER confirmed`() {
+ val product = Product(
+ barcode = "123",
+ name = "Bar",
+ brand = "B",
+ imageUrl = null,
+ ingredientsText = null,
+ allergensTags = listOf("en:peanuts"),
+ tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API)
+ assertThat(result.safetyStatus).isEqualTo(SafetyStatus.DANGER)
+ assertThat(result.detectedAllergens).hasSize(1)
+ assertThat(result.detectedAllergens.first().allergenType).isEqualTo(AllergenType.PEANUTS)
+ assertThat(result.detectedAllergens.first().detectionLevel).isEqualTo(DetectionLevel.CONFIRMED)
+ }
+
+ @Test
+ fun `OFF traces tag to WARNING`() {
+ val product = Product(
+ barcode = "123",
+ name = null, brand = null, imageUrl = null, ingredientsText = null,
+ allergensTags = emptyList(),
+ tracesTags = listOf("en:peanuts")
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API)
+ assertThat(result.safetyStatus).isEqualTo(SafetyStatus.WARNING)
+ assertThat(result.detectedAllergens.first().detectionLevel).isEqualTo(DetectionLevel.TRACE)
+ }
+
+ @Test
+ fun `French keyword with accent matches peanuts`() {
+ val product = Product(
+ barcode = "1", name = null, brand = null, imageUrl = null,
+ ingredientsText = "Ingrédients: chocolat, sucre, cacahuètes, sel.",
+ allergensTags = emptyList(), tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API, DetectionLanguage.FR)
+ assertThat(result.detectedAllergens.any { it.allergenType == AllergenType.PEANUTS }).isTrue()
+ assertThat(result.safetyStatus).isEqualTo(SafetyStatus.DANGER)
+ }
+
+ @Test
+ fun `English plural keyword matches`() {
+ val product = Product(
+ barcode = "1", name = null, brand = null, imageUrl = null,
+ ingredientsText = "Ingredients: sugar, peanut oil, salt.",
+ allergensTags = emptyList(), tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API, DetectionLanguage.EN)
+ assertThat(result.detectedAllergens.any { it.allergenType == AllergenType.PEANUTS }).isTrue()
+ }
+
+ @Test
+ fun `May contain pattern extracted as TRACE`() {
+ val product = Product(
+ barcode = "1", name = null, brand = null, imageUrl = null,
+ ingredientsText = "Ingrédients: sucre, chocolat. Peut contenir des traces de arachides et lait.",
+ allergensTags = emptyList(), tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API, DetectionLanguage.FR)
+ val peanut = result.detectedAllergens.firstOrNull { it.allergenType == AllergenType.PEANUTS }
+ assertThat(peanut).isNotNull()
+ assertThat(peanut!!.detectionLevel).isEqualTo(DetectionLevel.TRACE)
+ assertThat(result.safetyStatus).isEqualTo(SafetyStatus.WARNING)
+ }
+
+ @Test
+ fun `English may contain extracted as TRACE`() {
+ val product = Product(
+ barcode = "1", name = null, brand = null, imageUrl = null,
+ ingredientsText = "Ingredients: sugar, cocoa. May contain peanuts and milk.",
+ allergensTags = emptyList(), tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API, DetectionLanguage.EN)
+ val peanut = result.detectedAllergens.firstOrNull { it.allergenType == AllergenType.PEANUTS }
+ assertThat(peanut?.detectionLevel).isEqualTo(DetectionLevel.TRACE)
+ }
+
+ @Test
+ fun `No match to SAFE`() {
+ val product = Product(
+ barcode = "1", name = null, brand = null, imageUrl = null,
+ ingredientsText = "Ingredients: water, sugar, salt, natural flavour.",
+ allergensTags = emptyList(), tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API)
+ assertThat(result.safetyStatus).isEqualTo(SafetyStatus.SAFE)
+ assertThat(result.detectedAllergens).isEmpty()
+ }
+
+ @Test
+ fun `Empty product data to LOW confidence`() {
+ val product = Product(
+ barcode = "1", name = null, brand = null, imageUrl = null,
+ ingredientsText = null,
+ allergensTags = emptyList(), tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API)
+ assertThat(result.confidence).isEqualTo(AnalysisConfidence.LOW)
+ }
+
+ @Test
+ fun `OCR source always LOW confidence`() {
+ val product = Product(
+ barcode = "1", name = null, brand = null, imageUrl = null,
+ ingredientsText = "Peanuts",
+ allergensTags = listOf("en:peanuts"), tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.OCR)
+ assertThat(result.confidence).isEqualTo(AnalysisConfidence.LOW)
+ }
+
+ @Test
+ fun `Both tags and text to HIGH confidence`() {
+ val product = Product(
+ barcode = "1", name = null, brand = null, imageUrl = null,
+ ingredientsText = "Ingredients: peanuts, sugar.",
+ allergensTags = listOf("en:peanuts"), tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API)
+ assertThat(result.confidence).isEqualTo(AnalysisConfidence.HIGH)
+ }
+
+ @Test
+ fun `Moderate intolerance match to WARNING not DANGER`() {
+ val product = Product(
+ barcode = "1", name = null, brand = null, imageUrl = null,
+ ingredientsText = null,
+ allergensTags = listOf("en:gluten"), tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API)
+ assertThat(result.safetyStatus).isEqualTo(SafetyStatus.WARNING)
+ }
+
+ @Test
+ fun `Normalize strips accents and punctuation`() {
+ val normalized = AllergenAnalysisEngine.normalize("Céleri, Œuf (entier)!")
+ assertThat(normalized).isEqualTo("celeri oeuf entier")
+ }
+
+ @Test
+ fun `Empty profile list to SAFE and LOW confidence`() {
+ val product = Product("1", null, null, null, "peanut", listOf("en:peanuts"), emptyList())
+ val result = AllergenAnalysisEngine.analyze(product, emptyList(), DataSource.API)
+ assertThat(result.safetyStatus).isEqualTo(SafetyStatus.SAFE)
+ assertThat(result.confidence).isEqualTo(AnalysisConfidence.LOW)
+ }
+
+ @Test
+ fun `May contain does not double-count as confirmed`() {
+ val product = Product(
+ barcode = "1", name = null, brand = null, imageUrl = null,
+ ingredientsText = "Ingrédients: sucre. Peut contenir des traces d'arachides.",
+ allergensTags = emptyList(), tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API, DetectionLanguage.FR)
+ val peanut = result.detectedAllergens.first { it.allergenType == AllergenType.PEANUTS }
+ assertThat(peanut.detectionLevel).isEqualTo(DetectionLevel.TRACE)
+ }
+
+ @Test
+ fun `Milk keyword detected in French`() {
+ val product = Product(
+ barcode = "1", name = null, brand = null, imageUrl = null,
+ ingredientsText = "Ingrédients: farine, beurre, sel.",
+ allergensTags = emptyList(), tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API, DetectionLanguage.FR)
+ assertThat(result.detectedAllergens.any { it.allergenType == AllergenType.MILK }).isTrue()
+ }
+
+ @Test
+ fun `Custom ALLERGY item forces DANGER`() {
+ val profileWithCustom = profile.copy(
+ customItems = listOf(com.safebite.app.domain.model.CustomDietItem("huile de palme", com.safebite.app.domain.model.CustomItemTag.ALLERGY))
+ )
+ val product = Product(
+ barcode = "1", name = null, brand = null, imageUrl = null,
+ ingredientsText = "Ingrédients: sucre, huile de palme, sel.",
+ allergensTags = emptyList(), tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(profileWithCustom), DataSource.API, DetectionLanguage.FR)
+ assertThat(result.safetyStatus).isEqualTo(SafetyStatus.DANGER)
+ assertThat(result.detectedCustomItems).hasSize(1)
+ assertThat(result.detectedCustomItems.first().item.name).isEqualTo("huile de palme")
+ }
+
+ @Test
+ fun `Custom INTOLERANCE item triggers WARNING`() {
+ val p = profile.copy(
+ customItems = listOf(com.safebite.app.domain.model.CustomDietItem("carragenane", com.safebite.app.domain.model.CustomItemTag.INTOLERANCE))
+ )
+ val product = Product(
+ barcode = "1", name = null, brand = null, imageUrl = null,
+ ingredientsText = "Eau, sucre, carragénane.",
+ allergensTags = emptyList(), tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(p), DataSource.API, DetectionLanguage.FR)
+ assertThat(result.safetyStatus).isEqualTo(SafetyStatus.WARNING)
+ }
+
+ @Test
+ fun `Custom UNHEALTHY item does not force DANGER but flags health`() {
+ val p = profile.copy(
+ customItems = listOf(com.safebite.app.domain.model.CustomDietItem("sirop de glucose", com.safebite.app.domain.model.CustomItemTag.UNHEALTHY))
+ )
+ val product = Product(
+ barcode = "1", name = null, brand = null, imageUrl = null,
+ ingredientsText = "Eau, sucre, sirop de glucose.",
+ allergensTags = emptyList(), tracesTags = emptyList()
+ )
+ val result = AllergenAnalysisEngine.analyze(product, listOf(p), DataSource.API, DetectionLanguage.FR)
+ assertThat(result.safetyStatus).isEqualTo(SafetyStatus.SAFE)
+ assertThat(result.detectedCustomItems).hasSize(1)
+ assertThat(result.health.rating).isEqualTo(com.safebite.app.domain.model.HealthRating.UNHEALTHY)
+ }
+
+ @Test
+ fun `Health classifier - Nutri A is HEALTHY on NORMAL`() {
+ val assessment = com.safebite.app.domain.engine.HealthClassifier.classify(
+ Product(barcode = "1", name = null, brand = null, imageUrl = null, ingredientsText = null, nutriScore = "a", novaGroup = 1),
+ emptyList(),
+ com.safebite.app.domain.model.HealthStrictness.NORMAL
+ )
+ assertThat(assessment.rating).isEqualTo(com.safebite.app.domain.model.HealthRating.HEALTHY)
+ }
+
+ @Test
+ fun `Health classifier - Nutri D or Nova 4 is UNHEALTHY on NORMAL`() {
+ val assessment = com.safebite.app.domain.engine.HealthClassifier.classify(
+ Product(barcode = "1", name = null, brand = null, imageUrl = null, ingredientsText = null, nutriScore = "d", novaGroup = 4),
+ emptyList(),
+ com.safebite.app.domain.model.HealthStrictness.NORMAL
+ )
+ assertThat(assessment.rating).isEqualTo(com.safebite.app.domain.model.HealthRating.UNHEALTHY)
+ }
+
+ @Test
+ fun `Health classifier - STRICT treats B as MODERATE`() {
+ val assessment = com.safebite.app.domain.engine.HealthClassifier.classify(
+ Product(barcode = "1", name = null, brand = null, imageUrl = null, ingredientsText = null, nutriScore = "b", novaGroup = 2),
+ emptyList(),
+ com.safebite.app.domain.model.HealthStrictness.STRICT
+ )
+ assertThat(assessment.rating).isEqualTo(com.safebite.app.domain.model.HealthRating.MODERATE)
+ }
+
+ @Test
+ fun `Health classifier - no scores and no custom hits is UNKNOWN`() {
+ val assessment = com.safebite.app.domain.engine.HealthClassifier.classify(
+ Product(barcode = "1", name = null, brand = null, imageUrl = null, ingredientsText = null),
+ emptyList(),
+ com.safebite.app.domain.model.HealthStrictness.NORMAL
+ )
+ assertThat(assessment.rating).isEqualTo(com.safebite.app.domain.model.HealthRating.UNKNOWN)
+ }
+
+ @Test
+ fun `Product openFoodFactsUrl returns canonical URL`() {
+ val product = Product("3017620422003", null, null, null, null)
+ assertThat(product.openFoodFactsUrl()).isEqualTo("https://world.openfoodfacts.org/product/3017620422003")
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..f8973b6
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,8 @@
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.kotlin.parcelize) apply false
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.hilt) apply false
+}
diff --git a/build_apks.ps1 b/build_apks.ps1
new file mode 100644
index 0000000..b16bc8a
--- /dev/null
+++ b/build_apks.ps1
@@ -0,0 +1,105 @@
+param(
+ [switch]$Major,
+ [switch]$Minor,
+ [switch]$Patch,
+ [switch]$Help
+)
+
+if ($Help) {
+ Write-Host "Usage: .\build_apks.ps1 [-Major | -Minor | -Patch] [-Help]" -ForegroundColor White
+ Write-Host ""
+ Write-Host "Options:" -ForegroundColor White
+ Write-Host " -Major Increment the major version (X.0.0) and rebuild."
+ Write-Host " -Minor Increment the minor version (x.Y.0) and rebuild."
+ Write-Host " -Patch Increment the patch version (x.y.Z) and rebuild."
+ Write-Host " -Help Show this help message."
+ Write-Host ""
+ Write-Host "If no version switch is provided, the current version is used."
+ exit 0
+}
+
+$versionFile = Join-Path $PSScriptRoot "version.properties"
+
+if (-not (Test-Path $versionFile)) {
+ Write-Host "version.properties not found! Creating default..." -ForegroundColor Yellow
+ "MAJOR=1`nMINOR=0`nPATCH=0`nCODE=1" | Out-File $versionFile -Encoding ascii
+}
+
+# Read properties
+$properties = @{}
+Get-Content $versionFile | ForEach-Object {
+ if ($_ -match "^(.*?)=(.*)$") {
+ $properties[$matches[1].Trim()] = $matches[2].Trim()
+ }
+}
+
+[int]$vMajor = $properties["MAJOR"]
+[int]$vMinor = $properties["MINOR"]
+[int]$vPatch = $properties["PATCH"]
+[int]$vCode = $properties["CODE"]
+
+$versionChanged = $false
+
+if ($Major) {
+ $vMajor++
+ $vMinor = 0
+ $vPatch = 0
+ $versionChanged = $true
+} elseif ($Minor) {
+ $vMinor++
+ $vPatch = 0
+ $versionChanged = $true
+} elseif ($Patch) {
+ $vPatch++
+ $versionChanged = $true
+}
+
+if ($versionChanged) {
+ $vCode++
+ $newContent = "MAJOR=$vMajor`r`nMINOR=$vMinor`r`nPATCH=$vPatch`r`nCODE=$vCode"
+ Set-Content -Path $versionFile -Value $newContent -Encoding Ascii
+ Write-Host "Version incremented to: $vMajor.$vMinor.$vPatch (Code: $vCode)" -ForegroundColor Cyan
+} else {
+ Write-Host "Current version: $vMajor.$vMinor.$vPatch (Code: $vCode)" -ForegroundColor Cyan
+}
+
+Write-Host "`n--- Cleaning and Building ---" -ForegroundColor Green
+
+# Use cmd /c to ensure gradlew runs correctly from PowerShell if it's not in the PATH
+$gradlew = ".\gradlew.bat"
+if (-not (Test-Path $gradlew)) {
+ $gradlew = ".\gradlew"
+ if (-not (Test-Path $gradlew)) {
+ Write-Host "gradlew not found!" -ForegroundColor Red
+ exit 1
+ }
+}
+
+Write-Host "Task: :app:assembleDebug" -ForegroundColor Gray
+if ($gradlew.EndsWith(".bat")) {
+ & cmd.exe /c $gradlew :app:assembleDebug
+} else {
+ & $gradlew :app:assembleDebug
+}
+
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "Debug build failed!" -ForegroundColor Red
+ exit $LASTEXITCODE
+}
+
+Write-Host "`nTask: :app:assembleRelease" -ForegroundColor Gray
+if ($gradlew.EndsWith(".bat")) {
+ & cmd.exe /c $gradlew :app:assembleRelease
+} else {
+ & $gradlew :app:assembleRelease
+}
+
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "Release build failed!" -ForegroundColor Red
+ exit $LASTEXITCODE
+}
+
+Write-Host "`n--- Build Successful ---" -ForegroundColor Green
+Write-Host "APKs available at:"
+Write-Host " Debug: app\build\outputs\apk\debug\app-debug.apk" -ForegroundColor Yellow
+Write-Host " Release: app\build\outputs\apk\release\app-release.apk" -ForegroundColor Yellow
diff --git a/docs/flux-UX.md b/docs/flux-UX.md
new file mode 100644
index 0000000..80575e9
--- /dev/null
+++ b/docs/flux-UX.md
@@ -0,0 +1,1085 @@
+# 📱 APPLICATION IOS/ANDROID – SPÉCIFICATION UX/UI DÉTAILLÉE
+
+**Document technique pour implémentation • Version 1.0 • 25 avril 2026**
+
+---
+
+## 🎯 PRINCIPES FONDATEURS (GUIDING PRINCIPLES)
+
+> **Ces règles priment sur toute décision d'implémentation.**
+
+| # | Principe | Règle stricte |
+|---|----------|---------------|
+| P1 | **2 taps max** | L'action scanner est accessible en ≤ 2 taps depuis n'importe quel écran |
+| P2 | **Verdict immédiat** | Après scan, le verdict de sécurité s'affiche en < 500ms (perçues) |
+| P3 | **Feu tricolore** | 3 couleurs sémantiques max + neutres – jamais de bleu |
+| P4 | **Icônes + couleurs** | Aucune information critique ne repose uniquement sur la couleur |
+| P5 | **Guidage positif** | Pas de messages d'erreur bruts – toujours une action de repli proposée |
+| P6 | **Mobile-first** | Conception pour une main, pouce accessible (zone de confort inférieure) |
+
+---
+
+## 🎨 DESIGN SYSTEM
+
+### 2.1 Palette de couleurs
+
+```
+COULEURS SÉMANTIQUES (Système feu tricolore)
+─────────────────────────────────────────────
+🟢 Vert sécurité #2ECC71 → Produit compatible, OK famille
+🟠 Orange attention #E67E22 → Allergène détecté, vigilance
+🔴 Rouge danger #E74C3C → Allergène critique, interdit
+
+NEUTRES
+─────────────────────────────────────────────
+Fond principal #F5F5F0 → Gris chaud (réduit la fatigue oculaire)
+Surface carte #FFFFFF → Blanc pur pour les cartes
+Texte principal #2D3436 → Noir doux (pas #000, trop agressif)
+Texte secondaire #636E72 → Gris moyen
+Séparateurs #DFE6E9 → Gris clair
+
+ÉTATS
+─────────────────────────────────────────────
+Appui (pressed) Assombrir de 15% la couleur de base
+Désactivé Opacité 40% + grisé
+Focus (accessibilité) Anneau #2ECC71 de 2px (offset 2px)
+```
+
+### 2.2 Typographie
+
+```yaml
+platform_android:
+ font_family: "Google Sans" # ou Inter en fallback
+ scale:
+ display: {size: 28sp, weight: 700, line_height: 36sp, usage: "Titre écran"}
+ headline: {size: 22sp, weight: 600, line_height: 28sp, usage: "Titre section"}
+ body: {size: 16sp, weight: 400, line_height: 24sp, usage: "Texte principal"}
+ caption: {size: 13sp, weight: 400, line_height: 18sp, usage: "Légende, méta"}
+ button: {size: 15sp, weight: 600, line_height: 20sp, usage: "CTA, boutons"}
+
+platform_ios:
+ font_family: "SF Pro"
+ # Mêmes échelles, adaptées en pt
+```
+
+### 2.3 Iconographie
+
+```yaml
+system_icons:
+ ✅ sécurité_ok: "checkmark.shield.fill" # Vert - Produit sûr
+ ⚠️ vigilance: "exclamationmark.shield.fill" # Orange - Attention
+ ❌ danger: "xmark.shield.fill" # Rouge - Interdit
+
+ 📷 scanner: "camera.viewfinder" # Action principale
+ 📋 listes: "list.clipboard" # Onglet listes
+ 📊 suivi: "chart.bar.fill" # Onglet suivi
+ 👤 profil: "person.circle" # Onglet famille/profil
+
+icon_principles:
+ - Toujours accompagner la couleur d'une forme distincte
+ - Formes : cercle (OK), triangle (⚠️), losange (❌)
+ - Taille minimale des zones tactiles : 48x48dp (WCAG)
+```
+
+### 2.4 Élévation & Ombres (Android)
+
+```css
+/* Carte standard */
+.card {
+ elevation: 2dp;
+ /* Ombre: 0 1px 3px rgba(0,0,0,0.08) */
+}
+
+/* Bouton flottant (FAB) */
+.fab {
+ elevation: 6dp;
+ /* Ombre: 0 3px 8px rgba(0,0,0,0.16) */
+}
+
+/* Bottom sheet */
+.bottom-sheet {
+ elevation: 16dp;
+ /* Ombre: 0 8px 24px rgba(0,0,0,0.20) */
+}
+```
+
+---
+
+## 🏗️ ARCHITECTURE DE NAVIGATION
+
+### 3.1 Structure globale
+
+```
+┌─────────────────────────────────────────────────────┐
+│ APPLICATION │
+├─────────────────────────────────────────────────────┤
+│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────┐ │
+│ │ Scanner │ │ Listes │ │ Suivi │ │Famille│ │
+│ │ 📷 │ │ 📋 │ │ 📊 │ │ 👤 │ │
+│ └──────────┘ └──────────┘ └──────────┘ └──────┘ │
+│ ▲ │
+│ │ │
+│ ┌────┴─────────┐ <-- Bouton flottant central │
+│ │ SCANNER │ (FAB, 56dp, toujours visible)│
+│ └──────────────┘ Sauf pendant le scan lui-même│
+└─────────────────────────────────────────────────────┘
+
+Navigation: BottomNavigationView (4 items)
+FAB: FloatingActionButton centré, légèrement au-dessus de la barre
+```
+
+### 3.2 Bottom Navigation Bar – Spécification
+
+```kotlin
+// Spécification technique BottomNavigation
+data class BottomNavItem(
+ val id: String,
+ val icon: ImageVector, // Icône remplie (selected) / outline (unselected)
+ val label: String,
+ val contentDescription: String // Obligatoire pour TalkBack
+)
+
+val bottomNavItems = listOf(
+ BottomNavItem("scanner", Icons.Filled.Camera, "Scanner", "Scanner un produit"),
+ BottomNavItem("listes", Icons.Filled.List, "Listes", "Mes listes de courses"),
+ BottomNavItem("suivi", Icons.Filled.Chart, "Suivi", "Statistiques et historique"),
+ BottomNavItem("famille", Icons.Filled.Person, "Famille", "Profils et réglages")
+)
+
+// État visuel
+// Selected : icon color = #2D3436 (noir doux), label visible, indicator top
+// Unselected : icon color = #B2BEC3, label visible
+// Badge : point rouge 8dp si notification non lue
+```
+
+### 3.3 FAB (Floating Action Button) – Spécification
+
+```yaml
+fab:
+ size: 56dp # Standard Material Design
+ icon: 📷 camera_viewfinder (24dp)
+ color: "#2D3436" # Noir doux – contraste avec le fond
+ icon_color: "#FFFFFF"
+ elevation: 6dp
+ position:
+ horizontal: center # Centré horizontalement
+ vertical_offset: -28dp # Chevauchant la bottom bar
+ behavior:
+ - Visible sur tous les onglets SAUF pendant le scan actif
+ - Disparition: scale_down + fade_out (200ms)
+ - Apparition: scale_up + fade_in (200ms)
+ - Haptique légère (15ms) au tap
+ accessibility:
+ label: "Scanner un produit"
+ hint: "Appuyez deux fois pour ouvrir le scanner"
+```
+
+---
+
+## 📱 FLUX UTILISATEUR DÉTAILLÉS
+
+### FLOW 1 : Premier lancement (Onboarding)
+
+```
+[ÉCRAN 1] —────—— "Bienvenue !" —────—— [ÉCRAN 2]
+"Qui êtes-vous ?" "Des allergies ?"
+
+┌──────────────────┐ ┌──────────────────┐
+│ Logo │ │ Grille photos │
+│ │ │ (pas de texte) │
+│ "Votre prénom" │ │ │
+│ [Input text] │ │ [🥜] [🥛] [🍞] │
+│ │ │ [🦐] [🥚] [🐟] │
+│ [Continuer →] │ │ │
+│ │ │ [Continuer →] │
+└──────────────────┘ └──────────────────┘
+ │ │
+ ▼ ▼
+[ÉCRAN 3] —────—— "Un objectif ?" —────—— [ÉCRAN 4]
+ "Scannez !"
+┌──────────────────┐ ┌──────────────────┐
+│ │ │ Animation │
+│ ○ Mieux manger │ │ scanner qui │
+│ ○ Éviter additifs│ │ pulse │
+│ ○ Sans importance│ │ │
+│ │ │ Pointez l'appareil│
+│ [Ignorer] [OK] │ │ vers un code- │
+│ │ │ barres │
+│ │ │ │
+└──────────────────┘ │ [Commencer] │
+ │ └──────────────────┘
+ ▼ │
+ └──────────┬───────────────────┘
+ ▼
+ [DASHBOARD]
+```
+
+**Règles onboarding :**
+- Maximum 4 écrans, 60 secondes cumulées
+- Progression : indicateur en haut (4 points)
+- Skip possible à chaque étape (sauf écran 4 : CTA seul)
+- Pas de permissions demandées avant le scan effectif
+- Stockage local uniquement (pas de compte obligatoire)
+
+---
+
+### FLOW 2 : Scan produit (Parcours principal – 80% des usages)
+
+```yaml
+flow_scan_complete:
+
+ # ── ÉTAPE 1 : Activation scanner ──
+ step_1_open:
+ trigger: "Tap sur FAB OU tap sur onglet Scanner"
+ animation: "Expansion depuis le centre (Material ContainerTransform)"
+ duration: 300ms
+ state: |
+ ┌──────────────────────────┐
+ │ Zone de scan (70% écran) │
+ │ │
+ │ ┌──────────────┐ │
+ │ │ [reticule] │ │ Camera preview
+ │ │ │ │ + overlay réticule
+ │ └──────────────┘ │
+ │ │
+ │ "Placez le code-barres │
+ │ dans le cadre" │
+ │ │
+ │ ⚡ Détection auto │
+ ├──────────────────────────┤
+ │ [Saisie manuelle] [💡] │ Bottom bar locale
+ └──────────────────────────┘
+
+ permissions:
+ camera:
+ rationale: "Pour scanner les codes-barres"
+ on_denied: "Saisie manuelle proposée"
+ on_permanently_denied: "Lien vers Réglages + saisie manuelle"
+
+ # ── ÉTAPE 2 : Détection et analyse ──
+ step_2_processing:
+ trigger: "Code-barres ou OCR détecté automatiquement"
+ duration_max: 500ms # Perçues
+
+ states:
+ detection:
+ haptique: "Vibration légère 15ms"
+ son: "Déclic discret (optionnel, désactivé par défaut)"
+ animation: |
+ Le réticule pulse brièvement (scale 1.0 → 1.05 → 1.0, 200ms)
+ Puis transition vers l'écran résultat
+
+ loading:
+ type: "SKELETON SCREEN (jamais de spinner)"
+ layout_skeleton: |
+ ┌──────────────────────────┐
+ │ ████████████ (nom) │
+ │ ██████ (marque) │
+ │ ░░░░░░░░░░░░ (verdict) │ Background coloré
+ │ │
+ │ ████████████ │
+ │ ██████████ │
+ └──────────────────────────┘
+
+ animation: "Shimmer wave, gauche → droite, 1.5s loop"
+
+ timeout:
+ after_3s: "Afficher 'Analyse en cours...' + option annuler"
+ after_8s: "→ FLOW 7 (Gestion d'erreur)"
+
+ # ── ÉTAPE 3 : Verdict immédiat ──
+ step_3_verdict:
+ transition: "Slide up from bottom, 250ms, ease-out"
+
+ layout: |
+ ┌──────────────────────────────┐
+ │ ← Retour scan ⋮ Options │ Top bar
+ ├──────────────────────────────┤
+ │ │
+ │ [Image produit] │
+ │ (120dp, coins ronds) │
+ │ │
+ │ Biscuit Choco Lait │
+ │ Marque X – 200g │
+ │ │
+ │ ┌──────────────────────────┐ │
+ │ │ ✅ OK pour toute la │ │ ← Verdict banner
+ │ │ famille │ │ Fond vert #2ECC71
+ │ └──────────────────────────┘ │ Icône cercle blanc
+ │ │
+ │ [→ Voir détails] │
+ │ [→ Alternatives] │
+ │ [→ Ajouter à la liste] │
+ │ │
+ └──────────────────────────────┘
+
+ verdict_variants:
+ ok: |
+ ✅ "OK pour toute la famille"
+ Fond: #E8F8F5 (vert très clair)
+ Icône: checkmark.shield.fill, #2ECC71
+
+ warning: |
+ ⚠️ "Contient : NOISETTES"
+ "⚠️ Attention pour Julie"
+ Fond: #FEF5E7 (orange très clair)
+ Icône: exclamationmark.shield.fill, #E67E22
+ Allergène affiché en bold, #E67E22
+
+ danger: |
+ ❌ "Contient : ARACHIDES"
+ "❌ Interdit pour Julie (anaphylaxie)"
+ Fond: #FDEDEC (rouge très clair)
+ Icône: xmark.shield.fill, #E74C3C
+ Allergène affiché en bold, #E74C3C
+ + Message explicite : "Ne pas consommer"
+
+ actions_disponibles:
+ - {id: "details", label: "Voir détails", icon: "info.circle", priority: primary}
+ - {id: "alternatives", label: "Voir alternatives", icon: "arrow.triangle.swap", priority: secondary}
+ - {id: "add_list", label: "Ajouter à une liste", icon: "plus.circle", priority: secondary}
+ - {id: "scan_again", label: "Scanner un autre produit", icon: "camera", priority: tertiary}
+
+ accessibility:
+ verdict_announcement: "Verdict : {status}. {details}. Actions disponibles : voir détails, voir alternatives, ajouter à la liste."
+ # Ex: "Verdict : Attention pour Julie. Contient noisettes. Actions disponibles : ..."
+```
+
+---
+
+### FLOW 3 : Fiche produit détaillée
+
+```yaml
+flow_product_details:
+ trigger: "Tap 'Voir détails' depuis le verdict OU depuis l'historique"
+ transition: "Slide up (Bottom Sheet) → Plein écran au scroll"
+
+ structure_tabs:
+ - id: "resume"
+ label: "Résumé"
+ icon: "list.bullet"
+ content: |
+ - Verdict sécurité (répété en haut)
+ - Nutri-Score visuel (A à E, grandes pastilles colorées)
+ - Calories / 100g
+ - Taux de sucre, sel, gras (jauges visuelles horizontales)
+
+ - id: "allergenes"
+ label: "Allergènes"
+ icon: "exclamationmark.shield"
+ content: |
+ - Liste des 14 allergènes réglementaires
+ - Chaque allergène :
+ → Présent ❌ / Traces ⚠️ / Absent ✅
+ - Les allergènes de la famille sont mis en avant (highlight #FEF5E7)
+
+ - id: "additifs"
+ label: "Additifs"
+ icon: "flask"
+ content: |
+ - Liste des additifs avec code E
+ - Barème couleur : Vert (naturel) / Orange (discutable) / Rouge (éviter)
+ - Description courte de chaque additif
+ - Lien "En savoir plus" → WebView interne
+
+ - id: "alternatives"
+ label: "Alternatives"
+ icon: "arrow.triangle.swap"
+ priority: "high" # Affiché si verdict != OK
+ content: |
+ - Carousel horizontal de produits alternatifs
+ - Critère : même catégorie, sans l'allergène détecté
+ - Chaque carte : photo, nom, verdict mini (couleur fond)
+ - Tap → Verdict du produit alternatif
+```
+
+---
+
+### FLOW 4 : Dashboard contextuel
+
+```yaml
+flow_dashboard:
+ # Le dashboard n'est plus un onglet mais un overlay intelligent
+ # accessible depuis l'onglet "Scanner" (écran principal)
+
+ states:
+ store_mode: |
+ # Détecté via géolocalisation ou heure (8h-20h en semaine)
+ ┌──────────────────────────────┐
+ │ 🛒 Vous êtes en magasin ? │
+ │ │
+ │ [Scanner rapide] ← Large │
+ │ │
+ │ Votre liste en cours : │
+ │ ┌──────────────────────┐ │
+ │ │ 🥛 Lait demi-écrémé │ │
+ │ │ 🍞 Pain complet │ │
+ │ │ 🍎 Pommes x6 │ │
+ │ └──────────────────────┘ │
+ │ │
+ │ "3 produits restants" │
+ └──────────────────────────────┘
+
+ home_mode: |
+ # Soirée / weekend
+ ┌──────────────────────────────┐
+ │ 👋 Bonjour, Sophie │
+ │ │
+ │ 📊 Cette semaine : │
+ │ ████████░░ 78% produits OK │
+ │ │
+ │ 🔍 Derniers scans : │
+ │ ✅ Biscuit Choco │
+ │ ⚠️ Sauce Curry │
+ │ │
+ │ [Scanner] [Mes listes] │
+ └──────────────────────────────┘
+
+ first_time: |
+ # Dashboard vide, guidé
+ ┌──────────────────────────────┐
+ │ 🎉 Prêt à commencer ! │
+ │ │
+ │ 📷 Scannez votre premier │
+ │ produit │
+ │ │
+ │ [Commencer →] │
+ └──────────────────────────────┘
+```
+
+---
+
+### FLOW 5 : Listes intelligentes
+
+```yaml
+flow_lists:
+ trigger: "Onglet 📋 Listes"
+
+ default_view: |
+ ┌──────────────────────────────┐
+ │ Listes + Nouvelle │
+ ├──────────────────────────────┤
+ │ ┌────────────────────────┐ │
+ │ │ 🛒 Courses semaine │ │
+ │ │ 8 produits • 3 achetés │ │
+ │ │ ██████░░░░ 37% │ │
+ │ └────────────────────────┘ │
+ │ ┌────────────────────────┐ │
+ │ │ 🎉 Anniv Lucas │ │
+ │ │ 12 produits • 0 acheté │ │
+ │ └────────────────────────┘ │
+ └──────────────────────────────┘
+
+ list_detail: |
+ ┌──────────────────────────────┐
+ │ ← Courses semaine ⋮ │
+ ├──────────────────────────────┤
+ │ 🔍 Filtrer par rayon │ ← Chips horizontaux
+ │ [Tous] [Frais] [Épicerie] │
+ │ │
+ │ ☐ Lait demi-écrémé ✅ │
+ │ ☐ Pain complet ⚠️ │ → Attention allergies
+ │ ☑ Pommes x6 ✅ │ → Coché = acheté
+ │ ☐ Yaourt nature ✅ │
+ │ ☐ Sauce tomate ❌ │ → Interdit (allergène)
+ │ │
+ │ [Tout décocher] [Partager] │
+ └──────────────────────────────┘
+
+ swipe_actions:
+ swipe_right: "Cocher/décocher (acheté)"
+ swipe_left: "Supprimer de la liste (avec undo 5s)"
+
+ smart_features:
+ auto_categorize: "Détection rayon automatique (frais, épicerie, etc.)"
+ allergy_alert: "Icône ⚠️/❌ visible directement sur la ligne"
+ merge_lists: "Fusion de plusieurs listes en une"
+ share: "Partage via lien ou PDF (sms, email, etc.)"
+```
+
+---
+
+### FLOW 6 : Suivi & Historique
+
+```yaml
+flow_tracking:
+ trigger: "Onglet 📊 Suivi"
+
+ layout: |
+ ┌──────────────────────────────┐
+ │ Suivi Période ▼│
+ ├──────────────────────────────┤
+ │ │
+ │ ┌────────────────────┐ │
+ │ │ 78% produits OK │ │ ← Cercle de progression
+ │ │ cette semaine │ │ (donut chart)
+ │ └────────────────────┘ │
+ │ │
+ │ 📈 Évolution (graphique) │
+ │ ░░░░░░░░░░░░░░░░░░░░ │ ← Sparkline ou mini graph
+ │ │
+ │ 🏷️ Top allergènes détectés │
+ │ 🥜 Noisettes : 4 fois │
+ │ 🥛 Lactose : 2 fois │
+ │ │
+ │ 📋 Historique récent │
+ │ ┌────────────────────────┐ │
+ │ │ ✅ Biscuit Choco │ │
+ │ │ Il y a 2h │ │
+ │ ├────────────────────────┤ │
+ │ │ ⚠️ Sauce Curry │ │
+ │ │ Hier │ │
+ │ └────────────────────────┘ │
+ └──────────────────────────────┘
+
+ time_filters:
+ - {id: "week", label: "Cette semaine"}
+ - {id: "month", label: "Ce mois"}
+ - {id: "year", label: "Cette année"}
+ - {id: "all", label: "Tout"}
+
+ empty_state: |
+ ┌──────────────────────────────┐
+ │ 📊 │
+ │ Aucune statistique │
+ │ Scannez vos premiers │
+ │ produits ! │
+ │ │
+ │ [Scanner →] │
+ └──────────────────────────────┘
+```
+
+---
+
+### FLOW 7 : Gestion des erreurs & cas limites
+
+```yaml
+flow_errors:
+
+ # ── CAS 1 : Produit non trouvé en base ──
+ product_not_found:
+ trigger: "Code-barres scanné, mais absent de la base"
+ layout: |
+ ┌──────────────────────────────┐
+ │ 📷 │
+ │ │
+ │ "Produit non reconnu" │
+ │ │
+ │ "Photographiez l'étiquette │
+ │ pour nous aider à │
+ │ l'identifier" │
+ │ │
+ │ ┌──────────────────────┐ │
+ │ │ [Zone photo étiquette]│ │
+ │ └──────────────────────┘ │
+ │ │
+ │ [Prendre une photo] │
+ │ [Saisie manuelle] │
+ │ [Annuler] │
+ └──────────────────────────────┘
+
+ actions:
+ photo_label:
+ camera: "Prendre photo étiquette (ingrédients)"
+ crop_guide: "Cadrez la liste d'ingrédients"
+ on_success: "Merci ! Nous analysons votre photo. Le produit sera ajouté sous 24h."
+
+ manual_entry:
+ fields: ["Nom produit", "Marque", "Code-barres (pré-rempli)"]
+ on_submit: "Produit enregistré localement. Analyse basique des ingrédients si fournis."
+
+ # ── CAS 2 : Pas de connexion ──
+ no_connection:
+ detection: "Vérification avant chaque scan"
+ behavior: |
+ Mode dégradé transparent :
+ - Scan fonctionne avec base locale (produits déjà scannés)
+ - Nouveau produit : message "Connexion requise pour les nouveaux produits"
+ - Listes : synchronisation différée
+ - Pas de popup bloquante, un bandeau discret en haut
+ banner: |
+ ┌──────────────────────────────┐
+ │ 📡 Mode hors-ligne │ ← Bandeau 40dp, fond #FEF5E7
+ │ Produits déjà scannés dispo. │ Non bloquant
+ └──────────────────────────────┘
+
+ # ── CAS 3 : OCR illisible ──
+ ocr_unreadable:
+ trigger: "L'OCR n'a pas pu lire les ingrédients sur la photo"
+ layout: |
+ ┌──────────────────────────────┐
+ │ 💡 │
+ │ │
+ │ "L'image est trop floue" │
+ │ │
+ │ "Essayez avec plus de │
+ │ lumière ou rapprochez-vous │
+ │ de l'étiquette" │
+ │ │
+ │ [Réessayer] │
+ │ [Saisie manuelle] │
+ │ [Ignorer] │
+ └──────────────────────────────┘
+
+ tips_displayed:
+ - "Placez le produit sur une surface plane"
+ - "Évitez les reflets"
+ - "Utilisez le flash si nécessaire"
+
+ # ── CAS 4 : Permissions refusées ──
+ permission_denied:
+ camera:
+ rationale_dialog: |
+ Titre: "Accès à l'appareil photo"
+ Message: "L'appareil photo est nécessaire pour scanner les codes-barres."
+ Boutons: [Aller aux Réglages] [Pas maintenant]
+ fallback: "Saisie manuelle du code-barres toujours disponible"
+
+ notification:
+ rationale: |
+ Titre: "Notifications de sécurité"
+ Message: "Recevez les alertes uniquement en cas d'allergène critique détecté."
+ Boutons: [Activer] [Plus tard]
+ behavior: "Fonctionnalités non-bloquantes sans notification"
+```
+
+---
+
+### FLOW 8 : Profils famille
+
+```yaml
+flow_family:
+ trigger: "Onglet 👤 Famille"
+
+ layout: |
+ ┌──────────────────────────────┐
+ │ Ma famille │
+ ├──────────────────────────────┤
+ │ │
+ │ ┌────────────────────────┐ │
+ │ │ 👩 Sophie (moi) │ │ ← Profil principal
+ │ │ Allergies: - │ │
+ │ └────────────────────────┘ │
+ │ ┌────────────────────────┐ │
+ │ │ 👧 Julie, 7 ans │ │
+ │ │ ⚠️ Noisettes, arachides│ │
+ │ │ ❌ Anaphylaxie arachide│ │
+ │ └────────────────────────┘ │
+ │ ┌────────────────────────┐ │
+ │ │ 👦 Lucas, 10 ans │ │
+ │ │ ⚠️ Lactose │ │
+ │ └────────────────────────┘ │
+ │ │
+ │ [+ Ajouter un membre] │
+ └──────────────────────────────┘
+
+ profile_detail: |
+ ┌──────────────────────────────┐
+ │ ← Julie │
+ ├──────────────────────────────┤
+ │ Photo [📷] │
+ │ Prénom [Julie] │
+ │ Âge [7] │
+ │ │
+ │ ALLERGIES │
+ │ ┌────────────────────────┐ │
+ │ │ 🥜 Noisettes [⚠️] │ │ ← Niveau : traces (⚠️)
+ │ │ 🥜 Arachides [❌] │ │ ou critique (❌)
+ │ └────────────────────────┘ │
+ │ [+ Ajouter une allergie] │
+ │ │
+ │ ALERTES │
+ │ ☑ Alerte critique (anaphyl.)│
+ │ ☐ Alerte traces │
+ └──────────────────────────────┘
+
+ allergen_selection: |
+ # Sélecteur visuel (pas de liste texte)
+ Grille 3x5 de :
+ ┌───┐ ┌───┐ ┌───┐
+ │🥜│ │🥛│ │🍞│
+ └───┘ └───┘ └───┘
+ ┌───┐ ┌───┐ ┌───┐
+ │🦐│ │🥚│ │🐟│
+ └───┘ └───┘ └───┘
+ ...
+
+ Tap once: sélectionne (⚠️ traces/intolérance)
+ Tap again: critique (❌ allergie sévère)
+ Tap third: désélectionne
+
+ Couleur fond change selon sélection (⚠️ #FEF5E7, ❌ #FDEDEC)
+```
+
+---
+
+### FLOW 9 : Notifications
+
+```yaml
+flow_notifications:
+ # Règle : seules les notifications de SÉCURITÉ sont push
+ # Tout le reste est in-app uniquement
+
+ push_notifications:
+ security_alert:
+ trigger: "Allergène critique détecté pour un membre de la famille"
+ title: "⚠️ Attention : {allergène} détecté"
+ body: "{nom_produit} contient {allergène}. Interdit pour {membre}."
+ tap_action: "Ouvre la fiche produit avec le verdict danger"
+
+ new_alternative:
+ trigger: "Nouveau produit alternatif disponible"
+ title: "💡 Alternative trouvée"
+ body: "Un produit similaire sans {allergène} est disponible."
+ frequency: "Max 1/semaine"
+ user_control: "Désactivable dans Réglages"
+
+ in_app_notifications:
+ types:
+ - "Rappel liste de courses (si produits restants)"
+ - "Résumé hebdomadaire (chaque lundi matin)"
+ - "Nouvelle fonctionnalité"
+ display: "Centre de notifications intégré (cloche dans top bar)"
+```
+
+---
+
+## ⚡ PERFORMANCE & ANIMATIONS
+
+### 5.1 Spécifications de performance perçue
+
+| Métrique | Cible | Méthode |
+|----------|-------|---------|
+| Temps d'ouverture scanner | < 300ms | Pré-initialiser la caméra en arrière-plan |
+| Affichage résultat scan | < 500ms | Skeleton screen immédiat, données async |
+| Transition entre écrans | 200-300ms | Courbes ease-out, jamais > 400ms |
+| Scroll FPS | 60fps constants | RecyclerView + pagination |
+| Taille APK | < 25 Mo | Optimisation ressources, ProGuard |
+
+### 5.2 Animations standardisées
+
+```yaml
+animations:
+ screen_transition:
+ push: "Slide from right, 250ms, decelerate"
+ pop: "Slide to right, 200ms, accelerate"
+
+ fab_morph:
+ to_scanner: "ContainerTransform (Material), 300ms"
+
+ scan_success:
+ reticule_pulse: "scale 1.0 → 1.08 → 1.0, 200ms, ease-in-out"
+ haptic: "Vibration 15ms, légère"
+
+ verdict_appear:
+ type: "Slide up + fade in"
+ duration: 250ms
+ stagger: "Contenu principal 0ms, actions +50ms chaque"
+
+ list_check:
+ type: "Strikethrough animé + scale down + coche apparition"
+ duration: 300ms
+
+ skeleton:
+ type: "Shimmer gradient animé"
+ gradient: "transparent → rgba(255,255,255,0.4) → transparent"
+ duration: "1.5s, boucle infinie"
+```
+
+### 5.3 Transitions partagées (Shared Elements)
+
+```yaml
+shared_elements:
+ product_card_to_detail:
+ elements:
+ - product_image: "Corner radius animé (8dp → 16dp)"
+ - product_name: "Position et taille animées"
+ duration: 300ms
+ interpolator: "FastOutSlowIn (Material standard)"
+```
+
+---
+
+## ♿ ACCESSIBILITÉ (OBLIGATOIRE)
+
+### 6.1 Checklist WCAG 2.1 AA
+
+```yaml
+accessibility_checklist:
+
+ contrast:
+ text_normal: "Ratio ≥ 4.5:1"
+ text_large: "Ratio ≥ 3:1"
+ ui_components: "Ratio ≥ 3:1"
+ # Validation : test avec Accessibility Scanner (Android)
+
+ color_blindness:
+ deuteranopia: "Vert → Orange OK" # Distinction testée
+ protanopia: "Vert → Orange OK"
+ tritanopia: "Rare, mais testé"
+ # Règle : icônes + formes + couleurs, jamais couleur seule
+
+ touch_targets:
+ min_size: "48dp x 48dp" # Obligatoire
+ min_spacing: "8dp entre zones tactiles"
+
+ screen_reader:
+ talkback:
+ - "Tout élément interactif a un contentDescription"
+ - "Les images décoratives ont contentDescription = null"
+ - "Les changements d'état sont annoncés (verdict, chargement)"
+ - "Ordre de focus logique (gauche→droite, haut→bas)"
+
+ announcements:
+ scan_complete: "Produit scanné. {nom_produit}. {verdict}."
+ verdict_change: "Attention : verdict modifié. {nouveau_verdict}."
+ loading: "Analyse en cours, veuillez patienter."
+
+ dynamic_text:
+ scale: "Jusqu'à 200% sans perte de contenu"
+ implementation: "sp (Android) / Dynamic Type (iOS)"
+ testing: "Testé avec la plus grande taille système"
+
+ keyboard_navigation:
+ android: "DPad et clavier externe Bluetooth"
+ focus_indicators: "Visibles (anneau #2ECC71, 2dp)"
+```
+
+### 6.2 Icônes & formes (Système daltonien)
+
+```
+VERT (#2ECC71) ORANGE (#E67E22) ROUGE (#E74C3C)
+───── ──────── ──────
+[CERCLE] [TRIANGLE] [LOSANGE]
+ ✅ checkmark ⚠️ exclamation ❌ croix
+
+Jamais l'un sans l'autre : forme + couleur + icône.
+```
+
+---
+
+## 📐 GRILLE & LAYOUT
+
+### 7.1 Système de grille
+
+```yaml
+grid_system:
+ type: "Grille 4 colonnes (mobile)"
+ margins: "16dp gauche/droite"
+ gutter: "12dp entre colonnes"
+
+ responsive:
+ phone_portrait: "4 colonnes, margins 16dp"
+ phone_landscape: "6 colonnes, margins 24dp"
+ tablet: "8 colonnes, margins 32dp, max-width 840dp"
+```
+
+### 7.2 Composants standardisés
+
+```yaml
+components:
+ product_card_compact:
+ height: 88dp
+ layout: |
+ ┌──────────────────────────────┐
+ │ [IMG] Nom produit │
+ │ 48dp Marque • Poids │
+ │ ┌────────┐ │
+ │ │ ✅ │ ← Verdict │
+ │ └────────┘ miniature│
+ └──────────────────────────────┘
+
+ verdict_banner:
+ height: 56dp
+ padding: 16dp horizontal, 12dp vertical
+ radius: 12dp
+ icon_size: 24dp
+ background: "Dépend du verdict (voir couleur de fond)"
+
+ action_button:
+ height: 48dp
+ radius: 12dp
+ padding: 16dp horizontal
+ text: "Button style (15sp, weight 600)"
+
+ variants:
+ primary: "Fond #2D3436, texte blanc"
+ secondary: "Fond transparent, bordure #DFE6E9, texte #2D3436"
+ danger: "Fond #E74C3C, texte blanc"
+
+ chip_filter:
+ height: 32dp
+ radius: 16dp (pill)
+ padding: 8dp horizontal
+ selected: "Fond #2D3436, texte blanc"
+ unselected: "Fond #F5F5F0, texte #636E72"
+
+ input_field:
+ height: 56dp
+ radius: 12dp
+ background: "#FFFFFF"
+ border: "1dp #DFE6E9"
+ focus_border: "2dp #2ECC71"
+ label: "caption style, animé vers le haut au focus"
+```
+
+---
+
+## 🗄️ SPÉCIFICATIONS TECHNIQUES
+
+### 8.1 Architecture recommandée
+
+```yaml
+architecture:
+ pattern: "MVVM + Clean Architecture"
+
+ layers:
+ presentation:
+ - "Jetpack Compose (Android) / SwiftUI (iOS)"
+ - "ViewModel par écran"
+ - "StateFlow / @Published pour état UI"
+
+ domain:
+ - "UseCases (ScanProductUseCase, GetAlternativesUseCase...)"
+ - "Modèles métier (Product, Allergen, FamilyMember...)"
+
+ data:
+ - "Repository pattern"
+ - "Room (base locale) pour historique et listes"
+ - "Retrofit (API distante) pour base produits"
+ - "DataStore pour préférences utilisateur"
+
+ key_dependencies:
+ android:
+ camera: "CameraX (Google officiel)"
+ barcode: "ML Kit Barcode Scanning (Google)"
+ ocr: "ML Kit Text Recognition"
+ navigation: "Compose Navigation"
+ di: "Hilt"
+
+ ios:
+ camera: "AVFoundation"
+ barcode: "Vision Framework"
+ ocr: "Vision Framework"
+ navigation: "NavigationStack"
+```
+
+### 8.2 Flux de données – Scan
+
+```kotlin
+// Flux simplifié pour le scan
+sealed class ScanUiState {
+ object Idle : ScanUiState()
+ data class CameraReady(val isPermissionGranted: Boolean) : ScanUiState()
+ data class Scanning(val analyzedFrames: Int) : ScanUiState()
+ object Analyzing : ScanUiState() // → Skeleton screen
+ data class Verdict(val product: Product, val verdict: VerdictType) : ScanUiState()
+ data class Error(val errorType: ScanError, val recoveryAction: RecoveryAction) : ScanUiState()
+}
+
+enum class VerdictType {
+ SAFE, // ✅ OK famille
+ WARNING, // ⚠️ Allergène traces/intolérance
+ DANGER // ❌ Allergène critique
+}
+```
+
+---
+
+## 🧪 TESTS UX (Checklist validation)
+
+```yaml
+validation_checklist:
+
+ user_testing:
+ - "5 utilisateurs, scenario 'scanner un produit dangereux'"
+ - "Mesurer : temps pour comprendre le verdict"
+ - "Objectif : < 2 secondes pour identifier le danger"
+ - "Test daltonien : 1 utilisateur daltonien minimum"
+
+ edge_cases:
+ - "Scan rapide de 10 produits d'affilée"
+ - "Rotation écran pendant le scan"
+ - "Appel téléphonique interrompt le scan"
+ - "Batterie faible (mode économie d'énergie)"
+ - "Stockage presque plein"
+
+ accessibility_audit:
+ - "Test avec TalkBack activé, parcours complet"
+ - "Test avec taille de texte maximale"
+ - "Contraste vérifié avec Accessibility Scanner"
+```
+
+---
+
+## 📋 DÉVELOPPEMENT V1 – Scope minimal
+
+```yaml
+v1_mvp_scope:
+
+ must_have:
+ - module: "Scanner code-barres + verdict immédiat"
+ - module: "4 onglets navigation"
+ - module: "Profils famille (multi-membres avec allergies)"
+ - module: "Listes de courses basiques"
+ - module: "Système de couleurs feu tricolore complet"
+ - module: "Accessibilité de base (contraste + TalkBack)"
+
+ nice_to_have_v1:
+ - module: "OCR ingrédients sur photo"
+ - module: "Dashboard contextuel (heure/lieu)"
+ - module: "Alternatives intelligentes"
+ - module: "Suivi statistiques"
+
+ v2_planned:
+ - "Partage liste en temps réel (collaboratif)"
+ - "Scanner rayon (reconnaissance multiple)"
+ - "Intégration drive/click&collect"
+ - "Badge / scoring produit personnalisé"
+```
+
+---
+
+## 🎨 FICHIERS À PRODUIRE
+
+```
+assets/
+├── design_system/
+│ ├── colors.xml / Colors.xcassets
+│ ├── typography.xml / Typography.swift
+│ ├── shapes.xml / Shapes.swift
+│ └── icons/ (SVG export 24dp, 48dp)
+│
+├── screens/
+│ ├── onboarding_1_welcome.png
+│ ├── onboarding_2_allergies.png
+│ ├── onboarding_3_goal.png
+│ ├── onboarding_4_scan_tuto.png
+│ ├── scan_active.png
+│ ├── scan_skeleton.png
+│ ├── verdict_ok.png
+│ ├── verdict_warning.png
+│ ├── verdict_danger.png
+│ ├── product_detail.png
+│ ├── lists_empty.png
+│ ├── lists_detail.png
+│ ├── tracking.png
+│ ├── family_profiles.png
+│ └── family_member_detail.png
+│
+├── components/
+│ ├── fab_spec.png
+│ ├── bottom_nav_spec.png
+│ ├── verdict_banner_variants.png
+│ ├── product_card_compact.png
+│ └── error_states_all.png
+│
+└── flows/
+ ├── flow_scan_complete.pdf
+ ├── flow_onboarding.pdf
+ └── flow_error_handling.pdf
+```
+
+---
+
+**Ce document constitue la spécification UX/UI de référence. Toute ambiguïté doit être remontée avant implémentation. Les principes fondateurs (section 1) prévalent sur les détails d'implémentation en cas de conflit.**
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..4dee139
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,10 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+org.gradle.caching=true
+org.gradle.configuration-cache=false
+
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+
+kotlin.code.style=official
+ksp.incremental=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..237e195
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,100 @@
+[versions]
+agp = "8.13.2"
+kotlin = "2.0.20"
+ksp = "2.0.20-1.0.25"
+hilt = "2.52"
+hiltNav = "1.2.0"
+coreKtx = "1.13.1"
+lifecycle = "2.8.6"
+activityCompose = "1.9.2"
+composeBom = "2024.09.02"
+navCompose = "2.8.1"
+room = "2.6.1"
+datastore = "1.1.1"
+retrofit = "2.11.0"
+okhttp = "4.12.0"
+moshi = "1.15.1"
+coroutines = "1.8.1"
+cameraX = "1.3.4"
+mlkitBarcode = "17.3.0"
+mlkitText = "16.0.1"
+mlkitTextLatin = "16.0.0"
+coil = "2.7.0"
+timber = "5.0.1"
+accompanist = "0.34.0"
+junit = "4.13.2"
+androidxTestExt = "1.2.1"
+espresso = "3.6.1"
+truth = "1.4.4"
+mockk = "1.13.12"
+turbine = "1.1.0"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" }
+
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navCompose" }
+
+hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
+hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
+hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNav" }
+
+androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
+androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+
+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
+
+retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" }
+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
+moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" }
+moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
+moshi-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" }
+
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" }
+
+androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" }
+androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" }
+androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" }
+androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" }
+
+mlkit-barcode = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" }
+mlkit-text = { group = "com.google.mlkit", name = "text-recognition", version.ref = "mlkitText" }
+
+coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
+
+timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
+
+accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }
+
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
+mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
+turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
+
+androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
+androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..d997cfc
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..37f853b
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..739907d
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..c4bdd3a
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,93 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..98ae1d3
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,24 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "SafeBite"
+include(":app")
diff --git a/version.properties b/version.properties
new file mode 100644
index 0000000..318b066
--- /dev/null
+++ b/version.properties
@@ -0,0 +1,4 @@
+MAJOR=1
+MINOR=2
+PATCH=0
+CODE=3