feat: add user testing plan for SafeBite app

- Created a comprehensive user testing plan document to validate the app's usability, reliability, accessibility, and resilience.
- Included various test scenarios covering onboarding, product scanning, manual barcode entry, and accessibility features.

chore: update dependencies for code quality tools

- Added ktlint version 12.2.0 for Kotlin code style enforcement.
- Added detekt version 1.23.7 for static code analysis.

chore: increment version numbers

- Updated MINOR version to 32 and CODE to 43 in version.properties to reflect recent changes.
This commit is contained in:
Bruno Charest 2026-05-11 15:13:18 -04:00
parent 9e021397b7
commit c4add0a3fe
119 changed files with 11196 additions and 7958 deletions

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
# SafeBite — Ktlint configuration
# https://pinterest.github.io/ktlint/latest/rules/configuration-ktlint/
[*.kt]
# Disable function naming check — Compose @Composable functions use PascalCase
ktlint_standard_function-naming = disabled
# Max line length
ktlint_standard_max-line-length = disabled

View File

@ -5,6 +5,48 @@ Tous les changements notables de SafeBite seront documentés dans ce fichier.
Le format est basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.28.0] — 2026-05-11
### Ajouté
- **Haptic feedback sur le FAB Scanner** (15ms, distinct du scan 60ms)
- **Saisie manuelle du code-barres** dans le Scanner (`AlertDialog` avec `OutlinedTextField`, validation 8-13 chiffres)
- **Bouton "Voir les alternatives"** dans ResultScreen (visible si verdict != SAFE, navigation vers ProductDetail)
- **Dashboard données réelles** :
- Stats hebdomadaires (✅ % safe / ⚠️ warnings / ❌ dangers)
- 5 derniers scans avec verdict, marque, temps relatif
- 3 modes contextuels auto-détectés (FIRST_TIME / STORE / HOME)
- **Animations stagger** sur les actions ResultScreen (fadeIn + slideInVertically, délais 0/50/100/150ms)
- **Animation slide-up** sur le contenu ResultScreen (250ms, ease-out)
- **Validation format code-barres** dans le Scanner (manuel) et BarcodeAnalyzer (ML Kit)
### Modifié
- `DashboardViewModel` : injecte `GetScanHistoryUseCase`, calcule `WeeklyStats`
- `DashboardScreen` : 3 layouts contextuels distincts (FirstTimeContent, StoreContent, HomeContent)
- `ResultScreen` : nouveau callback `onOpenAlternatives`, composant `StaggeredAction`
- `ScannerScreen` : état `manualCode` + `AlertDialog` saisie manuelle
- `MainScreen` : `SafeBiteFab` avec retour haptique 15ms
- `NavGraph` : navigation `ResultScreen.onOpenAlternatives``ProductDetail`
### Vérifié
- Mode sombre : `StatusColors` light/dark correctement câblés dans `Theme.kt`
- Contraste Material 3 conforme WCAG 2.1 AA
---
## [1.27.0] — 2026-05-10
### Ajouté
- **Catalogue** : écrans Catalog, DomainCategories, CategoryItems, CatalogSearch
- **Gestion avancée des listes** : création, tri, région, nom/image, membres
- **Splash screen** configurable
- **Paramètres liste** : ListSettingsScreen, ListSortScreen, ListRegionScreen
### Modifié
- Navigation enrichie : routes Catalog*, ListCreate, ListSettings*
- `MainScreen` : bottom bar et FAB avec animations scale/fade/slide
---
## [1.2.0] — 2026-04-26
### Ajouté

View File

@ -1,4 +1,4 @@
# SafeBite
# SafeBite - Scan d'allergènes en épicerie
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.

View File

@ -7,6 +7,8 @@ plugins {
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.ktlint)
alias(libs.plugins.detekt)
}
android {
@ -154,3 +156,24 @@ dependencies {
// LeakCanary pour détection de fuites mémoire (debug uniquement)
debugImplementation(libs.leakcanary.android)
}
// Ktlint — formatage Kotlin
ktlint {
android = true
ignoreFailures = true
reporters {
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN)
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
}
}
// Detekt — analyse statique
detekt {
buildUponDefaultConfig = true
allRules = false
autoCorrect = false
parallel = true
ignoreFailures = true
config.setFrom(file("$rootDir/config/detekt/detekt.yml"))
baseline = file("$rootDir/config/detekt/baseline.xml")
}

View File

@ -12,7 +12,6 @@ import org.junit.runner.RunWith
*/
@RunWith(AndroidJUnit4::class)
class ExampleComposeTest {
@get:Rule
val composeTestRule = createComposeRule()

View File

@ -12,7 +12,6 @@ import javax.inject.Inject
@HiltAndroidApp
class SafeBiteApplication : Application() {
@Inject lateinit var catalogSeedManager: CatalogSeedManager
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

View File

@ -10,16 +10,14 @@ import com.safebite.app.domain.model.SafetyStatus
class Converters {
@TypeConverter
fun allergenSetToString(set: Set<AllergenType>?): String =
set.orEmpty().joinToString(",") { it.name }
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 }
fun restrictionSetToString(set: Set<DietaryRestriction>?): String = set.orEmpty().joinToString(",") { it.name }
@TypeConverter
fun stringToRestrictionSet(raw: String?): Set<DietaryRestriction> =
@ -29,12 +27,10 @@ class Converters {
.toSet()
@TypeConverter
fun stringListToString(list: List<String>?): String =
list.orEmpty().joinToString("\u0001")
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')
fun stringToStringList(raw: String?): List<String> = if (raw.isNullOrEmpty()) emptyList() else raw.split('\u0001')
@TypeConverter
fun safetyStatusToString(status: SafetyStatus): String = status.name
@ -55,7 +51,7 @@ class Converters {
listOf(
item.name.replace('|', '/').replace('\u0002', ' '),
item.tag.name,
item.keywords.joinToString(";") { it.replace(';', ',').replace('|', '/') }
item.keywords.joinToString(";") { it.replace(';', ',').replace('|', '/') },
).joinToString("|")
}
@ -67,9 +63,12 @@ class Converters {
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()
val keywords =
if (parts.size >= 3 && parts[2].isNotBlank()) {
parts[2].split(';').map { it.trim() }.filter { it.isNotBlank() }
} else {
emptyList()
}
CustomDietItem(name = name, tag = tag, keywords = keywords)
}
}

View File

@ -30,17 +30,21 @@ import com.safebite.app.data.local.database.entity.UserProfileEntity
ShoppingDomainEntity::class,
CategoryEntity::class,
CatalogItemEntity::class,
ItemCategoryCrossRef::class
ItemCategoryCrossRef::class,
],
version = 9,
exportSchema = false
exportSchema = false,
)
@TypeConverters(Converters::class)
abstract class SafeBiteDatabase : RoomDatabase() {
abstract fun userProfileDao(): UserProfileDao
abstract fun productCacheDao(): ProductCacheDao
abstract fun scanHistoryDao(): ScanHistoryDao
abstract fun shoppingListDao(): ShoppingListDao
abstract fun catalogDao(): CatalogDao
companion object {

View File

@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface CatalogDao {
// ── Domaines ──────────────────────────────────────────────────────────────
@Query("SELECT * FROM shopping_domains WHERE isActive = 1 ORDER BY sortOrder")
fun getAllDomains(): Flow<List<ShoppingDomainEntity>>
@ -67,9 +66,12 @@ interface CatalogDao {
popularity DESC,
name ASC
LIMIT :limit
"""
""",
)
fun searchItems(query: String, limit: Int = 20): Flow<List<CatalogItemEntity>>
fun searchItems(
query: String,
limit: Int = 20,
): Flow<List<CatalogItemEntity>>
@Query("SELECT * FROM catalog_items WHERE primaryCategoryId = :categoryId ORDER BY sortOrder, name")
fun getItemsForCategory(categoryId: String): Flow<List<CatalogItemEntity>>

View File

@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.Flow
*/
@Dao
interface ShoppingListDao {
// ── Shopping Lists ──────────────────────────────────────────────────────
@Query("SELECT * FROM shopping_lists WHERE isArchived = 0 ORDER BY displayOrder ASC, updatedAt DESC")
@ -65,7 +64,10 @@ interface ShoppingListDao {
suspend fun deleteItem(item: ShoppingListItemEntity)
@Query("UPDATE shopping_list_items SET isChecked = :checked WHERE id = :id")
suspend fun setItemChecked(id: Long, checked: Boolean)
suspend fun setItemChecked(
id: Long,
checked: Boolean,
)
@Query("UPDATE shopping_list_items SET isChecked = 0 WHERE listId = :listId")
suspend fun uncheckAllItems(listId: Long)
@ -101,7 +103,10 @@ interface ShoppingListDao {
// ── Transaction: ajouter un produit à une liste ─────────────────────────
@Transaction
suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) {
suspend fun addItemToList(
listId: Long,
item: ShoppingListItemEntity,
) {
// S'assurer que le listId est correct
val itemWithCorrectList = item.copy(listId = listId)
insertItem(itemWithCorrectList)

View File

@ -22,18 +22,20 @@ data class ShoppingDomainEntity(
val iconResName: String? = null,
val color: String? = null,
val sortOrder: Int,
val isActive: Boolean = true
val isActive: Boolean = true,
)
@Entity(
tableName = "categories",
foreignKeys = [ForeignKey(
entity = ShoppingDomainEntity::class,
parentColumns = ["domainId"],
childColumns = ["domainId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("domainId"), Index("name")]
foreignKeys = [
ForeignKey(
entity = ShoppingDomainEntity::class,
parentColumns = ["domainId"],
childColumns = ["domainId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("domainId"), Index("name")],
)
data class CategoryEntity(
@PrimaryKey val categoryId: String,
@ -43,22 +45,24 @@ data class CategoryEntity(
val iconResName: String? = null,
val color: String? = null,
val sortOrder: Int,
val isActive: Boolean = true
val isActive: Boolean = true,
)
@Entity(
tableName = "catalog_items",
foreignKeys = [ForeignKey(
entity = CategoryEntity::class,
parentColumns = ["categoryId"],
childColumns = ["primaryCategoryId"],
onDelete = ForeignKey.SET_NULL
)],
foreignKeys = [
ForeignKey(
entity = CategoryEntity::class,
parentColumns = ["categoryId"],
childColumns = ["primaryCategoryId"],
onDelete = ForeignKey.SET_NULL,
),
],
indices = [
Index("primaryCategoryId"),
Index("name"),
Index("barcode")
]
Index("barcode"),
],
)
data class CatalogItemEntity(
@PrimaryKey val itemId: String,
@ -72,7 +76,7 @@ data class CatalogItemEntity(
val variants: String = "",
val isUserCreated: Boolean = false,
val popularity: Int = 0,
val sortOrder: Int = 0
val sortOrder: Int = 0,
)
@Entity(
@ -83,18 +87,18 @@ data class CatalogItemEntity(
entity = CatalogItemEntity::class,
parentColumns = ["itemId"],
childColumns = ["itemId"],
onDelete = ForeignKey.CASCADE
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = CategoryEntity::class,
parentColumns = ["categoryId"],
childColumns = ["categoryId"],
onDelete = ForeignKey.CASCADE
)
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("categoryId")]
indices = [Index("categoryId")],
)
data class ItemCategoryCrossRef(
val itemId: String,
val categoryId: String
val categoryId: String,
)

View File

@ -17,7 +17,7 @@ data class UserProfileEntity(
val moderateIntolerances: Set<AllergenType>,
val dietaryRestrictions: Set<DietaryRestriction>,
val customItems: List<CustomDietItem> = emptyList(),
val isDefault: Boolean
val isDefault: Boolean,
)
@Entity(tableName = "product_cache")
@ -45,7 +45,7 @@ data class ProductCacheEntity(
val fiber100g: Double? = null,
val proteins100g: Double? = null,
val carbohydrates100g: Double? = null,
val cachedAt: Long
val cachedAt: Long,
)
@Entity(tableName = "scan_history")
@ -58,7 +58,7 @@ data class ScanHistoryEntity(
val safetyStatus: SafetyStatus,
val profileNames: List<String>,
val scannedAt: Long,
val source: DataSource
val source: DataSource,
)
// =============================================================================
@ -77,7 +77,7 @@ data class ShoppingListEntity(
val sortType: String = "category",
val displayOrder: Int = 0,
val visibleCategories: String? = null,
val categoryOrder: String? = null
val categoryOrder: String? = null,
)
@Entity(
@ -87,10 +87,10 @@ data class ShoppingListEntity(
entity = ShoppingListEntity::class,
parentColumns = ["id"],
childColumns = ["listId"],
onDelete = androidx.room.ForeignKey.CASCADE
)
onDelete = androidx.room.ForeignKey.CASCADE,
),
],
indices = [androidx.room.Index("listId")]
indices = [androidx.room.Index("listId")],
)
data class ShoppingListItemEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
@ -100,13 +100,13 @@ data class ShoppingListItemEntity(
val brand: String? = null,
val imageUrl: String? = null,
val isChecked: Boolean = false,
val category: String? = null, // "Frais", "Épicerie", etc.
val safetyStatus: String? = null, // "SAFE", "WARNING", "DANGER"
val category: String? = null, // "Frais", "Épicerie", etc.
val safetyStatus: String? = null, // "SAFE", "WARNING", "DANGER"
val allergenWarning: String? = null, // Allergène détecté pour alerte
val note: String? = null, // Quantité / description libre (ex: "2 kg")
val customEmoji: String? = null, // Emoji personnalisé choisi par l'utilisateur
val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever"
val addedAt: Long = System.currentTimeMillis()
val note: String? = null, // Quantité / description libre (ex: "2 kg")
val customEmoji: String? = null, // Emoji personnalisé choisi par l'utilisateur
val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever"
val addedAt: Long = System.currentTimeMillis(),
)
@Entity(
@ -116,10 +116,10 @@ data class ShoppingListItemEntity(
entity = ShoppingListEntity::class,
parentColumns = ["id"],
childColumns = ["listId"],
onDelete = androidx.room.ForeignKey.CASCADE
)
onDelete = androidx.room.ForeignKey.CASCADE,
),
],
indices = [androidx.room.Index("listId")]
indices = [androidx.room.Index("listId")],
)
data class ShoppingListMemberEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
@ -127,6 +127,6 @@ data class ShoppingListMemberEntity(
val name: String,
val email: String,
val avatarUrl: String? = null,
val role: String = "member", // "owner" | "member"
val joinedAt: Long = System.currentTimeMillis()
val role: String = "member", // "owner" | "member"
val joinedAt: Long = System.currentTimeMillis(),
)

View File

@ -8,73 +8,74 @@ import androidx.sqlite.db.SupportSQLiteDatabase
* (domaines, catégories, articles, cross-ref) + leurs index. Aucune
* donnée existante n'est touchée. Le seed JSON est appliqué après ouverture.
*/
val MIGRATION_7_8: Migration = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS shopping_domains (
domainId TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
emoji TEXT NOT NULL,
iconResName TEXT,
color TEXT,
sortOrder INTEGER NOT NULL,
isActive INTEGER NOT NULL
val MIGRATION_7_8: Migration =
object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS shopping_domains (
domainId TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
emoji TEXT NOT NULL,
iconResName TEXT,
color TEXT,
sortOrder INTEGER NOT NULL,
isActive INTEGER NOT NULL
)
""".trimIndent(),
)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS categories (
categoryId TEXT NOT NULL PRIMARY KEY,
domainId TEXT NOT NULL,
name TEXT NOT NULL,
emoji TEXT NOT NULL,
iconResName TEXT,
color TEXT,
sortOrder INTEGER NOT NULL,
isActive INTEGER NOT NULL,
FOREIGN KEY(domainId) REFERENCES shopping_domains(domainId) ON DELETE CASCADE
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS categories (
categoryId TEXT NOT NULL PRIMARY KEY,
domainId TEXT NOT NULL,
name TEXT NOT NULL,
emoji TEXT NOT NULL,
iconResName TEXT,
color TEXT,
sortOrder INTEGER NOT NULL,
isActive INTEGER NOT NULL,
FOREIGN KEY(domainId) REFERENCES shopping_domains(domainId) ON DELETE CASCADE
)
""".trimIndent(),
)
""".trimIndent()
)
db.execSQL("CREATE INDEX IF NOT EXISTS index_categories_domainId ON categories(domainId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_categories_name ON categories(name)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_categories_domainId ON categories(domainId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_categories_name ON categories(name)")
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS catalog_items (
itemId TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
primaryCategoryId TEXT,
emoji TEXT NOT NULL,
iconUrl TEXT,
barcode TEXT,
aliases TEXT NOT NULL,
tags TEXT NOT NULL,
isUserCreated INTEGER NOT NULL,
popularity INTEGER NOT NULL,
sortOrder INTEGER NOT NULL,
FOREIGN KEY(primaryCategoryId) REFERENCES categories(categoryId) ON DELETE SET NULL
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS catalog_items (
itemId TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
primaryCategoryId TEXT,
emoji TEXT NOT NULL,
iconUrl TEXT,
barcode TEXT,
aliases TEXT NOT NULL,
tags TEXT NOT NULL,
isUserCreated INTEGER NOT NULL,
popularity INTEGER NOT NULL,
sortOrder INTEGER NOT NULL,
FOREIGN KEY(primaryCategoryId) REFERENCES categories(categoryId) ON DELETE SET NULL
)
""".trimIndent(),
)
""".trimIndent()
)
db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_primaryCategoryId ON catalog_items(primaryCategoryId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_name ON catalog_items(name)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_barcode ON catalog_items(barcode)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_primaryCategoryId ON catalog_items(primaryCategoryId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_name ON catalog_items(name)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_barcode ON catalog_items(barcode)")
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS item_category_cross_ref (
itemId TEXT NOT NULL,
categoryId TEXT NOT NULL,
PRIMARY KEY(itemId, categoryId),
FOREIGN KEY(itemId) REFERENCES catalog_items(itemId) ON DELETE CASCADE,
FOREIGN KEY(categoryId) REFERENCES categories(categoryId) ON DELETE CASCADE
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS item_category_cross_ref (
itemId TEXT NOT NULL,
categoryId TEXT NOT NULL,
PRIMARY KEY(itemId, categoryId),
FOREIGN KEY(itemId) REFERENCES catalog_items(itemId) ON DELETE CASCADE,
FOREIGN KEY(categoryId) REFERENCES categories(categoryId) ON DELETE CASCADE
)
""".trimIndent(),
)
""".trimIndent()
)
db.execSQL("CREATE INDEX IF NOT EXISTS index_item_category_cross_ref_categoryId ON item_category_cross_ref(categoryId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_item_category_cross_ref_categoryId ON item_category_cross_ref(categoryId)")
}
}
}

View File

@ -7,10 +7,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase
* Migration additive : ajoute la colonne `variants` à la table `catalog_items`.
* Les données existantes conservent la valeur par défaut ('').
*/
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE catalog_items ADD COLUMN variants TEXT NOT NULL DEFAULT ''"
)
val MIGRATION_8_9: Migration =
object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE catalog_items ADD COLUMN variants TEXT NOT NULL DEFAULT ''",
)
}
}
}

View File

@ -11,7 +11,7 @@ import com.safebite.app.data.local.database.entity.ShoppingDomainEntity
data class DomainWithCategories(
@Embedded val domain: ShoppingDomainEntity,
@Relation(parentColumn = "domainId", entityColumn = "domainId")
val categories: List<CategoryEntity>
val categories: List<CategoryEntity>,
)
data class CategoryWithItems(
@ -19,13 +19,14 @@ data class CategoryWithItems(
@Relation(
parentColumn = "categoryId",
entityColumn = "itemId",
associateBy = Junction(
value = ItemCategoryCrossRef::class,
parentColumn = "categoryId",
entityColumn = "itemId"
)
associateBy =
Junction(
value = ItemCategoryCrossRef::class,
parentColumn = "categoryId",
entityColumn = "itemId",
),
)
val items: List<CatalogItemEntity>
val items: List<CatalogItemEntity>,
)
data class DomainWithCategoriesAndItems(
@ -33,7 +34,7 @@ data class DomainWithCategoriesAndItems(
@Relation(
entity = CategoryEntity::class,
parentColumn = "domainId",
entityColumn = "domainId"
entityColumn = "domainId",
)
val categoriesWithItems: List<CategoryWithItems>
val categoriesWithItems: List<CategoryWithItems>,
)

View File

@ -30,41 +30,46 @@ object UserPreferencesKeys {
}
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 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 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 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 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)
}
val healthStrictness: Flow<HealthStrictness> =
dataStore.data.map {
runCatching { HealthStrictness.valueOf(it[UserPreferencesKeys.HEALTH_STRICTNESS] ?: HealthStrictness.NORMAL.name) }
.getOrDefault(HealthStrictness.NORMAL)
}
val splashScreenEnabled: Flow<Boolean> = dataStore.data.map {
it[UserPreferencesKeys.SPLASH_SCREEN_ENABLED] ?: true
}
val splashScreenEnabled: Flow<Boolean> =
dataStore.data.map {
it[UserPreferencesKeys.SPLASH_SCREEN_ENABLED] ?: true
}
suspend fun setAppLanguage(value: AppLanguage) {
dataStore.edit { it[UserPreferencesKeys.APP_LANGUAGE] = value.name }

View File

@ -1,6 +1,7 @@
package com.safebite.app.data.local.seed
import android.content.Context
import android.util.Log
import androidx.room.withTransaction
import com.safebite.app.data.local.database.SafeBiteDatabase
import com.safebite.app.data.local.database.dao.CatalogDao
@ -8,7 +9,6 @@ import com.safebite.app.data.local.database.entity.CatalogItemEntity
import com.safebite.app.data.local.database.entity.CategoryEntity
import com.safebite.app.data.local.database.entity.ItemCategoryCrossRef
import com.safebite.app.data.local.database.entity.ShoppingDomainEntity
import android.util.Log
import com.squareup.moshi.Moshi
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
@ -22,96 +22,101 @@ import javax.inject.Singleton
* lancement (ou après une migration qui a créé les tables vides).
*/
@Singleton
class CatalogSeedManager @Inject constructor(
@ApplicationContext private val context: Context,
private val database: SafeBiteDatabase,
private val catalogDao: CatalogDao,
private val moshi: Moshi
) {
suspend fun seedIfNeeded() = withContext(Dispatchers.IO) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val storedVersion = prefs.getInt(PREF_SEED_VERSION, 0)
val jsonVersion = runCatching {
val json = context.assets.open(SEED_ASSET).bufferedReader().use { it.readText() }
moshi.adapter(CatalogSeed::class.java).fromJson(json)?.version ?: 0
}.getOrElse { 0 }
Log.i(TAG, "Catalog seed check: storedVersion=$storedVersion, jsonVersion=$jsonVersion")
if (jsonVersion <= storedVersion) return@withContext
runCatching { seedFromJson() }
.onSuccess {
Log.i(TAG, "Catalog seeded successfully v$jsonVersion")
prefs.edit().putInt(PREF_SEED_VERSION, jsonVersion).apply()
}
.onFailure {
Log.e(TAG, "Catalog seed failed: ${it.message}", it)
Timber.e(it, "Catalog seed failed")
}
}
private suspend fun seedFromJson() {
val json = context.assets.open(SEED_ASSET).bufferedReader().use { it.readText() }
val adapter = moshi.adapter(CatalogSeed::class.java)
val seed = adapter.fromJson(json) ?: error("catalog_seed.json invalide")
database.withTransaction {
seed.domains.forEach { domainSeed ->
catalogDao.insertDomains(
listOf(
ShoppingDomainEntity(
domainId = domainSeed.domainId,
name = domainSeed.name,
emoji = domainSeed.emoji,
color = domainSeed.color,
sortOrder = domainSeed.sortOrder,
isActive = true
)
)
)
domainSeed.categories.forEach { catSeed ->
catalogDao.insertCategories(
listOf(
CategoryEntity(
categoryId = catSeed.categoryId,
domainId = domainSeed.domainId,
name = catSeed.name,
emoji = catSeed.emoji,
color = catSeed.color,
sortOrder = catSeed.sortOrder,
isActive = true
)
)
)
val items = catSeed.items.mapIndexed { index, itemSeed ->
CatalogItemEntity(
itemId = itemSeed.itemId,
name = itemSeed.name,
primaryCategoryId = catSeed.categoryId,
emoji = itemSeed.emoji,
aliases = itemSeed.aliases.orEmpty(),
tags = itemSeed.tags.orEmpty(),
variants = itemSeed.variants.orEmpty(),
barcode = itemSeed.barcode,
sortOrder = index
)
class CatalogSeedManager
@Inject
constructor(
@ApplicationContext private val context: Context,
private val database: SafeBiteDatabase,
private val catalogDao: CatalogDao,
private val moshi: Moshi,
) {
suspend fun seedIfNeeded() =
withContext(Dispatchers.IO) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val storedVersion = prefs.getInt(PREF_SEED_VERSION, 0)
val jsonVersion =
runCatching {
val json = context.assets.open(SEED_ASSET).bufferedReader().use { it.readText() }
moshi.adapter(CatalogSeed::class.java).fromJson(json)?.version ?: 0
}.getOrElse { 0 }
Log.i(TAG, "Catalog seed check: storedVersion=$storedVersion, jsonVersion=$jsonVersion")
if (jsonVersion <= storedVersion) return@withContext
runCatching { seedFromJson() }
.onSuccess {
Log.i(TAG, "Catalog seeded successfully v$jsonVersion")
prefs.edit().putInt(PREF_SEED_VERSION, jsonVersion).apply()
}
if (items.isNotEmpty()) {
catalogDao.insertItems(items)
catalogDao.insertCrossRefs(
items.map { ItemCategoryCrossRef(it.itemId, catSeed.categoryId) }
.onFailure {
Log.e(TAG, "Catalog seed failed: ${it.message}", it)
Timber.e(it, "Catalog seed failed")
}
}
private suspend fun seedFromJson() {
val json = context.assets.open(SEED_ASSET).bufferedReader().use { it.readText() }
val adapter = moshi.adapter(CatalogSeed::class.java)
val seed = adapter.fromJson(json) ?: error("catalog_seed.json invalide")
database.withTransaction {
seed.domains.forEach { domainSeed ->
catalogDao.insertDomains(
listOf(
ShoppingDomainEntity(
domainId = domainSeed.domainId,
name = domainSeed.name,
emoji = domainSeed.emoji,
color = domainSeed.color,
sortOrder = domainSeed.sortOrder,
isActive = true,
),
),
)
domainSeed.categories.forEach { catSeed ->
catalogDao.insertCategories(
listOf(
CategoryEntity(
categoryId = catSeed.categoryId,
domainId = domainSeed.domainId,
name = catSeed.name,
emoji = catSeed.emoji,
color = catSeed.color,
sortOrder = catSeed.sortOrder,
isActive = true,
),
),
)
val items =
catSeed.items.mapIndexed { index, itemSeed ->
CatalogItemEntity(
itemId = itemSeed.itemId,
name = itemSeed.name,
primaryCategoryId = catSeed.categoryId,
emoji = itemSeed.emoji,
aliases = itemSeed.aliases.orEmpty(),
tags = itemSeed.tags.orEmpty(),
variants = itemSeed.variants.orEmpty(),
barcode = itemSeed.barcode,
sortOrder = index,
)
}
if (items.isNotEmpty()) {
catalogDao.insertItems(items)
catalogDao.insertCrossRefs(
items.map { ItemCategoryCrossRef(it.itemId, catSeed.categoryId) },
)
}
}
}
}
Timber.i("Catalog seeded: %d domains", seed.domains.size)
}
Timber.i("Catalog seeded: %d domains", seed.domains.size)
}
companion object {
private const val SEED_ASSET = "catalog_seed.json"
private const val TAG = "CatalogSeedManager"
private const val PREFS_NAME = "catalog_seed_prefs"
private const val PREF_SEED_VERSION = "seed_version"
companion object {
private const val SEED_ASSET = "catalog_seed.json"
private const val TAG = "CatalogSeedManager"
private const val PREFS_NAME = "catalog_seed_prefs"
private const val PREF_SEED_VERSION = "seed_version"
}
}
}

View File

@ -10,7 +10,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CatalogSeed(
val version: Int,
val domains: List<DomainSeed>
val domains: List<DomainSeed>,
)
@JsonClass(generateAdapter = true)
@ -20,7 +20,7 @@ data class DomainSeed(
val emoji: String,
val color: String? = null,
val sortOrder: Int,
val categories: List<CategorySeed>
val categories: List<CategorySeed>,
)
@JsonClass(generateAdapter = true)
@ -30,7 +30,7 @@ data class CategorySeed(
val emoji: String,
val color: String? = null,
val sortOrder: Int,
val items: List<ItemSeed>
val items: List<ItemSeed>,
)
@JsonClass(generateAdapter = true)
@ -41,5 +41,5 @@ data class ItemSeed(
val aliases: String? = null,
val tags: String? = null,
val variants: String? = null,
val barcode: String? = null
val barcode: String? = null,
)

View File

@ -7,7 +7,9 @@ import retrofit2.http.Path
interface OpenFoodFactsApi {
@GET("api/v2/product/{barcode}.json")
suspend fun getProduct(@Path("barcode") barcode: String): Response<ProductResponse>
suspend fun getProduct(
@Path("barcode") barcode: String,
): Response<ProductResponse>
companion object {
const val BASE_URL = "https://world.openfoodfacts.org/"

View File

@ -8,7 +8,7 @@ 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
@Json(name = "product") val product: ProductDto? = null,
)
@JsonClass(generateAdapter = true)
@ -30,7 +30,7 @@ data class ProductDto(
@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
@Json(name = "nutriments") val nutriments: NutrimentsDto? = null,
)
@JsonClass(generateAdapter = true)
@ -44,5 +44,5 @@ data class NutrimentsDto(
@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
@Json(name = "carbohydrates_100g") val carbohydrates100g: Double? = null,
)

View File

@ -6,82 +6,32 @@ 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 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(
fun NutrimentsDto.toDomain(): Nutriments =
Nutriments(
energyKcal100g = energyKcal100g,
energyKcalServing = energyKcalServing,
fat100g = fat100g,
@ -91,6 +41,63 @@ fun ProductCacheEntity.toDomain(): Product = Product(
sodium100g = sodium100g,
fiber100g = fiber100g,
proteins100g = proteins100g,
carbohydrates100g = carbohydrates100g
carbohydrates100g = carbohydrates100g,
)
fun Product.toCacheEntity(): ProductCacheEntity =
ProductCacheEntity(
barcode = barcode,
name = name,
brand = brand,
imageUrl = imageUrl,
ingredientsText = ingredientsText,
allergensTags = allergensTags,
tracesTags = tracesTags,
nutriScore = nutriScore,
novaGroup = novaGroup,
ecoScore = ecoScore,
servingSize = servingSize,
labels = labels,
categories = categories,
energyKcal100g = nutriments.energyKcal100g,
energyKcalServing = nutriments.energyKcalServing,
fat100g = nutriments.fat100g,
saturatedFat100g = nutriments.saturatedFat100g,
sugars100g = nutriments.sugars100g,
salt100g = nutriments.salt100g,
sodium100g = nutriments.sodium100g,
fiber100g = nutriments.fiber100g,
proteins100g = nutriments.proteins100g,
carbohydrates100g = nutriments.carbohydrates100g,
cachedAt = System.currentTimeMillis(),
)
fun ProductCacheEntity.toDomain(): Product =
Product(
barcode = barcode,
name = name,
brand = brand,
imageUrl = imageUrl,
ingredientsText = ingredientsText,
allergensTags = allergensTags,
tracesTags = tracesTags,
nutriScore = nutriScore,
novaGroup = novaGroup,
ecoScore = ecoScore,
servingSize = servingSize,
labels = labels,
categories = categories,
nutriments =
Nutriments(
energyKcal100g = energyKcal100g,
energyKcalServing = energyKcalServing,
fat100g = fat100g,
saturatedFat100g = saturatedFat100g,
sugars100g = sugars100g,
salt100g = salt100g,
sodium100g = sodium100g,
fiber100g = fiber100g,
proteins100g = proteins100g,
carbohydrates100g = carbohydrates100g,
),
)
)

View File

@ -18,65 +18,67 @@ import javax.inject.Singleton
* aux écrans Catalogue, Catégories, Articles et Recherche.
*/
@Singleton
class CatalogRepository @Inject constructor(
private val dao: CatalogDao
) {
class CatalogRepository
@Inject
constructor(
private val dao: CatalogDao,
) {
fun observeDomains(): Flow<List<ShoppingDomainEntity>> = dao.getAllDomains()
fun observeDomains(): Flow<List<ShoppingDomainEntity>> = dao.getAllDomains()
fun observeDomainsWithCategories(): Flow<List<DomainWithCategories>> = dao.getDomainsWithCategories()
fun observeDomainsWithCategories(): Flow<List<DomainWithCategories>> =
dao.getDomainsWithCategories()
fun observeDomainsWithCategoriesAndItems(): Flow<List<DomainWithCategoriesAndItems>> = dao.getDomainsWithCategoriesAndItems()
fun observeDomainsWithCategoriesAndItems(): Flow<List<DomainWithCategoriesAndItems>> =
dao.getDomainsWithCategoriesAndItems()
fun observeCategoriesForDomain(domainId: String): Flow<List<CategoryEntity>> = dao.getCategoriesForDomain(domainId)
fun observeCategoriesForDomain(domainId: String): Flow<List<CategoryEntity>> =
dao.getCategoriesForDomain(domainId)
fun observeCategoryWithItems(categoryId: String): Flow<CategoryWithItems?> = dao.getCategoryWithItems(categoryId)
fun observeCategoryWithItems(categoryId: String): Flow<CategoryWithItems?> =
dao.getCategoryWithItems(categoryId)
fun observeItemsForCategory(categoryId: String): Flow<List<CatalogItemEntity>> = dao.getItemsForCategory(categoryId)
fun observeItemsForCategory(categoryId: String): Flow<List<CatalogItemEntity>> =
dao.getItemsForCategory(categoryId)
fun observePopularItems(limit: Int = 15): Flow<List<CatalogItemEntity>> = dao.getPopularItems(limit)
fun observePopularItems(limit: Int = 15): Flow<List<CatalogItemEntity>> =
dao.getPopularItems(limit)
fun search(
query: String,
limit: Int = 20,
): Flow<List<CatalogItemEntity>> = dao.searchItems(query.trim(), limit)
fun search(query: String, limit: Int = 20): Flow<List<CatalogItemEntity>> =
dao.searchItems(query.trim(), limit)
suspend fun getDomain(domainId: String): ShoppingDomainEntity? = dao.getDomainById(domainId)
suspend fun getDomain(domainId: String): ShoppingDomainEntity? = dao.getDomainById(domainId)
suspend fun getCategory(categoryId: String): CategoryEntity? = dao.getCategoryById(categoryId)
suspend fun getItem(itemId: String): CatalogItemEntity? = dao.getItemById(itemId)
suspend fun getItemByBarcode(barcode: String): CatalogItemEntity? = dao.getItemByBarcode(barcode)
suspend fun incrementPopularity(itemId: String) = dao.incrementPopularity(itemId)
suspend fun getCategory(categoryId: String): CategoryEntity? = dao.getCategoryById(categoryId)
/**
* Crée un article personnalisé (généré par l'utilisateur), persisté avec
* `isUserCreated = true` afin de pouvoir filtrer / exporter ces ajouts.
*/
suspend fun createUserItem(
name: String,
emoji: String,
primaryCategoryId: String?,
aliases: String = "",
tags: String = ""
): CatalogItemEntity {
val item = CatalogItemEntity(
itemId = "user_${UUID.randomUUID()}",
name = name,
primaryCategoryId = primaryCategoryId,
emoji = emoji,
aliases = aliases,
tags = tags,
isUserCreated = true,
popularity = 0,
sortOrder = 0
)
dao.insertItem(item)
if (primaryCategoryId != null) {
dao.insertCrossRefs(listOf(ItemCategoryCrossRef(item.itemId, primaryCategoryId)))
suspend fun getItem(itemId: String): CatalogItemEntity? = dao.getItemById(itemId)
suspend fun getItemByBarcode(barcode: String): CatalogItemEntity? = dao.getItemByBarcode(barcode)
suspend fun incrementPopularity(itemId: String) = dao.incrementPopularity(itemId)
/**
* Crée un article personnalisé (généré par l'utilisateur), persisté avec
* `isUserCreated = true` afin de pouvoir filtrer / exporter ces ajouts.
*/
suspend fun createUserItem(
name: String,
emoji: String,
primaryCategoryId: String?,
aliases: String = "",
tags: String = "",
): CatalogItemEntity {
val item =
CatalogItemEntity(
itemId = "user_${UUID.randomUUID()}",
name = name,
primaryCategoryId = primaryCategoryId,
emoji = emoji,
aliases = aliases,
tags = tags,
isUserCreated = true,
popularity = 0,
sortOrder = 0,
)
dao.insertItem(item)
if (primaryCategoryId != null) {
dao.insertCrossRefs(listOf(ItemCategoryCrossRef(item.itemId, primaryCategoryId)))
}
return item
}
return item
}
}

View File

@ -16,67 +16,72 @@ 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 {
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()
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)
}
}
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")
}
}
}
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()}")
override suspend fun cacheProduct(product: Product) =
withContext(Dispatchers.IO) {
cacheDao.upsert(product.toCacheEntity())
}
val body = response.body()
val status = body?.status ?: 0
val dto = body?.product
if (status != 1 || dto == null) {
return@withContext ProductFetchResult.NotFound
override suspend fun getCachedProduct(barcode: String): Product? =
withContext(Dispatchers.IO) {
cacheDao.getByBarcode(barcode)?.toDomain()
}
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 clearCache() = withContext(Dispatchers.IO) { cacheDao.clear() }
override suspend fun getCachedProduct(barcode: String): Product? = withContext(Dispatchers.IO) {
cacheDao.getByBarcode(barcode)?.toDomain()
override suspend fun searchAlternatives(
category: String,
excludeAllergens: Set<String>,
limit: Int,
): List<Product> =
withContext(Dispatchers.IO) {
// TODO: Implémenter la recherche d'alternatives via l'API OFF
emptyList()
}
}
override suspend fun clearCache() = withContext(Dispatchers.IO) { cacheDao.clear() }
override suspend fun searchAlternatives(
category: String,
excludeAllergens: Set<String>,
limit: Int
): List<Product> = withContext(Dispatchers.IO) {
// TODO: Implémenter la recherche d'alternatives via l'API OFF
emptyList()
}
}

View File

@ -13,45 +13,48 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ScanHistoryRepositoryImpl @Inject constructor(
private val dao: ScanHistoryDao
) : ScanHistoryRepository {
class ScanHistoryRepositoryImpl
@Inject
constructor(
private val dao: ScanHistoryDao,
) : ScanHistoryRepository {
override fun observeHistory(): Flow<List<ScanHistoryItem>> = dao.observeAll().map { list -> list.map { it.toDomain() } }
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 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()
}
}
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
)
private fun ScanHistoryEntity.toDomain() =
ScanHistoryItem(
id = id,
barcode = barcode,
productName = productName,
brand = brand,
imageUrl = imageUrl,
safetyStatus = safetyStatus,
profileNames = profileNames,
scannedAt = scannedAt,
source = source,
)

View File

@ -10,24 +10,33 @@ 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 val splashScreenEnabled = prefs.splashScreenEnabled
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 val splashScreenEnabled = prefs.splashScreenEnabled
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)
override suspend fun setSplashScreenEnabled(enabled: Boolean) = prefs.setSplashScreenEnabled(enabled)
}
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)
override suspend fun setSplashScreenEnabled(enabled: Boolean) = prefs.setSplashScreenEnabled(enabled)
}

View File

@ -10,95 +10,96 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ShoppingListRepositoryImpl @Inject constructor(
private val dao: ShoppingListDao
) : ShoppingListRepository {
class ShoppingListRepositoryImpl
@Inject
constructor(
private val dao: ShoppingListDao,
) : ShoppingListRepository {
override fun observeActiveLists(): Flow<List<ShoppingListEntity>> = dao.observeActiveLists()
override fun observeActiveLists(): Flow<List<ShoppingListEntity>> =
dao.observeActiveLists()
override fun observeAllLists(): Flow<List<ShoppingListEntity>> = dao.observeAllLists()
override fun observeAllLists(): Flow<List<ShoppingListEntity>> =
dao.observeAllLists()
override suspend fun getListById(id: Long): ShoppingListEntity? = dao.getListById(id)
override suspend fun getListById(id: Long): ShoppingListEntity? =
dao.getListById(id)
override suspend fun createList(
name: String,
backgroundResName: String?,
): Long {
val list =
ShoppingListEntity(
name = name,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
backgroundResName = backgroundResName,
)
return dao.insertList(list)
}
override suspend fun createList(name: String, backgroundResName: String?): Long {
val list = ShoppingListEntity(
name = name,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
backgroundResName = backgroundResName
)
return dao.insertList(list)
override suspend fun updateList(list: ShoppingListEntity) {
dao.updateList(list.copy(updatedAt = System.currentTimeMillis()))
}
override suspend fun deleteList(list: ShoppingListEntity) {
dao.deleteList(list)
}
override suspend fun archiveList(id: Long) {
dao.archiveList(id)
}
override fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>> = dao.observeItems(listId)
override suspend fun getItems(listId: Long): List<ShoppingListItemEntity> = dao.getItems(listId)
override suspend fun addItem(item: ShoppingListItemEntity): Long = dao.insertItem(item)
override suspend fun updateItem(item: ShoppingListItemEntity) {
dao.updateItem(item)
}
override suspend fun deleteItem(item: ShoppingListItemEntity) {
dao.deleteItem(item)
}
override suspend fun setItemChecked(
id: Long,
checked: Boolean,
) {
dao.setItemChecked(id, checked)
}
override suspend fun uncheckAllItems(listId: Long) {
dao.uncheckAllItems(listId)
}
override suspend fun deleteAllItems(listId: Long) {
dao.deleteAllItems(listId)
}
override fun observeItemCount(listId: Long): Flow<Int> = dao.observeItemCount(listId)
override fun observeCheckedCount(listId: Long): Flow<Int> = dao.observeCheckedCount(listId)
override suspend fun addItemToList(
listId: Long,
item: ShoppingListItemEntity,
) {
dao.addItemToList(listId, item)
}
override fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>> = dao.observeMembers(listId)
override suspend fun addMember(member: ShoppingListMemberEntity): Long = dao.insertMember(member)
override suspend fun updateMember(member: ShoppingListMemberEntity) {
dao.updateMember(member)
}
override suspend fun removeMember(member: ShoppingListMemberEntity) {
dao.deleteMember(member)
}
override suspend fun deleteAllMembers(listId: Long) {
dao.deleteAllMembers(listId)
}
}
override suspend fun updateList(list: ShoppingListEntity) {
dao.updateList(list.copy(updatedAt = System.currentTimeMillis()))
}
override suspend fun deleteList(list: ShoppingListEntity) {
dao.deleteList(list)
}
override suspend fun archiveList(id: Long) {
dao.archiveList(id)
}
override fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>> =
dao.observeItems(listId)
override suspend fun getItems(listId: Long): List<ShoppingListItemEntity> =
dao.getItems(listId)
override suspend fun addItem(item: ShoppingListItemEntity): Long =
dao.insertItem(item)
override suspend fun updateItem(item: ShoppingListItemEntity) {
dao.updateItem(item)
}
override suspend fun deleteItem(item: ShoppingListItemEntity) {
dao.deleteItem(item)
}
override suspend fun setItemChecked(id: Long, checked: Boolean) {
dao.setItemChecked(id, checked)
}
override suspend fun uncheckAllItems(listId: Long) {
dao.uncheckAllItems(listId)
}
override suspend fun deleteAllItems(listId: Long) {
dao.deleteAllItems(listId)
}
override fun observeItemCount(listId: Long): Flow<Int> =
dao.observeItemCount(listId)
override fun observeCheckedCount(listId: Long): Flow<Int> =
dao.observeCheckedCount(listId)
override suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) {
dao.addItemToList(listId, item)
}
override fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>> =
dao.observeMembers(listId)
override suspend fun addMember(member: ShoppingListMemberEntity): Long =
dao.insertMember(member)
override suspend fun updateMember(member: ShoppingListMemberEntity) {
dao.updateMember(member)
}
override suspend fun removeMember(member: ShoppingListMemberEntity) {
dao.deleteMember(member)
}
override suspend fun deleteAllMembers(listId: Long) {
dao.deleteAllMembers(listId)
}
}

View File

@ -13,58 +13,68 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UserProfileRepositoryImpl @Inject constructor(
private val dao: UserProfileDao,
private val prefs: UserPreferences
) : UserProfileRepository {
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 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 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 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)
}
}
override suspend fun delete(profile: UserProfile) = withContext(Dispatchers.IO) {
dao.delete(profile.toEntity())
}
private fun UserProfileEntity.toDomain(): UserProfile =
UserProfile(
id = id,
name = name,
avatar = avatar,
severeAllergens = severeAllergens,
moderateIntolerances = moderateIntolerances,
dietaryRestrictions = dietaryRestrictions,
customItems = customItems,
isDefault = isDefault,
)
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
)
private fun UserProfile.toEntity(): UserProfileEntity =
UserProfileEntity(
id = id,
name = name,
avatar = avatar,
severeAllergens = severeAllergens,
moderateIntolerances = moderateIntolerances,
dietaryRestrictions = dietaryRestrictions,
customItems = customItems,
isDefault = isDefault,
)

View File

@ -11,7 +11,6 @@ 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
@ -19,18 +18,29 @@ class ConnectivityObserver(private val context: Context) {
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()
fun observe(): Flow<Boolean> =
callbackFlow {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val callback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(true)
}
override fun onLost(network: Network) {
trySend(false)
}
override fun onUnavailable() {
trySend(false)
}
}
val request =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
cm.registerNetworkCallback(request, callback)
trySend(isOnline())
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
}

View File

@ -16,18 +16,19 @@ 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)
fun provideUserPreferences(
@ApplicationContext context: Context,
): UserPreferences = UserPreferences(context.safeBiteDataStore)
@Provides
@Singleton
fun provideConnectivity(@ApplicationContext context: Context): ConnectivityObserver =
ConnectivityObserver(context)
fun provideConnectivity(
@ApplicationContext context: Context,
): ConnectivityObserver = ConnectivityObserver(context)
}

View File

@ -20,18 +20,23 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): SafeBiteDatabase =
fun provideDatabase(
@ApplicationContext context: Context,
): SafeBiteDatabase =
Room.databaseBuilder(context, SafeBiteDatabase::class.java, SafeBiteDatabase.NAME)
.addMigrations(MIGRATION_7_8, MIGRATION_8_9)
.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()
@Provides fun provideShoppingListDao(db: SafeBiteDatabase): ShoppingListDao = db.shoppingListDao()
@Provides fun provideCatalogDao(db: SafeBiteDatabase): CatalogDao = db.catalogDao()
}

View File

@ -10,7 +10,6 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object EngineModule {
@Provides
@Singleton
fun provideCategoryEngine(): CategoryEngine = CategoryEngine()

View File

@ -17,17 +17,17 @@ 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)
}
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)
@ -39,7 +39,10 @@ object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit =
fun provideRetrofit(
client: OkHttpClient,
moshi: Moshi,
): Retrofit =
Retrofit.Builder()
.baseUrl(OpenFoodFactsApi.BASE_URL)
.client(client)
@ -48,6 +51,5 @@ object NetworkModule {
@Provides
@Singleton
fun provideOpenFoodFactsApi(retrofit: Retrofit): OpenFoodFactsApi =
retrofit.create(OpenFoodFactsApi::class.java)
fun provideOpenFoodFactsApi(retrofit: Retrofit): OpenFoodFactsApi = retrofit.create(OpenFoodFactsApi::class.java)
}

View File

@ -19,7 +19,6 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository

View File

@ -9,8 +9,6 @@ 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
@ -27,16 +25,22 @@ import java.text.Normalizer
* 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)
)
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.
@ -48,7 +52,7 @@ object AllergenAnalysisEngine {
profiles: List<UserProfile>,
source: DataSource,
language: DetectionLanguage = DetectionLanguage.BOTH,
healthStrictness: HealthStrictness = HealthStrictness.NORMAL
healthStrictness: HealthStrictness = HealthStrictness.NORMAL,
): ScanResult {
if (profiles.isEmpty()) {
return ScanResult(
@ -59,7 +63,7 @@ object AllergenAnalysisEngine {
health = HealthClassifier.classify(product, emptyList(), healthStrictness),
analyzedProfiles = emptyList(),
confidence = AnalysisConfidence.LOW,
source = source
source = source,
)
}
@ -73,26 +77,28 @@ object AllergenAnalysisEngine {
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
)
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
)
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)
}
}
@ -110,32 +116,35 @@ object AllergenAnalysisEngine {
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
)
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
)
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) }
}
}
@ -144,41 +153,44 @@ object AllergenAnalysisEngine {
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 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 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 }
),
detectedAllergens =
detected.sortedWith(
compareByDescending<DetectedAllergen> { it.detectionLevel.ordinal == DetectionLevel.CONFIRMED.ordinal }
.thenByDescending { it.severe },
),
detectedCustomItems = customDetections,
health = health,
analyzedProfiles = profiles,
confidence = confidence,
source = source
source = source,
)
}
@ -186,7 +198,10 @@ object AllergenAnalysisEngine {
* 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> {
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.
@ -207,8 +222,8 @@ object AllergenAnalysisEngine {
DetectedCustomItem(
item = first,
matchedKeywords = hits,
profileIds = pairs.map { it.first.id }.distinct()
)
profileIds = pairs.map { it.first.id }.distinct(),
),
)
}
}
@ -220,9 +235,10 @@ object AllergenAnalysisEngine {
/** 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 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.
@ -233,14 +249,20 @@ object AllergenAnalysisEngine {
.trim()
}
private fun keywordsFor(allergen: AllergenType, language: DetectionLanguage): List<String> =
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> {
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() } }
@ -250,7 +272,10 @@ object AllergenAnalysisEngine {
* 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> {
private fun findKeywordMatches(
normalized: String,
keywords: List<String>,
): List<String> {
if (normalized.isBlank()) return emptyList()
val hits = mutableListOf<String>()
for (kw in keywords) {
@ -273,7 +298,10 @@ object AllergenAnalysisEngine {
}
}
private fun stripRegions(text: String, regions: List<String>): String {
private fun stripRegions(
text: String,
regions: List<String>,
): String {
var result = text
for (region in regions) {
result = result.replace(region, " ")
@ -283,7 +311,10 @@ object AllergenAnalysisEngine {
// endregion
private fun merge(existing: DetectedAllergen?, incoming: DetectedAllergen): DetectedAllergen {
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 = {
@ -297,28 +328,34 @@ object AllergenAnalysisEngine {
return best.copy(
matchedKeywords = (existing.matchedKeywords + incoming.matchedKeywords).distinct(),
source = if (best == existing) existing.source else incoming.source,
profileIds = (existing.profileIds + incoming.profileIds).distinct()
profileIds = (existing.profileIds + incoming.profileIds).distinct(),
)
}
private fun computeStatus(
detected: List<DetectedAllergen>,
severeSet: Set<AllergenType>,
customDetections: List<DetectedCustomItem>
customDetections: List<DetectedCustomItem>,
): SafetyStatus {
val hasSevereConfirmed = detected.any {
it.detectionLevel != DetectionLevel.TRACE && it.allergenType in severeSet
}
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
}
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 {
private fun computeConfidence(
product: Product,
source: DataSource,
hasAnyData: Boolean,
): AnalysisConfidence {
return when (source) {
DataSource.OCR -> AnalysisConfidence.LOW
DataSource.API, DataSource.CACHE -> {

View File

@ -9,71 +9,82 @@ import javax.inject.Singleton
* Associe des mots-clés à des catégories de magasin pour organiser les listes de courses.
*/
@Singleton
class CategoryEngine @Inject constructor() {
class CategoryEngine
@Inject
constructor() {
/**
* Détecte le rayon d'un produit basé sur son nom et ses catégories.
*/
fun detectCategory(
productName: String,
categories: List<String> = emptyList(),
): String {
val text = (listOf(productName) + categories).joinToString(" ").lowercase()
/**
* Détecte le rayon d'un produit basé sur son nom et ses catégories.
*/
fun detectCategory(productName: String, categories: List<String> = emptyList()): String {
val text = (listOf(productName) + categories).joinToString(" ").lowercase()
return when {
text.containsAny(freshKeywords) -> "Frais"
text.containsAny(fruitKeywords) -> "Fruits & Légumes"
text.containsAny(bakeryKeywords) -> "Boulangerie"
text.containsAny(meatKeywords) -> "Boucherie"
text.containsAny(dairyKeywords) -> "Produits laitiers"
text.containsAny(groceryKeywords) -> "Épicerie"
text.containsAny(beverageKeywords) -> "Boissons"
text.containsAny(frozenKeywords) -> "Surgelés"
text.containsAny(hygieneKeywords) -> "Hygiène"
text.containsAny(babyKeywords) -> "Bébé"
text.containsAny(petKeywords) -> "Animaux"
text.containsAny(cleaningKeywords) -> "Entretien"
else -> "Autre"
return when {
text.containsAny(freshKeywords) -> "Frais"
text.containsAny(fruitKeywords) -> "Fruits & Légumes"
text.containsAny(bakeryKeywords) -> "Boulangerie"
text.containsAny(meatKeywords) -> "Boucherie"
text.containsAny(dairyKeywords) -> "Produits laitiers"
text.containsAny(groceryKeywords) -> "Épicerie"
text.containsAny(beverageKeywords) -> "Boissons"
text.containsAny(frozenKeywords) -> "Surgelés"
text.containsAny(hygieneKeywords) -> "Hygiène"
text.containsAny(babyKeywords) -> "Bébé"
text.containsAny(petKeywords) -> "Animaux"
text.containsAny(cleaningKeywords) -> "Entretien"
else -> "Autre"
}
}
/**
* Catégorise une liste de produits et retourne un map catégorie -> produits.
*/
fun categorizeProducts(products: List<ProductInfo>): Map<String, List<ProductInfo>> {
return products
.groupBy { detectCategory(it.name, it.categories) }
.toSortedMap(compareBy { it })
}
data class ProductInfo(
val name: String,
val categories: List<String> = emptyList(),
)
private fun String.containsAny(keywords: List<String>): Boolean = keywords.any { this.contains(it) }
// ── Mots-clés par catégorie ─────────────────────────────────────────────
private val freshKeywords = listOf("frais", "fraise", "framboise", "myrtille", "salade", "tomate", "concombre", "crudite")
private val fruitKeywords =
listOf("pomme", "poire", "banane", "orange", "citron", "raisin", "fraise", "fruit", "abricot", "peche", "cerise", "melon", "pasteque", "ananas", "mangue", "kiwi", "legume", "carotte", "poireau", "epinard", "brocoli", "chou", "courgette", "aubergine", "poivron", "avocat", "haricot", "artichaut", "asperge", "betterave", "myrtille", "bok choy", "chou de bruxelles", "courge", "chou frise", "poiree", "tomate cerise", "chataigne", "chicoree", "piment", "ciboulette", "coriandre", "clementine", "canneberge", "cresson", "groseille", "aneth", "pitaya", "echalote", "edamame", "fenouil", "feve", "graines de lin", "baies de goji", "groseille a maquereau", "goyave", "citrouille", "herbes", "mache", "citron vert", "litchi", "mandarine", "marjolaine", "nectarine", "oignon", "olive", "papaye", "panais", "fruit de la passion", "patate", "pourpier", "coing", "chou rouge", "rhubarbe", "sauge", "salade au chou", "chou de milan", "carambole", "tomates sechees", "basilic thai", "thym", "herbe de ble", "salsifis", "girolle", "orange sanguine", "baies", "acai", "grenade", "prune", "figue", "datte", "navet", "radis", "celeri", "menthe", "persil", "basilic")
private val bakeryKeywords =
listOf("pain", "baguette", "biscotte", "croissant", "brioche", "boulang", "patisserie", "gateau", "tarte")
private val meatKeywords =
listOf("poulet", "boeuf", "porc", "agneau", "dinde", "veau", "jambon", "saucisse", "steak", "viande", "merguez", "lard", "bacon")
private val dairyKeywords =
listOf("lait", "yaourt", "fromage", "beurre", "creme", "oeuf", "laitier", "camembert", "emmental", "brie", "chevre", "mozzarella", "parmesan")
private val groceryKeywords =
listOf("riz", "pates", "spaghetti", "semoule", "quinoa", "lentille", "pois chiche", "haricot sec", "farine", "sucre", "sel", "huile", "vinaigre", "moutarde", "ketchup", "sauce", "epice", "herbe", "conserv", "soupe", "biscuit", "chocolat", "confiture", "miel", "cereale", "muesli")
private val beverageKeywords =
listOf("eau", "jus", "soda", "cola", "the", "cafe", "vin", "biere", "cidre", "champagne", "sirop", "nectar", "limonade", "boisson")
private val frozenKeywords = listOf("surgel", "congel", "glace", "sorbet", "pizza", "frite", "legume surgel", "fruit surgel")
private val hygieneKeywords =
listOf("savon", "shampoing", "dentifrice", "gel douche", "deodorant", "papier toilette", "mouchoir", "lingette", "creme solaire", "maquillage")
private val babyKeywords = listOf("bebe", "couche", "biberon", "lait bebe", "compote bebe", "petit suisse")
private val petKeywords = listOf("chien", "chat", "croquette", "patee", "oiseau", "poisson", "animal")
private val cleaningKeywords =
listOf("lessive", "adoucissant", "liquide vaisselle", "eponge", "chiffon", "javel", "nettoyant", "desinfectant", "aspirateur")
}
/**
* Catégorise une liste de produits et retourne un map catégorie -> produits.
*/
fun categorizeProducts(products: List<ProductInfo>): Map<String, List<ProductInfo>> {
return products
.groupBy { detectCategory(it.name, it.categories) }
.toSortedMap(compareBy { it })
}
data class ProductInfo(
val name: String,
val categories: List<String> = emptyList()
)
private fun String.containsAny(keywords: List<String>): Boolean =
keywords.any { this.contains(it) }
// ── Mots-clés par catégorie ─────────────────────────────────────────────
private val freshKeywords = listOf("frais", "fraise", "framboise", "myrtille", "salade", "tomate", "concombre", "crudite")
private val fruitKeywords = listOf("pomme", "poire", "banane", "orange", "citron", "raisin", "fraise", "fruit", "abricot", "peche", "cerise", "melon", "pasteque", "ananas", "mangue", "kiwi", "legume", "carotte", "poireau", "epinard", "brocoli", "chou", "courgette", "aubergine", "poivron", "avocat", "haricot", "artichaut", "asperge", "betterave", "myrtille", "bok choy", "chou de bruxelles", "courge", "chou frise", "poiree", "tomate cerise", "chataigne", "chicoree", "piment", "ciboulette", "coriandre", "clementine", "canneberge", "cresson", "groseille", "aneth", "pitaya", "echalote", "edamame", "fenouil", "feve", "graines de lin", "baies de goji", "groseille a maquereau", "goyave", "citrouille", "herbes", "mache", "citron vert", "litchi", "mandarine", "marjolaine", "nectarine", "oignon", "olive", "papaye", "panais", "fruit de la passion", "patate", "pourpier", "coing", "chou rouge", "rhubarbe", "sauge", "salade au chou", "chou de milan", "carambole", "tomates sechees", "basilic thai", "thym", "herbe de ble", "salsifis", "girolle", "orange sanguine", "baies", "acai", "grenade", "prune", "figue", "datte", "navet", "radis", "celeri", "menthe", "persil", "basilic")
private val bakeryKeywords = listOf("pain", "baguette", "biscotte", "croissant", "brioche", "boulang", "patisserie", "gateau", "tarte")
private val meatKeywords = listOf("poulet", "boeuf", "porc", "agneau", "dinde", "veau", "jambon", "saucisse", "steak", "viande", "merguez", "lard", "bacon")
private val dairyKeywords = listOf("lait", "yaourt", "fromage", "beurre", "creme", "oeuf", "laitier", "camembert", "emmental", "brie", "chevre", "mozzarella", "parmesan")
private val groceryKeywords = listOf("riz", "pates", "spaghetti", "semoule", "quinoa", "lentille", "pois chiche", "haricot sec", "farine", "sucre", "sel", "huile", "vinaigre", "moutarde", "ketchup", "sauce", "epice", "herbe", "conserv", "soupe", "biscuit", "chocolat", "confiture", "miel", "cereale", "muesli")
private val beverageKeywords = listOf("eau", "jus", "soda", "cola", "the", "cafe", "vin", "biere", "cidre", "champagne", "sirop", "nectar", "limonade", "boisson")
private val frozenKeywords = listOf("surgel", "congel", "glace", "sorbet", "pizza", "frite", "legume surgel", "fruit surgel")
private val hygieneKeywords = listOf("savon", "shampoing", "dentifrice", "gel douche", "deodorant", "papier toilette", "mouchoir", "lingette", "creme solaire", "maquillage")
private val babyKeywords = listOf("bebe", "couche", "biberon", "lait bebe", "compote bebe", "petit suisse")
private val petKeywords = listOf("chien", "chat", "croquette", "patee", "oiseau", "poisson", "animal")
private val cleaningKeywords = listOf("lessive", "adoucissant", "liquide vaisselle", "eponge", "chiffon", "javel", "nettoyant", "desinfectant", "aspirateur")
}

View File

@ -15,11 +15,10 @@ import com.safebite.app.domain.model.Product
* - 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
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 }
@ -30,13 +29,15 @@ object HealthClassifier {
if (unhealthyCustomHits.isNotEmpty()) {
reasons += "Contient: ${unhealthyCustomHits.joinToString()}"
rating = when (strictness) {
HealthStrictness.LENIENT -> when (rating) {
HealthRating.HEALTHY, HealthRating.UNKNOWN -> HealthRating.MODERATE
else -> rating
rating =
when (strictness) {
HealthStrictness.LENIENT ->
when (rating) {
HealthRating.HEALTHY, HealthRating.UNKNOWN -> HealthRating.MODERATE
else -> rating
}
HealthStrictness.NORMAL, HealthStrictness.STRICT -> HealthRating.UNHEALTHY
}
HealthStrictness.NORMAL, HealthStrictness.STRICT -> HealthRating.UNHEALTHY
}
}
// If we truly have nothing to judge on, keep UNKNOWN.
@ -49,7 +50,7 @@ object HealthClassifier {
reasons = reasons,
nutriScore = nutri,
novaGroup = nova,
ecoScore = eco
ecoScore = eco,
)
}
@ -57,7 +58,7 @@ object HealthClassifier {
nutri: String?,
nova: Int?,
strictness: HealthStrictness,
reasons: MutableList<String>
reasons: MutableList<String>,
): HealthRating {
// Score nutri (a=0 best, e=4 worst)
val nutriScoreValue = nutri?.let { it[0].code - 'a'.code }
@ -67,35 +68,38 @@ object HealthClassifier {
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
}
HealthStrictness.LENIENT ->
when {
nutriScoreValue != null && nutriScoreValue >= 3 && novaValue == 4 -> HealthRating.UNHEALTHY
nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.MODERATE
novaValue == 4 -> HealthRating.MODERATE
nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.HEALTHY
nutriScoreValue != null -> HealthRating.MODERATE
novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY
else -> HealthRating.UNKNOWN
}
HealthStrictness.NORMAL ->
when {
nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.UNHEALTHY
novaValue != null && novaValue >= 4 -> HealthRating.UNHEALTHY
nutriScoreValue == 2 && (novaValue ?: 1) >= 3 -> HealthRating.UNHEALTHY
nutriScoreValue == 2 -> HealthRating.MODERATE
novaValue == 3 && nutriScoreValue == null -> HealthRating.MODERATE
nutriScoreValue != null && nutriScoreValue <= 1 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY
nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.MODERATE
novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY
else -> HealthRating.UNKNOWN
}
HealthStrictness.STRICT ->
when {
nutriScoreValue != null && nutriScoreValue >= 2 -> HealthRating.UNHEALTHY
novaValue != null && novaValue >= 3 -> HealthRating.UNHEALTHY
nutriScoreValue == 1 -> HealthRating.MODERATE
nutriScoreValue == 0 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY
nutriScoreValue == 0 -> HealthRating.MODERATE
novaValue != null && novaValue <= 2 -> HealthRating.MODERATE
else -> HealthRating.UNKNOWN
}
}
}
}

View File

@ -11,192 +11,247 @@ enum class AllergenType(
val icon: String,
val openFoodFactsTags: List<String>,
val keywordsFr: List<String>,
val keywordsEn: List<String>
val keywordsEn: List<String>,
) {
GLUTEN(
"Gluten", "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"
"protéine de blé", "seitan",
),
listOf(
"gluten", "wheat", "rye", "barley", "oats", "spelt",
"kamut", "triticale", "malt", "wheat starch", "wheat flour"
)
"kamut", "triticale", "malt", "wheat starch", "wheat flour",
),
),
PEANUTS(
"Arachides", "Peanuts", "🥜",
"Arachides",
"Peanuts",
"🥜",
listOf("en:peanuts"),
listOf(
"arachide", "arachides", "cacahuète", "cacahuètes",
"beurre d'arachide", "huile d'arachide"
"arachide",
"arachides",
"cacahuète",
"cacahuètes",
"beurre d'arachide",
"huile d'arachide",
),
listOf(
"peanut", "peanuts", "peanut butter", "peanut oil",
"groundnut", "groundnuts"
)
"peanut",
"peanuts",
"peanut butter",
"peanut oil",
"groundnut",
"groundnuts",
),
),
TREE_NUTS(
"Noix", "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"
"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"
)
"marzipan", "almond paste",
),
),
MILK(
"Lait", "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"
"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"
)
"milk powder", "skim milk", "whole milk",
),
),
EGGS(
"Œufs", "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"
"œuf entier",
),
listOf(
"egg", "eggs", "albumin", "ovomucin", "ovomucoid",
"ovalbumin", "egg lecithin", "lysozyme", "egg yolk",
"egg white", "egg powder", "whole egg"
)
"egg white", "egg powder", "whole egg",
),
),
SOY(
"Soja", "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"
"huile de soja", "sauce soja", "miso",
),
listOf(
"soy", "soya", "soybean", "soybeans", "soy lecithin",
"soy protein", "tofu", "tempeh", "edamame",
"soybean oil", "soy sauce", "miso"
)
"soybean oil", "soy sauce", "miso",
),
),
FISH(
"Poisson", "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"
"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"
)
"fish sauce", "surimi", "fish gelatin",
),
),
CRUSTACEANS(
"Crustacés", "Crustaceans", "🦐",
"Crustacés",
"Crustaceans",
"🦐",
listOf("en:crustaceans"),
listOf(
"crustacé", "crustacés", "crevette", "crevettes",
"homard", "crabe", "langouste", "langoustine",
"écrevisse", "fruits de mer"
"écrevisse", "fruits de mer",
),
listOf(
"crustacean", "crustaceans", "shrimp", "lobster",
"crab", "crayfish", "prawn", "langoustine", "seafood"
)
"crab", "crayfish", "prawn", "langoustine", "seafood",
),
),
SESAME(
"Sésame", "Sesame", "",
"Sésame",
"Sesame",
"",
listOf("en:sesame-seeds"),
listOf(
"sésame", "graines de sésame", "huile de sésame",
"tahini", "tahina", "halva"
"sésame",
"graines de sésame",
"huile de sésame",
"tahini",
"tahina",
"halva",
),
listOf(
"sesame", "sesame seeds", "sesame oil", "tahini",
"tahina", "halva"
)
"sesame",
"sesame seeds",
"sesame oil",
"tahini",
"tahina",
"halva",
),
),
MUSTARD(
"Moutarde", "Mustard", "🟡",
"Moutarde",
"Mustard",
"🟡",
listOf("en:mustard"),
listOf(
"moutarde", "graines de moutarde", "huile de moutarde",
"farine de moutarde"
"moutarde",
"graines de moutarde",
"huile de moutarde",
"farine de moutarde",
),
listOf("mustard", "mustard seeds", "mustard oil", "mustard flour")
listOf("mustard", "mustard seeds", "mustard oil", "mustard flour"),
),
SULPHITES(
"Sulfites", "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"
"e220", "e221", "e222", "e223", "e224", "e225", "e226", "e228",
),
listOf(
"sulphite", "sulphites", "sulfite", "sulfites",
"sulphur dioxide", "bisulphite", "metabisulphite"
)
"sulphite",
"sulphites",
"sulfite",
"sulfites",
"sulphur dioxide",
"bisulphite",
"metabisulphite",
),
),
LUPIN(
"Lupin", "Lupin", "💐",
"Lupin",
"Lupin",
"💐",
listOf("en:lupin"),
listOf("lupin", "lupins", "farine de lupin"),
listOf("lupin", "lupine", "lupin flour")
listOf("lupin", "lupine", "lupin flour"),
),
MOLLUSCS(
"Mollusques", "Molluscs", "🐚",
"Mollusques",
"Molluscs",
"🐚",
listOf("en:molluscs"),
listOf(
"mollusque", "mollusques", "huître", "moule", "moules",
"palourde", "pétoncle", "calmar", "calamar", "pieuvre",
"poulpe", "escargot", "coquille saint-jacques"
"poulpe", "escargot", "coquille saint-jacques",
),
listOf(
"mollusc", "molluscs", "mollusk", "oyster", "mussel",
"clam", "scallop", "squid", "octopus", "snail"
)
"clam", "scallop", "squid", "octopus", "snail",
),
),
CELERY(
"Céleri", "Celery", "🥬",
"Céleri",
"Celery",
"🥬",
listOf("en:celery"),
listOf(
"céleri", "celeri", "sel de céleri", "graines de céleri",
"celeriac", "céleri-rave"
"céleri",
"celeri",
"sel de céleri",
"graines de céleri",
"celeriac",
"céleri-rave",
),
listOf("celery", "celeriac", "celery salt", "celery seed")
);
listOf("celery", "celeriac", "celery salt", "celery seed"),
),
;
companion object {
fun fromName(name: String): AllergenType? =
values().firstOrNull { it.name.equals(name, ignoreCase = true) }
fun fromName(name: String): AllergenType? = values().firstOrNull { it.name.equals(name, ignoreCase = true) }
}
}

View File

@ -13,7 +13,7 @@ enum class DietaryRestriction(val displayFr: String, val displayEn: String) {
VEGETARIAN("Végétarien", "Vegetarian"),
HALAL("Halal", "Halal"),
KOSHER("Casher", "Kosher"),
NO_PORK("Sans porc", "No pork")
NO_PORK("Sans porc", "No pork"),
}
enum class DetectionLanguage { FR, EN, BOTH }
@ -31,7 +31,7 @@ enum class CustomItemTag(val displayFr: String, val displayEn: String) {
ALLERGY("Allergie", "Allergy"),
INTOLERANCE("Intolérance", "Intolerance"),
DIET("Diète", "Diet"),
UNHEALTHY("Non-santé", "Unhealthy")
UNHEALTHY("Non-santé", "Unhealthy"),
}
/** A user-defined ingredient/substance to watch for (e.g. "huile de palme"). */
@ -39,10 +39,9 @@ data class CustomDietItem(
val name: String,
val tag: CustomItemTag,
/** Optional additional keywords; if empty, [name] is used. */
val keywords: List<String> = emptyList()
val keywords: List<String> = emptyList(),
) {
fun allKeywords(): List<String> =
(listOf(name) + keywords).filter { it.isNotBlank() }.distinct()
fun allKeywords(): List<String> = (listOf(name) + keywords).filter { it.isNotBlank() }.distinct()
}
/** A user's allergy profile. */
@ -54,7 +53,7 @@ data class UserProfile(
val moderateIntolerances: Set<AllergenType> = emptySet(),
val dietaryRestrictions: Set<DietaryRestriction> = emptySet(),
val customItems: List<CustomDietItem> = emptyList(),
val isDefault: Boolean = false
val isDefault: Boolean = false,
) {
/** Returns every allergen (severe + moderate) referenced by this profile. */
fun allAllergens(): Set<AllergenType> = severeAllergens + moderateIntolerances
@ -71,12 +70,13 @@ data class Nutriments(
val sodium100g: Double? = null,
val fiber100g: Double? = null,
val proteins100g: Double? = null,
val carbohydrates100g: Double? = null
val carbohydrates100g: Double? = null,
) {
fun isEmpty(): Boolean = listOf(
energyKcal100g, energyKcalServing, fat100g, saturatedFat100g,
sugars100g, salt100g, sodium100g, fiber100g, proteins100g, carbohydrates100g
).all { it == 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). */
@ -94,7 +94,7 @@ data class Product(
val servingSize: String? = null,
val nutriments: Nutriments = Nutriments(),
val labels: List<String> = emptyList(),
val categories: List<String> = emptyList()
val categories: List<String> = emptyList(),
) {
/** Public Open Food Facts product page URL. */
fun openFoodFactsUrl(): String = "https://world.openfoodfacts.org/product/$barcode"
@ -104,7 +104,7 @@ data class Product(
data class DetectedCustomItem(
val item: CustomDietItem,
val matchedKeywords: List<String>,
val profileIds: List<Long> = emptyList()
val profileIds: List<Long> = emptyList(),
)
/** High-level health verdict computed from Nutri-Score, Nova, Eco-Score + custom rules. */
@ -113,7 +113,7 @@ data class HealthAssessment(
val reasons: List<String> = emptyList(),
val nutriScore: String? = null,
val novaGroup: Int? = null,
val ecoScore: String? = null
val ecoScore: String? = null,
)
/** Describes a single allergen that was detected during analysis. */
@ -125,7 +125,7 @@ data class DetectedAllergen(
/** 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
val severe: Boolean = true,
)
data class ScanResult(
@ -136,7 +136,7 @@ data class ScanResult(
val health: HealthAssessment = HealthAssessment(),
val analyzedProfiles: List<UserProfile>,
val confidence: AnalysisConfidence,
val source: DataSource
val source: DataSource,
)
data class ScanHistoryItem(
@ -148,5 +148,5 @@ data class ScanHistoryItem(
val safetyStatus: SafetyStatus,
val profileNames: List<String>,
val scannedAt: Long,
val source: DataSource
val source: DataSource,
)

View File

@ -15,38 +15,54 @@ 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()
/** Search for products in the same category without specific allergens. */
suspend fun searchAlternatives(
category: String,
excludeAllergens: Set<String>,
limit: Int = 5
limit: Int = 5,
): List<Product>
}
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?
}
@ -61,12 +77,19 @@ interface SettingsRepository {
val splashScreenEnabled: Flow<Boolean>
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)
suspend fun setSplashScreenEnabled(enabled: Boolean)
}
@ -77,34 +100,61 @@ interface SettingsRepository {
interface ShoppingListRepository {
// Lists
fun observeActiveLists(): Flow<List<ShoppingListEntity>>
fun observeAllLists(): Flow<List<ShoppingListEntity>>
suspend fun getListById(id: Long): ShoppingListEntity?
suspend fun createList(name: String, backgroundResName: String? = null): Long
suspend fun createList(
name: String,
backgroundResName: String? = null,
): Long
suspend fun updateList(list: ShoppingListEntity)
suspend fun deleteList(list: ShoppingListEntity)
suspend fun archiveList(id: Long)
// Items
fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>>
suspend fun getItems(listId: Long): List<ShoppingListItemEntity>
suspend fun addItem(item: ShoppingListItemEntity): Long
suspend fun updateItem(item: ShoppingListItemEntity)
suspend fun deleteItem(item: ShoppingListItemEntity)
suspend fun setItemChecked(id: Long, checked: Boolean)
suspend fun setItemChecked(
id: Long,
checked: Boolean,
)
suspend fun uncheckAllItems(listId: Long)
suspend fun deleteAllItems(listId: Long)
// Stats
fun observeItemCount(listId: Long): Flow<Int>
fun observeCheckedCount(listId: Long): Flow<Int>
// Members
fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>>
suspend fun addMember(member: ShoppingListMemberEntity): Long
suspend fun updateMember(member: ShoppingListMemberEntity)
suspend fun removeMember(member: ShoppingListMemberEntity)
suspend fun deleteAllMembers(listId: Long)
// Helpers
suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity)
suspend fun addItemToList(
listId: Long,
item: ShoppingListItemEntity,
)
}

View File

@ -8,15 +8,17 @@ import javax.inject.Inject
* UseCase pour trouver des produits alternatifs dans la même catégorie
* sans les allergènes problématiques.
*/
class GetAlternativesUseCase @Inject constructor(
private val productRepository: ProductRepository
) {
suspend operator fun invoke(
category: String,
excludeAllergenTags: Set<String>,
limit: Int = 5
): List<Product> {
if (category.isBlank()) return emptyList()
return productRepository.searchAlternatives(category, excludeAllergenTags, limit)
class GetAlternativesUseCase
@Inject
constructor(
private val productRepository: ProductRepository,
) {
suspend operator fun invoke(
category: String,
excludeAllergenTags: Set<String>,
limit: Int = 5,
): List<Product> {
if (category.isBlank()) return emptyList()
return productRepository.searchAlternatives(category, excludeAllergenTags, limit)
}
}
}

View File

@ -2,7 +2,6 @@ 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
@ -16,108 +15,163 @@ 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)
}
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)
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 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 ManageProfileUseCase
@Inject
constructor(
private val repo: UserProfileRepository,
) {
fun observe(): Flow<List<UserProfile>> = repo.observeProfiles()
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)
}
suspend fun get(id: Long) = repo.getProfile(id)
class SaveScanUseCase @Inject constructor(
private val repo: ScanHistoryRepository
) {
suspend operator fun invoke(result: ScanResult): Long = repo.save(result)
}
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)
}
// =============================================================================
// Shopping List UseCases (Phase 2)
// =============================================================================
class GetShoppingListsUseCase @Inject constructor(
private val repo: com.safebite.app.domain.repository.ShoppingListRepository
) {
fun observeActive() = repo.observeActiveLists()
fun observeAll() = repo.observeAllLists()
suspend fun getList(id: Long) = repo.getListById(id)
suspend fun createList(name: String, backgroundResName: String? = null) = repo.createList(name, backgroundResName)
suspend fun updateList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.updateList(list)
suspend fun deleteList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.deleteList(list)
fun observeItemCount(listId: Long) = repo.observeItemCount(listId)
fun observeCheckedCount(listId: Long) = repo.observeCheckedCount(listId)
fun observeMembers(listId: Long) = repo.observeMembers(listId)
suspend fun addMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.addMember(member)
suspend fun removeMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.removeMember(member)
}
class GetShoppingListsUseCase
@Inject
constructor(
private val repo: com.safebite.app.domain.repository.ShoppingListRepository,
) {
fun observeActive() = repo.observeActiveLists()
class ManageShoppingListUseCase @Inject constructor(
private val repo: com.safebite.app.domain.repository.ShoppingListRepository
) {
fun observeItems(listId: Long) = repo.observeItems(listId)
suspend fun getItems(listId: Long) = repo.getItems(listId)
suspend fun addItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.addItem(item)
suspend fun updateItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.updateItem(item)
suspend fun deleteItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.deleteItem(item)
suspend fun setItemChecked(id: Long, checked: Boolean) = repo.setItemChecked(id, checked)
suspend fun uncheckAllItems(listId: Long) = repo.uncheckAllItems(listId)
suspend fun deleteAllItems(listId: Long) = repo.deleteAllItems(listId)
suspend fun addItemToList(listId: Long, item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.addItemToList(listId, item)
}
fun observeAll() = repo.observeAllLists()
suspend fun getList(id: Long) = repo.getListById(id)
suspend fun createList(
name: String,
backgroundResName: String? = null,
) = repo.createList(name, backgroundResName)
suspend fun updateList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.updateList(list)
suspend fun deleteList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.deleteList(list)
fun observeItemCount(listId: Long) = repo.observeItemCount(listId)
fun observeCheckedCount(listId: Long) = repo.observeCheckedCount(listId)
fun observeMembers(listId: Long) = repo.observeMembers(listId)
suspend fun addMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.addMember(member)
suspend fun removeMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.removeMember(member)
}
class ManageShoppingListUseCase
@Inject
constructor(
private val repo: com.safebite.app.domain.repository.ShoppingListRepository,
) {
fun observeItems(listId: Long) = repo.observeItems(listId)
suspend fun getItems(listId: Long) = repo.getItems(listId)
suspend fun addItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.addItem(item)
suspend fun updateItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.updateItem(item)
suspend fun deleteItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.deleteItem(item)
suspend fun setItemChecked(
id: Long,
checked: Boolean,
) = repo.setItemChecked(id, checked)
suspend fun uncheckAllItems(listId: Long) = repo.uncheckAllItems(listId)
suspend fun deleteAllItems(listId: Long) = repo.deleteAllItems(listId)
suspend fun addItemToList(
listId: Long,
item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity,
) = repo.addItemToList(
listId,
item,
)
}

View File

@ -4,11 +4,11 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.runtime.getValue
import androidx.core.view.WindowCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewModelScope
import com.safebite.app.domain.model.ThemePref
import com.safebite.app.domain.repository.SettingsRepository
import com.safebite.app.presentation.navigation.SafeBiteNavGraph
@ -25,30 +25,32 @@ data class RootUi(
val onboardingDone: Boolean = false,
val theme: ThemePref = ThemePref.SYSTEM,
val showSplash: Boolean = false,
val ready: Boolean = false
val ready: Boolean = false,
)
@HiltViewModel
class RootViewModel @Inject constructor(
settings: SettingsRepository
) : ViewModel() {
val state: StateFlow<RootUi> = combine(
settings.onboardingCompleted,
settings.theme,
settings.splashScreenEnabled
) { done, theme, splashEnabled ->
RootUi(
onboardingDone = done,
theme = theme,
showSplash = splashEnabled && done,
ready = true
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, RootUi())
}
class RootViewModel
@Inject
constructor(
settings: SettingsRepository,
) : ViewModel() {
val state: StateFlow<RootUi> =
combine(
settings.onboardingCompleted,
settings.theme,
settings.splashScreenEnabled,
) { done, theme, splashEnabled ->
RootUi(
onboardingDone = done,
theme = theme,
showSplash = splashEnabled && done,
ready = true,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, RootUi())
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val rootViewModel: RootViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
@ -56,16 +58,17 @@ class MainActivity : ComponentActivity() {
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()
}
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,
showSplash = ui.showSplash
showSplash = ui.showSplash,
)
}
}

View File

@ -31,27 +31,29 @@ import com.safebite.app.presentation.theme.LocalDimens
enum class AllergenLevel(val label: String, val emoji: String) {
NONE("Aucun", ""),
TRACE("Traces", "⚠️"),
SEVERE("Sévère", "")
SEVERE("Sévère", ""),
}
/**
* Couleurs de fond par état d'allergie (spec UX §4.2).
*/
fun AllergenLevel.backgroundColor(): Color = when (this) {
AllergenLevel.NONE -> Color.Transparent
AllergenLevel.TRACE -> Color(0xFFFEF5E7) // Orange clair
AllergenLevel.SEVERE -> Color(0xFFFDEDEC) // Rouge clair
}
fun AllergenLevel.backgroundColor(): Color =
when (this) {
AllergenLevel.NONE -> Color.Transparent
AllergenLevel.TRACE -> Color(0xFFFEF5E7) // Orange clair
AllergenLevel.SEVERE -> Color(0xFFFDEDEC) // Rouge clair
}
/**
* Couleur de bordure par état.
*/
@Composable
fun AllergenLevel.borderColor(): Color = when (this) {
AllergenLevel.NONE -> MaterialTheme.colorScheme.outlineVariant
AllergenLevel.TRACE -> Color(0xFFF39C12)
AllergenLevel.SEVERE -> Color(0xFFE74C3C)
}
fun AllergenLevel.borderColor(): Color =
when (this) {
AllergenLevel.NONE -> MaterialTheme.colorScheme.outlineVariant
AllergenLevel.TRACE -> Color(0xFFF39C12)
AllergenLevel.SEVERE -> Color(0xFFE74C3C)
}
/**
* Grille de sélection d'allergènes avec 3 états par tap.
@ -66,7 +68,7 @@ fun AllergenLevel.borderColor(): Color = when (this) {
fun AllergenSelectionGrid(
selectedAllergens: Map<AllergenType, AllergenLevel>,
onLevelChanged: (AllergenType, AllergenLevel) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val dimens = LocalDimens.current
val columns = 3
@ -74,12 +76,12 @@ fun AllergenSelectionGrid(
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm),
) {
rows.forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)
horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm),
) {
row.forEach { allergen ->
val currentLevel = selectedAllergens[allergen] ?: AllergenLevel.NONE
@ -88,13 +90,14 @@ fun AllergenSelectionGrid(
allergen = allergen,
level = currentLevel,
onClick = {
val nextLevel = when (currentLevel) {
AllergenLevel.NONE -> AllergenLevel.TRACE
AllergenLevel.TRACE -> AllergenLevel.SEVERE
AllergenLevel.SEVERE -> AllergenLevel.NONE
}
val nextLevel =
when (currentLevel) {
AllergenLevel.NONE -> AllergenLevel.TRACE
AllergenLevel.TRACE -> AllergenLevel.SEVERE
AllergenLevel.SEVERE -> AllergenLevel.NONE
}
onLevelChanged(allergen, nextLevel)
}
},
)
}
repeat(columns - row.size) {
@ -113,33 +116,37 @@ fun AllergenSelectionChip(
allergen: AllergenType,
level: AllergenLevel,
onClick: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val dimens = LocalDimens.current
Card(
modifier = modifier
.clickable(onClick = onClick),
modifier =
modifier
.clickable(onClick = onClick),
shape = RoundedCornerShape(dimens.radiusMd),
colors = CardDefaults.cardColors(
containerColor = level.backgroundColor()
),
border = androidx.compose.foundation.BorderStroke(
width = 2.dp,
color = level.borderColor()
)
colors =
CardDefaults.cardColors(
containerColor = level.backgroundColor(),
),
border =
androidx.compose.foundation.BorderStroke(
width = 2.dp,
color = level.borderColor(),
),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = dimens.spacingSm, horizontal = dimens.spacingXs),
horizontalAlignment = Alignment.CenterHorizontally
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = dimens.spacingSm, horizontal = dimens.spacingXs),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Emoji allergène
Text(
text = allergen.icon,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
)
// Nom court
@ -148,7 +155,7 @@ fun AllergenSelectionChip(
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 1
maxLines = 1,
)
// Indicateur d'état
@ -156,7 +163,7 @@ fun AllergenSelectionChip(
Text(
text = level.emoji,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
)
}
}
@ -171,7 +178,7 @@ fun AllergenSelectionChip(
fun AllergenDisplayGrid(
severeAllergens: Set<AllergenType>,
moderateIntolerances: Set<AllergenType>,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val dimens = LocalDimens.current
@ -180,29 +187,29 @@ fun AllergenDisplayGrid(
text = "Aucune allergie détectée ✅",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier.padding(dimens.spacingSm)
modifier = modifier.padding(dimens.spacingSm),
)
return
}
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm),
) {
// Allergènes sévères
if (severeAllergens.isNotEmpty()) {
Text(
text = "❌ Allergies sévères :",
style = MaterialTheme.typography.labelMedium,
color = Color(0xFFE74C3C)
color = Color(0xFFE74C3C),
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp)
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
severeAllergens.forEach { allergen ->
AllergenBadge(
allergen = allergen,
level = AllergenLevel.SEVERE
level = AllergenLevel.SEVERE,
)
}
}
@ -213,15 +220,15 @@ fun AllergenDisplayGrid(
Text(
text = "⚠️ Intolérances :",
style = MaterialTheme.typography.labelMedium,
color = Color(0xFFF39C12)
color = Color(0xFFF39C12),
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp)
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
moderateIntolerances.forEach { allergen ->
AllergenBadge(
allergen = allergen,
level = AllergenLevel.TRACE
level = AllergenLevel.TRACE,
)
}
}
@ -236,30 +243,31 @@ fun AllergenDisplayGrid(
fun AllergenBadge(
allergen: AllergenType,
level: AllergenLevel,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.background(
color = level.backgroundColor(),
shape = RoundedCornerShape(12.dp)
)
.border(
width = 1.dp,
color = level.borderColor(),
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 8.dp, vertical = 4.dp)
modifier =
modifier
.background(
color = level.backgroundColor(),
shape = RoundedCornerShape(12.dp),
)
.border(
width = 1.dp,
color = level.borderColor(),
shape = RoundedCornerShape(12.dp),
)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(text = allergen.icon, style = MaterialTheme.typography.bodySmall)
Text(
text = allergen.displayNameFr,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
)
}
}

View File

@ -32,21 +32,23 @@ fun SafeBiteTopAppBar(
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 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
}
style =
when (variant) {
AppBarVariant.Large -> MaterialTheme.typography.headlineMedium
AppBarVariant.CenterAligned -> MaterialTheme.typography.titleLarge
AppBarVariant.Small -> MaterialTheme.typography.titleLarge
},
)
}
val navIcon: @Composable () -> Unit = {
@ -57,35 +59,39 @@ fun SafeBiteTopAppBar(
}
}
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,
)
AppBarVariant.Small ->
TopAppBar(
title = titleComposable,
modifier = modifier,
navigationIcon = navIcon,
actions = actions,
colors = colors,
scrollBehavior = scrollBehavior,
)
AppBarVariant.CenterAligned ->
CenterAlignedTopAppBar(
title = titleComposable,
modifier = modifier,
navigationIcon = navIcon,
actions = actions,
colors = colors,
scrollBehavior = scrollBehavior,
)
AppBarVariant.Large ->
LargeTopAppBar(
title = titleComposable,
modifier = modifier,
navigationIcon = navIcon,
actions = actions,
colors =
TopAppBarDefaults.largeTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
scrollBehavior = scrollBehavior,
)
}
}

View File

@ -51,7 +51,7 @@ private fun pressedScale(interactionSource: MutableInteractionSource): Float {
val scale by animateFloatAsState(
targetValue = if (pressed) 0.96f else 1f,
animationSpec = tween(durationMillis = 120),
label = "buttonPressScale"
label = "buttonPressScale",
)
return scale
}
@ -76,10 +76,11 @@ fun PrimaryButton(
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),
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,
@ -104,10 +105,11 @@ fun SecondaryButton(
FilledTonalButton(
onClick = { if (!loading) onClick() },
enabled = enabled && !loading,
modifier = modifier
.scale(scale)
.heightIn(min = ButtonTokens.MinHeight)
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
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,
@ -132,10 +134,11 @@ fun OutlinedActionButton(
OutlinedButton(
onClick = { if (!loading) onClick() },
enabled = enabled && !loading,
modifier = modifier
.scale(scale)
.heightIn(min = ButtonTokens.MinHeight)
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
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,
@ -158,10 +161,11 @@ fun TertiaryButton(
TextButton(
onClick = onClick,
enabled = enabled,
modifier = modifier
.scale(scale)
.heightIn(min = ButtonTokens.MinHeight)
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
modifier =
modifier
.scale(scale)
.heightIn(min = ButtonTokens.MinHeight)
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
shape = MaterialTheme.shapes.medium,
interactionSource = interaction,
) {
@ -182,17 +186,19 @@ fun DestructiveButton(
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,
)
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),
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),
@ -203,20 +209,24 @@ fun DestructiveButton(
}
@Composable
private fun ButtonContent(text: String, icon: ImageVector?, loading: Boolean) {
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
color = LocalContentColor.current,
)
Spacer(Modifier.width(ButtonTokens.IconSpacer))
} else if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(ButtonTokens.IconSize)
modifier = Modifier.size(ButtonTokens.IconSize),
)
Spacer(Modifier.width(ButtonTokens.IconSpacer))
}

View File

@ -46,19 +46,21 @@ fun StandardCard(
onClick = onClick,
modifier = modifier,
shape = shape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
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,
)
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
) { InnerPadding(contentPadding, content) }
}
}
@ -79,6 +81,9 @@ fun StandardCard(
}
@Composable
private fun InnerPadding(pad: PaddingValues, content: @Composable () -> Unit) {
private fun InnerPadding(
pad: PaddingValues,
content: @Composable () -> Unit,
) {
androidx.compose.foundation.layout.Box(Modifier.padding(pad)) { content() }
}

View File

@ -51,19 +51,19 @@ fun DonutChart(
progressColor: Color = LocalStatusColors.current.safe,
centerText: String = "${(progress * 100).toInt()}%",
centerSubText: String? = null,
animated: Boolean = true
animated: Boolean = true,
) {
val dimens = LocalDimens.current
val animatedProgress by animateFloatAsState(
targetValue = progress.coerceIn(0f, 1f),
animationSpec = tween(durationMillis = 800),
label = "donutProgress"
label = "donutProgress",
)
val displayProgress = if (animated) animatedProgress else progress
Box(
modifier = modifier.size(size),
contentAlignment = Alignment.Center
contentAlignment = Alignment.Center,
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val sweepAngle = 360f * displayProgress
@ -75,7 +75,7 @@ fun DonutChart(
startAngle = -90f,
sweepAngle = 360f,
useCenter = false,
style = Stroke(width = stroke, cap = StrokeCap.Round)
style = Stroke(width = stroke, cap = StrokeCap.Round),
)
// Arc de progression
@ -85,27 +85,27 @@ fun DonutChart(
startAngle = -90f,
sweepAngle = sweepAngle,
useCenter = false,
style = Stroke(width = stroke, cap = StrokeCap.Round)
style = Stroke(width = stroke, cap = StrokeCap.Round),
)
}
}
// Texte central
Column(
horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = centerText,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
)
if (centerSubText != null) {
Text(
text = centerSubText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
)
}
}
@ -117,9 +117,10 @@ fun DonutChart(
*/
data class SparklineData(
val values: List<Float>,
val labels: List<String> = emptyList()
val labels: List<String> = emptyList(),
) {
fun max(): Float = values.maxOrNull() ?: 0f
fun min(): Float = values.minOrNull() ?: 0f
}
@ -138,20 +139,21 @@ fun Sparkline(
lineColor: Color = LocalStatusColors.current.safe,
fillColor: Color = LocalStatusColors.current.safe.copy(alpha = 0.15f),
height: Dp = 80.dp,
showDots: Boolean = true
showDots: Boolean = true,
) {
if (data.values.isEmpty()) return
val animatedProgress by animateFloatAsState(
targetValue = 1f,
animationSpec = tween(durationMillis = 600),
label = "sparklineProgress"
label = "sparklineProgress",
)
Canvas(
modifier = modifier
.fillMaxWidth()
.height(height)
modifier =
modifier
.fillMaxWidth()
.height(height),
) {
val width = size.width
val height = size.height
@ -163,39 +165,42 @@ fun Sparkline(
val stepX = (width - 2 * padding) / (data.values.size - 1).coerceAtLeast(1)
// Calcul des points
val points = data.values.mapIndexed { index, value ->
val x = padding + index * stepX
val normalizedValue = if (range > 0) (value - minVal) / range else 0.5f
val y = height - padding - normalizedValue * (height - 2 * padding)
Offset(x, y)
}
val points =
data.values.mapIndexed { index, value ->
val x = padding + index * stepX
val normalizedValue = if (range > 0) (value - minVal) / range else 0.5f
val y = height - padding - normalizedValue * (height - 2 * padding)
Offset(x, y)
}
// Zone de remplissage
if (points.size > 1) {
val fillPath = androidx.compose.ui.graphics.Path().apply {
moveTo(points.first().x, height)
lineTo(points.first().x, points.first().y)
points.forEach { point ->
lineTo(point.x, point.y)
val fillPath =
androidx.compose.ui.graphics.Path().apply {
moveTo(points.first().x, height)
lineTo(points.first().x, points.first().y)
points.forEach { point ->
lineTo(point.x, point.y)
}
lineTo(points.last().x, height)
close()
}
lineTo(points.last().x, height)
close()
}
drawPath(fillPath, color = fillColor)
}
// Ligne
if (points.size > 1) {
val linePath = androidx.compose.ui.graphics.Path().apply {
moveTo(points.first().x, points.first().y)
points.forEach { point ->
lineTo(point.x, point.y)
val linePath =
androidx.compose.ui.graphics.Path().apply {
moveTo(points.first().x, points.first().y)
points.forEach { point ->
lineTo(point.x, point.y)
}
}
}
drawPath(
path = linePath,
color = lineColor,
style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round)
style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round),
)
}
@ -205,12 +210,12 @@ fun Sparkline(
drawCircle(
color = lineColor,
radius = 4.dp.toPx(),
center = point
center = point,
)
drawCircle(
color = Color.White,
radius = 2.dp.toPx(),
center = point
center = point,
)
}
}
@ -221,13 +226,13 @@ fun Sparkline(
* Données pour un graphique à barres.
*/
data class BarChartData(
val items: List<BarChartItem>
val items: List<BarChartItem>,
)
data class BarChartItem(
val label: String,
val value: Int,
val color: Color = Color.Unspecified
val color: Color = Color.Unspecified,
)
/**
@ -244,7 +249,7 @@ fun HorizontalBarChart(
modifier: Modifier = Modifier,
maxValue: Int? = null,
height: Dp = 32.dp,
spacing: Dp = LocalDimens.current.spacingSm
spacing: Dp = LocalDimens.current.spacingSm,
) {
if (data.items.isEmpty()) return
@ -252,13 +257,13 @@ fun HorizontalBarChart(
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(spacing)
verticalArrangement = Arrangement.spacedBy(spacing),
) {
data.items.forEach { item ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd)
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd),
) {
// Label
Text(
@ -266,7 +271,7 @@ fun HorizontalBarChart(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(0.4f),
maxLines = 1
maxLines = 1,
)
// Barre
@ -274,13 +279,14 @@ fun HorizontalBarChart(
val animatedProgress by animateFloatAsState(
targetValue = progress,
animationSpec = tween(durationMillis = 500),
label = "barProgress"
label = "barProgress",
)
Box(
modifier = Modifier
.weight(0.4f)
.height(height)
modifier =
Modifier
.weight(0.4f)
.height(height),
) {
// Fond
Canvas(modifier = Modifier.fillMaxSize()) {
@ -288,7 +294,7 @@ fun HorizontalBarChart(
drawRoundRect(
color = androidx.compose.ui.graphics.Color(0xFFE3E2EC),
size = Size(size.width, size.height),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius)
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius),
)
// Progression
@ -296,7 +302,7 @@ fun HorizontalBarChart(
drawRoundRect(
color = item.color,
size = Size(size.width * animatedProgress, size.height),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius)
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius),
)
}
}
@ -306,8 +312,9 @@ fun HorizontalBarChart(
text = "${item.value}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.align(Alignment.CenterEnd)
.padding(end = 8.dp)
modifier =
Modifier.align(Alignment.CenterEnd)
.padding(end = 8.dp),
)
}
}
@ -324,30 +331,31 @@ fun StatCard(
value: String,
label: String,
modifier: Modifier = Modifier,
valueColor: Color = MaterialTheme.colorScheme.onSurface
valueColor: Color = MaterialTheme.colorScheme.onSurface,
) {
val dimens = LocalDimens.current
Column(
modifier = modifier
.padding(dimens.spacingSm),
horizontalAlignment = Alignment.CenterHorizontally
modifier =
modifier
.padding(dimens.spacingSm),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = icon,
style = MaterialTheme.typography.headlineSmall
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = value,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = valueColor
color = valueColor,
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
)
}
}
@ -359,25 +367,25 @@ enum class TimeFilter(val label: String) {
WEEK("Semaine"),
MONTH("Mois"),
YEAR("Année"),
ALL("Tout")
ALL("Tout"),
}
@Composable
fun TimeFilterRow(
selected: TimeFilter,
onFilterChanged: (TimeFilter) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm)
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm),
) {
TimeFilter.values().forEach { filter ->
androidx.compose.material3.FilterChip(
selected = selected == filter,
onClick = { onFilterChanged(filter) },
label = { Text(filter.label) }
)
}
androidx.compose.material3.FilterChip(
selected = selected == filter,
onClick = { onFilterChanged(filter) },
label = { Text(filter.label) },
)
}
}
}

View File

@ -4,7 +4,6 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -24,10 +23,8 @@ 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.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
@ -69,38 +66,39 @@ fun AllergenChip(
allergen: AllergenType,
selected: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier
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
val stateDesc = if (selected) "Sélectionné" else "Non sélectionné"
Surface(
modifier = modifier
.semantics {
contentDescription = "${allergen.displayNameFr} - $stateDesc"
role = Role.Checkbox
stateDescription = stateDesc
},
modifier =
modifier
.semantics {
contentDescription = "${allergen.displayNameFr} - $stateDesc"
role = Role.Checkbox
stateDescription = stateDesc
},
shape = RoundedCornerShape(dimens.radiusPill),
color = bg,
border = if (selected) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
onClick = onToggle
onClick = onToggle,
) {
Row(
modifier = Modifier.padding(horizontal = dimens.spacingMd, vertical = dimens.spacingSm),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = allergen.icon,
color = fg,
modifier = Modifier.semantics { contentDescription = "" }
modifier = Modifier.semantics { contentDescription = "" },
)
Spacer(Modifier.width(dimens.spacingXs + 2.dp))
Text(
text = allergen.displayNameFr,
color = fg,
style = MaterialTheme.typography.labelLarge
style = MaterialTheme.typography.labelLarge,
)
}
}
@ -120,101 +118,107 @@ fun SafetyStatusBanner(
modifier: Modifier = Modifier,
profileName: String? = null,
allergenName: String? = null,
severity: String? = null
severity: String? = null,
) {
val dimens = LocalDimens.current
val colors = LocalStatusColors.current
val a11yDescription = when (status) {
SafetyStatus.SAFE -> stringResource(R.string.a11y_verdict_safe)
SafetyStatus.WARNING -> stringResource(R.string.a11y_verdict_warning)
SafetyStatus.DANGER -> if (profileName != null)
stringResource(R.string.a11y_verdict_danger, profileName)
else
stringResource(R.string.a11y_danger_status, "")
}
val a11yDescription =
when (status) {
SafetyStatus.SAFE -> stringResource(R.string.a11y_verdict_safe)
SafetyStatus.WARNING -> stringResource(R.string.a11y_verdict_warning)
SafetyStatus.DANGER ->
if (profileName != null) {
stringResource(R.string.a11y_verdict_danger, profileName)
} else {
stringResource(R.string.a11y_danger_status, "")
}
}
val (titleRes, icon, shapeIcon, containerColor, onContainerColor) = when (status) {
SafetyStatus.SAFE -> {
VerdictBannerData(
titleRes = R.string.result_safe_headline,
icon = "",
shapeIcon = "",
containerColor = colors.safe,
onContainerColor = colors.onSafe
)
val (titleRes, icon, shapeIcon, containerColor, onContainerColor) =
when (status) {
SafetyStatus.SAFE -> {
VerdictBannerData(
titleRes = R.string.result_safe_headline,
icon = "",
shapeIcon = "",
containerColor = colors.safe,
onContainerColor = colors.onSafe,
)
}
SafetyStatus.WARNING -> {
VerdictBannerData(
titleRes = R.string.result_warning_headline,
icon = "⚠️",
shapeIcon = "🔺",
containerColor = colors.warning,
onContainerColor = colors.onWarning,
)
}
SafetyStatus.DANGER -> {
VerdictBannerData(
titleRes = R.string.result_danger_headline,
icon = "",
shapeIcon = "🔷",
containerColor = colors.danger,
onContainerColor = colors.onDanger,
)
}
}
SafetyStatus.WARNING -> {
VerdictBannerData(
titleRes = R.string.result_warning_headline,
icon = "⚠️",
shapeIcon = "🔺",
containerColor = colors.warning,
onContainerColor = colors.onWarning
)
}
SafetyStatus.DANGER -> {
VerdictBannerData(
titleRes = R.string.result_danger_headline,
icon = "",
shapeIcon = "🔷",
containerColor = colors.danger,
onContainerColor = colors.onDanger
)
}
}
Surface(
modifier = modifier
.fillMaxWidth()
.semantics {
contentDescription = a11yDescription
},
modifier =
modifier
.fillMaxWidth()
.semantics {
contentDescription = a11yDescription
},
color = containerColor,
contentColor = onContainerColor
contentColor = onContainerColor,
) {
Column(
modifier = Modifier.padding(dimens.spacingLg)
modifier = Modifier.padding(dimens.spacingLg),
) {
// Ligne supérieure : forme daltonienne + icône + titre
// Système daltonien : forme géométrique + couleur + icône
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
horizontalArrangement = Arrangement.Center,
) {
// Forme daltonienne (jamais couleur seule)
DaltonianShape(
status = status,
modifier = Modifier.size(32.dp)
modifier = Modifier.size(32.dp),
)
Spacer(Modifier.width(dimens.spacingXs))
Text(
text = icon,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.semantics { contentDescription = "" }
modifier = Modifier.semantics { contentDescription = "" },
)
Spacer(Modifier.width(dimens.spacingSm))
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
)
}
// Sous-titre contextuel (si allergène et profil)
if (allergenName != null && profileName != null) {
Spacer(Modifier.height(dimens.spacingXs))
val subtitle = when (status) {
SafetyStatus.WARNING -> "⚠️ Attention pour $profileName : $allergenName"
SafetyStatus.DANGER -> "❌ Interdit pour $profileName : $allergenName${if (severity == "anaphylaxis") " (anaphylaxie)" else ""}"
else -> ""
}
val subtitle =
when (status) {
SafetyStatus.WARNING -> "⚠️ Attention pour $profileName : $allergenName"
SafetyStatus.DANGER -> "❌ Interdit pour $profileName : $allergenName${if (severity == "anaphylaxis") " (anaphylaxie)" else ""}"
else -> ""
}
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
)
}
}
@ -225,7 +229,7 @@ fun SafetyStatusBanner(
Text(
text = "Ne pas consommer",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
)
}
}
@ -242,46 +246,50 @@ fun SafetyStatusBanner(
@Composable
fun DaltonianShape(
status: SafetyStatus,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val colors = LocalStatusColors.current
val color = when (status) {
SafetyStatus.SAFE -> colors.safe
SafetyStatus.WARNING -> colors.warning
SafetyStatus.DANGER -> colors.danger
}
val color =
when (status) {
SafetyStatus.SAFE -> colors.safe
SafetyStatus.WARNING -> colors.warning
SafetyStatus.DANGER -> colors.danger
}
when (status) {
SafetyStatus.SAFE -> {
// Cercle pour SAFE
Box(
modifier = modifier
.background(color, CircleShape)
.semantics { contentDescription = "" }
modifier =
modifier
.background(color, CircleShape)
.semantics { contentDescription = "" },
)
}
SafetyStatus.WARNING -> {
// Triangle pour WARNING (dessiné avec Canvas)
Canvas(modifier = modifier) {
val path = androidx.compose.ui.graphics.Path().apply {
moveTo(size.width / 2, 0f)
lineTo(size.width, size.height)
lineTo(0f, size.height)
close()
}
val path =
androidx.compose.ui.graphics.Path().apply {
moveTo(size.width / 2, 0f)
lineTo(size.width, size.height)
lineTo(0f, size.height)
close()
}
drawPath(path, color = color)
}
}
SafetyStatus.DANGER -> {
// Losange pour DANGER
Canvas(modifier = modifier) {
val path = androidx.compose.ui.graphics.Path().apply {
moveTo(size.width / 2, 0f)
lineTo(size.width, size.height / 2)
lineTo(size.width / 2, size.height)
lineTo(0f, size.height / 2)
close()
}
val path =
androidx.compose.ui.graphics.Path().apply {
moveTo(size.width / 2, 0f)
lineTo(size.width, size.height / 2)
lineTo(size.width / 2, size.height)
lineTo(0f, size.height / 2)
close()
}
drawPath(path, color = color)
}
}
@ -294,7 +302,7 @@ private data class VerdictBannerData(
val icon: String,
val shapeIcon: String,
val containerColor: androidx.compose.ui.graphics.Color,
val onContainerColor: androidx.compose.ui.graphics.Color
val onContainerColor: androidx.compose.ui.graphics.Color,
)
@Composable
@ -303,7 +311,7 @@ fun ProductCard(
subtitle: String?,
imageUrl: String?,
modifier: Modifier = Modifier,
imageContentDescription: String? = null
imageContentDescription: String? = null,
) {
val dimens = LocalDimens.current
val imgDesc = imageContentDescription ?: "Image du produit"
@ -317,26 +325,28 @@ fun ProductCard(
AsyncImage(
model = imageUrl,
contentDescription = imageContentDescription,
modifier = Modifier
.size(64.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(dimens.radiusMd)
)
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
modifier =
Modifier
.size(64.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(dimens.radiusMd),
),
contentAlignment = Alignment.Center,
) {
Text(
text = "🛒",
modifier = Modifier.semantics { contentDescription = imgDesc }
modifier = Modifier.semantics { contentDescription = imgDesc },
)
}
}
@ -346,13 +356,13 @@ fun ProductCard(
title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2
maxLines = 2,
)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@ -361,18 +371,23 @@ fun ProductCard(
}
@Composable
fun AvatarBubble(avatar: String, modifier: Modifier = Modifier, size: Dp = 40.dp) {
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
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
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}

View File

@ -52,25 +52,28 @@ fun ShimmerBox(
val progress by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Restart,
),
label = "shimmerProgress"
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),
)
val brush =
Brush.linearGradient(
colors = colors,
start = Offset(offset - 500f, 0f),
end = Offset(offset, 0f),
)
Box(
modifier = modifier
.clip(RoundedCornerShape(cornerRadius))
.background(brush)
modifier =
modifier
.clip(RoundedCornerShape(cornerRadius))
.background(brush),
)
}
@ -80,7 +83,7 @@ fun ShimmerListItem(modifier: Modifier = Modifier) {
val dimens = LocalDimens.current
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
ShimmerBox(modifier = Modifier.size(64.dp), cornerRadius = dimens.radiusMd)
Spacer(Modifier.width(dimens.spacingMd))
@ -109,54 +112,61 @@ fun ShimmerListItem(modifier: Modifier = Modifier) {
fun ProductSkeleton(modifier: Modifier = Modifier) {
val dimens = LocalDimens.current
Column(
modifier = modifier
.fillMaxWidth()
.padding(dimens.spacingMd),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
modifier =
modifier
.fillMaxWidth()
.padding(dimens.spacingMd),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
) {
// Image produit
ShimmerBox(
modifier = Modifier
.size(120.dp)
.align(Alignment.CenterHorizontally),
cornerRadius = dimens.radiusMd
modifier =
Modifier
.size(120.dp)
.align(Alignment.CenterHorizontally),
cornerRadius = dimens.radiusMd,
)
// Nom produit
ShimmerBox(
modifier = Modifier
.fillMaxWidth(0.8f)
.height(20.dp)
modifier =
Modifier
.fillMaxWidth(0.8f)
.height(20.dp),
)
// Marque
ShimmerBox(
modifier = Modifier
.fillMaxWidth(0.5f)
.height(14.dp)
modifier =
Modifier
.fillMaxWidth(0.5f)
.height(14.dp),
)
// Verdict banner (zone colorée)
ShimmerBox(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
cornerRadius = dimens.radiusMd
modifier =
Modifier
.fillMaxWidth()
.height(56.dp),
cornerRadius = dimens.radiusMd,
)
// Actions
ShimmerBox(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
cornerRadius = dimens.radiusPill
modifier =
Modifier
.fillMaxWidth()
.height(48.dp),
cornerRadius = dimens.radiusPill,
)
ShimmerBox(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
cornerRadius = dimens.radiusPill
modifier =
Modifier
.fillMaxWidth()
.height(48.dp),
cornerRadius = dimens.radiusPill,
)
}
}
@ -172,9 +182,10 @@ fun EmptyState(
) {
val dimens = LocalDimens.current
Column(
modifier = modifier
.fillMaxWidth()
.padding(dimens.spacingXl),
modifier =
modifier
.fillMaxWidth()
.padding(dimens.spacingXl),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(emoji, style = MaterialTheme.typography.displaySmall)
@ -182,7 +193,7 @@ fun EmptyState(
Text(
title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
)
if (message != null) {
Spacer(Modifier.height(dimens.spacingSm))
@ -212,17 +223,18 @@ fun LoadingIndicator(modifier: Modifier = Modifier) {
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
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)
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(dimens.spacingXs))
Text(
@ -238,27 +250,28 @@ fun OfflineIndicator(modifier: Modifier = Modifier) {
fun ErrorView(
message: String,
onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val dimens = LocalDimens.current
Column(
modifier = modifier
.fillMaxSize()
.padding(dimens.spacingXl),
modifier =
modifier
.fillMaxSize()
.padding(dimens.spacingXl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp)
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(dimens.spacingMd))
Text(
message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
)
if (onRetry != null) {
Spacer(Modifier.height(dimens.spacingLg))

View File

@ -62,7 +62,7 @@ fun ImageCropBottomSheet(
bitmap: Bitmap,
onCropComplete: (String?) -> Unit,
onDismiss: () -> Unit,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
val context = LocalContext.current
val density = LocalDensity.current
@ -105,33 +105,35 @@ fun ImageCropBottomSheet(
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
dragHandle = null
dragHandle = null,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(560.dp)
.padding(horizontal = 16.dp, vertical = 12.dp)
modifier =
Modifier
.fillMaxWidth()
.height(560.dp)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text(
text = "Ajuster le cadrage",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp)
modifier = Modifier.padding(bottom = 12.dp),
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.onSizeChanged { containerSize = it }
modifier =
Modifier
.weight(1f)
.fillMaxWidth()
.onSizeChanged { containerSize = it },
) {
// Photo fixe en arrière-plan (ContentScale.Fit)
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize(),
)
// Overlay sombre + cadre blanc
@ -145,22 +147,22 @@ fun ImageCropBottomSheet(
drawRect(
color = Color.Black.copy(alpha = 0.5f),
topLeft = Offset(0f, 0f),
size = Size(size.width, fT)
size = Size(size.width, fT),
)
drawRect(
color = Color.Black.copy(alpha = 0.5f),
topLeft = Offset(0f, fB),
size = Size(size.width, size.height - fB)
size = Size(size.width, size.height - fB),
)
drawRect(
color = Color.Black.copy(alpha = 0.5f),
topLeft = Offset(0f, fT),
size = Size(fL, fB - fT)
size = Size(fL, fB - fT),
)
drawRect(
color = Color.Black.copy(alpha = 0.5f),
topLeft = Offset(fR, fT),
size = Size(size.width - fR, fB - fT)
size = Size(size.width - fR, fB - fT),
)
// Bordure blanche
@ -168,35 +170,36 @@ fun ImageCropBottomSheet(
color = Color.White,
topLeft = Offset(fL, fT),
size = Size(fR - fL, fB - fT),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f)
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f),
)
}
// Zone centrale draggable pour déplacer le cadre entier
Box(
modifier = Modifier
.offset(
x = with(density) { frameLeft.toDp() },
y = with(density) { frameTop.toDp() }
)
.size(
width = with(density) { (frameRight - frameLeft).toDp() },
height = with(density) { (frameBottom - frameTop).toDp() }
)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
val dx = dragAmount.x
val dy = dragAmount.y
val w = frameRight - frameLeft
val h = frameBottom - frameTop
frameLeft = (frameLeft + dx).coerceIn(0f, containerW - w)
frameTop = (frameTop + dy).coerceIn(0f, containerH - h)
frameRight = frameLeft + w
frameBottom = frameTop + h
modifier =
Modifier
.offset(
x = with(density) { frameLeft.toDp() },
y = with(density) { frameTop.toDp() },
)
.size(
width = with(density) { (frameRight - frameLeft).toDp() },
height = with(density) { (frameBottom - frameTop).toDp() },
)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
val dx = dragAmount.x
val dy = dragAmount.y
val w = frameRight - frameLeft
val h = frameBottom - frameTop
frameLeft = (frameLeft + dx).coerceIn(0f, containerW - w)
frameTop = (frameTop + dy).coerceIn(0f, containerH - h)
frameRight = frameLeft + w
frameBottom = frameTop + h
}
}
}
.zIndex(1f)
.zIndex(1f),
)
// Poignée coin haut-gauche
@ -207,7 +210,7 @@ fun ImageCropBottomSheet(
onDrag = { dx, dy ->
frameLeft = min(frameLeft + dx, frameRight - minPx).coerceAtLeast(0f)
frameTop = min(frameTop + dy, frameBottom - minPx).coerceAtLeast(0f)
}
},
)
// Poignée coin haut-droit
@ -218,7 +221,7 @@ fun ImageCropBottomSheet(
onDrag = { dx, dy ->
frameRight = max(frameRight + dx, frameLeft + minPx).coerceAtMost(containerW)
frameTop = min(frameTop + dy, frameBottom - minPx).coerceAtLeast(0f)
}
},
)
// Poignée coin bas-gauche
@ -229,7 +232,7 @@ fun ImageCropBottomSheet(
onDrag = { dx, dy ->
frameLeft = min(frameLeft + dx, frameRight - minPx).coerceAtLeast(0f)
frameBottom = max(frameBottom + dy, frameTop + minPx).coerceAtMost(containerH)
}
},
)
// Poignée coin bas-droit
@ -240,21 +243,22 @@ fun ImageCropBottomSheet(
onDrag = { dx, dy ->
frameRight = max(frameRight + dx, frameLeft + minPx).coerceAtMost(containerW)
frameBottom = max(frameBottom + dy, frameTop + minPx).coerceAtMost(containerH)
}
},
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding(),
verticalAlignment = Alignment.CenterVertically
modifier =
Modifier
.fillMaxWidth()
.navigationBarsPadding(),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(
onClick = onDismiss,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
) {
Text("Annuler")
}
@ -271,14 +275,17 @@ fun ImageCropBottomSheet(
val cropped = Bitmap.createBitmap(bitmap, cropLeft, cropTop, cropW, cropH)
val maxSize = 512
val out = if (cropW > maxSize || cropH > maxSize) {
val ratio = maxSize.toFloat() / max(cropW, cropH)
val newW = (cropW * ratio).toInt()
val newH = (cropH * ratio).toInt()
Bitmap.createScaledBitmap(cropped, newW, newH, true).also {
if (it != cropped) cropped.recycle()
val out =
if (cropW > maxSize || cropH > maxSize) {
val ratio = maxSize.toFloat() / max(cropW, cropH)
val newW = (cropW * ratio).toInt()
val newH = (cropH * ratio).toInt()
Bitmap.createScaledBitmap(cropped, newW, newH, true).also {
if (it != cropped) cropped.recycle()
}
} else {
cropped
}
} else cropped
val file = File(context.cacheDir, "item_${System.currentTimeMillis()}.jpg")
file.outputStream().use { fos ->
@ -288,7 +295,7 @@ fun ImageCropBottomSheet(
onCropComplete(Uri.fromFile(file).toString())
},
modifier = Modifier.weight(1f),
large = true
large = true,
)
}
Spacer(modifier = Modifier.height(8.dp))
@ -301,40 +308,42 @@ private fun FrameHandle(
x: Float,
y: Float,
halfHandlePx: Float,
onDrag: (dx: Float, dy: Float) -> Unit
onDrag: (dx: Float, dy: Float) -> Unit,
) {
val density = LocalDensity.current
Box(
modifier = Modifier
.offset(
x = with(density) { (x - halfHandlePx).toDp() },
y = with(density) { (y - halfHandlePx).toDp() }
)
.size(HandleSize)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
onDrag(dragAmount.x, dragAmount.y)
modifier =
Modifier
.offset(
x = with(density) { (x - halfHandlePx).toDp() },
y = with(density) { (y - halfHandlePx).toDp() },
)
.size(HandleSize)
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
onDrag(dragAmount.x, dragAmount.y)
}
}
}
.zIndex(2f),
contentAlignment = Alignment.Center
.zIndex(2f),
contentAlignment = Alignment.Center,
) {
androidx.compose.foundation.layout.Box(
modifier = Modifier
.size(12.dp)
.zIndex(2f)
modifier =
Modifier
.size(12.dp)
.zIndex(2f),
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = Color.White,
radius = size.minDimension / 2f,
center = Offset(size.width / 2f, size.height / 2f)
center = Offset(size.width / 2f, size.height / 2f),
)
drawCircle(
color = Color(0xFF1976D2),
radius = size.minDimension / 2f - 2f,
center = Offset(size.width / 2f, size.height / 2f)
center = Offset(size.width / 2f, size.height / 2f),
)
}
}

View File

@ -4,14 +4,13 @@ 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.foundation.text.KeyboardOptions
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
/**
@ -65,8 +64,12 @@ fun StandardTextField(
Text(
msg,
style = MaterialTheme.typography.bodySmall,
color = if (isError) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
color =
if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
if (showCounter && maxLength != null) {
@ -74,7 +77,7 @@ fun StandardTextField(
Text(
"${value.length}/$maxLength",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

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

View File

@ -1,11 +1,11 @@
package com.safebite.app.presentation.navigation
import androidx.compose.foundation.ExperimentalFoundationApi
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.foundation.ExperimentalFoundationApi
import androidx.compose.runtime.Composable
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@ -13,29 +13,28 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.safebite.app.presentation.screen.catalog.CatalogScreen
import com.safebite.app.presentation.screen.catalog.CategoryItemsScreen
import com.safebite.app.presentation.screen.catalog.CatalogSearchScreen
import com.safebite.app.presentation.screen.catalog.CategoryItemsScreen
import com.safebite.app.presentation.screen.catalog.DomainCategoriesScreen
import com.safebite.app.presentation.screen.product.ProductDetailScreen
import com.safebite.app.presentation.screen.tracking.TrackingScreen
import com.safebite.app.presentation.screen.lists.ListDetailScreen
import com.safebite.app.presentation.screen.lists.ListsScreen
import com.safebite.app.presentation.screen.lists.create.CreateListScreen
import com.safebite.app.presentation.screen.lists.settings.ListMembersScreen
import com.safebite.app.presentation.screen.lists.settings.ListNameImageScreen
import com.safebite.app.presentation.screen.lists.settings.ListRegionScreen
import com.safebite.app.presentation.screen.lists.settings.ListSettingsScreen
import com.safebite.app.presentation.screen.lists.settings.ListSortScreen
import com.safebite.app.presentation.screen.lists.settings.ListRegionScreen
import com.safebite.app.presentation.screen.lists.settings.ListNameImageScreen
import com.safebite.app.presentation.screen.lists.settings.ListMembersScreen
import com.safebite.app.presentation.screen.main.MainScreen
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.product.ProductDetailScreen
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
import com.safebite.app.presentation.screen.splash.SplashScreen
import com.safebite.app.presentation.screen.tracking.TrackingScreen
/**
* Graph de navigation principal de l'application SafeBite.
@ -46,20 +45,26 @@ import com.safebite.app.presentation.screen.splash.SplashScreen
* - Écrans de navigation : Scanner, Result, OCR, Settings, etc.
*/
@Composable
fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) {
fun SafeBiteNavGraph(
onboardingCompleted: Boolean,
showSplash: Boolean = false,
) {
val navController = rememberNavController()
val startDestination = when {
showSplash -> Screen.Splash.route
onboardingCompleted -> Screen.Dashboard.route
else -> Screen.Onboarding.route
}
val startDestination =
when {
showSplash -> Screen.Splash.route
onboardingCompleted -> Screen.Dashboard.route
else -> Screen.Onboarding.route
}
val enterAnim = fadeIn(animationSpec = tween(250)) +
slideInHorizontally(animationSpec = tween(250)) { it / 24 }
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 }
val popExitAnim =
fadeOut(animationSpec = tween(200)) +
slideOutHorizontally(animationSpec = tween(250)) { it / 24 }
NavHost(
navController = navController,
@ -76,7 +81,7 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
navController.navigate(Screen.Dashboard.route) {
popUpTo(Screen.Splash.route) { inclusive = true }
}
}
},
)
}
@ -98,7 +103,7 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
onOpenListDetail = { id, name -> navController.navigate(Screen.ListDetail.build(id, name)) },
onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) },
onOpenListCreate = { navController.navigate(Screen.ListCreate.route) },
onOpenListSettings = { id -> navController.navigate(Screen.ListSettings.build(id)) }
onOpenListSettings = { id -> navController.navigate(Screen.ListSettings.build(id)) },
)
}
@ -110,7 +115,7 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
navController.navigate(Screen.Result.fromBarcode(code)) {
popUpTo(Screen.Dashboard.route)
}
}
},
)
}
@ -118,14 +123,14 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
composable(Screen.OcrCapture.route) {
OcrCaptureScreen(
onBack = { navController.popBackStack() },
onCaptured = { text -> navController.navigate(Screen.OcrReview.build(text)) }
onCaptured = { text -> navController.navigate(Screen.OcrReview.build(text)) },
)
}
// ── OCR Review ──
composable(
route = Screen.OcrReview.route,
arguments = listOf(navArgument("text") { type = NavType.StringType })
arguments = listOf(navArgument("text") { type = NavType.StringType }),
) { entry ->
val text = entry.arguments?.getString("text").orEmpty()
OcrReviewScreen(
@ -135,18 +140,26 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
navController.navigate(Screen.Result.fromOcr(edited)) {
popUpTo(Screen.Dashboard.route)
}
}
},
)
}
// ── Result ──
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 }
)
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
@ -165,7 +178,12 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
navController.navigate(Screen.OcrCapture.route) {
popUpTo(Screen.Dashboard.route)
}
}
},
onOpenAlternatives = {
barcode?.let {
navController.navigate(Screen.ProductDetail.build(it))
}
},
)
}
@ -174,24 +192,24 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
ProfileListScreen(
onBack = { navController.popBackStack() },
onNew = { navController.navigate(Screen.ProfileEdit.new()) },
onEdit = { id -> navController.navigate(Screen.ProfileEdit.edit(id)) }
onEdit = { id -> navController.navigate(Screen.ProfileEdit.edit(id)) },
)
}
composable(
route = Screen.ProfileEdit.route,
arguments = listOf(navArgument("id") { type = NavType.LongType })
arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry ->
val id = entry.arguments?.getLong("id") ?: 0L
ProfileEditScreen(
id = id,
onBack = { navController.popBackStack() },
onSaved = { navController.popBackStack() }
onSaved = { navController.popBackStack() },
)
}
composable(Screen.Tracking.route) {
TrackingScreen(
onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) },
onOpenScanner = { navController.navigate(Screen.Scanner.route) }
onOpenScanner = { navController.navigate(Screen.Scanner.route) },
)
}
composable(Screen.Settings.route) {
@ -202,10 +220,14 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
@OptIn(ExperimentalFoundationApi::class)
composable(
route = Screen.ListDetail.route,
arguments = listOf(
navArgument("id") { type = NavType.LongType },
navArgument("name") { type = NavType.StringType; defaultValue = "Ma liste" }
)
arguments =
listOf(
navArgument("id") { type = NavType.LongType },
navArgument("name") {
type = NavType.StringType
defaultValue = "Ma liste"
},
),
) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L
val listName = entry.arguments?.getString("name") ?: "Ma liste"
@ -215,14 +237,14 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
onBack = { navController.popBackStack() },
onOpenScanner = { navController.navigate(Screen.Scanner.route) },
onOpenProduct = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) },
onOpenCatalog = { id -> navController.navigate(Screen.Catalog.build(id)) }
onOpenCatalog = { id -> navController.navigate(Screen.Catalog.build(id)) },
)
}
// ── Catalogue (refonte) ──
composable(
route = Screen.Catalog.route,
arguments = listOf(navArgument("listId") { type = NavType.LongType })
arguments = listOf(navArgument("listId") { type = NavType.LongType }),
) { entry ->
val listId = entry.arguments?.getLong("listId") ?: 0L
CatalogScreen(
@ -233,15 +255,16 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
},
onOpenSearch = {
navController.navigate(Screen.CatalogSearch.build(listId))
}
},
)
}
composable(
route = Screen.CatalogDomain.route,
arguments = listOf(
navArgument("listId") { type = NavType.LongType },
navArgument("domainId") { type = NavType.StringType }
)
arguments =
listOf(
navArgument("listId") { type = NavType.LongType },
navArgument("domainId") { type = NavType.StringType },
),
) { entry ->
val listId = entry.arguments?.getLong("listId") ?: 0L
val domainId = entry.arguments?.getString("domainId").orEmpty()
@ -250,45 +273,46 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
onBack = { navController.popBackStack() },
onOpenCategory = { categoryId ->
navController.navigate(Screen.CatalogCategory.build(listId, categoryId))
}
},
)
}
composable(
route = Screen.CatalogCategory.route,
arguments = listOf(
navArgument("listId") { type = NavType.LongType },
navArgument("categoryId") { type = NavType.StringType }
)
arguments =
listOf(
navArgument("listId") { type = NavType.LongType },
navArgument("categoryId") { type = NavType.StringType },
),
) { entry ->
val listId = entry.arguments?.getLong("listId") ?: 0L
val categoryId = entry.arguments?.getString("categoryId").orEmpty()
CategoryItemsScreen(
categoryId = categoryId,
listId = listId,
onBack = { navController.popBackStack() }
onBack = { navController.popBackStack() },
)
}
composable(
route = Screen.CatalogSearch.route,
arguments = listOf(navArgument("listId") { type = NavType.LongType })
arguments = listOf(navArgument("listId") { type = NavType.LongType }),
) { entry ->
val listId = entry.arguments?.getLong("listId") ?: 0L
CatalogSearchScreen(
listId = listId,
onBack = { navController.popBackStack() }
onBack = { navController.popBackStack() },
)
}
// ── Product Detail (Phase 5) ──
composable(
route = Screen.ProductDetail.route,
arguments = listOf(navArgument("barcode") { type = NavType.StringType })
arguments = listOf(navArgument("barcode") { type = NavType.StringType }),
) { entry ->
val barcode = entry.arguments?.getString("barcode").orEmpty()
ProductDetailScreen(
barcode = barcode,
onBack = { navController.popBackStack() },
onOpenProduct = { b -> navController.navigate(Screen.ProductDetail.build(b)) }
onOpenProduct = { b -> navController.navigate(Screen.ProductDetail.build(b)) },
)
}
@ -296,12 +320,12 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
composable(Screen.ListCreate.route) {
CreateListScreen(
onBack = { navController.popBackStack() },
onListCreated = { navController.popBackStack() }
onListCreated = { navController.popBackStack() },
)
}
composable(
route = Screen.ListSettings.route,
arguments = listOf(navArgument("id") { type = NavType.LongType })
arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L
ListSettingsScreen(
@ -310,47 +334,47 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
onOpenSort = { navController.navigate(Screen.ListSort.build(listId)) },
onOpenRegion = { navController.navigate(Screen.ListRegion.build(listId)) },
onOpenNameImage = { navController.navigate(Screen.ListNameImage.build(listId)) },
onOpenMembers = { navController.navigate(Screen.ListMembers.build(listId)) }
onOpenMembers = { navController.navigate(Screen.ListMembers.build(listId)) },
)
}
composable(
route = Screen.ListSort.route,
arguments = listOf(navArgument("id") { type = NavType.LongType })
arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L
ListSortScreen(
listId = listId,
onBack = { navController.popBackStack() }
onBack = { navController.popBackStack() },
)
}
composable(
route = Screen.ListRegion.route,
arguments = listOf(navArgument("id") { type = NavType.LongType })
arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L
ListRegionScreen(
listId = listId,
onBack = { navController.popBackStack() }
onBack = { navController.popBackStack() },
)
}
composable(
route = Screen.ListNameImage.route,
arguments = listOf(navArgument("id") { type = NavType.LongType })
arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L
ListNameImageScreen(
listId = listId,
onBack = { navController.popBackStack() }
onBack = { navController.popBackStack() },
)
}
composable(
route = Screen.ListMembers.route,
arguments = listOf(navArgument("id") { type = NavType.LongType })
arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L
ListMembersScreen(
listId = listId,
onBack = { navController.popBackStack() }
onBack = { navController.popBackStack() },
)
}
}

View File

@ -22,56 +22,81 @@ import androidx.compose.ui.graphics.vector.ImageVector
sealed class Screen(val route: String) {
// ── Onglets principaux (Bottom Navigation) ──
data object Dashboard : Screen("dashboard")
data object Lists : Screen("lists")
data object Tracking : Screen("tracking")
data object Family : Screen("family")
// ── Écrans de navigation (non dans bottom nav) ──
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 Onboarding : Screen("onboarding")
data object Settings : Screen("settings")
data object Splash : Screen("splash")
// ── Sous-écrans ──
data object ProfileList : Screen("profiles")
data object ProfileEdit : Screen("profile/edit/{id}") {
fun new() = "profile/edit/0"
fun edit(id: Long) = "profile/edit/$id"
}
data object ProductDetail : Screen("product/{barcode}") {
fun build(barcode: String) = "product/$barcode"
}
data object ListDetail : Screen("list/{id}?name={name}") {
fun build(id: Long, name: String = "Ma liste") = "list/$id?name=${android.net.Uri.encode(name)}"
fun build(
id: Long,
name: String = "Ma liste",
) = "list/$id?name=${android.net.Uri.encode(name)}"
}
data object ListEdit : Screen("list/edit?id={id}") {
fun new() = "list/edit?id=0"
fun edit(id: Long) = "list/edit?id=$id"
}
// ── List management (refonte) ──
data object ListCreate : Screen("list/create")
data object ListSettings : Screen("list/settings/{id}") {
fun build(id: Long) = "list/settings/$id"
}
data object ListSort : Screen("list/sort/{id}") {
fun build(id: Long) = "list/sort/$id"
}
data object ListRegion : Screen("list/region/{id}") {
fun build(id: Long) = "list/region/$id"
}
data object ListNameImage : Screen("list/nameimage/{id}") {
fun build(id: Long) = "list/nameimage/$id"
}
data object ListMembers : Screen("list/members/{id}") {
fun build(id: Long) = "list/members/$id"
}
@ -80,12 +105,21 @@ sealed class Screen(val route: String) {
data object Catalog : Screen("catalog/{listId}") {
fun build(listId: Long) = "catalog/$listId"
}
data object CatalogDomain : Screen("catalog/{listId}/domain/{domainId}") {
fun build(listId: Long, domainId: String) = "catalog/$listId/domain/$domainId"
fun build(
listId: Long,
domainId: String,
) = "catalog/$listId/domain/$domainId"
}
data object CatalogCategory : Screen("catalog/{listId}/category/{categoryId}") {
fun build(listId: Long, categoryId: String) = "catalog/$listId/category/$categoryId"
fun build(
listId: Long,
categoryId: String,
) = "catalog/$listId/category/$categoryId"
}
data object CatalogSearch : Screen("catalog/{listId}/search") {
fun build(listId: Long) = "catalog/$listId/search"
}
@ -100,36 +134,37 @@ data class BottomNavItem(
val iconUnselected: ImageVector,
val label: String,
val contentDescription: String,
val badgeCount: Int = 0
val badgeCount: Int = 0,
)
val bottomNavItems = listOf(
BottomNavItem(
screen = Screen.Dashboard,
iconSelected = Icons.Filled.Home,
iconUnselected = Icons.Outlined.Home,
label = "Accueil",
contentDescription = "Tableau de bord"
),
BottomNavItem(
screen = Screen.Lists,
iconSelected = Icons.Filled.List,
iconUnselected = Icons.Outlined.List,
label = "Listes",
contentDescription = "Mes listes de courses"
),
BottomNavItem(
screen = Screen.Tracking,
iconSelected = Icons.Filled.ShowChart,
iconUnselected = Icons.Outlined.ShowChart,
label = "Suivi",
contentDescription = "Statistiques et historique"
),
BottomNavItem(
screen = Screen.Family,
iconSelected = Icons.Filled.People,
iconUnselected = Icons.Outlined.People,
label = "Famille",
contentDescription = "Profils et réglages"
val bottomNavItems =
listOf(
BottomNavItem(
screen = Screen.Dashboard,
iconSelected = Icons.Filled.Home,
iconUnselected = Icons.Outlined.Home,
label = "Accueil",
contentDescription = "Tableau de bord",
),
BottomNavItem(
screen = Screen.Lists,
iconSelected = Icons.Filled.List,
iconUnselected = Icons.Outlined.List,
label = "Listes",
contentDescription = "Mes listes de courses",
),
BottomNavItem(
screen = Screen.Tracking,
iconSelected = Icons.Filled.ShowChart,
iconUnselected = Icons.Outlined.ShowChart,
label = "Suivi",
contentDescription = "Statistiques et historique",
),
BottomNavItem(
screen = Screen.Family,
iconSelected = Icons.Filled.People,
iconUnselected = Icons.Outlined.People,
label = "Famille",
contentDescription = "Profils et réglages",
),
)
)

View File

@ -62,9 +62,10 @@ import com.safebite.app.data.local.database.entity.CategoryEntity
import com.safebite.app.data.local.database.relation.DomainWithCategoriesAndItems
import kotlinx.coroutines.launch
private fun parseColor(hex: String?): Color? = runCatching {
hex?.takeIf { it.startsWith("#") }?.let { Color(android.graphics.Color.parseColor(it)) }
}.getOrNull()
private fun parseColor(hex: String?): Color? =
runCatching {
hex?.takeIf { it.startsWith("#") }?.let { Color(android.graphics.Color.parseColor(it)) }
}.getOrNull()
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -73,7 +74,7 @@ fun CatalogScreen(
onBack: () -> Unit,
onOpenDomain: (String) -> Unit,
onOpenSearch: () -> Unit,
viewModel: CatalogViewModel = hiltViewModel()
viewModel: CatalogViewModel = hiltViewModel(),
) {
LaunchedEffect(listId) { viewModel.setActiveList(listId) }
val domains by viewModel.domains.collectAsStateWithLifecycle()
@ -91,9 +92,9 @@ fun CatalogScreen(
IconButton(onClick = onOpenSearch) {
Icon(Icons.Filled.Search, contentDescription = "Rechercher")
}
}
},
)
}
},
) { padding ->
if (domains.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
@ -106,12 +107,12 @@ fun CatalogScreen(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(domains, key = { it.domain.domainId }) { domain ->
DomainCard(
domain = domain,
onClick = { onOpenDomain(domain.domain.domainId) }
onClick = { onOpenDomain(domain.domain.domainId) },
)
}
}
@ -121,22 +122,22 @@ fun CatalogScreen(
@Composable
private fun DomainCard(
domain: DomainWithCategoriesAndItems,
onClick: () -> Unit
onClick: () -> Unit,
) {
val color = parseColor(domain.domain.color) ?: MaterialTheme.colorScheme.primaryContainer
val itemCount = domain.categoriesWithItems.sumOf { it.items.size }
Card(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clickable(onClick = onClick),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.18f))
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.18f)),
) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween
verticalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = domain.domain.emoji,
style = MaterialTheme.typography.displayMedium
style = MaterialTheme.typography.displayMedium,
)
Column {
Text(
@ -144,13 +145,13 @@ private fun DomainCard(
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(4.dp))
Text(
text = "${domain.categoriesWithItems.size} catégories • $itemCount articles",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@ -163,7 +164,7 @@ fun DomainCategoriesScreen(
domainId: String,
onBack: () -> Unit,
onOpenCategory: (String) -> Unit,
viewModel: CatalogViewModel = hiltViewModel()
viewModel: CatalogViewModel = hiltViewModel(),
) {
LaunchedEffect(domainId) { viewModel.selectDomain(domainId) }
val categories by viewModel.categoriesForSelectedDomain.collectAsStateWithLifecycle()
@ -178,14 +179,14 @@ fun DomainCategoriesScreen(
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
}
}
},
)
}
},
) { padding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(categories, key = { it.categoryId }) { cat ->
CategoryRow(category = cat, onClick = { onOpenCategory(cat.categoryId) })
@ -195,23 +196,27 @@ fun DomainCategoriesScreen(
}
@Composable
private fun CategoryRow(category: CategoryEntity, onClick: () -> Unit) {
private fun CategoryRow(
category: CategoryEntity,
onClick: () -> Unit,
) {
val color = parseColor(category.color) ?: MaterialTheme.colorScheme.surfaceVariant
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.20f))
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.20f)),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(color.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center
modifier =
Modifier
.size(48.dp)
.clip(CircleShape)
.background(color.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center,
) {
Text(text = category.emoji, style = MaterialTheme.typography.headlineSmall)
}
@ -220,7 +225,7 @@ private fun CategoryRow(category: CategoryEntity, onClick: () -> Unit) {
text = category.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
)
}
}
@ -232,7 +237,7 @@ fun CategoryItemsScreen(
categoryId: String,
listId: Long,
onBack: () -> Unit,
viewModel: CatalogViewModel = hiltViewModel()
viewModel: CatalogViewModel = hiltViewModel(),
) {
LaunchedEffect(categoryId, listId) {
viewModel.setActiveList(listId)
@ -250,17 +255,17 @@ fun CategoryItemsScreen(
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
}
}
},
)
},
snackbarHost = { SnackbarHost(snackbar) }
snackbarHost = { SnackbarHost(snackbar) },
) { padding ->
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items, key = { it.itemId }) { item ->
ItemTile(item = item, onClick = {
@ -273,19 +278,22 @@ fun CategoryItemsScreen(
}
@Composable
private fun ItemTile(item: CatalogItemEntity, onClick: () -> Unit) {
private fun ItemTile(
item: CatalogItemEntity,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Box(
modifier = Modifier.fillMaxSize().padding(8.dp),
contentAlignment = Alignment.Center
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Center,
) {
Text(text = item.emoji, style = MaterialTheme.typography.displayMedium)
Spacer(Modifier.height(6.dp))
@ -295,14 +303,14 @@ private fun ItemTile(item: CatalogItemEntity, onClick: () -> Unit) {
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Ajouter",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.TopEnd).size(20.dp)
modifier = Modifier.align(Alignment.TopEnd).size(20.dp),
)
}
}
@ -313,7 +321,7 @@ private fun ItemTile(item: CatalogItemEntity, onClick: () -> Unit) {
fun CatalogSearchScreen(
listId: Long,
onBack: () -> Unit,
viewModel: CatalogViewModel = hiltViewModel()
viewModel: CatalogViewModel = hiltViewModel(),
) {
LaunchedEffect(listId) { viewModel.setActiveList(listId) }
val query by viewModel.searchQuery.collectAsStateWithLifecycle()
@ -329,10 +337,10 @@ fun CatalogSearchScreen(
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
}
}
},
)
},
snackbarHost = { SnackbarHost(snackbar) }
snackbarHost = { SnackbarHost(snackbar) },
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
OutlinedTextField(
@ -349,12 +357,12 @@ fun CatalogSearchScreen(
}
},
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search)
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(results, key = { it.itemId }) { item ->
SearchResultRow(item = item, onAdd = {
@ -368,15 +376,18 @@ fun CatalogSearchScreen(
}
@Composable
private fun SearchResultRow(item: CatalogItemEntity, onAdd: () -> Unit) {
private fun SearchResultRow(
item: CatalogItemEntity,
onAdd: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onAdd),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = item.emoji, style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.width(12.dp))
@ -384,7 +395,7 @@ private fun SearchResultRow(item: CatalogItemEntity, onAdd: () -> Unit) {
Text(
text = item.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
)
if (item.aliases.isNotBlank()) {
Text(
@ -392,7 +403,7 @@ private fun SearchResultRow(item: CatalogItemEntity, onAdd: () -> Unit) {
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
)
}
}

View File

@ -27,95 +27,112 @@ import javax.inject.Inject
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class CatalogViewModel @Inject constructor(
private val repository: CatalogRepository,
private val manageListUseCase: ManageShoppingListUseCase
) : ViewModel() {
class CatalogViewModel
@Inject
constructor(
private val repository: CatalogRepository,
private val manageListUseCase: ManageShoppingListUseCase,
) : ViewModel() {
private val _activeListId = MutableStateFlow<Long?>(null)
val activeListId: StateFlow<Long?> = _activeListId.asStateFlow()
private val _activeListId = MutableStateFlow<Long?>(null)
val activeListId: StateFlow<Long?> = _activeListId.asStateFlow()
val domains: StateFlow<List<DomainWithCategoriesAndItems>> =
repository.observeDomainsWithCategoriesAndItems().stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()
)
private val _selectedDomainId = MutableStateFlow<String?>(null)
val selectedDomainId: StateFlow<String?> = _selectedDomainId.asStateFlow()
val categoriesForSelectedDomain: StateFlow<List<CategoryEntity>> =
_selectedDomainId
.flatMapLatest { id ->
if (id == null) flowOf(emptyList())
else repository.observeCategoriesForDomain(id)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _selectedCategoryId = MutableStateFlow<String?>(null)
val selectedCategoryId: StateFlow<String?> = _selectedCategoryId.asStateFlow()
val itemsForSelectedCategory: StateFlow<List<CatalogItemEntity>> =
_selectedCategoryId
.flatMapLatest { id ->
if (id == null) flowOf(emptyList())
else repository.observeItemsForCategory(id)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
val searchResults: StateFlow<List<CatalogItemEntity>> =
_searchQuery
.flatMapLatest { q ->
if (q.isBlank()) flowOf(emptyList())
else repository.search(q, limit = 30)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun setActiveList(listId: Long) {
_activeListId.value = listId.takeIf { it > 0 }
}
fun selectDomain(domainId: String) {
_selectedDomainId.value = domainId
}
fun selectCategory(categoryId: String) {
_selectedCategoryId.value = categoryId
}
fun updateSearchQuery(query: String) {
_searchQuery.value = query
}
fun clearSearch() {
_searchQuery.value = ""
}
/** Ajoute l'article du catalogue à la liste active courante. */
fun addItemToActiveList(item: CatalogItemEntity, categoryNameOverride: String? = null) {
val listId = _activeListId.value ?: return
viewModelScope.launch {
// Évite les doublons par nom (ignore-case).
val existing = manageListUseCase.getItems(listId)
.firstOrNull { it.productName.equals(item.name, ignoreCase = true) }
if (existing != null) {
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
return@launch
}
val categoryName = categoryNameOverride
?: item.primaryCategoryId?.let { repository.getCategory(it)?.name }
manageListUseCase.addItemToList(
listId,
ShoppingListItemEntity(
listId = listId,
productName = item.name,
category = categoryName,
customEmoji = item.emoji
)
val domains: StateFlow<List<DomainWithCategoriesAndItems>> =
repository.observeDomainsWithCategoriesAndItems().stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
emptyList(),
)
repository.incrementPopularity(item.itemId)
private val _selectedDomainId = MutableStateFlow<String?>(null)
val selectedDomainId: StateFlow<String?> = _selectedDomainId.asStateFlow()
val categoriesForSelectedDomain: StateFlow<List<CategoryEntity>> =
_selectedDomainId
.flatMapLatest { id ->
if (id == null) {
flowOf(emptyList())
} else {
repository.observeCategoriesForDomain(id)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _selectedCategoryId = MutableStateFlow<String?>(null)
val selectedCategoryId: StateFlow<String?> = _selectedCategoryId.asStateFlow()
val itemsForSelectedCategory: StateFlow<List<CatalogItemEntity>> =
_selectedCategoryId
.flatMapLatest { id ->
if (id == null) {
flowOf(emptyList())
} else {
repository.observeItemsForCategory(id)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
val searchResults: StateFlow<List<CatalogItemEntity>> =
_searchQuery
.flatMapLatest { q ->
if (q.isBlank()) {
flowOf(emptyList())
} else {
repository.search(q, limit = 30)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun setActiveList(listId: Long) {
_activeListId.value = listId.takeIf { it > 0 }
}
fun selectDomain(domainId: String) {
_selectedDomainId.value = domainId
}
fun selectCategory(categoryId: String) {
_selectedCategoryId.value = categoryId
}
fun updateSearchQuery(query: String) {
_searchQuery.value = query
}
fun clearSearch() {
_searchQuery.value = ""
}
/** Ajoute l'article du catalogue à la liste active courante. */
fun addItemToActiveList(
item: CatalogItemEntity,
categoryNameOverride: String? = null,
) {
val listId = _activeListId.value ?: return
viewModelScope.launch {
// Évite les doublons par nom (ignore-case).
val existing =
manageListUseCase.getItems(listId)
.firstOrNull { it.productName.equals(item.name, ignoreCase = true) }
if (existing != null) {
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
return@launch
}
val categoryName =
categoryNameOverride
?: item.primaryCategoryId?.let { repository.getCategory(it)?.name }
manageListUseCase.addItemToList(
listId,
ShoppingListItemEntity(
listId = listId,
productName = item.name,
category = categoryName,
customEmoji = item.emoji,
),
)
repository.incrementPopularity(item.itemId)
}
}
}
}

View File

@ -2,17 +2,22 @@ package com.safebite.app.presentation.screen.dashboard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.PaddingValues
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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@ -27,127 +32,374 @@ 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.ScanHistoryItem
import com.safebite.app.presentation.common.components.CardVariant
import com.safebite.app.presentation.common.components.PrimaryButton
import com.safebite.app.presentation.common.components.StandardCard
import com.safebite.app.presentation.common.components.CardVariant
import com.safebite.app.presentation.theme.LocalStatusColors
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Dashboard contextuel (spec UX §5.3).
*
* Trois modes :
* - first_time : aucun scan dans l'historique
* - store_mode : détecté via géolocalisation/heure
* - home_mode : mode par défaut
* - FIRST_TIME : aucun scan CTA "Commencer"
* - STORE : créneau magasin ou liste active scan prominent + liste en cours
* - HOME : soirée/weekend résumé hebdomadaire + derniers scans
*/
@Composable
fun DashboardScreen(
onScan: () -> Unit,
onOpenList: (Long, String) -> Unit,
onOpenHistoryItem: (String) -> Unit,
viewModel: DashboardViewModel = hiltViewModel()
viewModel: DashboardViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
containerColor = MaterialTheme.colorScheme.background
containerColor = MaterialTheme.colorScheme.background,
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Greeting
when (state.contextMode) {
DashboardContextMode.FIRST_TIME -> FirstTimeContent(onScan = onScan)
DashboardContextMode.STORE ->
StoreContent(
state = state,
onScan = onScan,
onOpenList = onOpenList,
)
DashboardContextMode.HOME ->
HomeContent(
state = state,
onScan = onScan,
onOpenList = onOpenList,
onOpenHistoryItem = onOpenHistoryItem,
)
}
}
}
}
// ─── FIRST_TIME ──────────────────────────────────────────────────────────────
@Composable
private fun FirstTimeContent(onScan: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Spacer(Modifier.height(32.dp))
Text("🎉", style = MaterialTheme.typography.displayLarge)
Text(
text = stringResource(R.string.dashboard_first_time_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(R.string.dashboard_first_time_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
PrimaryButton(
text = stringResource(R.string.dashboard_first_time_cta),
onClick = onScan,
modifier = Modifier.fillMaxWidth(),
)
}
}
// ─── STORE ───────────────────────────────────────────────────────────────────
@Composable
private fun StoreContent(
state: DashboardUiState,
onScan: () -> Unit,
onOpenList: (Long, String) -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Contexte : magasin
Text("🛒", style = MaterialTheme.typography.displayLarge)
Text(
text = stringResource(R.string.dashboard_store_mode_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
)
// Bouton scan prominent
PrimaryButton(
text = stringResource(R.string.dashboard_scan_button),
onClick = onScan,
modifier = Modifier.fillMaxWidth(),
)
// Liste en cours (si dispo)
if (state.lists.isNotEmpty()) {
Text(
text = stringResource(R.string.dashboard_greeting, state.greetingName),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold
text = stringResource(R.string.dashboard_current_list),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
// Quick actions
PrimaryButton(
text = stringResource(R.string.dashboard_scan_button),
onClick = onScan,
modifier = Modifier.fillMaxWidth()
)
// Shopping lists quick access
if (state.lists.isNotEmpty()) {
Row(
state.lists.forEach { list ->
StandardCard(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
variant = CardVariant.Filled,
onClick = { onOpenList(list.id, list.name) },
contentPadding = PaddingValues(12.dp),
) {
state.lists.forEach { list ->
StandardCard(
modifier = Modifier
.weight(1f)
.height(72.dp),
variant = CardVariant.Filled,
onClick = { onOpenList(list.id, list.name) },
contentPadding = PaddingValues(8.dp)
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = list.name,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(
R.string.dashboard_remaining,
list.remaining
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = list.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
text = stringResource(R.string.dashboard_remaining, list.remaining),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
Icons.Filled.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
}
// Weekly stats placeholder
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f)
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.dashboard_weekly_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(8.dp))
Text(
text = "78% produits OK",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// ─── HOME ────────────────────────────────────────────────────────────────────
@Composable
private fun HomeContent(
state: DashboardUiState,
onScan: () -> Unit,
onOpenList: (Long, String) -> Unit,
onOpenHistoryItem: (String) -> Unit,
) {
val statusColors = LocalStatusColors.current
// Greeting
Text(
text =
if (state.greetingName.isNotEmpty()) {
stringResource(R.string.dashboard_greeting, state.greetingName)
} else {
stringResource(R.string.app_name)
},
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
)
// Quick actions
PrimaryButton(
text = stringResource(R.string.dashboard_scan_button),
onClick = onScan,
modifier = Modifier.fillMaxWidth(),
)
// Shopping lists quick access
if (state.lists.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
state.lists.forEach { list ->
StandardCard(
modifier =
Modifier
.weight(1f)
.height(72.dp),
variant = CardVariant.Filled,
onClick = { onOpenList(list.id, list.name) },
contentPadding = PaddingValues(8.dp),
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = list.name,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.dashboard_remaining, list.remaining),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
// Recent scans
Text(
text = stringResource(R.string.dashboard_recent_scans),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
// Weekly stats
if (state.weeklyStats != null) {
WeeklyStatsCard(state.weeklyStats!!)
}
// Recent scans
Text(
text = stringResource(R.string.dashboard_recent_scans),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
if (state.recentScans.isNotEmpty()) {
state.recentScans.forEach { scan ->
RecentScanRow(
scan = scan,
onClick = { onOpenHistoryItem(scan.barcode) },
)
}
} else {
Text(
text = stringResource(R.string.dashboard_no_scans),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// ─── Shared components ──────────────────────────────────────────────────────
@Composable
private fun WeeklyStatsCard(stats: WeeklyStats) {
val statusColors = LocalStatusColors.current
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f),
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.dashboard_no_scans),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
text = stringResource(R.string.dashboard_weekly_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
StatBadge("", "${stats.safePercentage}%", stringResource(R.string.dashboard_stats_safe), statusColors.safe)
StatBadge("⚠️", "${stats.warningCount}", stringResource(R.string.dashboard_stats_warning), statusColors.warning)
StatBadge("", "${stats.dangerCount}", stringResource(R.string.dashboard_stats_danger), statusColors.danger)
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.dashboard_stats_total, stats.totalScans),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun StatBadge(
emoji: String,
count: String,
label: String,
color: androidx.compose.ui.graphics.Color,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(emoji, style = MaterialTheme.typography.headlineMedium)
Text(count, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = color)
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun RecentScanRow(
scan: ScanHistoryItem,
onClick: () -> Unit,
) {
val statusColors = LocalStatusColors.current
val emoji =
when (scan.safetyStatus) {
SafetyStatus.SAFE -> ""
SafetyStatus.WARNING -> "⚠️"
SafetyStatus.DANGER -> ""
}
StandardCard(
modifier = Modifier.fillMaxWidth(),
variant = CardVariant.Filled,
onClick = onClick,
contentPadding = PaddingValues(12.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(emoji, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.width(8.dp))
Column(Modifier.weight(1f)) {
Text(
scan.productName ?: scan.barcode,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!scan.brand.isNullOrBlank()) {
Text(
scan.brand,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
formatRelativeTime(scan.scannedAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
Icons.Filled.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
private fun formatRelativeTime(timestamp: Long): String {
val now = System.currentTimeMillis()
val diff = now - timestamp
return when {
diff < 60_000 -> "À l'instant"
diff < 3_600_000 -> "Il y a ${diff / 60_000} min"
diff < 86_400_000 -> "Il y a ${diff / 3_600_000}h"
diff < 604_800_000 -> "Il y a ${diff / 86_400_000}j"
else -> SimpleDateFormat("dd/MM", Locale.FRANCE).format(Date(timestamp))
}
}

View File

@ -2,8 +2,10 @@ package com.safebite.app.presentation.screen.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.safebite.app.data.local.database.entity.ShoppingListEntity
import com.safebite.app.domain.model.SafetyStatus
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.GetShoppingListsUseCase
import com.safebite.app.domain.usecase.ManageProfileUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
@ -15,66 +17,158 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import java.util.Calendar
import javax.inject.Inject
/** Mode contextuel du dashboard. */
enum class DashboardContextMode {
/** Aucun scan dans l'historique — onboarding implicite. */
FIRST_TIME,
/** Créneau 8h-20h en semaine OU liste active avec restants → mode magasin. */
STORE,
/** Soirée, weekend, ou aucune condition magasin remplie. */
HOME,
}
data class DashboardUiState(
val greetingName: String = "",
val lists: List<ListSummary> = emptyList()
val contextMode: DashboardContextMode = DashboardContextMode.FIRST_TIME,
val lists: List<ListSummary> = emptyList(),
val weeklyStats: WeeklyStats? = null,
val recentScans: List<ScanHistoryItem> = emptyList(),
)
data class WeeklyStats(
val safePercentage: Int,
val warningCount: Int,
val dangerCount: Int,
val totalScans: Int,
)
data class ListSummary(
val id: Long,
val name: String,
val remaining: Int
val remaining: Int,
)
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val manageProfile: ManageProfileUseCase,
private val getShoppingLists: GetShoppingListsUseCase
) : ViewModel() {
@OptIn(ExperimentalCoroutinesApi::class)
val state: StateFlow<DashboardUiState> = combine(
manageProfile.observe(),
manageProfile.observeActiveIds()
) { profiles, activeIds ->
profiles to activeIds
}.flatMapLatest { (profiles, activeIds) ->
val greetingName = resolveGreetingName(profiles, activeIds)
observeListsWithStats(greetingName)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = DashboardUiState()
)
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeListsWithStats(greetingName: String): Flow<DashboardUiState> {
return getShoppingLists.observeActive().flatMapLatest { lists ->
val sortedLists = lists.sortedBy { it.createdAt }.take(4)
if (sortedLists.isEmpty()) {
flowOf(DashboardUiState(greetingName = greetingName, lists = emptyList()))
} else {
val listFlows = sortedLists.map { list ->
combine(
getShoppingLists.observeItemCount(list.id),
getShoppingLists.observeCheckedCount(list.id)
) { total, checked ->
ListSummary(list.id, list.name, total - checked)
}
class DashboardViewModel
@Inject
constructor(
private val manageProfile: ManageProfileUseCase,
private val getShoppingLists: GetShoppingListsUseCase,
private val getScanHistory: GetScanHistoryUseCase,
) : ViewModel() {
@OptIn(ExperimentalCoroutinesApi::class)
val state: StateFlow<DashboardUiState> =
combine(
manageProfile.observe(),
manageProfile.observeActiveIds(),
) { profiles, activeIds ->
profiles to activeIds
}.flatMapLatest { (profiles, activeIds) ->
val greetingName = resolveGreetingName(profiles, activeIds)
combine(
observeListsWithStats(greetingName),
observeHistory(),
) { dashboard, history ->
val weeklyStats = computeWeeklyStats(history)
val contextMode = detectContextMode(history, dashboard.lists)
dashboard.copy(
contextMode = contextMode,
recentScans = history.take(5),
weeklyStats = weeklyStats,
)
}
combine(listFlows) { array ->
DashboardUiState(greetingName, array.toList())
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = DashboardUiState(),
)
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeListsWithStats(greetingName: String): Flow<DashboardUiState> {
return getShoppingLists.observeActive().flatMapLatest { lists ->
val sortedLists = lists.sortedBy { it.createdAt }.take(4)
if (sortedLists.isEmpty()) {
flowOf(DashboardUiState(greetingName = greetingName, lists = emptyList()))
} else {
val listFlows =
sortedLists.map { list ->
combine(
getShoppingLists.observeItemCount(list.id),
getShoppingLists.observeCheckedCount(list.id),
) { total, checked ->
ListSummary(list.id, list.name, total - checked)
}
}
combine(listFlows) { array ->
DashboardUiState(greetingName = greetingName, lists = array.toList())
}
}
}
}
}
private fun resolveGreetingName(profiles: List<UserProfile>, activeIds: Set<Long>): String {
return when {
activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }.firstOrNull()?.name
else -> profiles.filter { it.isDefault }.firstOrNull()?.name ?: profiles.firstOrNull()?.name
} ?: ""
private fun observeHistory(): Flow<List<ScanHistoryItem>> = getScanHistory.observe()
/**
* Détection du mode contextuel :
* - FIRST_TIME : aucun scan dans l'historique
* - STORE : heure 8h-20h en semaine, OU liste active avec produits restants
* - HOME : par défaut (soirée, weekend)
*/
private fun detectContextMode(
history: List<ScanHistoryItem>,
lists: List<ListSummary>,
): DashboardContextMode {
if (history.isEmpty()) return DashboardContextMode.FIRST_TIME
val cal = Calendar.getInstance()
val hour = cal.get(Calendar.HOUR_OF_DAY)
val dayOfWeek = cal.get(Calendar.DAY_OF_WEEK)
val isWeekday = dayOfWeek in Calendar.MONDAY..Calendar.FRIDAY
val isStoreHours = hour in 8..19
val hasActiveList = lists.any { it.remaining > 0 }
return if ((isWeekday && isStoreHours) || hasActiveList) {
DashboardContextMode.STORE
} else {
DashboardContextMode.HOME
}
}
private fun computeWeeklyStats(history: List<ScanHistoryItem>): WeeklyStats? {
if (history.isEmpty()) return null
val cal = Calendar.getInstance()
cal.set(Calendar.DAY_OF_WEEK, cal.firstDayOfWeek)
cal.set(Calendar.HOUR_OF_DAY, 0)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
cal.set(Calendar.MILLISECOND, 0)
val weekStart = cal.timeInMillis
val weeklyScans = history.filter { it.scannedAt >= weekStart }
if (weeklyScans.isEmpty()) return null
val total = weeklyScans.size
val safe = weeklyScans.count { it.safetyStatus == SafetyStatus.SAFE }
val warnings = weeklyScans.count { it.safetyStatus == SafetyStatus.WARNING }
val dangers = weeklyScans.count { it.safetyStatus == SafetyStatus.DANGER }
return WeeklyStats(
safePercentage = if (total > 0) (safe * 100) / total else 0,
warningCount = warnings,
dangerCount = dangers,
totalScans = total,
)
}
private fun resolveGreetingName(
profiles: List<UserProfile>,
activeIds: Set<Long>,
): String {
return when {
activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }.firstOrNull()?.name
else -> profiles.filter { it.isDefault }.firstOrNull()?.name ?: profiles.firstOrNull()?.name
} ?: ""
}
}
}

View File

@ -1,7 +1,6 @@
package com.safebite.app.presentation.screen.family
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -20,7 +19,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.AlertDialog
@ -43,13 +41,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -65,7 +59,7 @@ import com.safebite.app.presentation.theme.LocalDimens
fun FamilyScreen(
onOpenProfile: (Long) -> Unit,
onOpenSettings: () -> Unit,
viewModel: FamilyViewModel = hiltViewModel()
viewModel: FamilyViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val activeProfileIds by viewModel.activeProfileIds.collectAsStateWithLifecycle()
@ -78,7 +72,7 @@ fun FamilyScreen(
SafeBiteTopAppBar(
title = stringResource(R.string.family_title),
onBack = null,
backContentDescription = null
backContentDescription = null,
)
},
floatingActionButton = {
@ -86,39 +80,42 @@ fun FamilyScreen(
FloatingActionButton(
onClick = { onOpenProfile(0L) },
containerColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.semantics {
contentDescription = addContentDesc
}
modifier =
Modifier.semantics {
contentDescription = addContentDesc
},
) {
Icon(
Icons.Filled.Add,
contentDescription = null
contentDescription = null,
)
}
}
},
) { padding ->
if (uiState.profiles.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
modifier =
Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
EmptyState(
title = stringResource(R.string.family_no_profiles),
message = stringResource(R.string.family_no_profiles_body),
emoji = "👨‍👩‍👧‍👦"
emoji = "👨‍👩‍👧‍👦",
)
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = LocalDimens.current.spacingMd),
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = LocalDimens.current.spacingMd),
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd),
verticalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd)
verticalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd),
) {
items(uiState.profiles, key = { it.id }) { profile ->
val isActive = profile.id in activeProfileIds
@ -128,7 +125,7 @@ fun FamilyScreen(
onToggleActive = { viewModel.toggleProfileActive(profile.id) },
onEdit = { onOpenProfile(profile.id) },
onDelete = { showDeleteDialog = profile.id },
onSetDefault = { viewModel.setDefaultProfile(profile.id) }
onSetDefault = { viewModel.setDefaultProfile(profile.id) },
)
}
}
@ -148,7 +145,7 @@ fun FamilyScreen(
onClick = {
viewModel.deleteProfile(profile)
showDeleteDialog = null
}
},
) {
Text("Supprimer", color = MaterialTheme.colorScheme.error)
}
@ -157,7 +154,7 @@ fun FamilyScreen(
TextButton(onClick = { showDeleteDialog = null }) {
Text("Annuler")
}
}
},
)
}
}
@ -174,82 +171,96 @@ fun ProfileCard(
onEdit: () -> Unit,
onDelete: () -> Unit,
onSetDefault: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val dimens = LocalDimens.current
Card(
modifier = modifier
.clickable(onClick = onEdit),
modifier =
modifier
.clickable(onClick = onEdit),
shape = RoundedCornerShape(dimens.radiusMd),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(dimens.spacingMd)
modifier =
Modifier
.fillMaxWidth()
.padding(dimens.spacingMd),
) {
// En-tête avec avatar et nom
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
horizontalArrangement = Arrangement.SpaceBetween,
) {
// Avatar
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
if (isActive) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
),
contentAlignment = Alignment.Center
modifier =
Modifier
.size(48.dp)
.clip(CircleShape)
.background(
if (isActive) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
contentAlignment = Alignment.Center,
) {
Text(
text = profile.avatar,
style = MaterialTheme.typography.headlineMedium
style = MaterialTheme.typography.headlineMedium,
)
}
// Nom et badge
Column(
modifier = Modifier.weight(1f).padding(horizontal = dimens.spacingSm)
modifier = Modifier.weight(1f).padding(horizontal = dimens.spacingSm),
) {
Text(
text = profile.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1
maxLines = 1,
)
if (profile.isDefault) {
Text(
text = stringResource(R.string.profile_default_badge),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.primary,
)
}
}
// Bouton actif
val a11yDesc = if (isActive)
stringResource(R.string.a11y_profile_inactive, profile.name)
else
stringResource(R.string.a11y_profile_active, profile.name)
val a11yDesc =
if (isActive) {
stringResource(R.string.a11y_profile_inactive, profile.name)
} else {
stringResource(R.string.a11y_profile_active, profile.name)
}
IconButton(
onClick = onToggleActive,
modifier = Modifier.semantics {
contentDescription = a11yDesc
}
modifier =
Modifier.semantics {
contentDescription = a11yDesc
},
) {
Icon(
imageVector = if (isActive) Icons.Filled.Star else Icons.Filled.StarBorder,
contentDescription = null,
tint = if (isActive) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
tint =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
@ -260,7 +271,7 @@ fun ProfileCard(
AllergenDisplayGrid(
severeAllergens = profile.severeAllergens,
moderateIntolerances = profile.moderateIntolerances,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(dimens.spacingSm))
@ -271,7 +282,7 @@ fun ProfileCard(
text = profile.dietaryRestrictions.joinToString(", ") { it.displayFr },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
maxLines = 2,
)
}
}

View File

@ -19,45 +19,51 @@ import javax.inject.Inject
data class FamilyUiState(
val profiles: List<UserProfile> = emptyList(),
val activeProfileIds: Set<Long> = emptySet(),
val isLoading: Boolean = true
val isLoading: Boolean = true,
)
@HiltViewModel
class FamilyViewModel @Inject constructor(
private val manageProfileUseCase: ManageProfileUseCase
) : ViewModel() {
class FamilyViewModel
@Inject
constructor(
private val manageProfileUseCase: ManageProfileUseCase,
) : ViewModel() {
val uiState: StateFlow<FamilyUiState> =
manageProfileUseCase.observe()
.map { profiles ->
FamilyUiState(
profiles = profiles,
isLoading = false,
)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
FamilyUiState(),
)
val uiState: StateFlow<FamilyUiState> = manageProfileUseCase.observe()
.map { profiles ->
FamilyUiState(
profiles = profiles,
isLoading = false
)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
FamilyUiState()
)
val activeProfileIds: StateFlow<Set<Long>> =
manageProfileUseCase.observeActiveIds()
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
emptySet(),
)
val activeProfileIds: StateFlow<Set<Long>> = manageProfileUseCase.observeActiveIds()
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
emptySet()
)
fun toggleProfileActive(id: Long) =
viewModelScope.launch {
val current = manageProfileUseCase.observeActiveIds().first()
val newIds = if (id in current) current - id else current + id
manageProfileUseCase.setActive(newIds)
}
fun toggleProfileActive(id: Long) = viewModelScope.launch {
val current = manageProfileUseCase.observeActiveIds().first()
val newIds = if (id in current) current - id else current + id
manageProfileUseCase.setActive(newIds)
fun deleteProfile(profile: UserProfile) =
viewModelScope.launch {
manageProfileUseCase.delete(profile)
}
fun setDefaultProfile(id: Long) =
viewModelScope.launch {
manageProfileUseCase.setDefault(id)
}
}
fun deleteProfile(profile: UserProfile) = viewModelScope.launch {
manageProfileUseCase.delete(profile)
}
fun setDefaultProfile(id: Long) = viewModelScope.launch {
manageProfileUseCase.setDefault(id)
}
}

View File

@ -40,7 +40,6 @@ 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
@ -60,7 +59,7 @@ fun HomeScreen(
onHistory: () -> Unit,
onSettings: () -> Unit,
onOpenHistoryItem: (String) -> Unit,
viewModel: HomeViewModel = hiltViewModel()
viewModel: HomeViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@ -71,29 +70,36 @@ fun HomeScreen(
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)) }
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)
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
onManage = onProfiles,
)
ScanButton(onClick = onScan)
@ -102,35 +108,38 @@ fun HomeScreen(
text = stringResource(R.string.home_ocr_button),
onClick = onOcr,
icon = Icons.Filled.TextFields,
modifier = Modifier.fillMaxWidth().height(dimens.buttonHeightLg)
modifier = Modifier.fillMaxWidth().height(dimens.buttonHeightLg),
)
Text(
stringResource(R.string.home_recent_scans),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
color = MaterialTheme.colorScheme.onBackground,
)
if (state.recent.isEmpty()) {
Text(
stringResource(R.string.home_no_recent),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
state.recent.forEach { item ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
.fillMaxWidth()
.clickable { onOpenHistoryItem(item.barcode) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { onOpenHistoryItem(item.barcode) },
) {
Box(
modifier = Modifier.size(12.dp).background(statusColor(item.safetyStatus), CircleShape)
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)
modifier = Modifier.weight(1f),
)
}
}
@ -144,7 +153,7 @@ private fun ActiveProfilesRow(
profiles: List<UserProfile>,
active: List<UserProfile>,
onToggle: (UserProfile) -> Unit,
onManage: () -> Unit
onManage: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@ -159,7 +168,7 @@ private fun ActiveProfilesRow(
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
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)
@ -180,37 +189,40 @@ private fun ScanButton(onClick: () -> Unit) {
onClick = onClick,
icon = Icons.Filled.QrCodeScanner,
large = true,
modifier = Modifier
.fillMaxWidth()
.height(dimens.buttonHeightHero)
.semantics { contentDescription = "Scan a product" },
modifier =
Modifier
.fillMaxWidth()
.height(dimens.buttonHeightHero)
.semantics { contentDescription = "Scan a product" },
)
}
@Composable
private fun NoProfileBlock(modifier: Modifier, onCreate: () -> Unit) {
private fun NoProfileBlock(
modifier: Modifier,
onCreate: () -> Unit,
) {
val dimens = LocalDimens.current
Column(
modifier = modifier.fillMaxSize().padding(dimens.spacingXl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Center,
) {
Text(
stringResource(R.string.home_no_profile_title),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground
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
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(dimens.spacingLg))
PrimaryButton(
text = stringResource(R.string.home_create_profile),
onClick = onCreate
onClick = onCreate,
)
}
}

View File

@ -17,34 +17,39 @@ import javax.inject.Inject
data class HomeUi(
val profiles: List<UserProfile> = emptyList(),
val activeProfiles: List<UserProfile> = emptyList(),
val recent: List<ScanHistoryItem> = emptyList()
val recent: List<ScanHistoryItem> = emptyList(),
)
@HiltViewModel
class HomeViewModel @Inject constructor(
private val manageProfile: ManageProfileUseCase,
private val history: GetScanHistoryUseCase
) : ViewModel() {
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())
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 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))
}
}
fun setActiveOnly(profile: UserProfile) = viewModelScope.launch {
manageProfile.setActive(setOf(profile.id))
}
}

View File

@ -38,9 +38,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -52,13 +50,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.safebite.app.domain.engine.CatalogProvider
import javax.inject.Inject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -67,7 +63,7 @@ fun IconPickerSheet(
categories: List<String>,
onDismiss: () -> Unit,
onSelectIcon: (String) -> Unit,
catalogProvider: CatalogProvider = hiltViewModel<ListDetailViewModel>().catalog
catalogProvider: CatalogProvider = hiltViewModel<ListDetailViewModel>().catalog,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var searchQuery by remember { mutableStateOf("") }
@ -75,18 +71,19 @@ fun IconPickerSheet(
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp)
modifier =
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onDismiss) {
Icon(Icons.Filled.Close, contentDescription = "Fermer")
@ -95,34 +92,36 @@ fun IconPickerSheet(
text = "Choisir une icône",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
)
IconButton(onClick = { onSelectIcon("") }) {
Icon(
Icons.Filled.Delete,
contentDescription = "Supprimer l'icône",
tint = MaterialTheme.colorScheme.error
tint = MaterialTheme.colorScheme.error,
)
}
}
// Current icon display
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.size(120.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
modifier =
Modifier
.size(120.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = currentEmoji,
style = MaterialTheme.typography.displayLarge
style = MaterialTheme.typography.displayLarge,
)
}
}
@ -131,9 +130,10 @@ fun IconPickerSheet(
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
placeholder = { Text("Chercher une icône") },
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
@ -146,23 +146,26 @@ fun IconPickerSheet(
}
},
singleLine = true,
shape = RoundedCornerShape(28.dp)
shape = RoundedCornerShape(28.dp),
)
// Icon grid by category
// Icon grid by category (catalogue food items first, then extra emoji categories)
val extraCategories = remember { ExtraEmojiCategories.all }
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// 1. Catégories du catalogue alimentaire
categories.forEach { category ->
val categoryItems = catalogProvider.itemsForCategory(category)
val filteredItems = if (searchQuery.isNotBlank()) {
categoryItems.filter {
it.name.contains(searchQuery, ignoreCase = true)
val filteredItems =
if (searchQuery.isNotBlank()) {
categoryItems.filter {
it.name.contains(searchQuery, ignoreCase = true)
}
} else {
categoryItems
}
} else {
categoryItems
}
if (filteredItems.isNotEmpty()) {
val expanded = expandedCategories[category] ?: (searchQuery.isNotBlank())
@ -174,7 +177,7 @@ fun IconPickerSheet(
expanded = expanded,
onToggle = {
expandedCategories[category] = !expanded
}
},
)
}
@ -183,7 +186,51 @@ fun IconPickerSheet(
IconGrid(
items = filteredItems,
currentEmoji = currentEmoji,
onSelectIcon = onSelectIcon
onSelectIcon = onSelectIcon,
)
}
}
}
}
// 2. Catégories d'émojis supplémentaires (non-alimentaires)
extraCategories.forEach { (category, items) ->
val filteredItems =
if (searchQuery.isNotBlank()) {
items.filter {
it.name.contains(searchQuery, ignoreCase = true) ||
it.emoji.contains(searchQuery, ignoreCase = true)
}
} else {
items
}
if (filteredItems.isNotEmpty()) {
val expanded = expandedCategories[category] ?: (searchQuery.isNotBlank())
item(key = "header-extra-$category") {
CategoryHeader(
title = category,
count = filteredItems.size,
expanded = expanded,
onToggle = {
expandedCategories[category] = !expanded
},
)
}
if (expanded) {
item(key = "grid-extra-$category") {
IconGrid(
items = filteredItems.map {
CatalogProvider.CatalogItem(
name = it.name,
category = category,
emoji = it.emoji,
)
},
currentEmoji = currentEmoji,
onSelectIcon = onSelectIcon,
)
}
}
@ -201,34 +248,35 @@ private fun CategoryHeader(
title: String,
count: Int,
expanded: Boolean,
onToggle: () -> Unit
onToggle: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onToggle)
.padding(vertical = 12.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onToggle)
.padding(vertical = 12.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.rotate(if (expanded) 90f else 0f),
tint = MaterialTheme.colorScheme.onSurface
tint = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
)
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
modifier = Modifier.size(20.dp),
)
}
}
@ -237,23 +285,24 @@ private fun CategoryHeader(
private fun IconGrid(
items: List<CatalogProvider.CatalogItem>,
currentEmoji: String,
onSelectIcon: (String) -> Unit
onSelectIcon: (String) -> Unit,
) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier
.fillMaxWidth()
.height(((items.size + 2) / 3 * 100).dp.coerceAtMost(400.dp)),
modifier =
Modifier
.fillMaxWidth()
.height(((items.size + 2) / 3 * 100).dp.coerceAtMost(400.dp)),
contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items) { item ->
IconCard(
emoji = item.emoji,
label = item.name,
isSelected = item.emoji == currentEmoji,
onClick = { onSelectIcon(item.emoji) }
onClick = { onSelectIcon(item.emoji) },
)
}
}
@ -264,39 +313,42 @@ private fun IconCard(
emoji: String,
label: String,
isSelected: Boolean,
onClick: () -> Unit
onClick: () -> Unit,
) {
val backgroundColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val contentColor = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val backgroundColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val contentColor =
if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Card(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clickable(onClick = onClick),
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = backgroundColor)
colors = CardDefaults.cardColors(containerColor = backgroundColor),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(8.dp)
modifier = Modifier.padding(8.dp),
) {
Text(
text = emoji,
style = MaterialTheme.typography.headlineMedium
style = MaterialTheme.typography.headlineMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
@ -305,7 +357,7 @@ private fun IconCard(
color = contentColor,
textAlign = TextAlign.Center,
maxLines = 2,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
)
}
if (isSelected) {
@ -313,12 +365,150 @@ private fun IconCard(
imageVector = Icons.Filled.Check,
contentDescription = "Sélectionné",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(20.dp)
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(20.dp),
)
}
}
}
}
/** Émojis supplémentaires non-alimentaires pour l'IconPicker. */
private data class ExtraEmoji(val emoji: String, val name: String)
private object ExtraEmojiCategories {
val all: List<Pair<String, List<ExtraEmoji>>> =
listOf(
"🐾 Animaux" to
listOf(
ExtraEmoji("🐶", "Chien"),
ExtraEmoji("🐱", "Chat"),
ExtraEmoji("🐰", "Lapin"),
ExtraEmoji("🐻", "Ours"),
ExtraEmoji("🐼", "Panda"),
ExtraEmoji("🐨", "Koala"),
ExtraEmoji("🦁", "Lion"),
ExtraEmoji("🐮", "Vache"),
ExtraEmoji("🐷", "Cochon"),
ExtraEmoji("🐸", "Grenouille"),
ExtraEmoji("🐵", "Singe"),
ExtraEmoji("🐔", "Poule"),
ExtraEmoji("🐧", "Pingouin"),
ExtraEmoji("🐦", "Oiseau"),
ExtraEmoji("🐤", "Poussin"),
ExtraEmoji("🦆", "Canard"),
ExtraEmoji("🦉", "Chouette"),
ExtraEmoji("🦋", "Papillon"),
ExtraEmoji("🐝", "Abeille"),
ExtraEmoji("🐞", "Coccinelle"),
ExtraEmoji("🐠", "Poisson tropical"),
ExtraEmoji("🐬", "Dauphin"),
ExtraEmoji("🐳", "Baleine"),
ExtraEmoji("🦖", "Dinosaure"),
),
"⚽ Sports & Loisirs" to
listOf(
ExtraEmoji("", "Football"),
ExtraEmoji("🏀", "Basket"),
ExtraEmoji("🎾", "Tennis"),
ExtraEmoji("🏈", "Football US"),
ExtraEmoji("", "Baseball"),
ExtraEmoji("🏐", "Volley"),
ExtraEmoji("🏓", "Ping-pong"),
ExtraEmoji("🎱", "Billard"),
ExtraEmoji("🏊", "Natation"),
ExtraEmoji("🚴", "Vélo"),
ExtraEmoji("🏃", "Course"),
ExtraEmoji("🧘", "Yoga"),
ExtraEmoji("🎮", "Jeux vidéo"),
ExtraEmoji("🎲", "Jeu de dés"),
ExtraEmoji("♟️", "Échecs"),
ExtraEmoji("🎸", "Guitare"),
ExtraEmoji("🎹", "Piano"),
ExtraEmoji("🎧", "Musique"),
),
"🚗 Transports" to
listOf(
ExtraEmoji("🚗", "Voiture"),
ExtraEmoji("🚙", "SUV"),
ExtraEmoji("🏍️", "Moto"),
ExtraEmoji("🚌", "Bus"),
ExtraEmoji("🚛", "Camion"),
ExtraEmoji("✈️", "Avion"),
ExtraEmoji("🚀", "Fusée"),
ExtraEmoji("", "Bateau"),
ExtraEmoji("🚲", "Vélo"),
ExtraEmoji("🚂", "Train"),
ExtraEmoji("🚁", "Hélicoptère"),
ExtraEmoji("🛴", "Trottinette"),
),
"🏠 Maison & Objets" to
listOf(
ExtraEmoji("🏠", "Maison"),
ExtraEmoji("🛏️", "Lit"),
ExtraEmoji("🛋️", "Canapé"),
ExtraEmoji("🚿", "Douche"),
ExtraEmoji("🪥", "Brosse à dents"),
ExtraEmoji("🧴", "Lotion"),
ExtraEmoji("🧹", "Balai"),
ExtraEmoji("🧺", "Panier à linge"),
ExtraEmoji("🪴", "Plante"),
ExtraEmoji("💡", "Ampoule"),
ExtraEmoji("🔑", "Clé"),
ExtraEmoji("📱", "Téléphone"),
ExtraEmoji("💻", "Ordinateur"),
ExtraEmoji("📺", "Télévision"),
ExtraEmoji("📷", "Appareil photo"),
ExtraEmoji("🔋", "Batterie"),
),
"🎉 Fêtes & Événements" to
listOf(
ExtraEmoji("🎂", "Gâteau d'anniversaire"),
ExtraEmoji("🎁", "Cadeau"),
ExtraEmoji("🎈", "Ballon"),
ExtraEmoji("🎄", "Sapin de Noël"),
ExtraEmoji("🎃", "Citrouille Halloween"),
ExtraEmoji("🎆", "Feu d'artifice"),
ExtraEmoji("💝", "Saint-Valentin"),
ExtraEmoji("🥂", "Célébration"),
ExtraEmoji("🎊", "Confetti"),
ExtraEmoji("🕯️", "Bougie"),
),
"💊 Santé & Bien-être" to
listOf(
ExtraEmoji("💊", "Médicament"),
ExtraEmoji("🩹", "Pansement"),
ExtraEmoji("💉", "Seringue"),
ExtraEmoji("🩺", "Stéthoscope"),
ExtraEmoji("🧬", "ADN"),
ExtraEmoji("🦷", "Dent"),
ExtraEmoji("👁️", "Œil"),
ExtraEmoji("❤️", "Cœur"),
ExtraEmoji("🧠", "Cerveau"),
),
"⭐ Symboles" to
listOf(
ExtraEmoji("", "Étoile"),
ExtraEmoji("❤️", "Cœur rouge"),
ExtraEmoji("💚", "Cœur vert"),
ExtraEmoji("💙", "Cœur bleu"),
ExtraEmoji("🔥", "Feu"),
ExtraEmoji("💧", "Goutte"),
ExtraEmoji("", "Étincelles"),
ExtraEmoji("", "Check"),
ExtraEmoji("", "Croix"),
ExtraEmoji("⚠️", "Attention"),
ExtraEmoji("🚫", "Interdit"),
ExtraEmoji("💯", "100"),
ExtraEmoji("🆕", "Nouveau"),
ExtraEmoji("🔝", "Top"),
ExtraEmoji("💪", "Force"),
ExtraEmoji("👑", "Couronne"),
ExtraEmoji("💎", "Diamant"),
ExtraEmoji("🌈", "Arc-en-ciel"),
),
)
}

View File

@ -1,7 +1,5 @@
package com.safebite.app.presentation.screen.lists
import android.content.res.Resources
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -25,9 +23,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@ -62,7 +58,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -72,7 +67,6 @@ import com.safebite.app.presentation.common.components.EmptyState
import com.safebite.app.presentation.common.components.PrimaryButton
import com.safebite.app.presentation.screen.lists.util.backgroundByResName
import com.safebite.app.presentation.theme.LocalDimens
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@ -82,7 +76,7 @@ fun ListsScreen(
onOpenScanner: () -> Unit,
onOpenListCreate: () -> Unit,
onOpenListSettings: (Long) -> Unit,
viewModel: ListsViewModel = hiltViewModel()
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val isEditMode by viewModel.isEditMode.collectAsStateWithLifecycle()
@ -108,7 +102,7 @@ fun ListsScreen(
}
else -> {}
}
}
},
)
},
floatingActionButton = {
@ -116,16 +110,17 @@ fun ListsScreen(
onClick = onOpenListCreate,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
shape = MaterialTheme.shapes.medium
shape = MaterialTheme.shapes.medium,
) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.lists_new))
}
}
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
modifier =
Modifier
.fillMaxSize()
.padding(padding),
) {
when (val s = state) {
is ListsViewModel.UiState.Loading -> {
@ -139,9 +134,9 @@ fun ListsScreen(
action = {
PrimaryButton(
text = stringResource(R.string.lists_new),
onClick = onOpenListCreate
onClick = onOpenListCreate,
)
}
},
)
}
is ListsViewModel.UiState.Success -> {
@ -150,14 +145,14 @@ fun ListsScreen(
isEditMode = isEditMode,
onItemClick = { item -> onOpenList(item.list.id, item.list.name) },
onSettingsClick = { item -> onOpenListSettings(item.list.id) },
onReorder = { from, to -> viewModel.reorderLists(from, to) }
onReorder = { from, to -> viewModel.reorderLists(from, to) },
)
}
is ListsViewModel.UiState.Error -> {
EmptyState(
title = "Erreur",
message = s.message,
emoji = ""
emoji = "",
)
}
}
@ -171,7 +166,7 @@ private fun ReorderableList(
isEditMode: Boolean,
onItemClick: (ListsViewModel.ShoppingListWithStats) -> Unit,
onSettingsClick: (ListsViewModel.ShoppingListWithStats) -> Unit,
onReorder: (Int, Int) -> Unit
onReorder: (Int, Int) -> Unit,
) {
var draggedIndex by remember { mutableStateOf<Int?>(null) }
var dragOffsetY by remember { mutableFloatStateOf(0f) }
@ -182,11 +177,11 @@ private fun ReorderableList(
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
itemsIndexed(
items = items,
key = { _, item -> item.list.id }
key = { _, item -> item.list.id },
) { index, item ->
val isDragged = draggedIndex == index
val zIndex = if (isDragged) 1f else 0f
@ -194,45 +189,49 @@ private fun ReorderableList(
val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0
Box(
modifier = Modifier
.zIndex(zIndex)
.offset { IntOffset(0, offsetY) }
.graphicsLayer {
scaleX = if (isDragged) 1.02f else 1f
scaleY = if (isDragged) 1.02f else 1f
}
.then(
if (isEditMode) {
Modifier.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { draggedIndex = index },
onDragEnd = {
draggedIndex?.let { from ->
val to = (from + (dragOffsetY / itemPx).roundToInt())
.coerceIn(0, items.size - 1)
if (from != to) onReorder(from, to)
}
draggedIndex = null
dragOffsetY = 0f
},
onDragCancel = {
draggedIndex = null
dragOffsetY = 0f
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetY += dragAmount.y
}
)
}
} else Modifier
)
modifier =
Modifier
.zIndex(zIndex)
.offset { IntOffset(0, offsetY) }
.graphicsLayer {
scaleX = if (isDragged) 1.02f else 1f
scaleY = if (isDragged) 1.02f else 1f
}
.then(
if (isEditMode) {
Modifier.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { draggedIndex = index },
onDragEnd = {
draggedIndex?.let { from ->
val to =
(from + (dragOffsetY / itemPx).roundToInt())
.coerceIn(0, items.size - 1)
if (from != to) onReorder(from, to)
}
draggedIndex = null
dragOffsetY = 0f
},
onDragCancel = {
draggedIndex = null
dragOffsetY = 0f
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetY += dragAmount.y
},
)
}
} else {
Modifier
},
),
) {
ShoppingListCard(
item = item,
isEditMode = isEditMode,
onClick = { onItemClick(item) },
onSettingsClick = { onSettingsClick(item) }
onSettingsClick = { onSettingsClick(item) },
)
}
}
@ -244,18 +243,19 @@ private fun ShoppingListCard(
item: ListsViewModel.ShoppingListWithStats,
isEditMode: Boolean,
onClick: () -> Unit,
onSettingsClick: () -> Unit
onSettingsClick: () -> Unit,
) {
val dimens = LocalDimens.current
val bg = backgroundByResName(item.list.backgroundResName)
Card(
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.then(if (!isEditMode) Modifier.clickable(onClick = onClick) else Modifier),
modifier =
Modifier
.fillMaxWidth()
.height(160.dp)
.then(if (!isEditMode) Modifier.clickable(onClick = onClick) else Modifier),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Box(modifier = Modifier.fillMaxSize()) {
// Background image
@ -264,37 +264,40 @@ private fun ShoppingListCard(
painter = painterResource(id = bg.drawableRes),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.35f))
modifier =
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.35f)),
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer)
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer),
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(dimens.spacingMd)
modifier =
Modifier
.fillMaxSize()
.padding(dimens.spacingMd),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
verticalAlignment = Alignment.Top,
) {
if (isEditMode) {
Icon(
imageVector = Icons.Filled.DragHandle,
contentDescription = "Reorder",
tint = Color.White,
modifier = Modifier.size(24.dp)
modifier = Modifier.size(24.dp),
)
} else {
Spacer(modifier = Modifier.width(24.dp))
@ -302,13 +305,13 @@ private fun ShoppingListCard(
IconButton(
onClick = onSettingsClick,
modifier = Modifier.size(32.dp)
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = stringResource(R.string.lists_settings),
tint = Color.White,
modifier = Modifier.size(20.dp)
modifier = Modifier.size(20.dp),
)
}
}
@ -316,40 +319,41 @@ private fun ShoppingListCard(
Spacer(modifier = Modifier.weight(1f))
// List name
val regionFlagEmoji = item.list.region?.let { code ->
when (code) {
"de" -> "🇩🇪"
"au" -> "🇦🇺"
"at" -> "🇦🇹"
"ca" -> "🇨🇦"
"es" -> "🇪🇸"
"fr" -> "🇫🇷"
"hu" -> "🇭🇺"
"it" -> "🇮🇹"
"no" -> "🇳🇴"
"nl" -> "🇳🇱"
"pl" -> "🇵🇱"
"pt" -> "🇵🇹"
"gb" -> "🇬🇧"
"ru" -> "🇷🇺"
"ch_de", "ch_fr" -> "🇨🇭"
else -> ""
}
} ?: ""
val regionFlagEmoji =
item.list.region?.let { code ->
when (code) {
"de" -> "🇩🇪"
"au" -> "🇦🇺"
"at" -> "🇦🇹"
"ca" -> "🇨🇦"
"es" -> "🇪🇸"
"fr" -> "🇫🇷"
"hu" -> "🇭🇺"
"it" -> "🇮🇹"
"no" -> "🇳🇴"
"nl" -> "🇳🇱"
"pl" -> "🇵🇱"
"pt" -> "🇵🇹"
"gb" -> "🇬🇧"
"ru" -> "🇷🇺"
"ch_de", "ch_fr" -> "🇨🇭"
else -> ""
}
} ?: ""
Text(
text = "$regionFlagEmoji ${item.list.name}".trim(),
style = MaterialTheme.typography.titleLarge,
color = Color.White,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
// Item count badge
val remaining = item.itemCount - item.checkedCount
@ -358,16 +362,17 @@ private fun ShoppingListCard(
text = "$remaining articles",
style = MaterialTheme.typography.labelMedium,
color = Color.White,
modifier = Modifier
.background(badgeColor.copy(alpha = 0.85f), RoundedCornerShape(12.dp))
.padding(horizontal = 8.dp, vertical = 2.dp)
modifier =
Modifier
.background(badgeColor.copy(alpha = 0.85f), RoundedCornerShape(12.dp))
.padding(horizontal = 8.dp, vertical = 2.dp),
)
Spacer(modifier = Modifier.width(8.dp))
// Member avatars
Row(
horizontalArrangement = Arrangement.spacedBy((-8).dp)
horizontalArrangement = Arrangement.spacedBy((-8).dp),
) {
item.members.take(3).forEach { member ->
MemberAvatar(member = member)
@ -382,17 +387,18 @@ private fun ShoppingListCard(
@Composable
private fun MemberAvatar(member: ShoppingListMemberEntity) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
modifier =
Modifier
.size(32.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Text(
text = member.name.take(1).uppercase(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@ -7,8 +7,8 @@ import com.safebite.app.data.local.database.entity.ShoppingListMemberEntity
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
@ -21,88 +21,100 @@ import javax.inject.Inject
* ViewModel pour l'écran Listes (Phase 2).
*/
@HiltViewModel
class ListsViewModel @Inject constructor(
private val getShoppingListsUseCase: GetShoppingListsUseCase
) : ViewModel() {
class ListsViewModel
@Inject
constructor(
private val getShoppingListsUseCase: GetShoppingListsUseCase,
) : ViewModel() {
sealed class UiState {
object Loading : UiState()
sealed class UiState {
object Loading : UiState()
data class Success(
val lists: List<ShoppingListWithStats>
) : UiState()
data class Empty(val message: String = "") : UiState()
data class Error(val message: String) : UiState()
}
data class Success(
val lists: List<ShoppingListWithStats>,
) : UiState()
data class ShoppingListWithStats(
val list: ShoppingListEntity,
val itemCount: Int,
val checkedCount: Int,
val members: List<ShoppingListMemberEntity> = emptyList()
)
data class Empty(val message: String = "") : UiState()
private val _isEditMode = MutableStateFlow(false)
val isEditMode: StateFlow<Boolean> = _isEditMode
@OptIn(ExperimentalCoroutinesApi::class)
val state: StateFlow<UiState> = getShoppingListsUseCase.observeActive()
.flatMapLatest { lists ->
if (lists.isEmpty()) {
flowOf(UiState.Empty("Aucune liste de courses. Créez votre première liste !"))
} else {
val statsFlows = lists.sortedBy { it.displayOrder }.map { list ->
combine(
getShoppingListsUseCase.observeItemCount(list.id),
getShoppingListsUseCase.observeCheckedCount(list.id),
getShoppingListsUseCase.observeMembers(list.id)
) { itemCount, checkedCount, members ->
ShoppingListWithStats(list, itemCount, checkedCount, members.take(3))
}
}
combine(statsFlows) { array ->
UiState.Success(array.toList().sortedBy { it.list.displayOrder })
}
}
data class Error(val message: String) : UiState()
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UiState.Loading
data class ShoppingListWithStats(
val list: ShoppingListEntity,
val itemCount: Int,
val checkedCount: Int,
val members: List<ShoppingListMemberEntity> = emptyList(),
)
fun createList(name: String, backgroundResName: String? = null) {
viewModelScope.launch {
getShoppingListsUseCase.createList(name, backgroundResName)
}
}
private val _isEditMode = MutableStateFlow(false)
val isEditMode: StateFlow<Boolean> = _isEditMode
fun deleteList(list: ShoppingListEntity) {
viewModelScope.launch {
getShoppingListsUseCase.deleteList(list)
}
}
fun toggleEditMode() {
_isEditMode.value = !_isEditMode.value
}
fun updateList(list: ShoppingListEntity) {
viewModelScope.launch {
getShoppingListsUseCase.updateList(list)
}
}
fun reorderLists(fromIndex: Int, toIndex: Int) {
viewModelScope.launch {
val current = state.value as? UiState.Success ?: return@launch
val mutable = current.lists.toMutableList()
val moved = mutable.removeAt(fromIndex)
mutable.add(toIndex.coerceIn(0, mutable.size), moved)
mutable.forEachIndexed { index, item ->
getShoppingListsUseCase.updateList(
item.list.copy(displayOrder = index)
@OptIn(ExperimentalCoroutinesApi::class)
val state: StateFlow<UiState> =
getShoppingListsUseCase.observeActive()
.flatMapLatest { lists ->
if (lists.isEmpty()) {
flowOf(UiState.Empty("Aucune liste de courses. Créez votre première liste !"))
} else {
val statsFlows =
lists.sortedBy { it.displayOrder }.map { list ->
combine(
getShoppingListsUseCase.observeItemCount(list.id),
getShoppingListsUseCase.observeCheckedCount(list.id),
getShoppingListsUseCase.observeMembers(list.id),
) { itemCount, checkedCount, members ->
ShoppingListWithStats(list, itemCount, checkedCount, members.take(3))
}
}
combine(statsFlows) { array ->
UiState.Success(array.toList().sortedBy { it.list.displayOrder })
}
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UiState.Loading,
)
fun createList(
name: String,
backgroundResName: String? = null,
) {
viewModelScope.launch {
getShoppingListsUseCase.createList(name, backgroundResName)
}
}
fun deleteList(list: ShoppingListEntity) {
viewModelScope.launch {
getShoppingListsUseCase.deleteList(list)
}
}
fun toggleEditMode() {
_isEditMode.value = !_isEditMode.value
}
fun updateList(list: ShoppingListEntity) {
viewModelScope.launch {
getShoppingListsUseCase.updateList(list)
}
}
fun reorderLists(
fromIndex: Int,
toIndex: Int,
) {
viewModelScope.launch {
val current = state.value as? UiState.Success ?: return@launch
val mutable = current.lists.toMutableList()
val moved = mutable.removeAt(fromIndex)
mutable.add(toIndex.coerceIn(0, mutable.size), moved)
mutable.forEachIndexed { index, item ->
getShoppingListsUseCase.updateList(
item.list.copy(displayOrder = index),
)
}
}
}
}
}

View File

@ -1,13 +1,12 @@
package com.safebite.app.presentation.screen.lists.create
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
@ -22,9 +21,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -57,7 +54,7 @@ import com.safebite.app.presentation.screen.lists.util.allListBackgrounds
fun CreateListScreen(
onBack: () -> Unit,
onListCreated: () -> Unit,
viewModel: ListsViewModel = hiltViewModel()
viewModel: ListsViewModel = hiltViewModel(),
) {
var listName by remember { mutableStateOf("") }
var selectedBg by remember { mutableStateOf(allListBackgrounds.firstOrNull()?.resName) }
@ -79,26 +76,27 @@ fun CreateListScreen(
onListCreated()
}
},
enabled = listName.isNotBlank()
enabled = listName.isNotBlank(),
) {
Text(stringResource(R.string.list_create_next))
}
}
},
)
}
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
) {
OutlinedTextField(
value = listName,
onValueChange = { listName = it },
label = { Text(stringResource(R.string.list_name_hint)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
singleLine = true,
)
Spacer(modifier = Modifier.height(16.dp))
@ -106,7 +104,7 @@ fun CreateListScreen(
Text(
text = stringResource(R.string.list_choose_background),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
@ -116,43 +114,46 @@ fun CreateListScreen(
contentPadding = PaddingValues(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) {
items(allListBackgrounds) { bg ->
val isSelected = selectedBg == bg.resName
Card(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.5f),
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(1.5f),
shape = RoundedCornerShape(12.dp),
border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
onClick = { selectedBg = bg.resName }
onClick = { selectedBg = bg.resName },
) {
Box(modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(id = bg.drawableRes),
contentDescription = bg.label,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
contentScale = ContentScale.Crop,
)
if (isSelected) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
contentAlignment = Alignment.TopEnd
modifier =
Modifier
.fillMaxSize()
.padding(8.dp),
contentAlignment = Alignment.TopEnd,
) {
Box(
modifier = Modifier
.size(28.dp)
.background(Color.White, RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center
modifier =
Modifier
.size(28.dp)
.background(Color.White, RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
modifier = Modifier.size(18.dp),
)
}
}

View File

@ -26,7 +26,6 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
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
@ -38,7 +37,6 @@ 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.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@ -53,7 +51,7 @@ import com.safebite.app.presentation.screen.lists.ListsViewModel
fun ListMembersScreen(
listId: Long,
onBack: () -> Unit,
viewModel: ListsViewModel = hiltViewModel()
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
@ -67,15 +65,16 @@ fun ListMembersScreen(
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
}
},
)
}
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
) {
if (listData == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
@ -85,19 +84,19 @@ fun ListMembersScreen(
text = stringResource(R.string.list_members_count, members.size),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp)
modifier = Modifier.padding(vertical = 8.dp),
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) {
items(members) { member ->
MemberRow(
member = member,
onRemove = {
// TODO: remove member via viewmodel/usecase
}
},
)
}
}
@ -108,15 +107,16 @@ fun ListMembersScreen(
onClick = { /* TODO: invite UI placeholder */ },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.size(20.dp)
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.list_invite_member))
@ -132,33 +132,36 @@ fun ListMembersScreen(
@Composable
private fun MemberRow(
member: ShoppingListMemberEntity,
onRemove: () -> Unit
onRemove: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
modifier =
Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = member.name.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
@ -168,12 +171,12 @@ private fun MemberRow(
Text(
text = member.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
)
Text(
text = member.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@ -181,7 +184,7 @@ private fun MemberRow(
Icon(
imageVector = Icons.Filled.RemoveCircleOutline,
contentDescription = stringResource(R.string.list_remove_member),
tint = MaterialTheme.colorScheme.error
tint = MaterialTheme.colorScheme.error,
)
}
}

View File

@ -21,7 +21,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@ -57,7 +56,7 @@ import com.safebite.app.presentation.screen.lists.util.backgroundByResName
fun ListNameImageScreen(
listId: Long,
onBack: () -> Unit,
viewModel: ListsViewModel = hiltViewModel()
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
@ -68,10 +67,11 @@ fun ListNameImageScreen(
val onSave = {
listData?.let {
val updated = it.list.copy(
name = listName.ifBlank { it.list.name },
backgroundResName = selectedBg
)
val updated =
it.list.copy(
name = listName.ifBlank { it.list.name },
backgroundResName = selectedBg,
)
viewModel.updateList(updated)
}
onBack()
@ -90,18 +90,19 @@ fun ListNameImageScreen(
IconButton(onClick = onSave) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(R.string.action_save)
contentDescription = stringResource(R.string.action_save),
)
}
}
},
)
}
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
) {
if (listData == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
@ -110,10 +111,11 @@ fun ListNameImageScreen(
// Preview
val bg = backgroundByResName(selectedBg)
Card(
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
shape = RoundedCornerShape(16.dp)
modifier =
Modifier
.fillMaxWidth()
.height(120.dp),
shape = RoundedCornerShape(16.dp),
) {
Box(modifier = Modifier.fillMaxSize()) {
if (bg != null) {
@ -121,18 +123,20 @@ fun ListNameImageScreen(
painter = painterResource(id = bg.drawableRes),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.35f))
modifier =
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.35f)),
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer)
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer),
)
}
Text(
@ -140,9 +144,10 @@ fun ListNameImageScreen(
style = MaterialTheme.typography.titleLarge,
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
modifier =
Modifier
.align(Alignment.BottomStart)
.padding(16.dp),
)
}
}
@ -154,7 +159,7 @@ fun ListNameImageScreen(
onValueChange = { listName = it },
label = { Text(stringResource(R.string.list_name_hint)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
singleLine = true,
)
Spacer(modifier = Modifier.height(16.dp))
@ -162,7 +167,7 @@ fun ListNameImageScreen(
Text(
text = stringResource(R.string.list_choose_background),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
@ -172,45 +177,49 @@ fun ListNameImageScreen(
contentPadding = PaddingValues(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
) {
items(allListBackgrounds) { bg ->
val isSelected = selectedBg == bg.resName
Card(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.5f),
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(1.5f),
shape = RoundedCornerShape(12.dp),
border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
onClick = { selectedBg = bg.resName }
onClick = { selectedBg = bg.resName },
) {
Box(modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(id = bg.drawableRes),
contentDescription = bg.label,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
contentScale = ContentScale.Crop,
)
if (isSelected) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
contentAlignment = Alignment.TopEnd
modifier =
Modifier
.fillMaxSize()
.padding(8.dp),
contentAlignment = Alignment.TopEnd,
) {
Box(
modifier = Modifier
.size(28.dp)
.background(Color.White, RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center
modifier =
Modifier
.size(28.dp)
.background(Color.White, RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
modifier = Modifier.size(18.dp),
)
}
}

View File

@ -18,7 +18,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -35,7 +34,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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
@ -44,31 +42,32 @@ import com.safebite.app.presentation.screen.lists.ListsViewModel
private data class Region(val name: String, val code: String, val flag: String)
private val availableRegions = listOf(
Region("Allemagne", "de", "🇩🇪"),
Region("Australie", "au", "🇦🇺"),
Region("Autriche", "at", "🇦🇹"),
Region("Canada", "ca", "🇨🇦"),
Region("Espagne", "es", "🇪🇸"),
Region("France", "fr", "🇫🇷"),
Region("Hongrie", "hu", "🇭🇺"),
Region("Italie", "it", "🇮🇹"),
Region("Norvège", "no", "🇳🇴"),
Region("Pays-Bas", "nl", "🇳🇱"),
Region("Pologne", "pl", "🇵🇱"),
Region("Portugal", "pt", "🇵🇹"),
Region("Royaume-Uni", "gb", "🇬🇧"),
Region("Russie", "ru", "🇷🇺"),
Region("Suisse (Allemand)", "ch_de", "🇨🇭"),
Region("Suisse (français)", "ch_fr", "🇨🇭")
)
private val availableRegions =
listOf(
Region("Allemagne", "de", "🇩🇪"),
Region("Australie", "au", "🇦🇺"),
Region("Autriche", "at", "🇦🇹"),
Region("Canada", "ca", "🇨🇦"),
Region("Espagne", "es", "🇪🇸"),
Region("France", "fr", "🇫🇷"),
Region("Hongrie", "hu", "🇭🇺"),
Region("Italie", "it", "🇮🇹"),
Region("Norvège", "no", "🇳🇴"),
Region("Pays-Bas", "nl", "🇳🇱"),
Region("Pologne", "pl", "🇵🇱"),
Region("Portugal", "pt", "🇵🇹"),
Region("Royaume-Uni", "gb", "🇬🇧"),
Region("Russie", "ru", "🇷🇺"),
Region("Suisse (Allemand)", "ch_de", "🇨🇭"),
Region("Suisse (français)", "ch_fr", "🇨🇭"),
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListRegionScreen(
listId: Long,
onBack: () -> Unit,
viewModel: ListsViewModel = hiltViewModel()
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
@ -96,59 +95,63 @@ fun ListRegionScreen(
IconButton(onClick = onSave) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(R.string.action_save)
contentDescription = stringResource(R.string.action_save),
)
}
}
},
)
}
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
) {
Text(
text = stringResource(R.string.list_region_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
modifier = Modifier.padding(vertical = 8.dp),
)
LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
) {
items(availableRegions) { region ->
val isSelected = selectedRegion == region.code
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedRegion = region.code }
.padding(vertical = 14.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
modifier =
Modifier
.fillMaxWidth()
.clickable { selectedRegion = region.code }
.padding(vertical = 14.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${region.flag} ${region.name}",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
)
if (isSelected) {
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
modifier =
Modifier
.size(24.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(16.dp)
modifier = Modifier.size(16.dp),
)
}
}

View File

@ -5,8 +5,6 @@ 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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
@ -21,7 +19,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.People
import androidx.compose.material3.Button
@ -62,7 +59,7 @@ fun ListSettingsScreen(
onOpenRegion: () -> Unit,
onOpenNameImage: () -> Unit,
onOpenMembers: () -> Unit,
viewModel: ListsViewModel = hiltViewModel()
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
@ -75,15 +72,16 @@ fun ListSettingsScreen(
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
}
},
)
}
},
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
) {
if (listData == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
@ -92,10 +90,11 @@ fun ListSettingsScreen(
// Header card with list preview
val bg = backgroundByResName(listData.list.backgroundResName)
Card(
modifier = Modifier
.fillMaxWidth()
.height(140.dp),
shape = RoundedCornerShape(16.dp)
modifier =
Modifier
.fillMaxWidth()
.height(140.dp),
shape = RoundedCornerShape(16.dp),
) {
Box(modifier = Modifier.fillMaxSize()) {
if (bg != null) {
@ -103,18 +102,20 @@ fun ListSettingsScreen(
painter = painterResource(id = bg.drawableRes),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.35f))
modifier =
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.35f)),
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer)
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer),
)
}
Text(
@ -122,9 +123,10 @@ fun ListSettingsScreen(
style = MaterialTheme.typography.titleLarge,
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
modifier =
Modifier
.align(Alignment.BottomStart)
.padding(16.dp),
)
}
}
@ -135,7 +137,7 @@ fun ListSettingsScreen(
Text(
text = stringResource(R.string.list_personalize),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(12.dp))
@ -144,40 +146,43 @@ fun ListSettingsScreen(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) {
item {
SettingsTile(
icon = Icons.AutoMirrored.Filled.Sort,
label = stringResource(R.string.list_sort),
onClick = onOpenSort
onClick = onOpenSort,
)
}
item {
val regionCode = listData.list.region
val regionSubtitle = if (regionCode != null) {
val (flag, name) = regionFlagAndName(regionCode)
"$flag $name"
} else null
val regionSubtitle =
if (regionCode != null) {
val (flag, name) = regionFlagAndName(regionCode)
"$flag $name"
} else {
null
}
SettingsTile(
icon = Icons.Filled.Language,
label = stringResource(R.string.list_region_language),
subtitle = regionSubtitle,
onClick = onOpenRegion
onClick = onOpenRegion,
)
}
item {
SettingsTile(
icon = Icons.Filled.People,
label = stringResource(R.string.list_members),
onClick = onOpenMembers
onClick = onOpenMembers,
)
}
item {
SettingsTile(
icon = Icons.Filled.Brush,
label = stringResource(R.string.list_name_image),
onClick = onOpenNameImage
onClick = onOpenNameImage,
)
}
}
@ -192,10 +197,11 @@ fun ListSettingsScreen(
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) {
Text(stringResource(R.string.list_leave))
}
@ -207,55 +213,59 @@ fun ListSettingsScreen(
}
}
private fun regionFlagAndName(code: String): Pair<String, String> = when (code) {
"de" -> "🇩🇪" to "Allemagne"
"au" -> "🇦🇺" to "Australie"
"at" -> "🇦🇹" to "Autriche"
"ca" -> "🇨🇦" to "Canada"
"es" -> "🇪🇸" to "Espagne"
"fr" -> "🇫🇷" to "France"
"hu" -> "🇭🇺" to "Hongrie"
"it" -> "🇮🇹" to "Italie"
"no" -> "🇳🇴" to "Norvège"
"nl" -> "🇳🇱" to "Pays-Bas"
"pl" -> "🇵🇱" to "Pologne"
"pt" -> "🇵🇹" to "Portugal"
"gb" -> "🇬🇧" to "Royaume-Uni"
"ru" -> "🇷🇺" to "Russie"
"ch_de" -> "🇨🇭" to "Suisse (Allemand)"
"ch_fr" -> "🇨🇭" to "Suisse (français)"
else -> "" to code
}
private fun regionFlagAndName(code: String): Pair<String, String> =
when (code) {
"de" -> "🇩🇪" to "Allemagne"
"au" -> "🇦🇺" to "Australie"
"at" -> "🇦🇹" to "Autriche"
"ca" -> "🇨🇦" to "Canada"
"es" -> "🇪🇸" to "Espagne"
"fr" -> "🇫🇷" to "France"
"hu" -> "🇭🇺" to "Hongrie"
"it" -> "🇮🇹" to "Italie"
"no" -> "🇳🇴" to "Norvège"
"nl" -> "🇳🇱" to "Pays-Bas"
"pl" -> "🇵🇱" to "Pologne"
"pt" -> "🇵🇹" to "Portugal"
"gb" -> "🇬🇧" to "Royaume-Uni"
"ru" -> "🇷🇺" to "Russie"
"ch_de" -> "🇨🇭" to "Suisse (Allemand)"
"ch_fr" -> "🇨🇭" to "Suisse (français)"
else -> "" to code
}
@Composable
private fun SettingsTile(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
subtitle: String? = null,
onClick: () -> Unit
onClick: () -> Unit,
) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.2f),
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(1.2f),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
@ -263,7 +273,7 @@ private fun SettingsTile(
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Spacer(modifier = Modifier.height(2.dp))
@ -272,7 +282,7 @@ private fun SettingsTile(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
)
}
}

View File

@ -70,18 +70,19 @@ import kotlin.math.roundToInt
fun ListSortScreen(
listId: Long,
onBack: () -> Unit,
viewModel: ListsViewModel = hiltViewModel()
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
val catalog = remember { CatalogProvider() }
val savedOrder = listData?.list?.categoryOrder?.split(",")?.filter { it.isNotBlank() }
val orderedCategories = remember(listData?.list?.categoryOrder) {
mutableStateListOf<String>().apply {
addAll(savedOrder ?: catalog.categories)
val orderedCategories =
remember(listData?.list?.categoryOrder) {
mutableStateListOf<String>().apply {
addAll(savedOrder ?: catalog.categories)
}
}
}
val savedVisible = listData?.list?.visibleCategories?.split(",")?.filter { it.isNotBlank() }?.toSet()
var visibleCategories by remember(listData?.list?.visibleCategories) {
@ -109,99 +110,105 @@ fun ListSortScreen(
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier
.padding(end = 16.dp)
.clickable {
listData?.let {
viewModel.updateList(
it.list.copy(
visibleCategories = visibleCategories.joinToString(","),
categoryOrder = orderedCategories.joinToString(",")
modifier =
Modifier
.padding(end = 16.dp)
.clickable {
listData?.let {
viewModel.updateList(
it.list.copy(
visibleCategories = visibleCategories.joinToString(","),
categoryOrder = orderedCategories.joinToString(","),
),
)
)
}
onBack()
}
}
onBack()
},
)
}
},
)
}
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
) {
Text(
text = stringResource(R.string.list_sort_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
modifier = Modifier.padding(vertical = 8.dp),
)
// Preview card (collapsible)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.clickable { previewExpanded = !previewExpanded },
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.clickable { previewExpanded = !previewExpanded },
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = null,
modifier = Modifier.size(24.dp)
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(R.string.list_sort_preview),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
)
Icon(
imageVector = if (previewExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
contentDescription = if (previewExpanded) "Réduire" else "Développer"
contentDescription = if (previewExpanded) "Réduire" else "Développer",
)
}
AnimatedVisibility(
visible = previewExpanded,
enter = expandVertically(),
exit = shrinkVertically()
exit = shrinkVertically(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
modifier =
Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
orderedCategories.forEach { category ->
val isVisible = category in visibleCategories
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (isVisible) "" else "",
style = MaterialTheme.typography.bodySmall,
color = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
color = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = category,
style = MaterialTheme.typography.bodySmall,
color = if (isVisible) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
color = if (isVisible) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@ -216,92 +223,100 @@ fun ListSortScreen(
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 8.dp)
contentPadding = PaddingValues(vertical = 8.dp),
) {
itemsIndexed(
items = orderedCategories,
key = { _, item -> item }
key = { _, item -> item },
) { index, category ->
val isDragged = draggedIndex == index
val zIndex = if (isDragged) 1f else 0f
val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0
Box(
modifier = Modifier
.zIndex(zIndex)
.offset { IntOffset(0, offsetY) }
.graphicsLayer {
scaleX = if (isDragged) 1.02f else 1f
scaleY = if (isDragged) 1.02f else 1f
}
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { draggedIndex = index },
onDragEnd = {
draggedIndex?.let { from ->
val to = (from + (dragOffsetY / itemPx).roundToInt())
.coerceIn(0, orderedCategories.size - 1)
if (from != to) {
val moved = orderedCategories.removeAt(from)
orderedCategories.add(to, moved)
modifier =
Modifier
.zIndex(zIndex)
.offset { IntOffset(0, offsetY) }
.graphicsLayer {
scaleX = if (isDragged) 1.02f else 1f
scaleY = if (isDragged) 1.02f else 1f
}
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { draggedIndex = index },
onDragEnd = {
draggedIndex?.let { from ->
val to =
(from + (dragOffsetY / itemPx).roundToInt())
.coerceIn(0, orderedCategories.size - 1)
if (from != to) {
val moved = orderedCategories.removeAt(from)
orderedCategories.add(to, moved)
}
}
}
draggedIndex = null
dragOffsetY = 0f
},
onDragCancel = {
draggedIndex = null
dragOffsetY = 0f
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetY += dragAmount.y
}
)
}
draggedIndex = null
dragOffsetY = 0f
},
onDragCancel = {
draggedIndex = null
dragOffsetY = 0f
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetY += dragAmount.y
},
)
},
) {
val isVisible = category in visibleCategories
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(
if (isDragged) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface
)
.clickable {
visibleCategories = if (isVisible) {
visibleCategories - category
} else {
visibleCategories + category
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(
if (isDragged) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
},
)
.clickable {
visibleCategories =
if (isVisible) {
visibleCategories - category
} else {
visibleCategories + category
}
}
}
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = category,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
)
IconButton(onClick = {
visibleCategories = if (isVisible) {
visibleCategories - category
} else {
visibleCategories + category
}
visibleCategories =
if (isVisible) {
visibleCategories - category
} else {
visibleCategories + category
}
}) {
Icon(
imageVector = if (isVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = if (isVisible) "Masquer" else "Afficher",
tint = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
tint = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
imageVector = Icons.Filled.DragHandle,
contentDescription = "Réordonner",
modifier = Modifier.padding(start = 8.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@ -5,24 +5,23 @@ import com.safebite.app.R
data class ListBackground(
val resName: String,
val label: String,
val drawableRes: Int
val drawableRes: Int,
)
val allListBackgrounds: List<ListBackground> = listOf(
ListBackground("bg_animaux", "Animaux", R.drawable.bg_animaux),
ListBackground("bg_baby", "Bébé", R.drawable.bg_baby),
ListBackground("bg_epicerie", "Épicerie", R.drawable.bg_epicerie),
ListBackground("bg_epicerie2", "Épicerie 2", R.drawable.bg_epicerie2),
ListBackground("bg_jardinage", "Maison & Jardin", R.drawable.bg_jardinage),
ListBackground("bg_office", "Bureau", R.drawable.bg_office),
ListBackground("bg_party", "Fête", R.drawable.bg_party),
ListBackground("bg_pharmacie", "Pharmacie", R.drawable.bg_pharmacie),
ListBackground("bg_plage", "Plage", R.drawable.bg_plage),
ListBackground("bg_renovation", "Rénovation", R.drawable.bg_renovation)
)
val allListBackgrounds: List<ListBackground> =
listOf(
ListBackground("bg_animaux", "Animaux", R.drawable.bg_animaux),
ListBackground("bg_baby", "Bébé", R.drawable.bg_baby),
ListBackground("bg_epicerie", "Épicerie", R.drawable.bg_epicerie),
ListBackground("bg_epicerie2", "Épicerie 2", R.drawable.bg_epicerie2),
ListBackground("bg_jardinage", "Maison & Jardin", R.drawable.bg_jardinage),
ListBackground("bg_office", "Bureau", R.drawable.bg_office),
ListBackground("bg_party", "Fête", R.drawable.bg_party),
ListBackground("bg_pharmacie", "Pharmacie", R.drawable.bg_pharmacie),
ListBackground("bg_plage", "Plage", R.drawable.bg_plage),
ListBackground("bg_renovation", "Rénovation", R.drawable.bg_renovation),
)
fun backgroundByResName(name: String?): ListBackground? =
allListBackgrounds.firstOrNull { it.resName == name }
fun backgroundByResName(name: String?): ListBackground? = allListBackgrounds.firstOrNull { it.resName == name }
fun backgroundLabel(name: String?): String =
backgroundByResName(name)?.label ?: ""
fun backgroundLabel(name: String?): String = backgroundByResName(name)?.label ?: ""

View File

@ -1,5 +1,9 @@
package com.safebite.app.presentation.screen.main
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@ -31,11 +35,12 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -80,12 +85,14 @@ fun MainScreen(
val currentDestination = currentBackStackEntry?.destination
// Déterminer si le FAB doit être visible
val fabVisible = currentDestination?.route in listOf(
Screen.Dashboard.route,
Screen.Lists.route,
Screen.Tracking.route,
Screen.Family.route
)
val fabVisible =
currentDestination?.route in
listOf(
Screen.Dashboard.route,
Screen.Lists.route,
Screen.Tracking.route,
Screen.Family.route,
)
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
@ -96,12 +103,12 @@ fun MainScreen(
Image(
painter = painterResource(id = R.drawable.safebite_logo_nobg),
contentDescription = null,
modifier = Modifier.size(32.dp)
modifier = Modifier.size(32.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(R.string.app_name),
color = Color.White
color = Color.White,
)
}
},
@ -110,47 +117,49 @@ fun MainScreen(
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = stringResource(R.string.nav_settings),
tint = Color.White
tint = Color.White,
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
scrolledContainerColor = MaterialTheme.colorScheme.primary,
titleContentColor = Color.White,
navigationIconContentColor = Color.White,
actionIconContentColor = Color.White,
)
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
scrolledContainerColor = MaterialTheme.colorScheme.primary,
titleContentColor = Color.White,
navigationIconContentColor = Color.White,
actionIconContentColor = Color.White,
),
)
},
bottomBar = {
SafeBiteBottomNavigation(
navController = navController,
currentDestination = currentDestination,
items = bottomNavItems
items = bottomNavItems,
)
},
floatingActionButton = {
SafeBiteFab(
visible = fabVisible,
onClick = onOpenScanner
onClick = onOpenScanner,
)
},
floatingActionButtonPosition = FabPosition.Center
floatingActionButtonPosition = FabPosition.Center,
) { paddingValues ->
// NavHost pour les 4 onglets principaux
NavHost(
navController = navController,
startDestination = Screen.Dashboard.route,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
composable(Screen.Dashboard.route) {
DashboardScreen(
onScan = onOpenScanner,
onOpenList = onOpenListDetail,
onOpenHistoryItem = onOpenHistoryItem
onOpenHistoryItem = onOpenHistoryItem,
)
}
composable(Screen.Lists.route) {
@ -158,19 +167,19 @@ fun MainScreen(
onOpenList = { id, name -> onOpenListDetail(id, name) },
onOpenScanner = onOpenScanner,
onOpenListCreate = onOpenListCreate,
onOpenListSettings = onOpenListSettings
onOpenListSettings = onOpenListSettings,
)
}
composable(Screen.Tracking.route) {
TrackingScreen(
onOpenHistoryItem = onOpenHistoryItem,
onOpenScanner = onOpenScanner
onOpenScanner = onOpenScanner,
)
}
composable(Screen.Family.route) {
FamilyScreen(
onOpenProfile = onOpenProfile,
onOpenSettings = onOpenSettings
onOpenSettings = onOpenSettings,
)
}
}
@ -188,11 +197,11 @@ fun MainScreen(
private fun SafeBiteBottomNavigation(
navController: NavHostController,
currentDestination: NavDestination?,
items: List<BottomNavItem>
items: List<BottomNavItem>,
) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.primary,
tonalElevation = 2.dp
tonalElevation = 2.dp,
) {
items.forEach { item ->
val selected = currentDestination?.hierarchy?.any { it.route == item.screen.route } == true
@ -211,23 +220,24 @@ private fun SafeBiteBottomNavigation(
val icon = if (selected) item.iconSelected else item.iconUnselected
Icon(
imageVector = icon,
contentDescription = null
contentDescription = null,
)
},
label = {
Text(
text = item.label,
style = MaterialTheme.typography.labelMedium
style = MaterialTheme.typography.labelMedium,
)
},
alwaysShowLabel = true,
colors = androidx.compose.material3.NavigationBarItemDefaults.colors(
selectedIconColor = Color.White,
selectedTextColor = Color.White,
unselectedIconColor = Color.White.copy(alpha = 0.7f),
unselectedTextColor = Color.White.copy(alpha = 0.7f),
indicatorColor = Color.White.copy(alpha = 0.2f)
)
colors =
androidx.compose.material3.NavigationBarItemDefaults.colors(
selectedIconColor = Color.White,
selectedTextColor = Color.White,
unselectedIconColor = Color.White.copy(alpha = 0.7f),
unselectedTextColor = Color.White.copy(alpha = 0.7f),
indicatorColor = Color.White.copy(alpha = 0.2f),
),
)
}
}
@ -243,38 +253,60 @@ private fun SafeBiteBottomNavigation(
@Composable
private fun SafeBiteFab(
visible: Boolean,
onClick: () -> Unit
onClick: () -> Unit,
) {
val context = LocalContext.current
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(200)) +
enter =
fadeIn(animationSpec = tween(200)) +
scaleIn(initialScale = 0.8f, animationSpec = tween(200)) +
slideInVertically(
initialOffsetY = { it },
animationSpec = tween(200)
animationSpec = tween(200),
),
exit =
fadeOut(animationSpec = tween(200)) +
scaleOut(targetScale = 0.8f, animationSpec = tween(200)) +
slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(200),
),
exit = fadeOut(animationSpec = tween(200)) +
scaleOut(targetScale = 0.8f, animationSpec = tween(200)) +
slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(200)
)
) {
FloatingActionButton(
onClick = onClick,
onClick = {
triggerFabHaptic(context)
onClick()
},
containerColor = MaterialTheme.colorScheme.onSurface,
contentColor = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium,
elevation = androidx.compose.material3.FloatingActionButtonDefaults.elevation(
defaultElevation = 6.dp
)
elevation =
androidx.compose.material3.FloatingActionButtonDefaults.elevation(
defaultElevation = 6.dp,
),
) {
Icon(
imageVector = Icons.Filled.QrCodeScanner,
contentDescription = stringResource(R.string.fab_scan),
modifier = Modifier.size(24.dp)
modifier = Modifier.size(24.dp),
)
}
}
}
/** Retour haptique léger (15ms) au tap du FAB Scanner, distinct du scan (60ms). */
private fun triggerFabHaptic(context: android.content.Context) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vm = context.getSystemService(VibratorManager::class.java) ?: return
vm.defaultVibrator.vibrate(VibrationEffect.createOneShot(15, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
val v = context.getSystemService(android.content.Context.VIBRATOR_SERVICE) as? Vibrator
v?.vibrate(VibrationEffect.createOneShot(15, VibrationEffect.DEFAULT_AMPLITUDE))
}
} catch (_: Throwable) {
// Silencieux si le matériel ne supporte pas la vibration
}
}

View File

@ -30,10 +30,10 @@ 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 androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@ -50,7 +50,7 @@ import java.util.concurrent.Executors
@Composable
fun OcrCaptureScreen(
onBack: () -> Unit,
onCaptured: (String) -> Unit
onCaptured: (String) -> Unit,
) {
val permission = rememberPermissionState(android.Manifest.permission.CAMERA)
LaunchedEffect(Unit) { if (!permission.status.isGranted) permission.launchPermissionRequest() }
@ -65,41 +65,44 @@ fun OcrCaptureScreen(
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() }
onRetry = { permission.launchPermissionRequest() },
)
} else {
OcrCameraView(
onTextUpdate = { livePreviewText = it },
onCapture = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) }
onCapture = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) },
)
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(16.dp)
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)
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)
modifier =
Modifier
.background(Color(0xAA000000), RoundedCornerShape(8.dp))
.padding(8.dp),
)
}
Spacer(Modifier.height(12.dp))
@ -108,7 +111,7 @@ fun OcrCaptureScreen(
onClick = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) },
enabled = livePreviewText.isNotBlank(),
large = true,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
}
@ -117,7 +120,10 @@ fun OcrCaptureScreen(
}
@Composable
private fun OcrCameraView(onTextUpdate: (String) -> Unit, onCapture: () -> Unit) {
private fun OcrCameraView(
onTextUpdate: (String) -> Unit,
onCapture: () -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val executor = remember { Executors.newSingleThreadExecutor() }
@ -137,13 +143,17 @@ private fun OcrCameraView(onTextUpdate: (String) -> Unit, onCapture: () -> Unit)
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()
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 }
if (media == null) {
proxy.close()
return@setAnalyzer
}
val input = InputImage.fromMediaImage(media, proxy.imageInfo.rotationDegrees)
recognizer.process(input)
.addOnSuccessListener { result -> onTextUpdate(result.text) }
@ -152,11 +162,15 @@ private fun OcrCameraView(onTextUpdate: (String) -> Unit, onCapture: () -> Unit)
try {
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis,
)
} catch (_: Throwable) {}
} catch (_: Throwable) {
}
}, ContextCompat.getMainExecutor(ctx))
previewView
}
},
)
}

View File

@ -20,19 +20,18 @@ 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
import com.safebite.app.presentation.common.components.PrimaryButton
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OcrReviewScreen(
initialText: String,
onBack: () -> Unit,
onAnalyze: (String) -> Unit
onAnalyze: (String) -> Unit,
) {
var text by remember { mutableStateOf(initialText) }
Scaffold(
@ -43,11 +42,11 @@ fun OcrReviewScreen(
onBack = onBack,
backContentDescription = stringResource(R.string.action_back),
)
}
},
) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(stringResource(R.string.ocr_review_hint), color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedTextField(
@ -55,7 +54,7 @@ fun OcrReviewScreen(
onValueChange = { text = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.result_ingredients)) },
minLines = 8
minLines = 8,
)
val highlights = remember(text) { findHighlights(text) }
if (highlights.isNotEmpty()) {
@ -66,7 +65,7 @@ fun OcrReviewScreen(
onClick = { onAnalyze(text) },
enabled = text.isNotBlank(),
large = true,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
}

View File

@ -8,9 +8,13 @@ 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()
class OcrViewModel
@Inject
constructor() : ViewModel() {
private val _capturedText = MutableStateFlow("")
val capturedText: StateFlow<String> = _capturedText.asStateFlow()
fun setText(text: String) { _capturedText.value = text }
}
fun setText(text: String) {
_capturedText.value = text
}
}

View File

@ -5,12 +5,14 @@ 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.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.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@ -20,7 +22,9 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -30,38 +34,33 @@ 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.text.font.FontWeight
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
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 com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
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.AllergenLevel
import com.safebite.app.presentation.common.components.AllergenSelectionGrid
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.common.components.AllergenLevel
import com.safebite.app.presentation.common.components.AllergenSelectionGrid
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
import androidx.compose.material3.Switch
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun OnboardingScreen(
onFinished: () -> Unit,
viewModel: OnboardingViewModel = hiltViewModel()
viewModel: OnboardingViewModel = hiltViewModel(),
) {
var step by rememberSaveable { mutableStateOf(0) }
var name by rememberSaveable { mutableStateOf("") }
@ -77,49 +76,53 @@ fun OnboardingScreen(
when (step) {
0 -> WelcomeStep(onNext = { step = 1 })
1 -> HowStep(onNext = { step = 2 })
2 -> CreateProfileStep(
name = name,
onNameChange = { name = it },
avatar = avatar,
onAvatarChange = { avatar = it },
isDefault = isDefault,
onSetDefault = { isDefault = it },
allergenLevels = allergenLevels.value,
onSetAllergenLevel = { a, level ->
allergenLevels.value = if (level == AllergenLevel.NONE) {
allergenLevels.value - a
} else {
allergenLevels.value + (a to level)
}
},
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 = {
val severe = allergenLevels.value.filterValues { it == AllergenLevel.SEVERE }.keys
val moderate = allergenLevels.value.filterValues { it == AllergenLevel.TRACE }.keys
viewModel.createProfile(name, avatar, severe, moderate, 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()
})
2 ->
CreateProfileStep(
name = name,
onNameChange = { name = it },
avatar = avatar,
onAvatarChange = { avatar = it },
isDefault = isDefault,
onSetDefault = { isDefault = it },
allergenLevels = allergenLevels.value,
onSetAllergenLevel = { a, level ->
allergenLevels.value =
if (level == AllergenLevel.NONE) {
allergenLevels.value - a
} else {
allergenLevels.value + (a to level)
}
},
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 = {
val severe = allergenLevels.value.filterValues { it == AllergenLevel.SEVERE }.keys
val moderate = allergenLevels.value.filterValues { it == AllergenLevel.TRACE }.keys
viewModel.createProfile(name, avatar, severe, moderate, 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()
})
}
}
}
@ -129,31 +132,31 @@ private fun WelcomeStep(onNext: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Center,
) {
Image(
painter = painterResource(id = R.drawable.safebite_logo),
contentDescription = null,
modifier = Modifier.size(120.dp)
modifier = Modifier.size(120.dp),
)
Spacer(Modifier.height(24.dp))
Text(
stringResource(R.string.onboarding_welcome_title),
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.onboarding_welcome_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(32.dp))
PrimaryButton(
text = stringResource(R.string.action_continue),
onClick = onNext,
large = true,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
}
@ -162,66 +165,70 @@ private fun WelcomeStep(onNext: () -> Unit) {
private fun HowStep(onNext: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(20.dp)
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text(
stringResource(R.string.onboarding_how_title),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
color = MaterialTheme.colorScheme.onBackground,
)
Text(
stringResource(R.string.onboarding_how_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
val steps = listOf(
Triple("1", "👤", stringResource(R.string.onboarding_how_step1)),
Triple("2", "📷", stringResource(R.string.onboarding_how_step2)),
Triple("3", "", stringResource(R.string.onboarding_how_step3))
)
val steps =
listOf(
Triple("1", "👤", stringResource(R.string.onboarding_how_step1)),
Triple("2", "📷", stringResource(R.string.onboarding_how_step2)),
Triple("3", "", stringResource(R.string.onboarding_how_step3)),
)
for ((number, emoji, label) in steps) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(48.dp)
.background(
MaterialTheme.colorScheme.primaryContainer,
CircleShape
),
contentAlignment = Alignment.Center
modifier =
Modifier
.size(48.dp)
.background(
MaterialTheme.colorScheme.primaryContainer,
CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = number,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
)
}
Column {
Text(
text = emoji,
style = MaterialTheme.typography.headlineSmall
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@ -233,7 +240,7 @@ private fun HowStep(onNext: () -> Unit) {
text = stringResource(R.string.action_continue),
onClick = onNext,
large = true,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
}
@ -254,20 +261,20 @@ private fun CreateProfileStep(
customItems: List<CustomDietItem>,
onAddCustomItem: (String, CustomItemTag) -> Unit,
onRemoveCustomItem: (CustomDietItem) -> Unit,
onNext: () -> Unit
onNext: () -> Unit,
) {
val avatars = listOf("🙂", "😀", "👧", "👦", "👨", "👩", "👵", "👴", "🧑", "👶", "🧒", "🧓", "🍽️", "🛒", "🥗", "🍎")
val dimens = com.safebite.app.presentation.theme.LocalDimens.current
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
) {
item {
Text(
stringResource(R.string.onboarding_profile_title),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
color = MaterialTheme.colorScheme.onBackground,
)
}
item {
@ -288,7 +295,7 @@ private fun CreateProfileStep(
shape = CircleShape,
color = bg,
border = if (selected) androidx.compose.foundation.BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
modifier = Modifier.size(72.dp)
modifier = Modifier.size(72.dp),
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(a, fontSize = MaterialTheme.typography.displaySmall.fontSize)
@ -312,7 +319,7 @@ private fun CreateProfileStep(
item {
AllergenSelectionGrid(
selectedAllergens = allergenLevels,
onLevelChanged = onSetAllergenLevel
onLevelChanged = onSetAllergenLevel,
)
}
@ -324,7 +331,7 @@ private fun CreateProfileStep(
selected = r in restrictions,
onClick = { onToggleRestriction(r) },
label = { Text(r.displayFr) },
modifier = Modifier.padding(4.dp)
modifier = Modifier.padding(4.dp),
)
}
}
@ -347,27 +354,32 @@ private fun CreateProfileStep(
onClick = onNext,
enabled = name.isNotBlank(),
large = true,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Composable
private fun PermissionStep(granted: Boolean, rationale: Boolean, onRequest: () -> Unit, onNext: () -> Unit) {
private fun PermissionStep(
granted: Boolean,
rationale: Boolean,
onRequest: () -> Unit,
onNext: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
stringResource(R.string.onboarding_permission_title),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
color = MaterialTheme.colorScheme.onBackground,
)
Text(
stringResource(R.string.onboarding_permission_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.weight(1f))
if (granted) {
@ -375,14 +387,14 @@ private fun PermissionStep(granted: Boolean, rationale: Boolean, onRequest: () -
text = stringResource(R.string.action_continue),
onClick = onNext,
large = true,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
} else {
PrimaryButton(
text = stringResource(R.string.onboarding_permission_grant),
onClick = onRequest,
large = true,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
TertiaryButton(
text = stringResource(R.string.action_continue),
@ -397,27 +409,27 @@ private fun ReadyStep(onFinish: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
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
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.onboarding_ready_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(32.dp))
PrimaryButton(
text = stringResource(R.string.onboarding_start),
onClick = onFinish,
large = true,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
}

View File

@ -13,31 +13,34 @@ 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))
}
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) }
}
fun complete() = viewModelScope.launch { settings.setOnboardingCompleted(true) }
}

View File

@ -13,10 +13,7 @@ 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.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@ -71,7 +68,7 @@ fun ProductDetailScreen(
barcode: String,
onBack: () -> Unit,
onOpenProduct: (String) -> Unit,
viewModel: ProductDetailViewModel = hiltViewModel()
viewModel: ProductDetailViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -83,7 +80,7 @@ fun ProductDetailScreen(
SafeBiteTopAppBar(
title = stringResource(R.string.result_ingredients),
onBack = onBack,
backContentDescription = stringResource(R.string.a11y_back)
backContentDescription = stringResource(R.string.a11y_back),
)
when (val state = uiState) {
@ -105,7 +102,7 @@ fun ProductDetailScreen(
ProductDetailContent(
product = state.product,
scanResult = state.scanResult,
onOpenProduct = onOpenProduct
onOpenProduct = onOpenProduct,
)
}
}
@ -116,7 +113,7 @@ fun ProductDetailScreen(
private fun ProductDetailContent(
product: Product,
scanResult: ScanResult?,
onOpenProduct: (String) -> Unit
onOpenProduct: (String) -> Unit,
) {
val dimens = LocalDimens.current
val tabTitles = listOf("Résumé", "Allergènes", "Additifs", "Alternatives")
@ -133,15 +130,15 @@ private fun ProductDetailContent(
edgePadding = dimens.spacingMd,
indicator = { tabPositions ->
TabRowDefaults.SecondaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTab])
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTab]),
)
}
},
) {
tabTitles.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title, style = MaterialTheme.typography.labelLarge) }
text = { Text(title, style = MaterialTheme.typography.labelLarge) },
)
}
}
@ -157,24 +154,28 @@ private fun ProductDetailContent(
}
@Composable
private fun ProductHeader(product: Product, scanResult: ScanResult?) {
private fun ProductHeader(
product: Product,
scanResult: ScanResult?,
) {
val dimens = LocalDimens.current
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(
modifier = Modifier.padding(dimens.spacingMd),
horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Image produit
AsyncImage(
model = product.imageUrl,
contentDescription = stringResource(R.string.a11y_product_image),
modifier = Modifier
.size(120.dp)
.background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.medium)
modifier =
Modifier
.size(120.dp)
.background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.medium),
)
Spacer(Modifier.height(dimens.spacingSm))
@ -184,14 +185,14 @@ private fun ProductHeader(product: Product, scanResult: ScanResult?) {
text = product.name ?: "Produit inconnu",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
)
if (!product.brand.isNullOrBlank()) {
Text(
text = product.brand,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@ -206,17 +207,19 @@ private fun ProductHeader(product: Product, scanResult: ScanResult?) {
@Composable
private fun VerdictBadge(status: SafetyStatus) {
val (text, color, a11yDesc) = when (status) {
SafetyStatus.SAFE -> Triple("✅ Sûr", Color(0xFF2ECC71), "Produit sûr")
SafetyStatus.WARNING -> Triple("⚠️ Attention", Color(0xFFF39C12), "Attention : traces d'allergènes")
SafetyStatus.DANGER -> Triple("❌ Danger", Color(0xFFE74C3C), "Danger : allergènes détectés")
}
val (text, color, a11yDesc) =
when (status) {
SafetyStatus.SAFE -> Triple("✅ Sûr", Color(0xFF2ECC71), "Produit sûr")
SafetyStatus.WARNING -> Triple("⚠️ Attention", Color(0xFFF39C12), "Attention : traces d'allergènes")
SafetyStatus.DANGER -> Triple("❌ Danger", Color(0xFFE74C3C), "Danger : allergènes détectés")
}
Box(
modifier = Modifier
.background(color.copy(alpha = 0.15f), MaterialTheme.shapes.medium)
.padding(horizontal = 16.dp, vertical = 8.dp)
.semantics { contentDescription = a11yDesc }
modifier =
Modifier
.background(color.copy(alpha = 0.15f), MaterialTheme.shapes.medium)
.padding(horizontal = 16.dp, vertical = 8.dp)
.semantics { contentDescription = a11yDesc },
) {
Text(text = text, color = color, fontWeight = FontWeight.Bold)
}
@ -225,14 +228,18 @@ private fun VerdictBadge(status: SafetyStatus) {
// ── Tab Résumé ──
@Composable
private fun SummaryTab(product: Product, scanResult: ScanResult?) {
private fun SummaryTab(
product: Product,
scanResult: ScanResult?,
) {
val dimens = LocalDimens.current
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(dimens.spacingMd),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
modifier =
Modifier
.fillMaxSize()
.padding(dimens.spacingMd),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
) {
// Nutri-Score
if (scanResult?.health?.nutriScore != null) {
@ -265,30 +272,32 @@ private fun SummaryTab(product: Product, scanResult: ScanResult?) {
@Composable
private fun NutriScoreCard(grade: String) {
val dimens = LocalDimens.current
val color = when (grade.uppercase()) {
"A" -> Color(0xFF1E8E3E)
"B" -> Color(0xFF7CB342)
"C" -> Color(0xFFFBC02D)
"D" -> Color(0xFFEF6C00)
"E" -> Color(0xFFC62828)
else -> Color.Gray
}
val color =
when (grade.uppercase()) {
"A" -> Color(0xFF1E8E3E)
"B" -> Color(0xFF7CB342)
"C" -> Color(0xFFFBC02D)
"D" -> Color(0xFFEF6C00)
"E" -> Color(0xFFC62828)
else -> Color.Gray
}
val a11yDesc = stringResource(R.string.a11y_nutri_score, grade.uppercase())
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(dimens.spacingMd),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Text("Nutri-Score", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.size(48.dp)
.background(color, MaterialTheme.shapes.medium)
.semantics {
contentDescription = a11yDesc
},
contentAlignment = Alignment.Center
modifier =
Modifier
.size(48.dp)
.background(color, MaterialTheme.shapes.medium)
.semantics {
contentDescription = a11yDesc
},
contentAlignment = Alignment.Center,
) {
Text(grade.uppercase(), color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineSmall)
}
@ -303,7 +312,7 @@ private fun CaloriesCard(kcal: Double) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(dimens.spacingMd),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Text("🔥", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.width(dimens.spacingMd))
@ -332,7 +341,12 @@ private fun NutritionGauges(nutriments: Nutriments) {
}
@Composable
private fun GaugeRow(label: String, value: Double, max: Double, color: Color) {
private fun GaugeRow(
label: String,
value: Double,
max: Double,
color: Color,
) {
val dimens = LocalDimens.current
val progress = (value / max).toFloat().coerceIn(0f, 1f)
@ -343,16 +357,18 @@ private fun GaugeRow(label: String, value: Double, max: Double, color: Color) {
}
Spacer(Modifier.height(4.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.small)
modifier =
Modifier
.fillMaxWidth()
.height(8.dp)
.background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.small),
) {
Box(
modifier = Modifier
.fillMaxWidth(progress)
.height(8.dp)
.background(color, MaterialTheme.shapes.small)
modifier =
Modifier
.fillMaxWidth(progress)
.height(8.dp)
.background(color, MaterialTheme.shapes.small),
)
}
Spacer(Modifier.height(dimens.spacingSm))
@ -362,20 +378,21 @@ private fun GaugeRow(label: String, value: Double, max: Double, color: Color) {
@Composable
private fun HealthVerdictCard(health: HealthAssessment) {
val dimens = LocalDimens.current
val (emoji, text, color) = when (health.rating) {
HealthRating.HEALTHY -> Triple("💪", "Plutôt sain", Color(0xFF2E7D32))
HealthRating.MODERATE -> Triple("🙂", "Modération", Color(0xFFF57C00))
HealthRating.UNHEALTHY -> Triple("🚫", "Peu recommandable", Color(0xFFC62828))
HealthRating.UNKNOWN -> Triple("", "Inconnu", Color.Gray)
}
val (emoji, text, color) =
when (health.rating) {
HealthRating.HEALTHY -> Triple("💪", "Plutôt sain", Color(0xFF2E7D32))
HealthRating.MODERATE -> Triple("🙂", "Modération", Color(0xFFF57C00))
HealthRating.UNHEALTHY -> Triple("🚫", "Peu recommandable", Color(0xFFC62828))
HealthRating.UNKNOWN -> Triple("", "Inconnu", Color.Gray)
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f))
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f)),
) {
Row(
modifier = Modifier.padding(dimens.spacingMd),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Text(emoji, style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.width(dimens.spacingMd))
@ -394,10 +411,11 @@ private fun AllergensTab(scanResult: ScanResult?) {
val dimens = LocalDimens.current
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(dimens.spacingMd),
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
modifier =
Modifier
.fillMaxSize()
.padding(dimens.spacingMd),
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm),
) {
item {
Text("14 allergènes réglementaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
@ -406,7 +424,11 @@ private fun AllergensTab(scanResult: ScanResult?) {
if (scanResult == null) {
item {
Text("Aucune analyse disponible", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
"Aucune analyse disponible",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
items(AllergenType.entries.toList()) { allergen ->
@ -418,25 +440,30 @@ private fun AllergensTab(scanResult: ScanResult?) {
}
@Composable
private fun AllergenStatusRow(allergen: AllergenType, detected: com.safebite.app.domain.model.DetectedAllergen?) {
private fun AllergenStatusRow(
allergen: AllergenType,
detected: com.safebite.app.domain.model.DetectedAllergen?,
) {
val dimens = LocalDimens.current
val a11yAbsent = stringResource(R.string.a11y_allergen_absent)
val a11yTrace = stringResource(R.string.a11y_allergen_trace)
val a11yPresent = stringResource(R.string.a11y_allergen_present)
val (status, emoji, bgColor, a11yDesc) = when {
detected == null -> Quad("Absent", "", Color(0xFFE8F8F5), a11yAbsent)
detected.detectionLevel == DetectionLevel.TRACE -> Quad("Traces", "⚠️", Color(0xFFFEF5E7), a11yTrace)
else -> Quad("Présent", "", Color(0xFFFDEDEC), a11yPresent)
}
val (status, emoji, bgColor, a11yDesc) =
when {
detected == null -> Quad("Absent", "", Color(0xFFE8F8F5), a11yAbsent)
detected.detectionLevel == DetectionLevel.TRACE -> Quad("Traces", "⚠️", Color(0xFFFEF5E7), a11yTrace)
else -> Quad("Présent", "", Color(0xFFFDEDEC), a11yPresent)
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(bgColor, MaterialTheme.shapes.small)
.padding(dimens.spacingSm)
.semantics { contentDescription = "${allergen.displayNameFr}: $a11yDesc" },
modifier =
Modifier
.fillMaxWidth()
.background(bgColor, MaterialTheme.shapes.small)
.padding(dimens.spacingSm)
.semantics { contentDescription = "${allergen.displayNameFr}: $a11yDesc" },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(allergen.icon, style = MaterialTheme.typography.bodyLarge)
@ -456,9 +483,10 @@ private fun AdditivesTab(product: Product) {
val dimens = LocalDimens.current
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(dimens.spacingMd)
modifier =
Modifier
.fillMaxSize()
.padding(dimens.spacingMd),
) {
item {
Text("Additifs alimentaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
@ -479,9 +507,10 @@ private fun AlternativesTab(onOpenProduct: (String) -> Unit) {
val dimens = LocalDimens.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(dimens.spacingMd)
modifier =
Modifier
.fillMaxSize()
.padding(dimens.spacingMd),
) {
Text("Produits similaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(dimens.spacingMd))
@ -492,7 +521,7 @@ private fun AlternativesTab(onOpenProduct: (String) -> Unit) {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
}

View File

@ -21,49 +21,57 @@ import javax.inject.Inject
*/
sealed class ProductDetailUiState {
data object Loading : ProductDetailUiState()
data class Success(
val product: Product,
val scanResult: ScanResult?
val scanResult: ScanResult?,
) : ProductDetailUiState()
data class Error(val message: String) : ProductDetailUiState()
}
@HiltViewModel
class ProductDetailViewModel @Inject constructor(
private val fetchProduct: FetchProductUseCase,
private val analyzeProduct: AnalyzeProductUseCase,
private val manageProfile: ManageProfileUseCase
) : ViewModel() {
class ProductDetailViewModel
@Inject
constructor(
private val fetchProduct: FetchProductUseCase,
private val analyzeProduct: AnalyzeProductUseCase,
private val manageProfile: ManageProfileUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow<ProductDetailUiState>(ProductDetailUiState.Loading)
val uiState: StateFlow<ProductDetailUiState> = _uiState.asStateFlow()
private val _uiState = MutableStateFlow<ProductDetailUiState>(ProductDetailUiState.Loading)
val uiState: StateFlow<ProductDetailUiState> = _uiState.asStateFlow()
fun loadProduct(barcode: String) =
viewModelScope.launch {
_uiState.value = ProductDetailUiState.Loading
fun loadProduct(barcode: String) = viewModelScope.launch {
_uiState.value = ProductDetailUiState.Loading
when (val result = fetchProduct(barcode)) {
is ProductFetchResult.Found -> {
val profiles = resolveProfiles()
val scanResult =
if (profiles.isNotEmpty()) {
analyzeProduct(result.product, profiles, com.safebite.app.domain.model.DataSource.API)
} else {
null
}
_uiState.value = ProductDetailUiState.Success(result.product, scanResult)
}
is ProductFetchResult.NotFound -> {
_uiState.value = ProductDetailUiState.Error("Produit non trouvé")
}
is ProductFetchResult.Error -> {
_uiState.value = ProductDetailUiState.Error(result.message)
}
}
}
when (val result = fetchProduct(barcode)) {
is ProductFetchResult.Found -> {
val profiles = resolveProfiles()
val scanResult = if (profiles.isNotEmpty()) {
analyzeProduct(result.product, profiles, com.safebite.app.domain.model.DataSource.API)
} else null
_uiState.value = ProductDetailUiState.Success(result.product, scanResult)
private suspend fun resolveProfiles() =
run {
val all = manageProfile.observe().first()
val activeIds = manageProfile.observeActiveIds().first()
when {
activeIds.isNotEmpty() -> all.filter { it.id in activeIds }
else -> all.filter { it.isDefault }.ifEmpty { all.take(1) }
}
}
is ProductFetchResult.NotFound -> {
_uiState.value = ProductDetailUiState.Error("Produit non trouvé")
}
is ProductFetchResult.Error -> {
_uiState.value = ProductDetailUiState.Error(result.message)
}
}
}
private suspend fun resolveProfiles() = run {
val all = manageProfile.observe().first()
val activeIds = manageProfile.observeActiveIds().first()
when {
activeIds.isNotEmpty() -> all.filter { it.id in activeIds }
else -> all.filter { it.isDefault }.ifEmpty { all.take(1) }
}
}
}

View File

@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Arrangement
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -40,7 +39,10 @@ import com.safebite.app.domain.model.CustomItemTag
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AllergenGrid(selected: Set<AllergenType>, onToggle: (AllergenType) -> Unit) {
fun AllergenGrid(
selected: Set<AllergenType>,
onToggle: (AllergenType) -> Unit,
) {
FlowRow {
AllergenType.entries.forEach { a ->
FilterChip(
@ -48,7 +50,7 @@ fun AllergenGrid(selected: Set<AllergenType>, onToggle: (AllergenType) -> Unit)
onClick = { onToggle(a) },
leadingIcon = { Text(a.icon) },
label = { Text(a.displayNameFr) },
modifier = Modifier.padding(4.dp)
modifier = Modifier.padding(4.dp),
)
}
}
@ -66,7 +68,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
onValueChange = { name = it },
label = { Text(stringResource(R.string.profile_custom_name)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
singleLine = true,
)
Text(stringResource(R.string.profile_custom_tag), style = MaterialTheme.typography.labelLarge)
FlowRow {
@ -75,7 +77,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
selected = tag == t,
onClick = { tag = t },
label = { Text(tagLabel(t)) },
modifier = Modifier.padding(4.dp)
modifier = Modifier.padding(4.dp),
)
}
}
@ -85,7 +87,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
name = ""
},
enabled = name.isNotBlank(),
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(6.dp))
@ -97,7 +99,10 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun CustomItemsList(items: List<CustomDietItem>, onRemove: (CustomDietItem) -> Unit) {
fun CustomItemsList(
items: List<CustomDietItem>,
onRemove: (CustomDietItem) -> Unit,
) {
if (items.isEmpty()) {
Text(stringResource(R.string.profile_custom_empty), color = MaterialTheme.colorScheme.onSurfaceVariant)
return
@ -109,38 +114,42 @@ fun CustomItemsList(items: List<CustomDietItem>, onRemove: (CustomDietItem) -> U
label = {
Text(
"${tagIcon(item.tag)} ${item.name}",
fontWeight = FontWeight.Medium
fontWeight = FontWeight.Medium,
)
},
trailingIcon = { Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(16.dp)) },
colors = AssistChipDefaults.assistChipColors(
containerColor = tagColor(item.tag).copy(alpha = 0.18f)
),
modifier = Modifier.padding(4.dp)
colors =
AssistChipDefaults.assistChipColors(
containerColor = tagColor(item.tag).copy(alpha = 0.18f),
),
modifier = Modifier.padding(4.dp),
)
}
}
}
@Composable
fun tagLabel(tag: CustomItemTag): String = when (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)
}
fun tagLabel(tag: CustomItemTag): String =
when (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)
}
fun tagIcon(tag: CustomItemTag): String = when (tag) {
CustomItemTag.ALLERGY -> ""
CustomItemTag.INTOLERANCE -> "⚠️"
CustomItemTag.DIET -> "🥗"
CustomItemTag.UNHEALTHY -> "🍩"
}
fun tagIcon(tag: CustomItemTag): String =
when (tag) {
CustomItemTag.ALLERGY -> ""
CustomItemTag.INTOLERANCE -> "⚠️"
CustomItemTag.DIET -> "🥗"
CustomItemTag.UNHEALTHY -> "🍩"
}
@Composable
fun tagColor(tag: CustomItemTag): Color = when (tag) {
CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.error
CustomItemTag.INTOLERANCE -> Color(0xFFFFA000)
CustomItemTag.DIET -> MaterialTheme.colorScheme.tertiary
CustomItemTag.UNHEALTHY -> Color(0xFF9575CD)
}
fun tagColor(tag: CustomItemTag): Color =
when (tag) {
CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.error
CustomItemTag.INTOLERANCE -> Color(0xFFFFA000)
CustomItemTag.DIET -> MaterialTheme.colorScheme.tertiary
CustomItemTag.UNHEALTHY -> Color(0xFF9575CD)
}

View File

@ -2,7 +2,6 @@ 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
@ -28,20 +27,16 @@ 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.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.AllergenLevel
import com.safebite.app.domain.model.DietaryRestriction
import com.safebite.app.presentation.common.components.AllergenSelectionGrid
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.CustomDietItem
import com.safebite.app.domain.model.CustomItemTag
import com.safebite.app.domain.model.DietaryRestriction
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@ -49,7 +44,7 @@ fun ProfileEditScreen(
id: Long,
onBack: () -> Unit,
onSaved: () -> Unit,
viewModel: ProfileViewModel = hiltViewModel()
viewModel: ProfileViewModel = hiltViewModel(),
) {
LaunchedEffect(id) { viewModel.load(id) }
val ui by viewModel.edit.collectAsStateWithLifecycle()
@ -64,13 +59,13 @@ fun ProfileEditScreen(
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)
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
) {
item {
StandardTextField(
@ -89,8 +84,16 @@ fun ProfileEditScreen(
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)
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)
@ -114,7 +117,7 @@ fun ProfileEditScreen(
item {
AllergenSelectionGrid(
selectedAllergens = ui.allergenLevels,
onLevelChanged = viewModel::setAllergenLevel
onLevelChanged = viewModel::setAllergenLevel,
)
}
@ -126,7 +129,7 @@ fun ProfileEditScreen(
selected = r in ui.restrictions,
onClick = { viewModel.toggleRestriction(r) },
label = { Text(r.displayFr) },
modifier = Modifier.padding(4.dp)
modifier = Modifier.padding(4.dp),
)
}
}
@ -147,11 +150,9 @@ fun ProfileEditScreen(
PrimaryButton(
text = stringResource(R.string.action_save),
onClick = { viewModel.save(onSaved) },
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}

View File

@ -42,7 +42,7 @@ fun ProfileListScreen(
onBack: () -> Unit,
onNew: () -> Unit,
onEdit: (Long) -> Unit,
viewModel: ProfileViewModel = hiltViewModel()
viewModel: ProfileViewModel = hiltViewModel(),
) {
val profiles by viewModel.profiles.collectAsStateWithLifecycle()
val dimens = LocalDimens.current
@ -61,14 +61,15 @@ fun ProfileListScreen(
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)
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
) {
items(profiles, key = { it.id }) { profile ->
StandardCard(
@ -78,7 +79,7 @@ fun ProfileListScreen(
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
AvatarBubble(avatar = profile.avatar)
Spacer(Modifier.size(dimens.spacingMd))
@ -87,34 +88,34 @@ fun ProfileListScreen(
Text(
profile.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
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)) }
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
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { onEdit(profile.id) }) {
Icon(
Icons.Filled.Edit,
contentDescription = stringResource(R.string.action_save),
tint = MaterialTheme.colorScheme.onSurfaceVariant
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { viewModel.delete(profile) }) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.onSurfaceVariant
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@ -27,7 +27,7 @@ data class ProfileEditUi(
val restrictions: Set<DietaryRestriction> = emptySet(),
val customItems: List<CustomDietItem> = emptyList(),
val isDefault: Boolean = false,
val loaded: Boolean = false
val loaded: Boolean = false,
) {
// Propriétés calculées pour la compatibilité
val severe: Set<AllergenType>
@ -38,110 +38,134 @@ data class ProfileEditUi(
}
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val manage: ManageProfileUseCase
) : ViewModel() {
class ProfileViewModel
@Inject
constructor(
private val manage: ManageProfileUseCase,
) : ViewModel() {
val profiles: StateFlow<List<UserProfile>> =
manage.observe()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
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()
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) {
// Construire la map des niveaux d'allergènes
val allergenLevels = mutableMapOf<AllergenType, AllergenLevel>()
p.severeAllergens.forEach { allergenLevels[it] = AllergenLevel.SEVERE }
p.moderateIntolerances.forEach { allergenLevels[it] = AllergenLevel.TRACE }
fun load(id: Long) = viewModelScope.launch {
if (id == 0L) {
_edit.value = ProfileEditUi(loaded = true)
} else {
val p = manage.get(id)
if (p != null) {
// Construire la map des niveaux d'allergènes
val allergenLevels = mutableMapOf<AllergenType, AllergenLevel>()
p.severeAllergens.forEach { allergenLevels[it] = AllergenLevel.SEVERE }
p.moderateIntolerances.forEach { allergenLevels[it] = AllergenLevel.TRACE }
_edit.value =
ProfileEditUi(
id = p.id,
name = p.name,
avatar = p.avatar,
allergenLevels = allergenLevels,
restrictions = p.dietaryRestrictions,
customItems = p.customItems,
isDefault = p.isDefault,
loaded = true,
)
}
}
}
_edit.value = ProfileEditUi(
id = p.id,
name = p.name,
avatar = p.avatar,
allergenLevels = allergenLevels,
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) }
/** Met à jour le niveau d'un allergène (cycle : NONE → TRACE → SEVERE → NONE) */
fun setAllergenLevel(
allergen: AllergenType,
level: AllergenLevel,
) = _edit.update { s ->
val newLevels =
if (level == AllergenLevel.NONE) {
s.allergenLevels - allergen
} else {
s.allergenLevels + (allergen to level)
}
s.copy(allergenLevels = newLevels)
}
// Méthodes de compatibilité pour l'ancien AllergenGrid
fun toggleSevere(a: AllergenType) =
_edit.update { s ->
val newLevel = if (a in s.severe) AllergenLevel.NONE else AllergenLevel.SEVERE
val newLevels =
if (newLevel == AllergenLevel.NONE) {
s.allergenLevels - a
} else {
s.allergenLevels + (a to newLevel)
}
s.copy(allergenLevels = newLevels)
}
fun toggleModerate(a: AllergenType) =
_edit.update { s ->
val newLevel = if (a in s.moderate) AllergenLevel.NONE else AllergenLevel.TRACE
val newLevels =
if (newLevel == AllergenLevel.NONE) {
s.allergenLevels - a
} else {
s.allergenLevels + (a to newLevel)
}
s.copy(allergenLevels = newLevels)
}
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) }
}
fun setName(v: String) = _edit.update { it.copy(name = v) }
fun setAvatar(v: String) = _edit.update { it.copy(avatar = v) }
/** Met à jour le niveau d'un allergène (cycle : NONE → TRACE → SEVERE → NONE) */
fun setAllergenLevel(allergen: AllergenType, level: AllergenLevel) = _edit.update { s ->
val newLevels = if (level == AllergenLevel.NONE) {
s.allergenLevels - allergen
} else {
s.allergenLevels + (allergen to level)
}
s.copy(allergenLevels = newLevels)
}
// Méthodes de compatibilité pour l'ancien AllergenGrid
fun toggleSevere(a: AllergenType) = _edit.update { s ->
val newLevel = if (a in s.severe) AllergenLevel.NONE else AllergenLevel.SEVERE
val newLevels = if (newLevel == AllergenLevel.NONE) {
s.allergenLevels - a
} else {
s.allergenLevels + (a to newLevel)
}
s.copy(allergenLevels = newLevels)
}
fun toggleModerate(a: AllergenType) = _edit.update { s ->
val newLevel = if (a in s.moderate) AllergenLevel.NONE else AllergenLevel.TRACE
val newLevels = if (newLevel == AllergenLevel.NONE) {
s.allergenLevels - a
} else {
s.allergenLevels + (a to newLevel)
}
s.copy(allergenLevels = newLevels)
}
fun toggleRestriction(r: DietaryRestriction) = _edit.update { s ->
s.copy(restrictions = if (r in s.restrictions) s.restrictions - r else s.restrictions + r)
}
fun setDefault(v: Boolean) = _edit.update { it.copy(isDefault = v) }
fun addCustomItem(name: String, tag: CustomItemTag) {
val trimmed = name.trim()
if (trimmed.isBlank()) return
_edit.update { s ->
if (s.customItems.any { it.name.equals(trimmed, ignoreCase = true) && it.tag == tag }) s
else s.copy(customItems = s.customItems + CustomDietItem(trimmed, tag))
}
}
fun removeCustomItem(item: CustomDietItem) = _edit.update { s ->
s.copy(customItems = s.customItems.filterNot { it.name == item.name && it.tag == item.tag })
}
fun save(onDone: () -> Unit) = viewModelScope.launch {
val ui = _edit.value
val id = manage.save(
UserProfile(
id = ui.id,
name = ui.name.ifBlank { "Profil" },
avatar = ui.avatar,
severeAllergens = ui.severe,
moderateIntolerances = ui.moderate,
dietaryRestrictions = ui.restrictions,
customItems = ui.customItems,
isDefault = ui.isDefault
)
)
if (ui.isDefault) manage.setDefault(id)
onDone()
}
fun delete(profile: UserProfile) = viewModelScope.launch { manage.delete(profile) }
}

View File

@ -2,7 +2,6 @@ package com.safebite.app.presentation.screen.result
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
@ -10,7 +9,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CameraAlt
@ -33,9 +31,7 @@ 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.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.safebite.app.R
import com.safebite.app.presentation.common.components.PrimaryButton
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
@ -58,7 +54,7 @@ fun ProductNotFoundScreen(
onBack: () -> Unit,
onOpenOcr: () -> Unit,
onManualSubmit: (String) -> Unit,
onScanAgain: () -> Unit
onScanAgain: () -> Unit,
) {
val dimens = LocalDimens.current
var manualBarcode by remember { mutableStateOf("") }
@ -71,19 +67,20 @@ fun ProductNotFoundScreen(
SafeBiteTopAppBar(
title = stringResource(R.string.result_product_not_found),
onBack = onBack,
backContentDescription = stringResource(R.string.a11y_back)
backContentDescription = stringResource(R.string.a11y_back),
)
}
},
) { padding ->
if (submitted) {
// Message de confirmation
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(dimens.spacingXl),
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(dimens.spacingXl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Center,
) {
Text("", style = MaterialTheme.typography.displayMedium)
Spacer(Modifier.height(dimens.spacingMd))
@ -91,55 +88,57 @@ fun ProductNotFoundScreen(
text = "Merci pour votre contribution !",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(dimens.spacingSm))
Text(
text = "Le produit sera analysé sous 24h. Vous recevrez une notification quand le résultat sera disponible.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(dimens.spacingLg))
PrimaryButton(
text = "Scanner un autre produit",
onClick = onScanAgain,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(dimens.spacingLg),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(dimens.spacingLg),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
) {
// Message principal
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier.padding(dimens.spacingMd),
horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("🔍", style = MaterialTheme.typography.displayMedium)
Spacer(Modifier.height(dimens.spacingSm))
Text(
text = "Produit non reconnu",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(dimens.spacingSm))
Text(
text = "Ce produit (code: $barcode) n'est pas dans notre base de données.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
)
}
}
@ -148,13 +147,14 @@ fun ProductNotFoundScreen(
Text(
text = "Option 1 : Photographier les ingrédients",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
)
OutlinedButton(
onClick = onOpenOcr,
modifier = Modifier
.fillMaxWidth()
.semantics { contentDescription = "Prendre une photo des ingrédients" }
modifier =
Modifier
.fillMaxWidth()
.semantics { contentDescription = "Prendre une photo des ingrédients" },
) {
Icon(Icons.Filled.CameraAlt, contentDescription = null)
Spacer(Modifier.size(dimens.spacingSm))
@ -165,7 +165,7 @@ fun ProductNotFoundScreen(
Text(
text = "Option 2 : Saisie manuelle",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(dimens.spacingMd)) {
@ -173,7 +173,7 @@ fun ProductNotFoundScreen(
value = productName,
onValueChange = { productName = it },
label = "Nom du produit (optionnel)",
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(dimens.spacingSm))
StandardTextField(
@ -181,7 +181,7 @@ fun ProductNotFoundScreen(
onValueChange = { manualBarcode = it },
label = "Code-barres",
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) },
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(dimens.spacingMd))
PrimaryButton(
@ -193,7 +193,7 @@ fun ProductNotFoundScreen(
}
},
enabled = manualBarcode.isNotBlank(),
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
}
@ -203,7 +203,7 @@ fun ProductNotFoundScreen(
text = "💡 Vous pouvez aussi scanner un autre produit similaire en magasin.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
)
}
}

View File

@ -3,6 +3,9 @@ package com.safebite.app.presentation.screen.result
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -10,7 +13,6 @@ 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
@ -52,7 +54,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
@ -74,7 +75,6 @@ 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
@ -82,7 +82,6 @@ import com.safebite.app.presentation.common.components.ProductSkeleton
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
@ -93,7 +92,8 @@ fun ResultScreen(
onBack: () -> Unit,
onScanAgain: () -> Unit,
onOcr: () -> Unit,
viewModel: ResultViewModel = hiltViewModel()
onOpenAlternatives: () -> Unit,
viewModel: ResultViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val lists by viewModel.lists.collectAsStateWithLifecycle()
@ -114,46 +114,50 @@ fun ResultScreen(
onBack = onBack,
backContentDescription = stringResource(R.string.a11y_back),
)
}
},
) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) {
when (val s = state) {
UiState.Idle, UiState.Loading -> ProductSkeleton()
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)
}
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)
}
val errorContentDesc = stringResource(R.string.a11y_error, msg)
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.semantics {
contentDescription = errorContentDesc
},
verticalArrangement = Arrangement.spacedBy(12.dp)
modifier =
Modifier
.fillMaxSize()
.padding(16.dp)
.semantics {
contentDescription = errorContentDesc
},
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()
modifier = Modifier.fillMaxWidth(),
)
PrimaryButton(
text = stringResource(R.string.action_scan_again),
onClick = onScanAgain,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
}
is UiState.Success -> ResultContent(
result = s.data,
onScanAgain = onScanAgain,
onOcr = onOcr,
onAddToList = { showListPicker = true }
)
is UiState.Success ->
ResultContent(
result = s.data,
onScanAgain = onScanAgain,
onOcr = onOcr,
onAddToList = { showListPicker = true },
onOpenAlternatives = onOpenAlternatives,
)
}
if (showListPicker) {
@ -163,7 +167,7 @@ fun ResultScreen(
viewModel.addToList(listId)
showListPicker = false
},
onDismiss = { showListPicker = false }
onDismiss = { showListPicker = false },
)
}
}
@ -176,167 +180,207 @@ private fun ResultContent(
result: ScanResult,
onScanAgain: () -> Unit,
onOcr: () -> Unit,
onAddToList: () -> Unit
onAddToList: () -> Unit,
onOpenAlternatives: () -> Unit,
) {
var ingredientsExpanded by remember { mutableStateOf(false) }
var actionsVisible by remember { mutableStateOf(false) }
var contentVisible by remember { mutableStateOf(false) }
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
LaunchedEffect(Unit) {
contentVisible = true
actionsVisible = true
}
AnimatedVisibility(
visible = contentVisible,
enter = fadeIn(tween(250)) + slideInVertically(tween(250)) { it / 8 },
) {
// Annonce TalkBack pour le verdict
val verdictAnnouncement = when (result.safetyStatus) {
com.safebite.app.domain.model.SafetyStatus.SAFE -> stringResource(R.string.a11y_verdict_safe)
com.safebite.app.domain.model.SafetyStatus.WARNING -> stringResource(R.string.a11y_verdict_warning)
com.safebite.app.domain.model.SafetyStatus.DANGER -> {
val profile = result.analyzedProfiles.firstOrNull()?.name ?: ""
if (profile.isNotEmpty()) {
stringResource(R.string.a11y_verdict_danger, profile)
} else {
stringResource(R.string.a11y_danger_status, "")
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
// Annonce TalkBack pour le verdict
val verdictAnnouncement =
when (result.safetyStatus) {
com.safebite.app.domain.model.SafetyStatus.SAFE -> stringResource(R.string.a11y_verdict_safe)
com.safebite.app.domain.model.SafetyStatus.WARNING -> stringResource(R.string.a11y_verdict_warning)
com.safebite.app.domain.model.SafetyStatus.DANGER -> {
val profile = result.analyzedProfiles.firstOrNull()?.name ?: ""
if (profile.isNotEmpty()) {
stringResource(R.string.a11y_verdict_danger, profile)
} else {
stringResource(R.string.a11y_danger_status, "")
}
}
}
}
}
Text(
text = "",
modifier = Modifier
.semantics {
liveRegion = LiveRegionMode.Assertive
contentDescription = verdictAnnouncement
}
)
SafetyStatusBanner(
status = result.safetyStatus,
profileName = result.analyzedProfiles.firstOrNull()?.name,
allergenName = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr,
severity = if (result.detectedAllergens.any { it.severe }) "anaphylaxis" else null
)
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,
imageContentDescription = stringResource(R.string.a11y_product_image)
Text(
text = "",
modifier =
Modifier
.semantics {
liveRegion = LiveRegionMode.Assertive
contentDescription = verdictAnnouncement
},
)
// 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()
SafetyStatusBanner(
status = result.safetyStatus,
profileName = result.analyzedProfiles.firstOrNull()?.name,
allergenName = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr,
severity = if (result.detectedAllergens.any { it.severe }) "anaphylaxis" else null,
)
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,
imageContentDescription = stringResource(R.string.a11y_product_image),
)
}
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)
// Open on OFF (only when we have a real barcode, not an OCR synthetic one).
if (result.source != DataSource.OCR) {
StaggeredAction(visible = actionsVisible, delayMs = 0) {
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(),
)
IconButton(onClick = { ingredientsExpanded = !ingredientsExpanded }) {
Icon(
if (ingredientsExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (ingredientsExpanded)
stringResource(R.string.a11y_collapse)
else
stringResource(R.string.a11y_expand)
}
}
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,
contentDescription =
if (ingredientsExpanded) {
stringResource(R.string.a11y_collapse)
} else {
stringResource(R.string.a11y_expand)
},
)
}
}
AnimatedVisibility(visible = ingredientsExpanded) {
Text(
result.product.ingredientsText
?: stringResource(R.string.result_ingredients_unavailable),
style = MaterialTheme.typography.bodyMedium,
)
}
}
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,
)
StaggeredAction(visible = actionsVisible, delayMs = 50) {
OutlinedActionButton(
text = stringResource(R.string.result_add_to_list),
onClick = onAddToList,
modifier = Modifier.fillMaxWidth(),
)
}
// Bouton Alternatives (uniquement si verdict != SAFE)
if (result.safetyStatus != com.safebite.app.domain.model.SafetyStatus.SAFE) {
StaggeredAction(visible = actionsVisible, delayMs = 100) {
PrimaryButton(
text = stringResource(R.string.result_see_alternatives),
onClick = onOpenAlternatives,
modifier = Modifier.fillMaxWidth(),
)
}
}
StaggeredAction(visible = actionsVisible, delayMs = 150) {
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),
)
}
}
}
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.result_disclaimer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedActionButton(
text = stringResource(R.string.result_add_to_list),
onClick = onAddToList,
modifier = Modifier.fillMaxWidth()
)
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)
)
}
}
}
} // AnimatedVisibility (slide-up transition)
}
@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
}
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))
@ -347,18 +391,23 @@ private fun ConfidenceRow(confidence: AnalysisConfidence, source: DataSource) {
@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)
}
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
)
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)
@ -367,14 +416,14 @@ private fun AllergenRow(d: DetectedAllergen) {
Text(
d.allergenType.displayNameFr,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
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
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@ -384,25 +433,28 @@ private fun AllergenRow(d: DetectedAllergen) {
@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
}
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)
colors = CardDefaults.cardColors(containerColor = bg),
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text(icon, style = MaterialTheme.typography.headlineMedium)
@ -414,7 +466,7 @@ private fun CustomItemRow(d: DetectedCustomItem) {
Text(
d.matchedKeywords.joinToString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@ -424,15 +476,16 @@ private fun CustomItemRow(d: DetectedCustomItem) {
@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), "")
}
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))
colors = CardDefaults.cardColors(containerColor = ratingColor.copy(alpha = 0.12f)),
) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Text(emoji, style = MaterialTheme.typography.displaySmall)
@ -441,14 +494,14 @@ private fun HealthSection(health: HealthAssessment) {
Text(
stringResource(R.string.result_health_verdict),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
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
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@ -457,7 +510,10 @@ private fun HealthSection(health: HealthAssessment) {
}
@Composable
private fun NutritionSection(n: Nutriments, servingSize: String?) {
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)
@ -465,7 +521,7 @@ private fun NutritionSection(n: Nutriments, servingSize: String?) {
Text(
stringResource(R.string.result_nutrition_unavailable),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Card
}
@ -473,22 +529,38 @@ private fun NutritionSection(n: Nutriments, servingSize: String?) {
Text(
stringResource(R.string.result_nutrition_serving_size, servingSize),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
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)
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)
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
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_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")
@ -501,7 +573,13 @@ private fun NutritionSection(n: Nutriments, servingSize: String?) {
}
@Composable
private fun NutritionRow(label: String, per100: Double?, perServing: Double?, unit: String, emphasize: Boolean = false) {
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) {
@ -511,7 +589,7 @@ private fun NutritionRow(label: String, per100: Double?, perServing: Double?, un
modifier = Modifier.width(80.dp),
textAlign = TextAlign.End,
style = style,
fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal
fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal,
)
if (perServing != null) {
Text(
@ -519,16 +597,20 @@ private fun NutritionRow(label: String, per100: Double?, perServing: Double?, un
modifier = Modifier.width(80.dp),
textAlign = TextAlign.End,
style = style,
fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal
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('.', ',')
return if (d >= 100) {
d.toInt().toString()
} else if (d >= 10) {
"%.1f".format(d)
} else {
"%.2f".format(d).trimEnd('0').trimEnd('.', ',')
}
}
@OptIn(ExperimentalLayoutApi::class)
@ -542,28 +624,29 @@ private fun ScoresSection(health: HealthAssessment) {
ScoreRow(
title = stringResource(R.string.result_nutriscore),
details = stringResource(R.string.result_nutriscore_details),
badge = { NutriScoreBadge(health.nutriScore) }
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)
}
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) }
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) }
badge = { EcoScoreBadge(health.ecoScore) },
)
}
}
@ -571,7 +654,11 @@ private fun ScoresSection(health: HealthAssessment) {
}
@Composable
private fun ScoreRow(title: String, details: String, badge: @Composable () -> Unit) {
private fun ScoreRow(
title: String,
details: String,
badge: @Composable () -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
badge()
Spacer(Modifier.width(12.dp))
@ -585,20 +672,22 @@ private fun ScoreRow(title: String, details: String, badge: @Composable () -> Un
@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)
}
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
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)
}
@ -606,18 +695,20 @@ private fun NutriScoreBadge(grade: String) {
@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)
}
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
modifier =
Modifier
.size(56.dp)
.background(color, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(group.toString(), color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineMedium)
}
@ -626,19 +717,21 @@ private fun NovaBadge(group: Int) {
@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)
}
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
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)
}
@ -649,48 +742,50 @@ private fun EcoScoreBadge(grade: String) {
private fun ListPickerBottomSheet(
lists: List<com.safebite.app.data.local.database.entity.ShoppingListEntity>,
onSelect: (Long) -> Unit,
onDismiss: () -> Unit
onDismiss: () -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp)
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
Text(
text = stringResource(R.string.result_choose_list),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(12.dp))
if (lists.isEmpty()) {
Text(
text = "Aucune liste disponible",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
lists.forEach { list ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onSelect(list.id) }
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onSelect(list.id) }
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = list.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
)
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
tint = MaterialTheme.colorScheme.primary,
)
}
}
@ -699,3 +794,26 @@ private fun ListPickerBottomSheet(
}
}
}
/**
* Animation stagger pour les actions du verdict.
* Chaque bouton apparaît avec un délai progressif (+50ms).
*/
@Composable
private fun StaggeredAction(
visible: Boolean,
delayMs: Int,
content: @Composable () -> Unit,
) {
AnimatedVisibility(
visible = visible,
enter =
fadeIn(animationSpec = tween(durationMillis = 300, delayMillis = delayMs)) +
slideInVertically(
initialOffsetY = { it / 4 },
animationSpec = tween(durationMillis = 300, delayMillis = delayMs),
),
) {
content()
}
}

View File

@ -2,13 +2,13 @@ package com.safebite.app.presentation.screen.result
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
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.data.local.database.entity.ShoppingListItemEntity
import com.safebite.app.domain.usecase.FetchProductUseCase
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
import com.safebite.app.domain.usecase.ManageProfileUseCase
@ -26,76 +26,82 @@ 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,
private val getLists: GetShoppingListsUseCase,
private val manageList: ManageShoppingListUseCase
) : ViewModel() {
class ResultViewModel
@Inject
constructor(
private val fetchProduct: FetchProductUseCase,
private val analyzeProduct: AnalyzeProductUseCase,
private val analyzeText: AnalyzeIngredientsTextUseCase,
private val manageProfile: ManageProfileUseCase,
private val saveScan: SaveScanUseCase,
private val getLists: GetShoppingListsUseCase,
private val manageList: ManageShoppingListUseCase,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<ScanResult>>(UiState.Idle)
val state: StateFlow<UiState<ScanResult>> = _state.asStateFlow()
private val _state = MutableStateFlow<UiState<ScanResult>>(UiState.Idle)
val state: StateFlow<UiState<ScanResult>> = _state.asStateFlow()
val lists =
getLists.observeActive()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val lists = getLists.observeActive()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
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 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)
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)
}
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
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) }
}
}
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) }
}
fun addToList(listId: Long) =
viewModelScope.launch {
val currentState = _state.value
if (currentState !is UiState.Success) return@launch
val result = currentState.data
val entity =
ShoppingListItemEntity(
listId = listId,
barcode = result.product.barcode,
productName = result.product.name ?: result.product.barcode,
brand = result.product.brand,
imageUrl = result.product.imageUrl,
isChecked = false,
safetyStatus = result.safetyStatus.name,
allergenWarning = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr,
)
manageList.addItemToList(listId, entity)
}
}
fun addToList(listId: Long) = viewModelScope.launch {
val currentState = _state.value
if (currentState !is UiState.Success) return@launch
val result = currentState.data
val entity = ShoppingListItemEntity(
listId = listId,
barcode = result.product.barcode,
productName = result.product.name ?: result.product.barcode,
brand = result.product.brand,
imageUrl = result.product.imageUrl,
isChecked = false,
safetyStatus = result.safetyStatus.name,
allergenWarning = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr
)
manageList.addItemToList(listId, entity)
}
}

View File

@ -13,27 +13,33 @@ import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
class BarcodeAnalyzer(
private val onBarcode: (String) -> Unit
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 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 }
if (consumed.get()) {
image.close()
return
}
val mediaImage = image.image
if (mediaImage == null) { image.close(); return }
if (mediaImage == null) {
image.close()
return
}
val input = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees)
scanner.process(input)
.addOnSuccessListener { barcodes ->

View File

@ -20,21 +20,27 @@ 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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FlashOff
import androidx.compose.material.icons.filled.FlashOn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@ -49,13 +55,14 @@ 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.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@ -68,11 +75,15 @@ import java.util.concurrent.Executors
@Composable
fun ScannerScreen(
onBack: () -> Unit,
onBarcode: (String) -> Unit
onBarcode: (String) -> Unit,
) {
val permission = rememberPermissionState(android.Manifest.permission.CAMERA)
LaunchedEffect(Unit) { if (!permission.status.isGranted) permission.launchPermissionRequest() }
var showManualDialog by remember { mutableStateOf(false) }
var manualCode by remember { mutableStateOf("") }
var manualError by remember { mutableStateOf<String?>(null) }
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
@ -81,7 +92,7 @@ fun ScannerScreen(
onBack = onBack,
backContentDescription = stringResource(R.string.a11y_back),
)
}
},
) { padding ->
val scanAreaDesc = stringResource(R.string.a11y_scan_area)
Box(
@ -90,22 +101,98 @@ fun ScannerScreen(
.padding(padding)
.semantics {
contentDescription = scanAreaDesc
}
},
) {
if (!permission.status.isGranted) {
ErrorView(
message = stringResource(R.string.scanner_camera_denied),
onRetry = { permission.launchPermissionRequest() }
)
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
ErrorView(
message = stringResource(R.string.scanner_camera_denied),
onRetry = { permission.launchPermissionRequest() },
)
Spacer(Modifier.size(16.dp))
TextButton(onClick = { showManualDialog = true }) {
Icon(Icons.Filled.Edit, contentDescription = null)
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.scanner_manual_entry_button))
}
}
} else {
CameraView(onBarcode = onBarcode)
CameraView(
onBarcode = onBarcode,
onManualEntry = { showManualDialog = true },
)
}
}
}
// Dialog de saisie manuelle
if (showManualDialog) {
AlertDialog(
onDismissRequest = {
showManualDialog = false
manualCode = ""
manualError = null
},
title = { Text(stringResource(R.string.scanner_manual_entry_title)) },
text = {
Column {
OutlinedTextField(
value = manualCode,
onValueChange = {
manualCode = it.filter { c -> c.isDigit() }.take(13)
manualError = null
},
label = { Text(stringResource(R.string.scanner_manual_entry_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = manualError != null,
supportingText =
if (manualError != null) {
{ Text(manualError!!) }
} else {
null
},
)
}
},
confirmButton = {
val invalidMsg = stringResource(R.string.scanner_manual_entry_invalid)
TextButton(onClick = {
val code = manualCode.trim()
if (isValidBarcodeFormat(code)) {
showManualDialog = false
manualCode = ""
manualError = null
onBarcode(code)
} else {
manualError = invalidMsg
}
}) {
Text(stringResource(R.string.scanner_manual_entry_search))
}
},
dismissButton = {
TextButton(onClick = {
showManualDialog = false
manualCode = ""
manualError = null
}) {
Text(stringResource(R.string.a11y_cancel))
}
},
)
}
}
@Composable
private fun CameraView(onBarcode: (String) -> Unit) {
private fun CameraView(
onBarcode: (String) -> Unit,
onManualEntry: () -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var torch by remember { mutableStateOf(false) }
@ -119,66 +206,99 @@ private fun CameraView(onBarcode: (String) -> Unit) {
androidx.compose.ui.viewinterop.AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
val previewView = PreviewView(ctx).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
}
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)
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
)
val camera =
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis,
)
cameraControl = camera.cameraControl
} catch (t: Throwable) { /* ignore */ }
} 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
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)
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)
},
modifier = Modifier.size(48.dp)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (torch) Icons.Filled.FlashOn else Icons.Filled.FlashOff,
contentDescription = stringResource(R.string.a11y_torch),
tint = Color.White
)
TextButton(onClick = onManualEntry) {
Icon(
Icons.Filled.Edit,
contentDescription = null,
tint = Color.White,
)
Spacer(Modifier.size(4.dp))
Text(
stringResource(R.string.scanner_manual_entry_button),
color = Color.White,
)
}
IconButton(
onClick = {
torch = !torch
cameraControl?.enableTorch(torch)
},
modifier = Modifier.size(48.dp),
) {
Icon(
if (torch) Icons.Filled.FlashOn else Icons.Filled.FlashOff,
contentDescription = stringResource(R.string.a11y_torch),
tint = Color.White,
)
}
}
}
}
@ -190,11 +310,12 @@ private fun ScanOverlay(modifier: Modifier = Modifier) {
val y by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "scanY"
animationSpec =
infiniteRepeatable(
animation = tween(1800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "scanY",
)
Canvas(modifier = modifier) {
val w = size.width
@ -203,35 +324,35 @@ private fun ScanOverlay(modifier: Modifier = Modifier) {
val topLeft = Offset((w - boxSize.width) / 2f, (h - boxSize.height) / 2f)
drawRect(
color = Color(0xB3000000),
size = Size(w, topLeft.y)
size = Size(w, topLeft.y),
)
drawRect(
color = Color(0xB3000000),
topLeft = Offset(0f, topLeft.y + boxSize.height),
size = Size(w, h - 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)
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)
size = Size(w - topLeft.x - boxSize.width, boxSize.height),
)
drawRect(
color = Color.White,
topLeft = topLeft,
size = boxSize,
style = Stroke(width = 4f)
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
strokeWidth = 4f,
)
}
}
@ -246,5 +367,15 @@ private fun triggerHaptic(context: android.content.Context) {
val v = context.getSystemService(android.content.Context.VIBRATOR_SERVICE) as? Vibrator
v?.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE))
}
} catch (_: Throwable) { /* ignore */ }
} catch (_: Throwable) {
// ignore
}
}
/** Valide le format d'un code-barres (EAN-13, EAN-8, UPC-A, UPC-E). */
private fun isValidBarcodeFormat(code: String): Boolean {
if (code.isEmpty()) return false
val digits = code.filter { it.isDigit() }
if (digits.length != code.length) return false
return digits.length in 8..13
}

View File

@ -38,7 +38,7 @@ import com.safebite.app.presentation.theme.LocalDimens
@Composable
fun SettingsScreen(
onBack: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
viewModel: SettingsViewModel = hiltViewModel(),
) {
val ui by viewModel.state.collectAsStateWithLifecycle()
@ -51,27 +51,42 @@ fun SettingsScreen(
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)
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") })
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)) })
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) {
@ -84,17 +99,29 @@ fun SettingsScreen(
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)) })
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)) })
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)) })
}
}
@ -102,12 +129,12 @@ fun SettingsScreen(
DestructiveButton(
text = stringResource(R.string.settings_clear_cache),
onClick = viewModel::clearCache,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
DestructiveButton(
text = stringResource(R.string.settings_clear_history),
onClick = viewModel::clearHistory,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
@ -115,43 +142,50 @@ fun SettingsScreen(
Text(
stringResource(R.string.settings_version, BuildConfig.VERSION_NAME),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(R.string.settings_off_attribution),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"https://world.openfoodfacts.org",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun Section(title: String, content: @Composable () -> Unit) {
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
color = MaterialTheme.colorScheme.onBackground,
)
content()
}
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
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)
modifier = Modifier.weight(1f),
)
Switch(checked = checked, onCheckedChange = onChange)
}

View File

@ -24,41 +24,52 @@ data class SettingsUi(
val sound: Boolean = true,
val theme: ThemePref = ThemePref.SYSTEM,
val healthStrictness: HealthStrictness = HealthStrictness.NORMAL,
val splashScreenEnabled: Boolean = true
val splashScreenEnabled: Boolean = true,
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settings: SettingsRepository,
private val productRepo: ProductRepository,
private val historyRepo: ScanHistoryRepository
) : ViewModel() {
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)
}
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,
settings.splashScreenEnabled,
) { core, strict, splash ->
core.copy(healthStrictness = strict, splashScreenEnabled = splash)
}.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 setSplashScreenEnabled(v: Boolean) = viewModelScope.launch { settings.setSplashScreenEnabled(v) }
fun clearCache() = viewModelScope.launch { productRepo.clearCache() }
fun clearHistory() = viewModelScope.launch { historyRepo.clear() }
}
val state: StateFlow<SettingsUi> = combine(
coreFlow,
settings.healthStrictness,
settings.splashScreenEnabled
) { core, strict, splash ->
core.copy(healthStrictness = strict, splashScreenEnabled = splash)
}.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 setSplashScreenEnabled(v: Boolean) = viewModelScope.launch { settings.setSplashScreenEnabled(v) }
fun clearCache() = viewModelScope.launch { productRepo.clearCache() }
fun clearHistory() = viewModelScope.launch { historyRepo.clear() }
}

View File

@ -31,7 +31,7 @@ import com.safebite.app.presentation.theme.ShieldGradient
@Composable
fun SplashScreen(
onFinished: () -> Unit,
durationMillis: Int = 2500
durationMillis: Int = 2500,
) {
val scale = remember { Animatable(0.6f) }
val alpha = remember { Animatable(0f) }
@ -44,39 +44,43 @@ fun SplashScreen(
}
Box(
modifier = Modifier
.fillMaxSize()
.background(ShieldGradient),
contentAlignment = Alignment.Center
modifier =
Modifier
.fillMaxSize()
.background(ShieldGradient),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(24.dp)
modifier = Modifier.padding(24.dp),
) {
Image(
painter = painterResource(id = R.drawable.safebite_logo_nobg),
contentDescription = null,
modifier = Modifier
.size(160.dp)
.scale(scale.value)
modifier =
Modifier
.size(160.dp)
.scale(scale.value),
)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = androidx.compose.ui.graphics.Color.White
),
modifier = Modifier.alpha(alpha.value)
style =
MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
color = androidx.compose.ui.graphics.Color.White,
),
modifier = Modifier.alpha(alpha.value),
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.onboarding_welcome_subtitle),
style = MaterialTheme.typography.bodyLarge.copy(
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f)
),
modifier = Modifier.alpha(alpha.value)
style =
MaterialTheme.typography.bodyLarge.copy(
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f),
),
modifier = Modifier.alpha(alpha.value),
)
}
}

View File

@ -60,7 +60,7 @@ import java.util.Date
fun TrackingScreen(
onOpenHistoryItem: (String) -> Unit,
onOpenScanner: () -> Unit,
viewModel: TrackingViewModel = hiltViewModel()
viewModel: TrackingViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val timeFilter by viewModel.timeFilter.collectAsStateWithLifecycle()
@ -80,18 +80,19 @@ fun TrackingScreen(
val clearAllDesc = stringResource(R.string.a11y_clear_all)
IconButton(
onClick = viewModel::clearAll,
modifier = Modifier.semantics {
contentDescription = clearAllDesc
}
modifier =
Modifier.semantics {
contentDescription = clearAllDesc
},
) {
Icon(
Icons.Filled.DeleteSweep,
contentDescription = null
contentDescription = null,
)
}
}
},
)
}
},
) { padding ->
when (uiState) {
is TrackingUiState.Loading -> {
@ -99,32 +100,34 @@ fun TrackingScreen(
}
is TrackingUiState.Empty -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
modifier =
Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
EmptyState(
title = stringResource(R.string.tracking_empty_title),
message = stringResource(R.string.tracking_empty_body),
emoji = "📊"
emoji = "📊",
)
}
}
is TrackingUiState.Success -> {
val success = uiState as TrackingUiState.Success
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = dimens.spacingLg),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = dimens.spacingLg),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
) {
// Filtres temporels
item {
TimeFilterRow(
selected = success.timeFilter,
onFilterChanged = viewModel::setTimeFilter
onFilterChanged = viewModel::setTimeFilter,
)
}
@ -132,7 +135,7 @@ fun TrackingScreen(
item {
StatsSection(
stats = success.stats,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
@ -140,7 +143,7 @@ fun TrackingScreen(
item {
EvolutionChart(
data = success.stats.sparklineData,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
@ -148,7 +151,7 @@ fun TrackingScreen(
item {
VerdictDistribution(
data = success.stats.barChartData,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
@ -157,7 +160,7 @@ fun TrackingScreen(
item {
TopAllergensSection(
allergens = success.stats.topAllergens,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
}
}
@ -166,7 +169,7 @@ fun TrackingScreen(
item {
StatusFilterRow(
selected = success.statusFilter,
onFilterChanged = viewModel::setStatusFilter
onFilterChanged = viewModel::setStatusFilter,
)
}
@ -176,7 +179,7 @@ fun TrackingScreen(
value = searchQuery,
onValueChange = viewModel::setSearchQuery,
placeholder = "Rechercher un produit...",
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) }
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) },
)
}
@ -186,7 +189,7 @@ fun TrackingScreen(
text = stringResource(R.string.tracking_recent_scans),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
)
}
@ -197,9 +200,10 @@ fun TrackingScreen(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = dimens.spacingXl)
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = dimens.spacingXl),
)
}
} else {
@ -207,7 +211,7 @@ fun TrackingScreen(
HistoryItemCard(
item = item,
onClick = { onOpenHistoryItem(item.barcode) },
onDelete = { viewModel.deleteItem(item.id) }
onDelete = { viewModel.deleteItem(item.id) },
)
}
}
@ -223,28 +227,30 @@ fun TrackingScreen(
@Composable
fun StatsSection(
stats: TrackingStats,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val dimens = LocalDimens.current
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(dimens.spacingMd),
horizontalAlignment = Alignment.CenterHorizontally
modifier =
Modifier
.fillMaxWidth()
.padding(dimens.spacingMd),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.tracking_stats_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(dimens.spacingMd))
@ -252,7 +258,7 @@ fun StatsSection(
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
horizontalArrangement = Arrangement.SpaceEvenly,
) {
// Donut chart
DonutChart(
@ -260,32 +266,32 @@ fun StatsSection(
size = 120.dp,
strokeWidth = 12.dp,
centerText = "${(stats.safePercentage * 100).toInt()}%",
centerSubText = stringResource(R.string.tracking_safe_rate)
centerSubText = stringResource(R.string.tracking_safe_rate),
)
// Stats cards
Column(
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm),
) {
StatCardMini(
icon = "📊",
value = "${stats.totalScans}",
label = stringResource(R.string.tracking_total_scans)
label = stringResource(R.string.tracking_total_scans),
)
StatCardMini(
icon = "",
value = "${stats.safeCount}",
label = "Sûrs"
label = "Sûrs",
)
StatCardMini(
icon = "⚠️",
value = "${stats.warningCount}",
label = "Attention"
label = "Attention",
)
StatCardMini(
icon = "",
value = "${stats.dangerCount}",
label = "Danger"
label = "Danger",
)
}
}
@ -297,11 +303,11 @@ fun StatsSection(
fun StatCardMini(
icon: String,
value: String,
label: String
label: String,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = icon, style = MaterialTheme.typography.bodyLarge)
Column {
@ -309,12 +315,12 @@ fun StatCardMini(
text = value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@ -326,7 +332,7 @@ fun StatCardMini(
@Composable
fun EvolutionChart(
data: com.safebite.app.presentation.common.components.SparklineData,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val dimens = LocalDimens.current
@ -334,28 +340,30 @@ fun EvolutionChart(
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(dimens.spacingMd)
modifier =
Modifier
.fillMaxWidth()
.padding(dimens.spacingMd),
) {
Text(
text = stringResource(R.string.tracking_evolution),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(dimens.spacingSm))
Sparkline(
data = data,
height = 100.dp,
lineColor = MaterialTheme.colorScheme.primary,
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
)
}
}
@ -367,27 +375,29 @@ fun EvolutionChart(
@Composable
fun VerdictDistribution(
data: com.safebite.app.presentation.common.components.BarChartData,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val dimens = LocalDimens.current
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(dimens.spacingMd)
modifier =
Modifier
.fillMaxWidth()
.padding(dimens.spacingMd),
) {
Text(
text = stringResource(R.string.tracking_distribution),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(dimens.spacingSm))
HorizontalBarChart(data = data)
@ -401,47 +411,50 @@ fun VerdictDistribution(
@Composable
fun TopAllergensSection(
allergens: List<AllergenCount>,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val dimens = LocalDimens.current
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(dimens.spacingMd)
modifier =
Modifier
.fillMaxWidth()
.padding(dimens.spacingMd),
) {
Text(
text = stringResource(R.string.tracking_top_allergens),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(dimens.spacingSm))
allergens.forEach { allergen ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${allergen.emoji} ${allergen.name}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "${allergen.count}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@ -455,35 +468,35 @@ fun TopAllergensSection(
@Composable
fun StatusFilterRow(
selected: SafetyStatus?,
onFilterChanged: (SafetyStatus?) -> Unit
onFilterChanged: (SafetyStatus?) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm)
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm),
) {
FilterChip(
selected = selected == null,
onClick = { onFilterChanged(null) },
label = { Text("Tous") },
modifier = Modifier.semantics { contentDescription = "Filtrer par tous les produits" }
modifier = Modifier.semantics { contentDescription = "Filtrer par tous les produits" },
)
FilterChip(
selected = selected == SafetyStatus.DANGER,
onClick = { onFilterChanged(SafetyStatus.DANGER) },
label = { Text("❌ Danger") },
modifier = Modifier.semantics { contentDescription = "Filtrer par produits dangereux" }
modifier = Modifier.semantics { contentDescription = "Filtrer par produits dangereux" },
)
FilterChip(
selected = selected == SafetyStatus.WARNING,
onClick = { onFilterChanged(SafetyStatus.WARNING) },
label = { Text("⚠️ Attention") },
modifier = Modifier.semantics { contentDescription = "Filtrer par produits avec attention" }
modifier = Modifier.semantics { contentDescription = "Filtrer par produits avec attention" },
)
FilterChip(
selected = selected == SafetyStatus.SAFE,
onClick = { onFilterChanged(SafetyStatus.SAFE) },
label = { Text("✅ Sûr") },
modifier = Modifier.semantics { contentDescription = "Filtrer par produits sûrs" }
modifier = Modifier.semantics { contentDescription = "Filtrer par produits sûrs" },
)
}
}
@ -495,31 +508,35 @@ fun StatusFilterRow(
fun HistoryItemCard(
item: com.safebite.app.domain.model.ScanHistoryItem,
onClick: () -> Unit,
onDelete: () -> Unit
onDelete: () -> Unit,
) {
val dimens = LocalDimens.current
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(dimens.spacingMd),
modifier =
Modifier
.fillMaxWidth()
.padding(dimens.spacingMd),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(dimens.spacingMd)
horizontalArrangement = Arrangement.spacedBy(dimens.spacingMd),
) {
// Indicateur de couleur
Box(
modifier = Modifier
.size(12.dp)
.background(statusColor(item.safetyStatus), CircleShape)
modifier =
Modifier
.size(12.dp)
.background(statusColor(item.safetyStatus), CircleShape),
)
// Infos produit
@ -528,19 +545,20 @@ fun HistoryItemCard(
text = item.productName ?: item.barcode,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1
maxLines = 1,
)
Text(
text = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
.format(Date(item.scannedAt)),
text =
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
.format(Date(item.scannedAt)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (item.profileNames.isNotEmpty()) {
Text(
text = item.profileNames.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
color = MaterialTheme.colorScheme.error,
)
}
}
@ -549,14 +567,15 @@ fun HistoryItemCard(
val deleteDesc = stringResource(R.string.a11y_delete, item.productName ?: item.barcode)
IconButton(
onClick = onDelete,
modifier = Modifier.semantics {
contentDescription = deleteDesc
}
modifier =
Modifier.semantics {
contentDescription = deleteDesc
},
) {
Icon(
Icons.Filled.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@ -572,34 +591,38 @@ fun TrackingLoadingSkeleton(modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier.padding(horizontal = dimens.spacingLg),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
) {
item {
ShimmerBox(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
modifier =
Modifier
.fillMaxWidth()
.height(48.dp),
)
}
item {
ShimmerBox(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
modifier =
Modifier
.fillMaxWidth()
.height(200.dp),
)
}
item {
ShimmerBox(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
modifier =
Modifier
.fillMaxWidth()
.height(150.dp),
)
}
item {
ShimmerBox(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
modifier =
Modifier
.fillMaxWidth()
.height(150.dp),
)
}
}

View File

@ -34,7 +34,7 @@ data class TrackingStats(
val weeklyScans: Int = 0,
val weeklySafePercentage: Float = 0f,
val sparklineData: SparklineData = SparklineData(emptyList()),
val barChartData: BarChartData = BarChartData(emptyList())
val barChartData: BarChartData = BarChartData(emptyList()),
)
/**
@ -43,7 +43,7 @@ data class TrackingStats(
data class AllergenCount(
val name: String,
val count: Int,
val emoji: String = "⚠️"
val emoji: String = "⚠️",
)
/**
@ -51,204 +51,224 @@ data class AllergenCount(
*/
sealed class TrackingUiState {
data object Loading : TrackingUiState()
data class Success(
val stats: TrackingStats,
val historyItems: List<ScanHistoryItem>,
val timeFilter: TimeFilter,
val statusFilter: SafetyStatus? = null,
val searchQuery: String = ""
val searchQuery: String = "",
) : TrackingUiState()
data object Empty : TrackingUiState()
}
@HiltViewModel
class TrackingViewModel @Inject constructor(
private val getScanHistoryUseCase: GetScanHistoryUseCase
) : ViewModel() {
class TrackingViewModel
@Inject
constructor(
private val getScanHistoryUseCase: GetScanHistoryUseCase,
) : ViewModel() {
private val _timeFilter = MutableStateFlow(TimeFilter.WEEK)
val timeFilter: StateFlow<TimeFilter> = _timeFilter.asStateFlow()
private val _timeFilter = MutableStateFlow(TimeFilter.WEEK)
val timeFilter: StateFlow<TimeFilter> = _timeFilter.asStateFlow()
private val _statusFilter = MutableStateFlow<SafetyStatus?>(null)
val statusFilter: StateFlow<SafetyStatus?> = _statusFilter.asStateFlow()
private val _statusFilter = MutableStateFlow<SafetyStatus?>(null)
val statusFilter: StateFlow<SafetyStatus?> = _statusFilter.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
val uiState: StateFlow<TrackingUiState> =
combine(
getScanHistoryUseCase.observe(),
_timeFilter,
_statusFilter,
_searchQuery,
) { items, timeFilter, statusFilter, query ->
val filteredItems =
items
.filterByTime(timeFilter)
.filter { statusFilter == null || it.safetyStatus == statusFilter }
.filter { query.isBlank() || matchesSearch(it, query) }
val uiState: StateFlow<TrackingUiState> = combine(
getScanHistoryUseCase.observe(),
_timeFilter,
_statusFilter,
_searchQuery
) { items, timeFilter, statusFilter, query ->
val filteredItems = items
.filterByTime(timeFilter)
.filter { statusFilter == null || it.safetyStatus == statusFilter }
.filter { query.isBlank() || matchesSearch(it, query) }
if (items.isEmpty()) {
TrackingUiState.Empty
} else {
val stats = computeStats(items, timeFilter)
TrackingUiState.Success(
stats = stats,
historyItems = filteredItems,
timeFilter = timeFilter,
statusFilter = statusFilter,
searchQuery = query
if (items.isEmpty()) {
TrackingUiState.Empty
} else {
val stats = computeStats(items, timeFilter)
TrackingUiState.Success(
stats = stats,
historyItems = filteredItems,
timeFilter = timeFilter,
statusFilter = statusFilter,
searchQuery = query,
)
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
TrackingUiState.Loading,
)
fun setTimeFilter(filter: TimeFilter) {
_timeFilter.value = filter
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
TrackingUiState.Loading
)
fun setTimeFilter(filter: TimeFilter) {
_timeFilter.value = filter
}
fun setStatusFilter(status: SafetyStatus?) {
_statusFilter.value = status
}
fun setSearchQuery(query: String) {
_searchQuery.value = query
}
fun deleteItem(id: Long) = viewModelScope.launch {
getScanHistoryUseCase.delete(id)
}
fun clearAll() = viewModelScope.launch {
getScanHistoryUseCase.clear()
}
private fun List<ScanHistoryItem>.filterByTime(filter: TimeFilter): List<ScanHistoryItem> {
val calendar = Calendar.getInstance()
val cutoffTime = when (filter) {
TimeFilter.WEEK -> {
calendar.add(Calendar.DAY_OF_YEAR, -7)
calendar.timeInMillis
}
TimeFilter.MONTH -> {
calendar.add(Calendar.MONTH, -1)
calendar.timeInMillis
}
TimeFilter.YEAR -> {
calendar.add(Calendar.YEAR, -1)
calendar.timeInMillis
}
TimeFilter.ALL -> 0L
fun setStatusFilter(status: SafetyStatus?) {
_statusFilter.value = status
}
return this.filter { it.scannedAt >= cutoffTime }
}
private fun matchesSearch(item: ScanHistoryItem, query: String): Boolean {
return item.productName?.contains(query, ignoreCase = true) == true ||
fun setSearchQuery(query: String) {
_searchQuery.value = query
}
fun deleteItem(id: Long) =
viewModelScope.launch {
getScanHistoryUseCase.delete(id)
}
fun clearAll() =
viewModelScope.launch {
getScanHistoryUseCase.clear()
}
private fun List<ScanHistoryItem>.filterByTime(filter: TimeFilter): List<ScanHistoryItem> {
val calendar = Calendar.getInstance()
val cutoffTime =
when (filter) {
TimeFilter.WEEK -> {
calendar.add(Calendar.DAY_OF_YEAR, -7)
calendar.timeInMillis
}
TimeFilter.MONTH -> {
calendar.add(Calendar.MONTH, -1)
calendar.timeInMillis
}
TimeFilter.YEAR -> {
calendar.add(Calendar.YEAR, -1)
calendar.timeInMillis
}
TimeFilter.ALL -> 0L
}
return this.filter { it.scannedAt >= cutoffTime }
}
private fun matchesSearch(
item: ScanHistoryItem,
query: String,
): Boolean {
return item.productName?.contains(query, ignoreCase = true) == true ||
item.brand?.contains(query, ignoreCase = true) == true ||
item.barcode.contains(query)
}
}
private fun computeStats(allItems: List<ScanHistoryItem>, timeFilter: TimeFilter): TrackingStats {
val items = allItems.filterByTime(timeFilter)
val total = items.size
val safeCount = items.count { it.safetyStatus == SafetyStatus.SAFE }
val warningCount = items.count { it.safetyStatus == SafetyStatus.WARNING }
val dangerCount = items.count { it.safetyStatus == SafetyStatus.DANGER }
val safePercentage = if (total > 0) safeCount.toFloat() / total else 0f
private fun computeStats(
allItems: List<ScanHistoryItem>,
timeFilter: TimeFilter,
): TrackingStats {
val items = allItems.filterByTime(timeFilter)
val total = items.size
val safeCount = items.count { it.safetyStatus == SafetyStatus.SAFE }
val warningCount = items.count { it.safetyStatus == SafetyStatus.WARNING }
val dangerCount = items.count { it.safetyStatus == SafetyStatus.DANGER }
val safePercentage = if (total > 0) safeCount.toFloat() / total else 0f
// Calcul des allergènes top (simulé à partir des noms de produits)
val topAllergens = computeTopAllergens(items)
// Calcul des allergènes top (simulé à partir des noms de produits)
val topAllergens = computeTopAllergens(items)
// Données sparkline (scans par jour sur la période)
val sparklineData = computeSparklineData(items, timeFilter)
// Données sparkline (scans par jour sur la période)
val sparklineData = computeSparklineData(items, timeFilter)
// Données bar chart (répartition par statut)
val barChartData = BarChartData(
items = listOf(
BarChartItem("Sûr", safeCount, SemanticColors.Safe),
BarChartItem("Attention", warningCount, SemanticColors.Warning),
BarChartItem("Danger", dangerCount, SemanticColors.Danger)
// Données bar chart (répartition par statut)
val barChartData =
BarChartData(
items =
listOf(
BarChartItem("Sûr", safeCount, SemanticColors.Safe),
BarChartItem("Attention", warningCount, SemanticColors.Warning),
BarChartItem("Danger", dangerCount, SemanticColors.Danger),
),
)
return TrackingStats(
totalScans = total,
safeCount = safeCount,
warningCount = warningCount,
dangerCount = dangerCount,
safePercentage = safePercentage,
topAllergens = topAllergens,
weeklyScans = items.size,
weeklySafePercentage = safePercentage,
sparklineData = sparklineData,
barChartData = barChartData,
)
)
return TrackingStats(
totalScans = total,
safeCount = safeCount,
warningCount = warningCount,
dangerCount = dangerCount,
safePercentage = safePercentage,
topAllergens = topAllergens,
weeklyScans = items.size,
weeklySafePercentage = safePercentage,
sparklineData = sparklineData,
barChartData = barChartData
)
}
private fun computeTopAllergens(items: List<ScanHistoryItem>): List<AllergenCount> {
// Simulation : on compte les profils associés aux scans danger/warning
val allergenCounts = mutableMapOf<String, Int>()
items.filter { it.safetyStatus != SafetyStatus.SAFE }.forEach { item ->
item.profileNames.forEach { profileName ->
allergenCounts[profileName] = allergenCounts.getOrDefault(profileName, 0) + 1
}
}
return allergenCounts.entries
.sortedByDescending { it.value }
.take(5)
.map { AllergenCount(it.key, it.value) }
}
private fun computeSparklineData(items: List<ScanHistoryItem>, timeFilter: TimeFilter): SparklineData {
val calendar = Calendar.getInstance()
val days = when (timeFilter) {
TimeFilter.WEEK -> 7
TimeFilter.MONTH -> 30
TimeFilter.YEAR -> 12
TimeFilter.ALL -> {
val oldest = items.minOfOrNull { it.scannedAt } ?: 0L
val daysDiff = ((System.currentTimeMillis() - oldest) / (1000 * 60 * 60 * 24)).toInt()
daysDiff.coerceAtMost(365)
}
}
val labels = mutableListOf<String>()
val values = mutableListOf<Float>()
if (timeFilter == TimeFilter.YEAR) {
// Par mois
for (i in 11 downTo 0) {
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -i)
val monthStart = cal.timeInMillis
cal.add(Calendar.MONTH, 1)
val monthEnd = cal.timeInMillis
val count = items.count { it.scannedAt in monthStart..<monthEnd }
values.add(count.toFloat())
labels.add(cal.getDisplayName(Calendar.MONTH, Calendar.SHORT, java.util.Locale.getDefault()))
}
} else {
// Par jour
for (i in days - 1 downTo 0) {
val cal = Calendar.getInstance()
cal.add(Calendar.DAY_OF_YEAR, -i)
val dayStart = cal.timeInMillis
cal.set(Calendar.HOUR_OF_DAY, 23)
cal.set(Calendar.MINUTE, 59)
cal.set(Calendar.SECOND, 59)
val dayEnd = cal.timeInMillis
val count = items.count { it.scannedAt in dayStart..dayEnd }
values.add(count.toFloat())
if (timeFilter == TimeFilter.WEEK || i % 7 == 0) {
labels.add(cal.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT, java.util.Locale.getDefault()))
} else {
labels.add("")
private fun computeTopAllergens(items: List<ScanHistoryItem>): List<AllergenCount> {
// Simulation : on compte les profils associés aux scans danger/warning
val allergenCounts = mutableMapOf<String, Int>()
items.filter { it.safetyStatus != SafetyStatus.SAFE }.forEach { item ->
item.profileNames.forEach { profileName ->
allergenCounts[profileName] = allergenCounts.getOrDefault(profileName, 0) + 1
}
}
return allergenCounts.entries
.sortedByDescending { it.value }
.take(5)
.map { AllergenCount(it.key, it.value) }
}
return SparklineData(values, labels)
private fun computeSparklineData(
items: List<ScanHistoryItem>,
timeFilter: TimeFilter,
): SparklineData {
val calendar = Calendar.getInstance()
val days =
when (timeFilter) {
TimeFilter.WEEK -> 7
TimeFilter.MONTH -> 30
TimeFilter.YEAR -> 12
TimeFilter.ALL -> {
val oldest = items.minOfOrNull { it.scannedAt } ?: 0L
val daysDiff = ((System.currentTimeMillis() - oldest) / (1000 * 60 * 60 * 24)).toInt()
daysDiff.coerceAtMost(365)
}
}
val labels = mutableListOf<String>()
val values = mutableListOf<Float>()
if (timeFilter == TimeFilter.YEAR) {
// Par mois
for (i in 11 downTo 0) {
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -i)
val monthStart = cal.timeInMillis
cal.add(Calendar.MONTH, 1)
val monthEnd = cal.timeInMillis
val count = items.count { it.scannedAt in monthStart..<monthEnd }
values.add(count.toFloat())
labels.add(cal.getDisplayName(Calendar.MONTH, Calendar.SHORT, java.util.Locale.getDefault()))
}
} else {
// Par jour
for (i in days - 1 downTo 0) {
val cal = Calendar.getInstance()
cal.add(Calendar.DAY_OF_YEAR, -i)
val dayStart = cal.timeInMillis
cal.set(Calendar.HOUR_OF_DAY, 23)
cal.set(Calendar.MINUTE, 59)
cal.set(Calendar.SECOND, 59)
val dayEnd = cal.timeInMillis
val count = items.count { it.scannedAt in dayStart..dayEnd }
values.add(count.toFloat())
if (timeFilter == TimeFilter.WEEK || i % 7 == 0) {
labels.add(cal.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT, java.util.Locale.getDefault()))
} else {
labels.add("")
}
}
}
return SparklineData(values, labels)
}
}
}

View File

@ -11,17 +11,17 @@ import androidx.compose.ui.graphics.Color
// Ces couleurs sont indépendantes du thème M3 pour cohérence marque.
object SemanticColors {
// Light mode
val Safe = Color(0xFF43A047) // Vert sécurité
val Safe = Color(0xFF43A047) // Vert sécurité
val SafeContainer = Color(0xFFE8F5E9) // Fond très clair
val OnSafe = Color(0xFFFFFFFF)
val OnSafeContainer = Color(0xFF1A3A2A)
val Warning = Color(0xFFFFA000) // Orange attention
val Warning = Color(0xFFFFA000) // Orange attention
val WarningContainer = Color(0xFFFFF3E0)
val OnWarning = Color(0xFFFFFFFF)
val OnWarningContainer = Color(0xFF4A2800)
val Danger = Color(0xFFD32F2F) // Rouge danger
val Danger = Color(0xFFD32F2F) // Rouge danger
val DangerContainer = Color(0xFFFFEBEE)
val OnDanger = Color(0xFFFFFFFF)
val OnDangerContainer = Color(0xFF5C0B0B)
@ -37,137 +37,150 @@ object SemanticColors {
// ---- NEUTRES (spec UX §2.1) ------------------------------------------------
object NeutralColors {
val Background = Color(0xFFF1F8E9) // Fond principal light
val Surface = Color(0xFFFFFFFF) // Blanc pur pour cartes
val TextPrimary = Color(0xFF212121) // Texte principal
val Background = Color(0xFFF1F8E9) // Fond principal light
val Surface = Color(0xFFFFFFFF) // Blanc pur pour cartes
val TextPrimary = Color(0xFF212121) // Texte principal
val TextSecondary = Color(0xFF757575) // Texte secondaire
val Separator = Color(0xFFBDBDBD) // Séparateurs
val Separator = Color(0xFFBDBDBD) // Séparateurs
}
// ---- Brand anchors (Material 3) --------------------------------------------
val BrandPrimary = Color(0xFF1B7A2B)
val BrandPrimaryDark = Color(0xFF0D5E1A)
val BrandPrimaryLight = Color(0xFF4CAF50)
val BrandSecondary = Color(0xFF2E7D32)
val BrandPrimary = Color(0xFF1B7A2B)
val BrandPrimaryDark = Color(0xFF0D5E1A)
val BrandPrimaryLight = Color(0xFF4CAF50)
val BrandSecondary = Color(0xFF2E7D32)
// ---- Light scheme ---------------------------------------------------------
val LightPrimary = Color(0xFF1B7A2B)
val LightOnPrimary = Color(0xFFFFFFFF)
val LightPrimaryContainer = Color(0xFFA5D6A7)
val LightOnPrimaryContainer = Color(0xFF0D3B12)
val LightPrimary = Color(0xFF1B7A2B)
val LightOnPrimary = Color(0xFFFFFFFF)
val LightPrimaryContainer = Color(0xFFA5D6A7)
val LightOnPrimaryContainer = Color(0xFF0D3B12)
val LightSecondary = Color(0xFF2E7D32)
val LightOnSecondary = Color(0xFFFFFFFF)
val LightSecondaryContainer = Color(0xFFC8E6C9)
val LightOnSecondaryContainer = Color(0xFF1B5E20)
val LightSecondary = Color(0xFF2E7D32)
val LightOnSecondary = Color(0xFFFFFFFF)
val LightSecondaryContainer = Color(0xFFC8E6C9)
val LightOnSecondaryContainer = Color(0xFF1B5E20)
val LightTertiary = Color(0xFF00796B)
val LightOnTertiary = Color(0xFFFFFFFF)
val LightTertiaryContainer = Color(0xFFB2DFDB)
val LightOnTertiaryContainer = Color(0xFF004D40)
val LightTertiary = Color(0xFF00796B)
val LightOnTertiary = Color(0xFFFFFFFF)
val LightTertiaryContainer = Color(0xFFB2DFDB)
val LightOnTertiaryContainer = Color(0xFF004D40)
val LightError = Color(0xFFD32F2F)
val LightOnError = Color(0xFFFFFFFF)
val LightErrorContainer = Color(0xFFFFCDD2)
val LightOnErrorContainer = Color(0xFF5C0B0B)
val LightError = Color(0xFFD32F2F)
val LightOnError = Color(0xFFFFFFFF)
val LightErrorContainer = Color(0xFFFFCDD2)
val LightOnErrorContainer = Color(0xFF5C0B0B)
val LightBackground = NeutralColors.Background // #F1F8E9
val LightOnBackground = NeutralColors.TextPrimary // #212121
val LightSurface = NeutralColors.Surface // #FFFFFF
val LightOnSurface = NeutralColors.TextPrimary // #212121
val LightSurfaceVariant = Color(0xFFE8F5E9)
val LightOnSurfaceVariant = NeutralColors.TextSecondary
val LightSurfaceTint = LightPrimary
val LightBackground = NeutralColors.Background // #F1F8E9
val LightOnBackground = NeutralColors.TextPrimary // #212121
val LightSurface = NeutralColors.Surface // #FFFFFF
val LightOnSurface = NeutralColors.TextPrimary // #212121
val LightSurfaceVariant = Color(0xFFE8F5E9)
val LightOnSurfaceVariant = NeutralColors.TextSecondary
val LightSurfaceTint = LightPrimary
val LightOutline = NeutralColors.Separator
val LightOutlineVariant = Color(0xFFE0E0E0)
val LightOutline = NeutralColors.Separator
val LightOutlineVariant = Color(0xFFE0E0E0)
val LightInverseSurface = Color(0xFF2F3033)
val LightInverseOnSurface = Color(0xFFF1F0F4)
val LightInversePrimary = Color(0xFF81C784)
val LightInverseSurface = Color(0xFF2F3033)
val LightInverseOnSurface = Color(0xFFF1F0F4)
val LightInversePrimary = Color(0xFF81C784)
val LightScrim = Color(0xFF000000)
val LightScrim = Color(0xFF000000)
// ---- Dark scheme (surfaces élevées M3) ------------------------------------
val DarkPrimary = Color(0xFF81C784)
val DarkOnPrimary = Color(0xFF0D3B12)
val DarkPrimaryContainer = Color(0xFF1B5E20)
val DarkOnPrimaryContainer = Color(0xFFA5D6A7)
val DarkPrimary = Color(0xFF81C784)
val DarkOnPrimary = Color(0xFF0D3B12)
val DarkPrimaryContainer = Color(0xFF1B5E20)
val DarkOnPrimaryContainer = Color(0xFFA5D6A7)
val DarkSecondary = Color(0xFFA5D6A7)
val DarkOnSecondary = Color(0xFF1B5E20)
val DarkSecondaryContainer = Color(0xFF2E7D32)
val DarkOnSecondaryContainer = Color(0xFFC8E6C9)
val DarkSecondary = Color(0xFFA5D6A7)
val DarkOnSecondary = Color(0xFF1B5E20)
val DarkSecondaryContainer = Color(0xFF2E7D32)
val DarkOnSecondaryContainer = Color(0xFFC8E6C9)
val DarkTertiary = Color(0xFF4DB6AC)
val DarkOnTertiary = Color(0xFF00332C)
val DarkTertiaryContainer = Color(0xFF00695C)
val DarkOnTertiaryContainer = Color(0xFFB2DFDB)
val DarkTertiary = Color(0xFF4DB6AC)
val DarkOnTertiary = Color(0xFF00332C)
val DarkTertiaryContainer = Color(0xFF00695C)
val DarkOnTertiaryContainer = Color(0xFFB2DFDB)
val DarkError = Color(0xFFEF9A9A)
val DarkOnError = Color(0xFF690005)
val DarkErrorContainer = Color(0xFF93000A)
val DarkOnErrorContainer = Color(0xFFFFCDD2)
val DarkError = Color(0xFFEF9A9A)
val DarkOnError = Color(0xFF690005)
val DarkErrorContainer = Color(0xFF93000A)
val DarkOnErrorContainer = Color(0xFFFFCDD2)
val DarkBackground = Color(0xFF1A1C1A)
val DarkOnBackground = Color(0xFFE0E0E0)
val DarkSurface = Color(0xFF2D2F2D)
val DarkOnSurface = Color(0xFFE0E0E0)
val DarkSurfaceVariant = Color(0xFF3A3F3A)
val DarkOnSurfaceVariant = Color(0xFFBDBDBD)
val DarkSurfaceTint = DarkPrimary
val DarkBackground = Color(0xFF1A1C1A)
val DarkOnBackground = Color(0xFFE0E0E0)
val DarkSurface = Color(0xFF2D2F2D)
val DarkOnSurface = Color(0xFFE0E0E0)
val DarkSurfaceVariant = Color(0xFF3A3F3A)
val DarkOnSurfaceVariant = Color(0xFFBDBDBD)
val DarkSurfaceTint = DarkPrimary
val DarkOutline = Color(0xFF90909A)
val DarkOutlineVariant = Color(0xFF46464F)
val DarkOutline = Color(0xFF90909A)
val DarkOutlineVariant = Color(0xFF46464F)
val DarkInverseSurface = Color(0xFFE6E1E5)
val DarkInverseOnSurface = Color(0xFF2F3033)
val DarkInversePrimary = Color(0xFF1B7A2B)
val DarkInverseSurface = Color(0xFFE6E1E5)
val DarkInverseOnSurface = Color(0xFF2F3033)
val DarkInversePrimary = Color(0xFF1B7A2B)
val DarkScrim = Color(0xFF000000)
val DarkScrim = Color(0xFF000000)
// ---- Dégradé signature (rappel du fond du logo bouclier) --------------------
val ShieldGradient = androidx.compose.ui.graphics.Brush.linearGradient(
colors = listOf(
Color(0xFF4CAF50), // Vert clair (haut-gauche)
Color(0xFF1B7A2B), // Vert moyen
Color(0xFF0D5E1A) // Vert foncé (bas-droite)
),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY)
)
val ShieldGradient =
androidx.compose.ui.graphics.Brush.linearGradient(
colors =
listOf(
Color(0xFF4CAF50), // Vert clair (haut-gauche)
Color(0xFF1B7A2B), // Vert moyen
Color(0xFF0D5E1A), // Vert foncé (bas-droite)
),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY),
)
// ---- Legacy aliases (backward compat pour code existant) -------------------
@Deprecated("Use SemanticColors.Safe", ReplaceWith("SemanticColors.Safe"))
val StatusSafe get() = SemanticColors.Safe
@Deprecated("Use SemanticColors.SafeContainer", ReplaceWith("SemanticColors.SafeContainer"))
val StatusSafeContainer get() = SemanticColors.SafeContainer
@Deprecated("Use SemanticColors.OnSafe", ReplaceWith("SemanticColors.OnSafe"))
val OnStatusSafe get() = SemanticColors.OnSafe
@Deprecated("Use SemanticColors.Warning", ReplaceWith("SemanticColors.Warning"))
val StatusWarning get() = SemanticColors.Warning
@Deprecated("Use SemanticColors.WarningContainer", ReplaceWith("SemanticColors.WarningContainer"))
val StatusWarningContainer get() = SemanticColors.WarningContainer
@Deprecated("Use SemanticColors.OnWarning", ReplaceWith("SemanticColors.OnWarning"))
val OnStatusWarning get() = SemanticColors.OnWarning
@Deprecated("Use SemanticColors.Danger", ReplaceWith("SemanticColors.Danger"))
val StatusDanger get() = SemanticColors.Danger
@Deprecated("Use SemanticColors.DangerContainer", ReplaceWith("SemanticColors.DangerContainer"))
val StatusDangerContainer get() = SemanticColors.DangerContainer
@Deprecated("Use SemanticColors.OnDanger", ReplaceWith("SemanticColors.OnDanger"))
val OnStatusDanger get() = SemanticColors.OnDanger
@Deprecated("Use SemanticColors.SafeDark", ReplaceWith("SemanticColors.SafeDark"))
val StatusSafeDark get() = SemanticColors.SafeDark
@Deprecated("Use SemanticColors.SafeContainerDark", ReplaceWith("SemanticColors.SafeContainerDark"))
val StatusSafeContainerDark get() = SemanticColors.SafeContainerDark
@Deprecated("Use SemanticColors.WarningDark", ReplaceWith("SemanticColors.WarningDark"))
val StatusWarningDark get() = SemanticColors.WarningDark
@Deprecated("Use SemanticColors.WarningContainerDark", ReplaceWith("SemanticColors.WarningContainerDark"))
val StatusWarningContainerDark get() = SemanticColors.WarningContainerDark
@Deprecated("Use SemanticColors.DangerDark", ReplaceWith("SemanticColors.DangerDark"))
val StatusDangerDark get() = SemanticColors.DangerDark
@Deprecated("Use SemanticColors.DangerContainerDark", ReplaceWith("SemanticColors.DangerContainerDark"))
val StatusDangerContainerDark get() = SemanticColors.DangerContainerDark

View File

@ -22,7 +22,6 @@ data class Dimens(
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,
@ -30,14 +29,12 @@ data class Dimens(
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,

View File

@ -4,10 +4,11 @@ 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)
)
val SafeBiteShapes =
Shapes(
extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(24.dp),
)

Some files were not shown because too many files have changed in this diff Show More