first commit

This commit is contained in:
Bruno Charest 2026-04-25 10:26:13 -04:00
parent 83b92c942d
commit 134f23f9a7
89 changed files with 9043 additions and 2 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
*.iml
.gradle/
local.properties
.idea/
.DS_Store
build/
captures/
.externalNativeBuild
.cxx
*.apk
*.aab
*.keystore

133
README.md Normal file
View File

@ -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**.

146
app/build.gradle.kts Normal file
View File

@ -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))
}

22
app/proguard-rules.pro vendored Normal file
View File

@ -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.** { *; }

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-feature
android:name="android.hardware.camera"
android:required="true" />
<uses-feature
android:name="android.hardware.camera.autofocus"
android:required="false" />
<application
android:name=".SafeBiteApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.SafeBite"
tools:targetApi="34">
<activity
android:name=".presentation.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.SafeBite">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- ML Kit install-time model download -->
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode,ocr" />
</application>
</manifest>

View File

@ -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())
}
}
}

View File

@ -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<AllergenType>?): String =
set.orEmpty().joinToString(",") { it.name }
@TypeConverter
fun stringToAllergenSet(raw: String?): Set<AllergenType> =
raw.orEmpty().split(',').mapNotNull { AllergenType.fromName(it.trim()) }.toSet()
@TypeConverter
fun restrictionSetToString(set: Set<DietaryRestriction>?): String =
set.orEmpty().joinToString(",") { it.name }
@TypeConverter
fun stringToRestrictionSet(raw: String?): Set<DietaryRestriction> =
raw.orEmpty().split(',')
.filter { it.isNotBlank() }
.mapNotNull { runCatching { DietaryRestriction.valueOf(it.trim()) }.getOrNull() }
.toSet()
@TypeConverter
fun stringListToString(list: List<String>?): String =
list.orEmpty().joinToString("\u0001")
@TypeConverter
fun stringToStringList(raw: String?): List<String> =
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<CustomDietItem>?): 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<CustomDietItem> {
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)
}
}
}

View File

@ -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"
}
}

View File

@ -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()
}

View File

@ -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<List<ScanHistoryEntity>>
@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()
}

View File

@ -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<List<UserProfileEntity>>
@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)
}

View File

@ -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<AllergenType>,
val moderateIntolerances: Set<AllergenType>,
val dietaryRestrictions: Set<DietaryRestriction>,
val customItems: List<CustomDietItem> = 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<String>,
val tracesTags: List<String>,
val nutriScore: String?,
val novaGroup: Int?,
val ecoScore: String? = null,
val servingSize: String? = null,
val labels: List<String> = emptyList(),
val categories: List<String> = 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<String>,
val scannedAt: Long,
val source: DataSource
)

View File

@ -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<Preferences> 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<Preferences>) {
val appLanguage: Flow<AppLanguage> = dataStore.data.map {
runCatching { AppLanguage.valueOf(it[UserPreferencesKeys.APP_LANGUAGE] ?: AppLanguage.FR.name) }
.getOrDefault(AppLanguage.FR)
}
val detectionLanguage: Flow<DetectionLanguage> = dataStore.data.map {
runCatching { DetectionLanguage.valueOf(it[UserPreferencesKeys.DETECTION_LANGUAGE] ?: DetectionLanguage.BOTH.name) }
.getOrDefault(DetectionLanguage.BOTH)
}
val haptics: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.HAPTICS] ?: true }
val sound: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.SOUND] ?: true }
val theme: Flow<ThemePref> = dataStore.data.map {
runCatching { ThemePref.valueOf(it[UserPreferencesKeys.THEME] ?: ThemePref.SYSTEM.name) }
.getOrDefault(ThemePref.SYSTEM)
}
val onboardingCompleted: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.ONBOARDING_DONE] ?: false }
val activeProfileIds: Flow<Set<Long>> = dataStore.data.map { prefs ->
prefs[UserPreferencesKeys.ACTIVE_PROFILE_IDS].orEmpty()
.mapNotNull { it.toLongOrNull() }
.toSet()
}
val healthStrictness: Flow<HealthStrictness> = 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<Long>) {
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 }
}
}

View File

@ -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<ProductResponse>
companion object {
const val BASE_URL = "https://world.openfoodfacts.org/"
}
}

View File

@ -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<String>? = null,
@Json(name = "traces_tags") val tracesTags: List<String>? = 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<String>? = null,
@Json(name = "categories_tags") val categoriesTags: List<String>? = 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
)

View File

@ -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
)
)

View File

@ -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() }
}

View File

@ -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<List<ScanHistoryItem>> =
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
)

View File

@ -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)
}

View File

