first commit
This commit is contained in:
parent
83b92c942d
commit
134f23f9a7
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
build/
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.keystore
|
||||||
133
README.md
Normal file
133
README.md
Normal 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
146
app/build.gradle.kts
Normal 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
22
app/proguard-rules.pro
vendored
Normal 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.** { *; }
|
||||||
45
app/src/main/AndroidManifest.xml
Normal file
45
app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
15
app/src/main/java/com/safebite/app/SafeBiteApplication.kt
Normal file
15
app/src/main/java/com/safebite/app/SafeBiteApplication.kt
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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/"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -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() }
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
@ -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()
|
||||||
|
}
|
||||||
33
app/src/main/java/com/safebite/app/di/AppModule.kt
Normal file
33
app/src/main/java/com/safebite/app/di/AppModule.kt
Normal 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)
|
||||||
|
}
|
||||||
30
app/src/main/java/com/safebite/app/di/DatabaseModule.kt
Normal file
30
app/src/main/java/com/safebite/app/di/DatabaseModule.kt
Normal 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()
|
||||||
|
}
|
||||||
53
app/src/main/java/com/safebite/app/di/NetworkModule.kt
Normal file
53
app/src/main/java/com/safebite/app/di/NetworkModule.kt
Normal 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)
|
||||||
|
}
|
||||||
32
app/src/main/java/com/safebite/app/di/RepositoryModule.kt
Normal file
32
app/src/main/java/com/safebite/app/di/RepositoryModule.kt
Normal 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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
202
app/src/main/java/com/safebite/app/domain/model/AllergenType.kt
Normal file
202
app/src/main/java/com/safebite/app/domain/model/AllergenType.kt
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
152
app/src/main/java/com/safebite/app/domain/model/DomainModels.kt
Normal file
152
app/src/main/java/com/safebite/app/domain/model/DomainModels.kt
Normal 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
|
||||||
|
)
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() }
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
}
|
||||||
@ -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() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() }
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) }
|
||||||
|
}
|
||||||
@ -42,7 +42,7 @@ import com.safebite.app.domain.model.CustomItemTag
|
|||||||
@Composable
|
@Composable
|
||||||
fun AllergenGrid(selected: Set<AllergenType>, onToggle: (AllergenType) -> Unit) {
|
fun AllergenGrid(selected: Set<AllergenType>, onToggle: (AllergenType) -> Unit) {
|
||||||
FlowRow {
|
FlowRow {
|
||||||
AllergenType.values().forEach { a ->
|
AllergenType.entries.forEach { a ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = a in selected,
|
selected = a in selected,
|
||||||
onClick = { onToggle(a) },
|
onClick = { onToggle(a) },
|
||||||
@ -70,7 +70,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
|
|||||||
)
|
)
|
||||||
Text(stringResource(R.string.profile_custom_tag), style = MaterialTheme.typography.labelLarge)
|
Text(stringResource(R.string.profile_custom_tag), style = MaterialTheme.typography.labelLarge)
|
||||||
FlowRow {
|
FlowRow {
|
||||||
CustomItemTag.values().forEach { t ->
|
CustomItemTag.entries.forEach { t ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = tag == t,
|
selected = tag == t,
|
||||||
onClick = { tag = t },
|
onClick = { tag = t },
|
||||||
|
|||||||
@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) }
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 */ }
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() }
|
||||||
|
}
|
||||||
111
app/src/main/java/com/safebite/app/presentation/theme/Color.kt
Normal file
111
app/src/main/java/com/safebite/app/presentation/theme/Color.kt
Normal 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)
|
||||||
@ -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 }
|
||||||
@ -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)
|
||||||
|
)
|
||||||
@ -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 }
|
||||||
165
app/src/main/java/com/safebite/app/presentation/theme/Theme.kt
Normal file
165
app/src/main/java/com/safebite/app/presentation/theme/Theme.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
135
app/src/main/java/com/safebite/app/presentation/theme/Type.kt
Normal file
135
app/src/main/java/com/safebite/app/presentation/theme/Type.kt
Normal 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
|
||||||
|
)
|
||||||
|
)
|
||||||
60
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
60
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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>
|
||||||
196
app/src/main/res/values-en/strings.xml
Normal file
196
app/src/main/res/values-en/strings.xml
Normal 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>
|
||||||
6
app/src/main/res/values-night/colors.xml
Normal file
6
app/src/main/res/values-night/colors.xml
Normal 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>
|
||||||
11
app/src/main/res/values-night/themes.xml
Normal file
11
app/src/main/res/values-night/themes.xml
Normal 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>
|
||||||
8
app/src/main/res/values/colors.xml
Normal file
8
app/src/main/res/values/colors.xml
Normal 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>
|
||||||
209
app/src/main/res/values/strings.xml
Normal file
209
app/src/main/res/values/strings.xml
Normal 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>
|
||||||
11
app/src/main/res/values/themes.xml
Normal file
11
app/src/main/res/values/themes.xml
Normal 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>
|
||||||
5
app/src/main/res/xml/backup_rules.xml
Normal file
5
app/src/main/res/xml/backup_rules.xml
Normal 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>
|
||||||
11
app/src/main/res/xml/data_extraction_rules.xml
Normal file
11
app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||||
@ -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
8
build.gradle.kts
Normal 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
105
build_apks.ps1
Normal 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
1085
docs/flux-UX.md
Normal file
File diff suppressed because it is too large
Load Diff
10
gradle.properties
Normal file
10
gradle.properties
Normal 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
100
gradle/libs.versions.toml
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
gradlew
vendored
Normal 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
93
gradlew.bat
vendored
Normal 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
24
settings.gradle.kts
Normal 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
4
version.properties
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
MAJOR=1
|
||||||
|
MINOR=2
|
||||||
|
PATCH=0
|
||||||
|
CODE=3
|
||||||
Loading…
x
Reference in New Issue
Block a user