@ -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<List<UserProfile>> =
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<Set<Long>> = prefs.activeProfileIds
override suspend fun setActiveProfileIds(ids: Set<Long>) { 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
)

View File

@ -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<Boolean> = 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()
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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<UserProfile>,
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<AllergenType, DetectedAllergen>()
// 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<DetectedAllergen> { 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<UserProfile>): List<DetectedCustomItem> {
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<Pair<String, CustomItemTag>, MutableList<Pair<UserProfile, CustomDietItem>>>()
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<DetectedCustomItem>()
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<String> =
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<String>, allergenTags: List<String>): List<String> {
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<String>): List<String> {
if (normalized.isBlank()) return emptyList()
val hits = mutableListOf<String>()
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("(?<!\\p{L})$escaped$suffix(?!\\p{L})")
if (regex.containsMatchIn(normalized)) {
hits.add(kw)
}
}
return hits.distinct()
}
private fun extractTraceRegions(normalizedText: String): List<String> {
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>): 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<DetectedAllergen>,
severeSet: Set<AllergenType>,
customDetections: List<DetectedCustomItem>
): 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
}
}
}
}
}

View File

@ -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<String>,
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<String>()
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<String>
): 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
}
}
}
}

View File

@ -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<String>,
val keywordsFr: List<String>,
val keywordsEn: List<String>
) {
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) }
}
}

View File

@ -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<String> = emptyList()
) {
fun allKeywords(): List<String> =
(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<AllergenType> = emptySet(),
val moderateIntolerances: Set<AllergenType> = emptySet(),
val dietaryRestrictions: Set<DietaryRestriction> = emptySet(),
val customItems: List<CustomDietItem> = emptyList(),
val isDefault: Boolean = false
) {
/** Returns every allergen (severe + moderate) referenced by this profile. */
fun allAllergens(): Set<AllergenType> = 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<String> = emptyList(),
val tracesTags: List<String> = emptyList(),
val nutriScore: String? = null,
val novaGroup: Int? = null,
val ecoScore: String? = null,
val servingSize: String? = null,
val nutriments: Nutriments = Nutriments(),
val labels: List<String> = emptyList(),
val categories: List<String> = 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<String>,
val profileIds: List<Long> = 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<String> = 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<String>,
val source: String,
/** Which profiles this detection concerns (useful for multi-profile scans). */
val profileIds: List<Long> = 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<DetectedAllergen>,
val detectedCustomItems: List<DetectedCustomItem> = emptyList(),
val health: HealthAssessment = HealthAssessment(),
val analyzedProfiles: List<UserProfile>,
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<String>,
val scannedAt: Long,
val source: DataSource
)

View File

@ -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<List<UserProfile>>
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<Set<Long>>
suspend fun setActiveProfileIds(ids: Set<Long>)
}
interface ScanHistoryRepository {
fun observeHistory(): Flow<List<ScanHistoryItem>>
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<AppLanguage>
val detectionLanguage: Flow<DetectionLanguage>
val hapticsEnabled: Flow<Boolean>
val soundEnabled: Flow<Boolean>
val theme: Flow<ThemePref>
val onboardingCompleted: Flow<Boolean>
val healthStrictness: Flow<HealthStrictness>
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)
}

View File

@ -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<UserProfile>,
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<UserProfile>,
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<List<UserProfile>> = 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<Long>) = repo.setActiveProfileIds(ids)
}
class GetScanHistoryUseCase @Inject constructor(
private val repo: ScanHistoryRepository
) {
fun observe(): Flow<List<com.safebite.app.domain.model.ScanHistoryItem>> = 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)
}

View File

@ -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<RootUi> = 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)
}
}
}
}
}

View File

@ -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,
)
}
}

View File

@ -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,
)
}
}

View File

@ -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() }
}

View File

@ -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<Status>` (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
)
}
}

View File

@ -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,
)
}
}
}

View File

@ -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
)
}
}
}
}
}

View File

@ -0,0 +1,8 @@
package com.safebite.app.presentation.common.util
sealed interface UiState<out T> {
data object Idle : UiState<Nothing>
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val message: String, val offline: Boolean = false) : UiState<Nothing>
}

View File

@ -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() })
}
}
}

View File

@ -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")
}

View File

@ -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
)
}
}
}
}
}
}
}
}
}

View File

@ -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<ScanHistoryItem> = emptyList(),
val filter: SafetyStatus? = null,
val query: String = ""
)
@HiltViewModel
class HistoryViewModel @Inject constructor(
private val useCase: GetScanHistoryUseCase
) : ViewModel() {
private val _filter = MutableStateFlow<SafetyStatus?>(null)
private val _query = MutableStateFlow("")
val filter: StateFlow<SafetyStatus?> = _filter.asStateFlow()
val query: StateFlow<String> = _query.asStateFlow()
val state: StateFlow<HistoryUi> = 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() }
}

View File

@ -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<UserProfile>,
active: List<UserProfile>,
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
)
}
}

View File

@ -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<UserProfile> = emptyList(),
val activeProfiles: List<UserProfile> = emptyList(),
val recent: List<ScanHistoryItem> = emptyList()
)
@HiltViewModel
class HomeViewModel @Inject constructor(
private val manageProfile: ManageProfileUseCase,
private val history: GetScanHistoryUseCase
) : ViewModel() {
val state: StateFlow<HomeUi> = 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))
}
}

View File

@ -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
}
)
}

View File

@ -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<AllergenType> {
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) }
}
}

View File

@ -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<String> = _capturedText.asStateFlow()
fun setText(text: String) { _capturedText.value = text }
}

View File

@ -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<Set<AllergenType>>(emptySet()) }
val moderate = remember { mutableStateOf<Set<AllergenType>>(emptySet()) }
val restrictions = remember { mutableStateOf<Set<DietaryRestriction>>(emptySet()) }
val customItems = remember { mutableStateOf<List<CustomDietItem>>(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<AllergenType>,
onToggleSevere: (AllergenType) -> Unit,
moderate: Set<AllergenType>,
onToggleModerate: (AllergenType) -> Unit,
restrictions: Set<DietaryRestriction>,
onToggleRestriction: (DietaryRestriction) -> Unit,
customItems: List<CustomDietItem>,
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()
)
}
}

View File

@ -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<AllergenType>,
moderate: Set<AllergenType>,
restrictions: Set<DietaryRestriction> = emptySet(),
customItems: List<CustomDietItem> = 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) }
}

View File

@ -42,7 +42,7 @@ import com.safebite.app.domain.model.CustomItemTag
@Composable
fun AllergenGrid(selected: Set<AllergenType>, 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 },

View File

@ -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()
)
}
}
}
}

View File

@ -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
)
}
}
}
}
}
}
}

View File

@ -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<AllergenType> = emptySet(),
val moderate: Set<AllergenType> = emptySet(),
val restrictions: Set<DietaryRestriction> = emptySet(),
val customItems: List<CustomDietItem> = emptyList(),
val isDefault: Boolean = false,
val loaded: Boolean = false
)
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val manage: ManageProfileUseCase
) : ViewModel() {
val profiles: StateFlow<List<UserProfile>> = manage.observe()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _edit = MutableStateFlow(ProfileEditUi())
val edit: StateFlow<ProfileEditUi> = _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) }
}

View File

@ -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)
}
}

View File

@ -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<ScanResult>>(UiState.Idle)
val state: StateFlow<UiState<ScanResult>> = _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<UserProfile> {
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) }
}
}
}

View File

@ -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() }
}
}

View File

@ -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<androidx.camera.core.CameraControl?>(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<VibratorManager>() ?: 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 */ }
}

View File

@ -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)
}
}

View File

@ -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<SettingsUi> = 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() }
}

View File

@ -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)

View File

@ -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 }

View File

@ -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)
)

View File

@ -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 }

View File

@ -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<androidx.compose.ui.graphics.Color>(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,
)
}

View File

@ -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
)
)

View File

@ -0,0 +1,60 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Panier (arrière-plan gauche) -->
<path
android:fillColor="#2D4F7C"
android:pathData="M18,40 L30,40 L38,70 L74,70 L82,40 L90,40 L82,75 L34,75 Z" />
<!-- Grille panier -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:fillColor="@android:color/transparent"
android:pathData="M34,45 L80,45 M36,55 L78,55 M38,65 L76,65" />
<!-- Poignée gauche -->
<path
android:strokeColor="#2D4F7C"
android:strokeWidth="4"
android:fillColor="@android:color/transparent"
android:pathData="M30,40 Q30,25 45,25" />
<!-- Poignée droite -->
<path
android:strokeColor="#2D4F7C"
android:strokeWidth="4"
android:fillColor="@android:color/transparent"
android:pathData="M78,40 Q78,25 63,25" />
<!-- Bouclier -->
<path
android:fillColor="#1A3E6E"
android:pathData="M54,20
L82,30
L82,55
Q82,75 54,88
Q26,75 26,55
L26,30 Z" />
<!-- Bordure bouclier -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="3"
android:fillColor="@android:color/transparent"
android:pathData="M54,22
L80,31
L80,54
Q80,73 54,85
Q28,73 28,54
L28,31 Z" />
<!-- Checkmark -->
<path
android:fillColor="#FFFFFF"
android:pathData="M44,55 L52,63 L68,45 L64,41 L52,55 L48,51 Z" />
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/brand_primary" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/brand_primary" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,196 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SafeBite</string>
<string name="action_continue">Continue</string>
<string name="action_next">Next</string>
<string name="action_back">Back</string>
<string name="action_save">Save</string>
<string name="action_cancel">Cancel</string>
<string name="action_delete">Delete</string>
<string name="action_edit">Edit</string>
<string name="action_retry">Retry</string>
<string name="action_close">Close</string>
<string name="action_done">Done</string>
<string name="action_analyze">Analyze</string>
<string name="action_scan_again">Scan another product</string>
<string name="action_read_ingredients">Read ingredients (OCR)</string>
<string name="onboarding_welcome_title">Welcome to SafeBite</string>
<string name="onboarding_welcome_subtitle">Scan, verify, eat safely.</string>
<string name="onboarding_how_title">How it works</string>
<string name="onboarding_how_step1">1. Create an allergy profile</string>
<string name="onboarding_how_step2">2. Scan the product barcode</string>
<string name="onboarding_how_step3">3. Get an instant verdict</string>
<string name="onboarding_profile_title">Create your first profile</string>
<string name="onboarding_permission_title">Camera permission</string>
<string name="onboarding_permission_body">SafeBite needs the camera to scan barcodes and read labels. No image ever leaves your device.</string>
<string name="onboarding_permission_grant">Grant camera access</string>
<string name="onboarding_ready_title">You\'re ready!</string>
<string name="onboarding_ready_body">Scan your first product now.</string>
<string name="onboarding_start">Start</string>
<string name="home_scan_button">Scan a product</string>
<string name="home_ocr_button">Read ingredients (OCR)</string>
<string name="home_active_profile">Active profile</string>
<string name="home_change_profile">Change</string>
<string name="home_recent_scans">Recent scans</string>
<string name="home_no_recent">No recent scans</string>
<string name="home_no_profile_title">No profile set up</string>
<string name="home_no_profile_body">Create an allergy profile to get started.</string>
<string name="home_create_profile">Create profile</string>
<string name="nav_history">History</string>
<string name="nav_profiles">Profiles</string>
<string name="nav_settings">Settings</string>
<string name="scanner_title">Scan a barcode</string>
<string name="scanner_hint">Place the barcode inside the frame</string>
<string name="scanner_torch">Torch</string>
<string name="scanner_camera_denied">Camera access is required to scan.</string>
<string name="scanner_open_settings">Open settings</string>
<string name="result_safe_headline">NO ALLERGEN DETECTED FOR YOUR PROFILE</string>
<string name="result_warning_headline">THIS PRODUCT MAY CONTAIN TRACES OF ALLERGENS</string>
<string name="result_danger_headline">THIS PRODUCT CONTAINS ALLERGENS FROM YOUR PROFILE</string>
<string name="result_detected_allergens">Detected allergens</string>
<string name="result_no_allergen_detected">No allergen detected</string>
<string name="result_ingredients">Ingredients</string>
<string name="result_ingredients_unavailable">Ingredients unavailable</string>
<string name="result_confidence">Analysis confidence</string>
<string name="result_confidence_high">High</string>
<string name="result_confidence_medium">Medium</string>
<string name="result_confidence_low">Low</string>
<string name="result_source_api">Open Food Facts</string>
<string name="result_source_cache">Local cache</string>
<string name="result_source_ocr">OCR (photo)</string>
<string name="result_profiles_checked">Profiles checked</string>
<string name="result_level_confirmed">Confirmed</string>
<string name="result_level_trace">Traces</string>
<string name="result_level_suspected">Suspected</string>
<string name="result_disclaimer">⚠️ 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.</string>
<string name="result_product_not_found">Product not found in Open Food Facts</string>
<string name="result_try_ocr">Would you like to take a photo of the ingredients?</string>
<string name="ocr_capture_title">Photograph the ingredients</string>
<string name="ocr_capture_hint">Frame the ingredient list</string>
<string name="ocr_capture_action">Capture</string>
<string name="ocr_review_title">Review text</string>
<string name="ocr_review_hint">Fix the text if needed before analysis.</string>
<string name="ocr_no_text">No text detected. Try again.</string>
<string name="profile_list_title">Profiles</string>
<string name="profile_new">New profile</string>
<string name="profile_edit_title">Edit profile</string>
<string name="profile_name">Profile name</string>
<string name="profile_avatar">Avatar</string>
<string name="profile_allergies">Allergies (severe)</string>
<string name="profile_allergies_help">Trigger a DANGER verdict</string>
<string name="profile_intolerances">Intolerances (moderate)</string>
<string name="profile_intolerances_help">Trigger a WARNING verdict</string>
<string name="profile_restrictions">Dietary restrictions</string>
<string name="profile_restriction_vegan">Vegan</string>
<string name="profile_restriction_vegetarian">Vegetarian</string>
<string name="profile_restriction_halal">Halal</string>
<string name="profile_restriction_kosher">Kosher</string>
<string name="profile_restriction_no_pork">No pork</string>
<string name="profile_set_default">Set as default</string>
<string name="profile_default_badge">Default</string>
<string name="profile_delete_confirm">Delete this profile?</string>
<string name="profile_select_for_scan">Use for scanning</string>
<string name="history_title">History</string>
<string name="history_empty">No products scanned yet</string>
<string name="history_filter_all">All</string>
<string name="history_filter_danger">Danger</string>
<string name="history_filter_warning">Warning</string>
<string name="history_filter_safe">Safe</string>
<string name="history_search">Search</string>
<string name="history_clear_all">Clear all</string>
<string name="settings_title">Settings</string>
<string name="settings_language">App language</string>
<string name="settings_detection_language">Ingredient detection language</string>
<string name="settings_detection_fr">French</string>
<string name="settings_detection_en">English</string>
<string name="settings_detection_both">Both</string>
<string name="settings_haptics">Vibration on scan</string>
<string name="settings_sound">Sound on scan</string>
<string name="settings_theme">Theme</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_theme_system">System</string>
<string name="settings_clear_cache">Clear product cache</string>
<string name="settings_clear_history">Clear history</string>
<string name="settings_about">About</string>
<string name="settings_version">Version %1$s</string>
<string name="settings_off_attribution">Data provided by Open Food Facts</string>
<string name="error_no_connection">No Internet connection</string>
<string name="error_generic">An error occurred</string>
<string name="error_product_unavailable">Product unavailable. Try OCR.</string>
<string name="offline_indicator">Offline</string>
<string name="profile_custom_items">Custom items</string>
<string name="profile_custom_items_help">Add your own ingredients to watch for and assign them a tag.</string>
<string name="profile_custom_add">Add item</string>
<string name="profile_custom_name">Name (e.g. palm oil)</string>
<string name="profile_custom_tag">Tag</string>
<string name="profile_custom_tag_allergy">Allergy</string>
<string name="profile_custom_tag_intolerance">Intolerance</string>
<string name="profile_custom_tag_diet">Diet</string>
<string name="profile_custom_tag_unhealthy">Unhealthy</string>
<string name="profile_custom_empty">No custom items.</string>
<string name="result_open_in_off">View on Open Food Facts</string>
<string name="result_nutrition">Nutrition facts</string>
<string name="result_nutrition_per_100g">per 100 g</string>
<string name="result_nutrition_per_serving">per serving</string>
<string name="result_nutrition_serving_size">Serving size: %1$s</string>
<string name="result_nutrition_energy">Energy</string>
<string name="result_nutrition_fat">Fat</string>
<string name="result_nutrition_saturated_fat">of which saturated</string>
<string name="result_nutrition_carbs">Carbohydrates</string>
<string name="result_nutrition_sugars">of which sugars</string>
<string name="result_nutrition_fiber">Fiber</string>
<string name="result_nutrition_proteins">Proteins</string>
<string name="result_nutrition_salt">Salt</string>
<string name="result_nutrition_sodium">Sodium</string>
<string name="result_nutrition_unavailable">Nutrition information unavailable.</string>
<string name="result_scores_section">Indicators</string>
<string name="result_nutriscore">Nutri-Score</string>
<string name="result_nutriscore_details">Nutritional quality (A = best, E = avoid).</string>
<string name="result_nova">NOVA</string>
<string name="result_nova_details">Processing level (1 = unprocessed, 4 = ultra-processed).</string>
<string name="result_nova_1">Unprocessed or minimally processed foods</string>
<string name="result_nova_2">Processed culinary ingredients</string>
<string name="result_nova_3">Processed foods</string>
<string name="result_nova_4">Ultra-processed foods</string>
<string name="result_ecoscore">Eco-Score</string>
<string name="result_ecoscore_details">Environmental impact (A = low, E = high).</string>
<string name="result_health_verdict">Health verdict</string>
<string name="result_health_healthy">Healthy</string>
<string name="result_health_moderate">Consume in moderation</string>
<string name="result_health_unhealthy">Not recommended</string>
<string name="result_health_unknown">Not enough data</string>
<string name="result_custom_matches">Your custom matches</string>
<string name="settings_health_strictness">Health verdict strictness</string>
<string name="settings_health_lenient">Lenient</string>
<string name="settings_health_normal">Normal</string>
<string name="settings_health_strict">Strict</string>
<string name="allergen_gluten">Gluten</string>
<string name="allergen_peanuts">Peanuts</string>
<string name="allergen_tree_nuts">Tree Nuts</string>
<string name="allergen_milk">Milk</string>
<string name="allergen_eggs">Eggs</string>
<string name="allergen_soy">Soy</string>
<string name="allergen_fish">Fish</string>
<string name="allergen_crustaceans">Crustaceans</string>
<string name="allergen_sesame">Sesame</string>
<string name="allergen_mustard">Mustard</string>
<string name="allergen_sulphites">Sulphites</string>
<string name="allergen_lupin">Lupin</string>
<string name="allergen_molluscs">Molluscs</string>
<string name="allergen_celery">Celery</string>
</resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Dark variant of the window/splash background to avoid white flash
when the system is in dark mode. -->
<color name="window_background">#121212</color>
</resources>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Dark variant : parent Material (pas Light) pour que la window soit sombre
dès le splash, évitant tout flash blanc. windowLightStatusBar = false. -->
<style name="Theme.SafeBite" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/window_background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Single source of truth for window/splash background.
Toutes les autres couleurs sont définies dans Compose (Color.kt). -->
<color name="window_background">#FAFAFA</color>
<!-- Used by adaptive launcher icons (mipmap-anydpi-v26). -->
<color name="brand_primary">#1A237E</color>
</resources>

View File

@ -0,0 +1,209 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SafeBite</string>
<!-- Common -->
<string name="action_continue">Continuer</string>
<string name="action_next">Suivant</string>
<string name="action_back">Retour</string>
<string name="action_save">Enregistrer</string>
<string name="action_cancel">Annuler</string>
<string name="action_delete">Supprimer</string>
<string name="action_edit">Modifier</string>
<string name="action_retry">Réessayer</string>
<string name="action_close">Fermer</string>
<string name="action_done">Terminé</string>
<string name="action_analyze">Analyser</string>
<string name="action_scan_again">Scanner un autre produit</string>
<string name="action_read_ingredients">Lire les ingrédients (OCR)</string>
<!-- Onboarding -->
<string name="onboarding_welcome_title">Bienvenue sur SafeBite</string>
<string name="onboarding_welcome_subtitle">Scannez, vérifiez, mangez en toute sécurité.</string>
<string name="onboarding_how_title">Comment ça fonctionne</string>
<string name="onboarding_how_step1">1. Créez un profil d\'allergies</string>
<string name="onboarding_how_step2">2. Scannez le code-barres du produit</string>
<string name="onboarding_how_step3">3. Obtenez un verdict instantané</string>
<string name="onboarding_profile_title">Créez votre premier profil</string>
<string name="onboarding_permission_title">Autorisation caméra</string>
<string name="onboarding_permission_body">SafeBite a besoin de la caméra pour scanner les codes-barres et lire les étiquettes. Aucune image n\'est envoyée sur Internet.</string>
<string name="onboarding_permission_grant">Autoriser la caméra</string>
<string name="onboarding_ready_title">Vous êtes prêt !</string>
<string name="onboarding_ready_body">Scannez votre premier produit dès maintenant.</string>
<string name="onboarding_start">Commencer</string>
<!-- Home -->
<string name="home_scan_button">Scanner un produit</string>
<string name="home_ocr_button">Lire les ingrédients (OCR)</string>
<string name="home_active_profile">Profil actif</string>
<string name="home_change_profile">Changer</string>
<string name="home_recent_scans">Scans récents</string>
<string name="home_no_recent">Aucun scan récent</string>
<string name="home_no_profile_title">Aucun profil configuré</string>
<string name="home_no_profile_body">Créez un profil d\'allergies pour commencer.</string>
<string name="home_create_profile">Créer un profil</string>
<string name="nav_history">Historique</string>
<string name="nav_profiles">Profils</string>
<string name="nav_settings">Paramètres</string>
<!-- Scanner -->
<string name="scanner_title">Scanner un code-barres</string>
<string name="scanner_hint">Placez le code-barres dans le cadre</string>
<string name="scanner_torch">Lampe</string>
<string name="scanner_camera_denied">L\'accès à la caméra est nécessaire pour scanner.</string>
<string name="scanner_open_settings">Ouvrir les paramètres</string>
<!-- Result -->
<string name="result_safe_headline">AUCUN ALLERGÈNE DÉTECTÉ POUR VOTRE PROFIL</string>
<string name="result_warning_headline">CE PRODUIT PEUT CONTENIR DES TRACES D\'ALLERGÈNES</string>
<string name="result_danger_headline">CE PRODUIT CONTIENT DES ALLERGÈNES DE VOTRE PROFIL</string>
<string name="result_detected_allergens">Allergènes détectés</string>
<string name="result_no_allergen_detected">Aucun allergène détecté</string>
<string name="result_ingredients">Ingrédients</string>
<string name="result_ingredients_unavailable">Ingrédients non disponibles</string>
<string name="result_confidence">Confiance de l\'analyse</string>
<string name="result_confidence_high">Élevée</string>
<string name="result_confidence_medium">Moyenne</string>
<string name="result_confidence_low">Faible</string>
<string name="result_source_api">Open Food Facts</string>
<string name="result_source_cache">Cache local</string>
<string name="result_source_ocr">OCR (photo)</string>
<string name="result_profiles_checked">Profils vérifiés</string>
<string name="result_level_confirmed">Confirmé</string>
<string name="result_level_trace">Traces</string>
<string name="result_level_suspected">Suspecté</string>
<string name="result_disclaimer">⚠️ 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.</string>
<string name="result_product_not_found">Produit introuvable dans Open Food Facts</string>
<string name="result_try_ocr">Voulez-vous prendre une photo des ingrédients ?</string>
<!-- OCR -->
<string name="ocr_capture_title">Photographier les ingrédients</string>
<string name="ocr_capture_hint">Cadrez la liste d\'ingrédients</string>
<string name="ocr_capture_action">Capturer</string>
<string name="ocr_review_title">Vérifier le texte</string>
<string name="ocr_review_hint">Corrigez le texte si nécessaire avant l\'analyse.</string>
<string name="ocr_no_text">Aucun texte détecté. Réessayez.</string>
<!-- Profile -->
<string name="profile_list_title">Profils</string>
<string name="profile_new">Nouveau profil</string>
<string name="profile_edit_title">Modifier le profil</string>
<string name="profile_name">Nom du profil</string>
<string name="profile_avatar">Avatar</string>
<string name="profile_allergies">Allergies (sévères)</string>
<string name="profile_allergies_help">Déclenchent un DANGER</string>
<string name="profile_intolerances">Intolérances (modérées)</string>
<string name="profile_intolerances_help">Déclenchent un AVERTISSEMENT</string>
<string name="profile_restrictions">Restrictions alimentaires</string>
<string name="profile_restriction_vegan">Végane</string>
<string name="profile_restriction_vegetarian">Végétarien</string>
<string name="profile_restriction_halal">Halal</string>
<string name="profile_restriction_kosher">Casher</string>
<string name="profile_restriction_no_pork">Sans porc</string>
<string name="profile_set_default">Définir par défaut</string>
<string name="profile_default_badge">Par défaut</string>
<string name="profile_delete_confirm">Supprimer ce profil ?</string>
<string name="profile_select_for_scan">Utiliser pour le scan</string>
<!-- History -->
<string name="history_title">Historique</string>
<string name="history_empty">Aucun produit scanné pour l\'instant</string>
<string name="history_filter_all">Tous</string>
<string name="history_filter_danger">Danger</string>
<string name="history_filter_warning">Attention</string>
<string name="history_filter_safe">Safe</string>
<string name="history_search">Rechercher</string>
<string name="history_clear_all">Tout effacer</string>
<!-- Settings -->
<string name="settings_title">Paramètres</string>
<string name="settings_language">Langue de l\'application</string>
<string name="settings_detection_language">Langue de détection des ingrédients</string>
<string name="settings_detection_fr">Français</string>
<string name="settings_detection_en">Anglais</string>
<string name="settings_detection_both">Les deux</string>
<string name="settings_haptics">Vibration au scan</string>
<string name="settings_sound">Son au scan</string>
<string name="settings_theme">Thème</string>
<string name="settings_theme_light">Clair</string>
<string name="settings_theme_dark">Sombre</string>
<string name="settings_theme_system">Système</string>
<string name="settings_clear_cache">Vider le cache des produits</string>
<string name="settings_clear_history">Vider l\'historique</string>
<string name="settings_about">À propos</string>
<string name="settings_version">Version %1$s</string>
<string name="settings_off_attribution">Données fournies par Open Food Facts</string>
<!-- Errors -->
<string name="error_no_connection">Pas de connexion Internet</string>
<string name="error_generic">Une erreur est survenue</string>
<string name="error_product_unavailable">Produit indisponible. Essayez l\'OCR.</string>
<string name="offline_indicator">Hors ligne</string>
<!-- Custom diet items -->
<string name="profile_custom_items">Éléments personnalisés</string>
<string name="profile_custom_items_help">Ajoutez vos propres ingrédients à surveiller et attribuez-leur un tag.</string>
<string name="profile_custom_add">Ajouter un élément</string>
<string name="profile_custom_name">Nom (ex. huile de palme)</string>
<string name="profile_custom_tag">Tag</string>
<string name="profile_custom_tag_allergy">Allergie</string>
<string name="profile_custom_tag_intolerance">Intolérance</string>
<string name="profile_custom_tag_diet">Diète</string>
<string name="profile_custom_tag_unhealthy">Non-santé</string>
<string name="profile_custom_empty">Aucun élément personnalisé.</string>
<!-- Health / nutrition -->
<string name="result_open_in_off">Voir sur Open Food Facts</string>
<string name="result_nutrition">Valeurs nutritionnelles</string>
<string name="result_nutrition_per_100g">pour 100 g</string>
<string name="result_nutrition_per_serving">par portion</string>
<string name="result_nutrition_serving_size">Taille de portion : %1$s</string>
<string name="result_nutrition_energy">Énergie</string>
<string name="result_nutrition_fat">Matières grasses</string>
<string name="result_nutrition_saturated_fat">dont saturées</string>
<string name="result_nutrition_carbs">Glucides</string>
<string name="result_nutrition_sugars">dont sucres</string>
<string name="result_nutrition_fiber">Fibres</string>
<string name="result_nutrition_proteins">Protéines</string>
<string name="result_nutrition_salt">Sel</string>
<string name="result_nutrition_sodium">Sodium</string>
<string name="result_nutrition_unavailable">Informations nutritionnelles indisponibles.</string>
<string name="result_scores_section">Indicateurs</string>
<string name="result_nutriscore">Nutri-Score</string>
<string name="result_nutriscore_details">Qualité nutritionnelle (A = meilleure, E = à éviter).</string>
<string name="result_nova">NOVA</string>
<string name="result_nova_details">Degré de transformation (1 = non transformé, 4 = ultra-transformé).</string>
<string name="result_nova_1">Aliments non transformés ou peu transformés</string>
<string name="result_nova_2">Ingrédients culinaires transformés</string>
<string name="result_nova_3">Aliments transformés</string>
<string name="result_nova_4">Aliments ultra-transformés</string>
<string name="result_ecoscore">Éco-Score</string>
<string name="result_ecoscore_details">Impact environnemental (A = faible, E = élevé).</string>
<string name="result_health_verdict">Verdict santé</string>
<string name="result_health_healthy">Plutôt sain</string>
<string name="result_health_moderate">À consommer avec modération</string>
<string name="result_health_unhealthy">Peu recommandable</string>
<string name="result_health_unknown">Données insuffisantes</string>
<string name="result_custom_matches">Vos éléments détectés</string>
<string name="settings_health_strictness">Sévérité du verdict santé</string>
<string name="settings_health_lenient">Permissif</string>
<string name="settings_health_normal">Normal</string>
<string name="settings_health_strict">Strict</string>
<!-- Allergens -->
<string name="allergen_gluten">Gluten</string>
<string name="allergen_peanuts">Arachides</string>
<string name="allergen_tree_nuts">Noix</string>
<string name="allergen_milk">Lait</string>
<string name="allergen_eggs">Œufs</string>
<string name="allergen_soy">Soja</string>
<string name="allergen_fish">Poisson</string>
<string name="allergen_crustaceans">Crustacés</string>
<string name="allergen_sesame">Sésame</string>
<string name="allergen_mustard">Moutarde</string>
<string name="allergen_sulphites">Sulfites</string>
<string name="allergen_lupin">Lupin</string>
<string name="allergen_molluscs">Mollusques</string>
<string name="allergen_celery">Céleri</string>
</resources>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base theme (light). Le vrai rendu Compose gère tout : ici on assure
uniquement un splash / window background cohérent avec la surface. -->
<style name="Theme.SafeBite" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/window_background</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="database" path="." />
<include domain="sharedpref" path="." />
</full-backup-content>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="database" path="." />
<include domain="sharedpref" path="." />
</cloud-backup>
<device-transfer>
<include domain="database" path="." />
<include domain="sharedpref" path="." />
</device-transfer>
</data-extraction-rules>

View File

@ -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")
}
}

8
build.gradle.kts Normal file
View File

@ -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
}

105
build_apks.ps1 Normal file
View File

@ -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

1085
docs/flux-UX.md Normal file

File diff suppressed because it is too large Load Diff

10
gradle.properties Normal file
View File

@ -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

100
gradle/libs.versions.toml Normal file
View File

@ -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" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -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

248
gradlew vendored Normal file
View File

@ -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" "$@"

93
gradlew.bat vendored Normal file
View File

@ -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

24
settings.gradle.kts Normal file
View File

@ -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")

4
version.properties Normal file
View File

@ -0,0 +1,4 @@
MAJOR=1
MINOR=2
PATCH=0
CODE=3