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/), 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). 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 ## [1.2.0] — 2026-04-26
### Ajouté ### 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. 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.kotlin.parcelize)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.hilt) alias(libs.plugins.hilt)
alias(libs.plugins.ktlint)
alias(libs.plugins.detekt)
} }
android { android {
@ -154,3 +156,24 @@ dependencies {
// LeakCanary pour détection de fuites mémoire (debug uniquement) // LeakCanary pour détection de fuites mémoire (debug uniquement)
debugImplementation(libs.leakcanary.android) 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) @RunWith(AndroidJUnit4::class)
class ExampleComposeTest { class ExampleComposeTest {
@get:Rule @get:Rule
val composeTestRule = createComposeRule() val composeTestRule = createComposeRule()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ data class UserProfileEntity(
val moderateIntolerances: Set<AllergenType>, val moderateIntolerances: Set<AllergenType>,
val dietaryRestrictions: Set<DietaryRestriction>, val dietaryRestrictions: Set<DietaryRestriction>,
val customItems: List<CustomDietItem> = emptyList(), val customItems: List<CustomDietItem> = emptyList(),
val isDefault: Boolean val isDefault: Boolean,
) )
@Entity(tableName = "product_cache") @Entity(tableName = "product_cache")
@ -45,7 +45,7 @@ data class ProductCacheEntity(
val fiber100g: Double? = null, val fiber100g: Double? = null,
val proteins100g: Double? = null, val proteins100g: Double? = null,
val carbohydrates100g: Double? = null, val carbohydrates100g: Double? = null,
val cachedAt: Long val cachedAt: Long,
) )
@Entity(tableName = "scan_history") @Entity(tableName = "scan_history")
@ -58,7 +58,7 @@ data class ScanHistoryEntity(
val safetyStatus: SafetyStatus, val safetyStatus: SafetyStatus,
val profileNames: List<String>, val profileNames: List<String>,
val scannedAt: Long, val scannedAt: Long,
val source: DataSource val source: DataSource,
) )
// ============================================================================= // =============================================================================
@ -77,7 +77,7 @@ data class ShoppingListEntity(
val sortType: String = "category", val sortType: String = "category",
val displayOrder: Int = 0, val displayOrder: Int = 0,
val visibleCategories: String? = null, val visibleCategories: String? = null,
val categoryOrder: String? = null val categoryOrder: String? = null,
) )
@Entity( @Entity(
@ -87,10 +87,10 @@ data class ShoppingListEntity(
entity = ShoppingListEntity::class, entity = ShoppingListEntity::class,
parentColumns = ["id"], parentColumns = ["id"],
childColumns = ["listId"], 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( data class ShoppingListItemEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0L, @PrimaryKey(autoGenerate = true) val id: Long = 0L,
@ -100,13 +100,13 @@ data class ShoppingListItemEntity(
val brand: String? = null, val brand: String? = null,
val imageUrl: String? = null, val imageUrl: String? = null,
val isChecked: Boolean = false, val isChecked: Boolean = false,
val category: String? = null, // "Frais", "Épicerie", etc. val category: String? = null, // "Frais", "Épicerie", etc.
val safetyStatus: String? = null, // "SAFE", "WARNING", "DANGER" val safetyStatus: String? = null, // "SAFE", "WARNING", "DANGER"
val allergenWarning: String? = null, // Allergène détecté pour alerte val allergenWarning: String? = null, // Allergène détecté pour alerte
val note: String? = null, // Quantité / description libre (ex: "2 kg") val note: String? = null, // Quantité / description libre (ex: "2 kg")
val customEmoji: String? = null, // Emoji personnalisé choisi par l'utilisateur val customEmoji: String? = null, // Emoji personnalisé choisi par l'utilisateur
val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever" val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever"
val addedAt: Long = System.currentTimeMillis() val addedAt: Long = System.currentTimeMillis(),
) )
@Entity( @Entity(
@ -116,10 +116,10 @@ data class ShoppingListItemEntity(
entity = ShoppingListEntity::class, entity = ShoppingListEntity::class,
parentColumns = ["id"], parentColumns = ["id"],
childColumns = ["listId"], 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( data class ShoppingListMemberEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0L, @PrimaryKey(autoGenerate = true) val id: Long = 0L,
@ -127,6 +127,6 @@ data class ShoppingListMemberEntity(
val name: String, val name: String,
val email: String, val email: String,
val avatarUrl: String? = null, val avatarUrl: String? = null,
val role: String = "member", // "owner" | "member" val role: String = "member", // "owner" | "member"
val joinedAt: Long = System.currentTimeMillis() 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 * (domaines, catégories, articles, cross-ref) + leurs index. Aucune
* donnée existante n'est touchée. Le seed JSON est appliqué après ouverture. * donnée existante n'est touchée. Le seed JSON est appliqué après ouverture.
*/ */
val MIGRATION_7_8: Migration = object : Migration(7, 8) { val MIGRATION_7_8: Migration =
override fun migrate(db: SupportSQLiteDatabase) { object : Migration(7, 8) {
db.execSQL( override fun migrate(db: SupportSQLiteDatabase) {
""" db.execSQL(
CREATE TABLE IF NOT EXISTS shopping_domains ( """
domainId TEXT NOT NULL PRIMARY KEY, CREATE TABLE IF NOT EXISTS shopping_domains (
name TEXT NOT NULL, domainId TEXT NOT NULL PRIMARY KEY,
emoji TEXT NOT NULL, name TEXT NOT NULL,
iconResName TEXT, emoji TEXT NOT NULL,
color TEXT, iconResName TEXT,
sortOrder INTEGER NOT NULL, color TEXT,
isActive INTEGER NOT NULL sortOrder INTEGER NOT NULL,
isActive INTEGER NOT NULL
)
""".trimIndent(),
) )
""".trimIndent()
)
db.execSQL( db.execSQL(
""" """
CREATE TABLE IF NOT EXISTS categories ( CREATE TABLE IF NOT EXISTS categories (
categoryId TEXT NOT NULL PRIMARY KEY, categoryId TEXT NOT NULL PRIMARY KEY,
domainId TEXT NOT NULL, domainId TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
emoji TEXT NOT NULL, emoji TEXT NOT NULL,
iconResName TEXT, iconResName TEXT,
color TEXT, color TEXT,
sortOrder INTEGER NOT NULL, sortOrder INTEGER NOT NULL,
isActive INTEGER NOT NULL, isActive INTEGER NOT NULL,
FOREIGN KEY(domainId) REFERENCES shopping_domains(domainId) ON DELETE CASCADE 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( db.execSQL(
""" """
CREATE TABLE IF NOT EXISTS catalog_items ( CREATE TABLE IF NOT EXISTS catalog_items (
itemId TEXT NOT NULL PRIMARY KEY, itemId TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
primaryCategoryId TEXT, primaryCategoryId TEXT,
emoji TEXT NOT NULL, emoji TEXT NOT NULL,
iconUrl TEXT, iconUrl TEXT,
barcode TEXT, barcode TEXT,
aliases TEXT NOT NULL, aliases TEXT NOT NULL,
tags TEXT NOT NULL, tags TEXT NOT NULL,
isUserCreated INTEGER NOT NULL, isUserCreated INTEGER NOT NULL,
popularity INTEGER NOT NULL, popularity INTEGER NOT NULL,
sortOrder INTEGER NOT NULL, sortOrder INTEGER NOT NULL,
FOREIGN KEY(primaryCategoryId) REFERENCES categories(categoryId) ON DELETE SET 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_primaryCategoryId ON catalog_items(primaryCategoryId)") 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_name ON catalog_items(name)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_barcode ON catalog_items(barcode)")
db.execSQL( db.execSQL(
""" """
CREATE TABLE IF NOT EXISTS item_category_cross_ref ( CREATE TABLE IF NOT EXISTS item_category_cross_ref (
itemId TEXT NOT NULL, itemId TEXT NOT NULL,
categoryId TEXT NOT NULL, categoryId TEXT NOT NULL,
PRIMARY KEY(itemId, categoryId), PRIMARY KEY(itemId, categoryId),
FOREIGN KEY(itemId) REFERENCES catalog_items(itemId) ON DELETE CASCADE, FOREIGN KEY(itemId) REFERENCES catalog_items(itemId) ON DELETE CASCADE,
FOREIGN KEY(categoryId) REFERENCES categories(categoryId) 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`. * Migration additive : ajoute la colonne `variants` à la table `catalog_items`.
* Les données existantes conservent la valeur par défaut (''). * Les données existantes conservent la valeur par défaut ('').
*/ */
val MIGRATION_8_9: Migration = object : Migration(8, 9) { val MIGRATION_8_9: Migration =
override fun migrate(db: SupportSQLiteDatabase) { object : Migration(8, 9) {
db.execSQL( override fun migrate(db: SupportSQLiteDatabase) {
"ALTER TABLE catalog_items ADD COLUMN variants TEXT NOT NULL DEFAULT ''" 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( data class DomainWithCategories(
@Embedded val domain: ShoppingDomainEntity, @Embedded val domain: ShoppingDomainEntity,
@Relation(parentColumn = "domainId", entityColumn = "domainId") @Relation(parentColumn = "domainId", entityColumn = "domainId")
val categories: List<CategoryEntity> val categories: List<CategoryEntity>,
) )
data class CategoryWithItems( data class CategoryWithItems(
@ -19,13 +19,14 @@ data class CategoryWithItems(
@Relation( @Relation(
parentColumn = "categoryId", parentColumn = "categoryId",
entityColumn = "itemId", entityColumn = "itemId",
associateBy = Junction( associateBy =
value = ItemCategoryCrossRef::class, Junction(
parentColumn = "categoryId", value = ItemCategoryCrossRef::class,
entityColumn = "itemId" parentColumn = "categoryId",
) entityColumn = "itemId",
),
) )
val items: List<CatalogItemEntity> val items: List<CatalogItemEntity>,
) )
data class DomainWithCategoriesAndItems( data class DomainWithCategoriesAndItems(
@ -33,7 +34,7 @@ data class DomainWithCategoriesAndItems(
@Relation( @Relation(
entity = CategoryEntity::class, entity = CategoryEntity::class,
parentColumn = "domainId", 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>) { 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 { val detectionLanguage: Flow<DetectionLanguage> =
runCatching { AppLanguage.valueOf(it[UserPreferencesKeys.APP_LANGUAGE] ?: AppLanguage.FR.name) } dataStore.data.map {
.getOrDefault(AppLanguage.FR) 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 haptics: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.HAPTICS] ?: true }
val sound: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.SOUND] ?: true } val sound: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.SOUND] ?: true }
val theme: Flow<ThemePref> = dataStore.data.map { val theme: Flow<ThemePref> =
runCatching { ThemePref.valueOf(it[UserPreferencesKeys.THEME] ?: ThemePref.SYSTEM.name) } dataStore.data.map {
.getOrDefault(ThemePref.SYSTEM) 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 onboardingCompleted: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.ONBOARDING_DONE] ?: false }
val activeProfileIds: Flow<Set<Long>> = dataStore.data.map { prefs -> val activeProfileIds: Flow<Set<Long>> =
prefs[UserPreferencesKeys.ACTIVE_PROFILE_IDS].orEmpty() dataStore.data.map { prefs ->
.mapNotNull { it.toLongOrNull() } prefs[UserPreferencesKeys.ACTIVE_PROFILE_IDS].orEmpty()
.toSet() .mapNotNull { it.toLongOrNull() }
} .toSet()
}
val healthStrictness: Flow<HealthStrictness> = dataStore.data.map { val healthStrictness: Flow<HealthStrictness> =
runCatching { HealthStrictness.valueOf(it[UserPreferencesKeys.HEALTH_STRICTNESS] ?: HealthStrictness.NORMAL.name) } dataStore.data.map {
.getOrDefault(HealthStrictness.NORMAL) runCatching { HealthStrictness.valueOf(it[UserPreferencesKeys.HEALTH_STRICTNESS] ?: HealthStrictness.NORMAL.name) }
} .getOrDefault(HealthStrictness.NORMAL)
}
val splashScreenEnabled: Flow<Boolean> = dataStore.data.map { val splashScreenEnabled: Flow<Boolean> =
it[UserPreferencesKeys.SPLASH_SCREEN_ENABLED] ?: true dataStore.data.map {
} it[UserPreferencesKeys.SPLASH_SCREEN_ENABLED] ?: true
}
suspend fun setAppLanguage(value: AppLanguage) { suspend fun setAppLanguage(value: AppLanguage) {
dataStore.edit { it[UserPreferencesKeys.APP_LANGUAGE] = value.name } dataStore.edit { it[UserPreferencesKeys.APP_LANGUAGE] = value.name }

View File

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

View File

@ -10,7 +10,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class CatalogSeed( data class CatalogSeed(
val version: Int, val version: Int,
val domains: List<DomainSeed> val domains: List<DomainSeed>,
) )
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@ -20,7 +20,7 @@ data class DomainSeed(
val emoji: String, val emoji: String,
val color: String? = null, val color: String? = null,
val sortOrder: Int, val sortOrder: Int,
val categories: List<CategorySeed> val categories: List<CategorySeed>,
) )
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@ -30,7 +30,7 @@ data class CategorySeed(
val emoji: String, val emoji: String,
val color: String? = null, val color: String? = null,
val sortOrder: Int, val sortOrder: Int,
val items: List<ItemSeed> val items: List<ItemSeed>,
) )
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@ -41,5 +41,5 @@ data class ItemSeed(
val aliases: String? = null, val aliases: String? = null,
val tags: String? = null, val tags: String? = null,
val variants: 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 { interface OpenFoodFactsApi {
@GET("api/v2/product/{barcode}.json") @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 { companion object {
const val BASE_URL = "https://world.openfoodfacts.org/" 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 = "code") val code: String? = null,
@Json(name = "status") val status: Int? = null, @Json(name = "status") val status: Int? = null,
@Json(name = "status_verbose") val statusVerbose: String? = 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) @JsonClass(generateAdapter = true)
@ -30,7 +30,7 @@ data class ProductDto(
@Json(name = "serving_size") val servingSize: String? = null, @Json(name = "serving_size") val servingSize: String? = null,
@Json(name = "labels_tags") val labelsTags: List<String>? = null, @Json(name = "labels_tags") val labelsTags: List<String>? = null,
@Json(name = "categories_tags") val categoriesTags: 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) @JsonClass(generateAdapter = true)
@ -44,5 +44,5 @@ data class NutrimentsDto(
@Json(name = "sodium_100g") val sodium100g: Double? = null, @Json(name = "sodium_100g") val sodium100g: Double? = null,
@Json(name = "fiber_100g") val fiber100g: Double? = null, @Json(name = "fiber_100g") val fiber100g: Double? = null,
@Json(name = "proteins_100g") val proteins100g: 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.Nutriments
import com.safebite.app.domain.model.Product import com.safebite.app.domain.model.Product
fun ProductDto.toDomain(barcode: String): Product = Product( fun ProductDto.toDomain(barcode: String): Product =
barcode = barcode, Product(
name = productNameFr?.takeIf { it.isNotBlank() } barcode = barcode,
?: productNameEn?.takeIf { it.isNotBlank() } name =
?: productName?.takeIf { it.isNotBlank() }, productNameFr?.takeIf { it.isNotBlank() }
brand = brands?.takeIf { it.isNotBlank() }, ?: productNameEn?.takeIf { it.isNotBlank() }
imageUrl = imageFrontUrl ?: imageUrl, ?: productName?.takeIf { it.isNotBlank() },
ingredientsText = ingredientsTextFr?.takeIf { it.isNotBlank() } brand = brands?.takeIf { it.isNotBlank() },
?: ingredientsTextEn?.takeIf { it.isNotBlank() } imageUrl = imageFrontUrl ?: imageUrl,
?: ingredientsText, ingredientsText =
allergensTags = allergensTags.orEmpty(), ingredientsTextFr?.takeIf { it.isNotBlank() }
tracesTags = tracesTags.orEmpty(), ?: ingredientsTextEn?.takeIf { it.isNotBlank() }
nutriScore = nutriScoreGrade, ?: ingredientsText,
novaGroup = novaGroup, allergensTags = allergensTags.orEmpty(),
ecoScore = ecoScoreGrade, tracesTags = tracesTags.orEmpty(),
servingSize = servingSize, nutriScore = nutriScoreGrade,
nutriments = nutriments?.toDomain() ?: Nutriments(), novaGroup = novaGroup,
labels = labelsTags.orEmpty(), ecoScore = ecoScoreGrade,
categories = categoriesTags.orEmpty() servingSize = servingSize,
) nutriments = nutriments?.toDomain() ?: Nutriments(),
labels = labelsTags.orEmpty(),
categories = categoriesTags.orEmpty(),
)
fun NutrimentsDto.toDomain(): Nutriments = Nutriments( fun NutrimentsDto.toDomain(): Nutriments =
energyKcal100g = energyKcal100g, Nutriments(
energyKcalServing = energyKcalServing,
fat100g = fat100g,
saturatedFat100g = saturatedFat100g,
sugars100g = sugars100g,
salt100g = salt100g,
sodium100g = sodium100g,
fiber100g = fiber100g,
proteins100g = proteins100g,
carbohydrates100g = carbohydrates100g
)
fun Product.toCacheEntity(): ProductCacheEntity = ProductCacheEntity(
barcode = barcode,
name = name,
brand = brand,
imageUrl = imageUrl,
ingredientsText = ingredientsText,
allergensTags = allergensTags,
tracesTags = tracesTags,
nutriScore = nutriScore,
novaGroup = novaGroup,
ecoScore = ecoScore,
servingSize = servingSize,
labels = labels,
categories = categories,
energyKcal100g = nutriments.energyKcal100g,
energyKcalServing = nutriments.energyKcalServing,
fat100g = nutriments.fat100g,
saturatedFat100g = nutriments.saturatedFat100g,
sugars100g = nutriments.sugars100g,
salt100g = nutriments.salt100g,
sodium100g = nutriments.sodium100g,
fiber100g = nutriments.fiber100g,
proteins100g = nutriments.proteins100g,
carbohydrates100g = nutriments.carbohydrates100g,
cachedAt = System.currentTimeMillis()
)
fun ProductCacheEntity.toDomain(): Product = Product(
barcode = barcode,
name = name,
brand = brand,
imageUrl = imageUrl,
ingredientsText = ingredientsText,
allergensTags = allergensTags,
tracesTags = tracesTags,
nutriScore = nutriScore,
novaGroup = novaGroup,
ecoScore = ecoScore,
servingSize = servingSize,
labels = labels,
categories = categories,
nutriments = Nutriments(
energyKcal100g = energyKcal100g, energyKcal100g = energyKcal100g,
energyKcalServing = energyKcalServing, energyKcalServing = energyKcalServing,
fat100g = fat100g, fat100g = fat100g,
@ -91,6 +41,63 @@ fun ProductCacheEntity.toDomain(): Product = Product(
sodium100g = sodium100g, sodium100g = sodium100g,
fiber100g = fiber100g, fiber100g = fiber100g,
proteins100g = proteins100g, 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. * aux écrans Catalogue, Catégories, Articles et Recherche.
*/ */
@Singleton @Singleton
class CatalogRepository @Inject constructor( class CatalogRepository
private val dao: CatalogDao @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>> = fun observeDomainsWithCategoriesAndItems(): Flow<List<DomainWithCategoriesAndItems>> = dao.getDomainsWithCategoriesAndItems()
dao.getDomainsWithCategories()
fun observeDomainsWithCategoriesAndItems(): Flow<List<DomainWithCategoriesAndItems>> = fun observeCategoriesForDomain(domainId: String): Flow<List<CategoryEntity>> = dao.getCategoriesForDomain(domainId)
dao.getDomainsWithCategoriesAndItems()
fun observeCategoriesForDomain(domainId: String): Flow<List<CategoryEntity>> = fun observeCategoryWithItems(categoryId: String): Flow<CategoryWithItems?> = dao.getCategoryWithItems(categoryId)
dao.getCategoriesForDomain(domainId)
fun observeCategoryWithItems(categoryId: String): Flow<CategoryWithItems?> = fun observeItemsForCategory(categoryId: String): Flow<List<CatalogItemEntity>> = dao.getItemsForCategory(categoryId)
dao.getCategoryWithItems(categoryId)
fun observeItemsForCategory(categoryId: String): Flow<List<CatalogItemEntity>> = fun observePopularItems(limit: Int = 15): Flow<List<CatalogItemEntity>> = dao.getPopularItems(limit)
dao.getItemsForCategory(categoryId)
fun observePopularItems(limit: Int = 15): Flow<List<CatalogItemEntity>> = fun search(
dao.getPopularItems(limit) query: String,
limit: Int = 20,
): Flow<List<CatalogItemEntity>> = dao.searchItems(query.trim(), limit)
fun search(query: String, limit: Int = 20): Flow<List<CatalogItemEntity>> = suspend fun getDomain(domainId: String): ShoppingDomainEntity? = dao.getDomainById(domainId)
dao.searchItems(query.trim(), limit)
suspend fun getDomain(domainId: String): ShoppingDomainEntity? = dao.getDomainById(domainId) suspend fun getCategory(categoryId: String): CategoryEntity? = dao.getCategoryById(categoryId)
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 getItem(itemId: String): CatalogItemEntity? = dao.getItemById(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 getItemByBarcode(barcode: String): CatalogItemEntity? = dao.getItemByBarcode(barcode)
*/
suspend fun createUserItem( suspend fun incrementPopularity(itemId: String) = dao.incrementPopularity(itemId)
name: String,
emoji: String, /**
primaryCategoryId: String?, * Crée un article personnalisé (généré par l'utilisateur), persisté avec
aliases: String = "", * `isUserCreated = true` afin de pouvoir filtrer / exporter ces ajouts.
tags: String = "" */
): CatalogItemEntity { suspend fun createUserItem(
val item = CatalogItemEntity( name: String,
itemId = "user_${UUID.randomUUID()}", emoji: String,
name = name, primaryCategoryId: String?,
primaryCategoryId = primaryCategoryId, aliases: String = "",
emoji = emoji, tags: String = "",
aliases = aliases, ): CatalogItemEntity {
tags = tags, val item =
isUserCreated = true, CatalogItemEntity(
popularity = 0, itemId = "user_${UUID.randomUUID()}",
sortOrder = 0 name = name,
) primaryCategoryId = primaryCategoryId,
dao.insertItem(item) emoji = emoji,
if (primaryCategoryId != null) { aliases = aliases,
dao.insertCrossRefs(listOf(ItemCategoryCrossRef(item.itemId, primaryCategoryId))) 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 import javax.inject.Singleton
@Singleton @Singleton
class ProductRepositoryImpl @Inject constructor( class ProductRepositoryImpl
private val api: OpenFoodFactsApi, @Inject
private val cacheDao: ProductCacheDao, constructor(
private val connectivity: ConnectivityObserver private val api: OpenFoodFactsApi,
) : ProductRepository { 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) { if (!online) {
val cached = cacheDao.getByBarcode(barcode)?.toDomain() return@withContext if (cached != null) {
val online = connectivity.isOnline() ProductFetchResult.Found(cached, fromCache = true)
} else {
ProductFetchResult.Error("offline", offline = true)
}
}
if (!online) { try {
return@withContext if (cached != null) { val response = api.getProduct(barcode)
ProductFetchResult.Found(cached, fromCache = true) if (!response.isSuccessful) {
} else { Timber.w("OFF returned HTTP ${response.code()} for $barcode")
ProductFetchResult.Error("offline", offline = true) 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 { override suspend fun cacheProduct(product: Product) =
val response = api.getProduct(barcode) withContext(Dispatchers.IO) {
if (!response.isSuccessful) { cacheDao.upsert(product.toCacheEntity())
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 override suspend fun getCachedProduct(barcode: String): Product? =
val dto = body?.product withContext(Dispatchers.IO) {
if (status != 1 || dto == null) { cacheDao.getByBarcode(barcode)?.toDomain()
return@withContext ProductFetchResult.NotFound
} }
val product = dto.toDomain(barcode)
cacheDao.upsert(product.toCacheEntity())
ProductFetchResult.Found(product, fromCache = false)
} catch (io: IOException) {
Timber.w(io, "Network error fetching $barcode")
cached?.let { ProductFetchResult.Found(it, fromCache = true) }
?: ProductFetchResult.Error(io.message ?: "network_error", offline = true)
} catch (t: Throwable) {
Timber.e(t, "Unexpected error fetching $barcode")
cached?.let { ProductFetchResult.Found(it, fromCache = true) }
?: ProductFetchResult.Error(t.message ?: "unknown_error")
}
}
override suspend fun cacheProduct(product: Product) = withContext(Dispatchers.IO) { override suspend fun clearCache() = withContext(Dispatchers.IO) { cacheDao.clear() }
cacheDao.upsert(product.toCacheEntity())
}
override suspend fun getCachedProduct(barcode: String): Product? = withContext(Dispatchers.IO) { override suspend fun searchAlternatives(
cacheDao.getByBarcode(barcode)?.toDomain() 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 import javax.inject.Singleton
@Singleton @Singleton
class ScanHistoryRepositoryImpl @Inject constructor( class ScanHistoryRepositoryImpl
private val dao: ScanHistoryDao @Inject
) : ScanHistoryRepository { 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>> = override suspend fun save(result: ScanResult): Long =
dao.observeAll().map { list -> list.map { it.toDomain() } } 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) { override suspend fun delete(id: Long) = withContext(Dispatchers.IO) { dao.deleteById(id) }
dao.insert(
ScanHistoryEntity( override suspend fun clear() = withContext(Dispatchers.IO) { dao.clear() }
barcode = result.product.barcode,
productName = result.product.name, override suspend fun getById(id: Long): ScanHistoryItem? =
brand = result.product.brand, withContext(Dispatchers.IO) {
imageUrl = result.product.imageUrl, dao.getById(id)?.toDomain()
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) } private fun ScanHistoryEntity.toDomain() =
ScanHistoryItem(
override suspend fun clear() = withContext(Dispatchers.IO) { dao.clear() } id = id,
barcode = barcode,
override suspend fun getById(id: Long): ScanHistoryItem? = withContext(Dispatchers.IO) { productName = productName,
dao.getById(id)?.toDomain() brand = brand,
} imageUrl = imageUrl,
} safetyStatus = safetyStatus,
profileNames = profileNames,
private fun ScanHistoryEntity.toDomain() = ScanHistoryItem( scannedAt = scannedAt,
id = id, source = source,
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 import javax.inject.Singleton
@Singleton @Singleton
class SettingsRepositoryImpl @Inject constructor( class SettingsRepositoryImpl
private val prefs: UserPreferences @Inject
) : SettingsRepository { constructor(
override val appLanguage = prefs.appLanguage private val prefs: UserPreferences,
override val detectionLanguage = prefs.detectionLanguage ) : SettingsRepository {
override val hapticsEnabled = prefs.haptics override val appLanguage = prefs.appLanguage
override val soundEnabled = prefs.sound override val detectionLanguage = prefs.detectionLanguage
override val theme = prefs.theme override val hapticsEnabled = prefs.haptics
override val onboardingCompleted = prefs.onboardingCompleted override val soundEnabled = prefs.sound
override val healthStrictness = prefs.healthStrictness override val theme = prefs.theme
override val splashScreenEnabled = prefs.splashScreenEnabled 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 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 setDetectionLanguage(value: DetectionLanguage) = prefs.setDetectionLanguage(value)
override suspend fun setSound(enabled: Boolean) = prefs.setSound(enabled)
override suspend fun setTheme(value: ThemePref) = prefs.setTheme(value) override suspend fun setHaptics(enabled: Boolean) = prefs.setHaptics(enabled)
override suspend fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
override suspend fun setHealthStrictness(value: HealthStrictness) = prefs.setHealthStrictness(value) override suspend fun setSound(enabled: Boolean) = prefs.setSound(enabled)
override suspend fun setSplashScreenEnabled(enabled: Boolean) = prefs.setSplashScreenEnabled(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 import javax.inject.Singleton
@Singleton @Singleton
class ShoppingListRepositoryImpl @Inject constructor( class ShoppingListRepositoryImpl
private val dao: ShoppingListDao @Inject
) : ShoppingListRepository { constructor(
private val dao: ShoppingListDao,
) : ShoppingListRepository {
override fun observeActiveLists(): Flow<List<ShoppingListEntity>> = dao.observeActiveLists()
override fun observeActiveLists(): Flow<List<ShoppingListEntity>> = override fun observeAllLists(): Flow<List<ShoppingListEntity>> = dao.observeAllLists()
dao.observeActiveLists()
override fun observeAllLists(): Flow<List<ShoppingListEntity>> = override suspend fun getListById(id: Long): ShoppingListEntity? = dao.getListById(id)
dao.observeAllLists()
override suspend fun getListById(id: Long): ShoppingListEntity? = override suspend fun createList(
dao.getListById(id) 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 { override suspend fun updateList(list: ShoppingListEntity) {
val list = ShoppingListEntity( dao.updateList(list.copy(updatedAt = System.currentTimeMillis()))
name = name, }
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(), override suspend fun deleteList(list: ShoppingListEntity) {
backgroundResName = backgroundResName dao.deleteList(list)
) }
return dao.insertList(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 import javax.inject.Singleton
@Singleton @Singleton
class UserProfileRepositoryImpl @Inject constructor( class UserProfileRepositoryImpl
private val dao: UserProfileDao, @Inject
private val prefs: UserPreferences constructor(
) : UserProfileRepository { 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>> = override suspend fun getProfile(id: Long): UserProfile? =
dao.observeAll().map { list -> list.map { it.toDomain() } } withContext(Dispatchers.IO) {
dao.getById(id)?.toDomain()
}
override suspend fun getProfile(id: Long): UserProfile? = withContext(Dispatchers.IO) { override suspend fun upsert(profile: UserProfile): Long =
dao.getById(id)?.toDomain() 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) { override suspend fun delete(profile: UserProfile) =
val entity = profile.toEntity() withContext(Dispatchers.IO) {
if (profile.id == 0L) dao.insert(entity) else { dao.delete(profile.toEntity())
dao.update(entity) }
profile.id
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) { private fun UserProfileEntity.toDomain(): UserProfile =
dao.delete(profile.toEntity()) 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) { private fun UserProfile.toEntity(): UserProfileEntity =
dao.clearDefault() UserProfileEntity(
dao.markDefault(id) id = id,
} name = name,
avatar = avatar,
override fun observeActiveProfileIds(): Flow<Set<Long>> = prefs.activeProfileIds severeAllergens = severeAllergens,
moderateIntolerances = moderateIntolerances,
override suspend fun setActiveProfileIds(ids: Set<Long>) { prefs.setActiveProfileIds(ids) } dietaryRestrictions = dietaryRestrictions,
} customItems = customItems,
isDefault = isDefault,
private fun UserProfileEntity.toDomain(): UserProfile = UserProfile( )
id = id,
name = name,
avatar = avatar,
severeAllergens = severeAllergens,
moderateIntolerances = moderateIntolerances,
dietaryRestrictions = dietaryRestrictions,
customItems = customItems,
isDefault = isDefault
)
private fun UserProfile.toEntity(): UserProfileEntity = UserProfileEntity(
id = id,
name = name,
avatar = avatar,
severeAllergens = severeAllergens,
moderateIntolerances = moderateIntolerances,
dietaryRestrictions = dietaryRestrictions,
customItems = customItems,
isDefault = isDefault
)

View File

@ -11,7 +11,6 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
class ConnectivityObserver(private val context: Context) { class ConnectivityObserver(private val context: Context) {
fun isOnline(): Boolean { fun isOnline(): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: 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) caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
} }
fun observe(): Flow<Boolean> = callbackFlow { fun observe(): Flow<Boolean> =
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
override fun onAvailable(network: Network) { trySend(true) } val callback =
override fun onLost(network: Network) { trySend(false) } object : ConnectivityManager.NetworkCallback() {
override fun onUnavailable() { trySend(false) } override fun onAvailable(network: Network) {
} trySend(true)
val request = NetworkRequest.Builder() }
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build() override fun onLost(network: Network) {
cm.registerNetworkCallback(request, callback) trySend(false)
trySend(isOnline()) }
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged() 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 @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object AppModule { object AppModule {
@Provides @Provides
@Singleton @Singleton
fun provideMoshi(): Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() fun provideMoshi(): Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
@Provides @Provides
@Singleton @Singleton
fun provideUserPreferences(@ApplicationContext context: Context): UserPreferences = fun provideUserPreferences(
UserPreferences(context.safeBiteDataStore) @ApplicationContext context: Context,
): UserPreferences = UserPreferences(context.safeBiteDataStore)
@Provides @Provides
@Singleton @Singleton
fun provideConnectivity(@ApplicationContext context: Context): ConnectivityObserver = fun provideConnectivity(
ConnectivityObserver(context) @ApplicationContext context: Context,
): ConnectivityObserver = ConnectivityObserver(context)
} }

View File

@ -20,18 +20,23 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object DatabaseModule { object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideDatabase(@ApplicationContext context: Context): SafeBiteDatabase = fun provideDatabase(
@ApplicationContext context: Context,
): SafeBiteDatabase =
Room.databaseBuilder(context, SafeBiteDatabase::class.java, SafeBiteDatabase.NAME) Room.databaseBuilder(context, SafeBiteDatabase::class.java, SafeBiteDatabase.NAME)
.addMigrations(MIGRATION_7_8, MIGRATION_8_9) .addMigrations(MIGRATION_7_8, MIGRATION_8_9)
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.build() .build()
@Provides fun provideUserProfileDao(db: SafeBiteDatabase): UserProfileDao = db.userProfileDao() @Provides fun provideUserProfileDao(db: SafeBiteDatabase): UserProfileDao = db.userProfileDao()
@Provides fun provideProductCacheDao(db: SafeBiteDatabase): ProductCacheDao = db.productCacheDao() @Provides fun provideProductCacheDao(db: SafeBiteDatabase): ProductCacheDao = db.productCacheDao()
@Provides fun provideScanHistoryDao(db: SafeBiteDatabase): ScanHistoryDao = db.scanHistoryDao() @Provides fun provideScanHistoryDao(db: SafeBiteDatabase): ScanHistoryDao = db.scanHistoryDao()
@Provides fun provideShoppingListDao(db: SafeBiteDatabase): ShoppingListDao = db.shoppingListDao() @Provides fun provideShoppingListDao(db: SafeBiteDatabase): ShoppingListDao = db.shoppingListDao()
@Provides fun provideCatalogDao(db: SafeBiteDatabase): CatalogDao = db.catalogDao() @Provides fun provideCatalogDao(db: SafeBiteDatabase): CatalogDao = db.catalogDao()
} }

View File

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

View File

@ -17,17 +17,17 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object NetworkModule { object NetworkModule {
private const val USER_AGENT = "SafeBite/1.0 (Android; contact@safebite.app)" private const val USER_AGENT = "SafeBite/1.0 (Android; contact@safebite.app)"
@Provides @Provides
@Singleton @Singleton
fun provideOkHttp(): OkHttpClient { fun provideOkHttp(): OkHttpClient {
val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
val uaInterceptor = Interceptor { chain -> val uaInterceptor =
val req = chain.request().newBuilder().header("User-Agent", USER_AGENT).build() Interceptor { chain ->
chain.proceed(req) val req = chain.request().newBuilder().header("User-Agent", USER_AGENT).build()
} chain.proceed(req)
}
return OkHttpClient.Builder() return OkHttpClient.Builder()
.addInterceptor(uaInterceptor) .addInterceptor(uaInterceptor)
.addInterceptor(logging) .addInterceptor(logging)
@ -39,7 +39,10 @@ object NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun provideRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit = fun provideRetrofit(
client: OkHttpClient,
moshi: Moshi,
): Retrofit =
Retrofit.Builder() Retrofit.Builder()
.baseUrl(OpenFoodFactsApi.BASE_URL) .baseUrl(OpenFoodFactsApi.BASE_URL)
.client(client) .client(client)
@ -48,6 +51,5 @@ object NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun provideOpenFoodFactsApi(retrofit: Retrofit): OpenFoodFactsApi = fun provideOpenFoodFactsApi(retrofit: Retrofit): OpenFoodFactsApi = retrofit.create(OpenFoodFactsApi::class.java)
retrofit.create(OpenFoodFactsApi::class.java)
} }

View File

@ -19,7 +19,6 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class RepositoryModule { abstract class RepositoryModule {
@Binds @Singleton @Binds @Singleton
abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository 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.DetectedCustomItem
import com.safebite.app.domain.model.DetectionLanguage import com.safebite.app.domain.model.DetectionLanguage
import com.safebite.app.domain.model.DetectionLevel 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.HealthStrictness
import com.safebite.app.domain.model.Product import com.safebite.app.domain.model.Product
import com.safebite.app.domain.model.SafetyStatus 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. * 3. "May contain / traces" pattern extraction from the ingredients text.
*/ */
object AllergenAnalysisEngine { object AllergenAnalysisEngine {
/** Regexes for "may contain" disclosures, in French and English. */ /** Regexes for "may contain" disclosures, in French and English. */
private val MAY_CONTAIN_PATTERNS = listOf( private val MAY_CONTAIN_PATTERNS =
Regex("peut contenir(?:\\s+des\\s+traces\\s+de)?\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE), listOf(
Regex("traces?\\s+possibles?\\s+de\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE), Regex("peut contenir(?:\\s+des\\s+traces\\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("traces?\\s+possibles?\\s+de\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE),
Regex("may\\s+contain\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE), Regex(
Regex("manufactured\\s+in\\s+a\\s+facility\\s+that\\s+(?:processes|also\\s+processes|handles)[^.]{1,200}", RegexOption.IGNORE_CASE), "fabriqué\\s+dans\\s+un\\s+(?:atelier|environnement|établissement)\\s+(?:contenant|utilisant|qui\\s+utilise)[^.]{1,200}",
Regex("produced\\s+in\\s+a\\s+plant\\s+that\\s+also\\s+handles[^.]{1,200}", RegexOption.IGNORE_CASE) 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. * Analyze a product against the given profiles.
@ -48,7 +52,7 @@ object AllergenAnalysisEngine {
profiles: List<UserProfile>, profiles: List<UserProfile>,
source: DataSource, source: DataSource,
language: DetectionLanguage = DetectionLanguage.BOTH, language: DetectionLanguage = DetectionLanguage.BOTH,
healthStrictness: HealthStrictness = HealthStrictness.NORMAL healthStrictness: HealthStrictness = HealthStrictness.NORMAL,
): ScanResult { ): ScanResult {
if (profiles.isEmpty()) { if (profiles.isEmpty()) {
return ScanResult( return ScanResult(
@ -59,7 +63,7 @@ object AllergenAnalysisEngine {
health = HealthClassifier.classify(product, emptyList(), healthStrictness), health = HealthClassifier.classify(product, emptyList(), healthStrictness),
analyzedProfiles = emptyList(), analyzedProfiles = emptyList(),
confidence = AnalysisConfidence.LOW, confidence = AnalysisConfidence.LOW,
source = source source = source,
) )
} }
@ -73,26 +77,28 @@ object AllergenAnalysisEngine {
for (allergen in watched) { for (allergen in watched) {
val tagHits = matchTags(product.allergensTags, allergen.openFoodFactsTags) val tagHits = matchTags(product.allergensTags, allergen.openFoodFactsTags)
if (tagHits.isNotEmpty()) { if (tagHits.isNotEmpty()) {
detections[allergen] = DetectedAllergen( detections[allergen] =
allergenType = allergen, DetectedAllergen(
detectionLevel = DetectionLevel.CONFIRMED, allergenType = allergen,
matchedKeywords = tagHits, detectionLevel = DetectionLevel.CONFIRMED,
source = "API allergens_tags", matchedKeywords = tagHits,
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id }, source = "API allergens_tags",
severe = allergen in severeSet profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
) severe = allergen in severeSet,
)
} }
val traceTagHits = matchTags(product.tracesTags, allergen.openFoodFactsTags) val traceTagHits = matchTags(product.tracesTags, allergen.openFoodFactsTags)
if (traceTagHits.isNotEmpty()) { if (traceTagHits.isNotEmpty()) {
detections.compute(allergen) { _, existing -> detections.compute(allergen) { _, existing ->
val hit = DetectedAllergen( val hit =
allergenType = allergen, DetectedAllergen(
detectionLevel = DetectionLevel.TRACE, allergenType = allergen,
matchedKeywords = traceTagHits, detectionLevel = DetectionLevel.TRACE,
source = "API traces_tags", matchedKeywords = traceTagHits,
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id }, source = "API traces_tags",
severe = allergen in severeSet profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
) severe = allergen in severeSet,
)
merge(existing, hit) merge(existing, hit)
} }
} }
@ -110,32 +116,35 @@ object AllergenAnalysisEngine {
val ingMatches = findKeywordMatches(ingredientsOnly, keywords) val ingMatches = findKeywordMatches(ingredientsOnly, keywords)
if (ingMatches.isNotEmpty()) { if (ingMatches.isNotEmpty()) {
val level = if (detections[allergen]?.detectionLevel == DetectionLevel.CONFIRMED) { val level =
DetectionLevel.CONFIRMED if (detections[allergen]?.detectionLevel == DetectionLevel.CONFIRMED) {
} else { DetectionLevel.CONFIRMED
DetectionLevel.SUSPECTED } else {
} DetectionLevel.SUSPECTED
val hit = DetectedAllergen( }
allergenType = allergen, val hit =
detectionLevel = level, DetectedAllergen(
matchedKeywords = ingMatches, allergenType = allergen,
source = "Ingredients text", detectionLevel = level,
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id }, matchedKeywords = ingMatches,
severe = allergen in severeSet source = "Ingredients text",
) profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
severe = allergen in severeSet,
)
detections.compute(allergen) { _, existing -> merge(existing, hit) } detections.compute(allergen) { _, existing -> merge(existing, hit) }
} }
val traceMatches = traceRegions.flatMap { region -> findKeywordMatches(region, keywords) } val traceMatches = traceRegions.flatMap { region -> findKeywordMatches(region, keywords) }
if (traceMatches.isNotEmpty()) { if (traceMatches.isNotEmpty()) {
val hit = DetectedAllergen( val hit =
allergenType = allergen, DetectedAllergen(
detectionLevel = DetectionLevel.TRACE, allergenType = allergen,
matchedKeywords = traceMatches.distinct(), detectionLevel = DetectionLevel.TRACE,
source = "May-contain mention", matchedKeywords = traceMatches.distinct(),
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id }, source = "May-contain mention",
severe = allergen in severeSet profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
) severe = allergen in severeSet,
)
detections.compute(allergen) { _, existing -> merge(existing, hit) } detections.compute(allergen) { _, existing -> merge(existing, hit) }
} }
} }
@ -144,41 +153,44 @@ object AllergenAnalysisEngine {
val detected = detections.values.toList() val detected = detections.values.toList()
// Custom items: per profile, scan against ingredients text + OFF tags / labels / categories. // Custom items: per profile, scan against ingredients text + OFF tags / labels / categories.
val searchable = buildString { val searchable =
append(normalizedIngredients) buildString {
append(' ') append(normalizedIngredients)
append(product.allergensTags.joinToString(" ") { normalize(it) }) append(' ')
append(' ') append(product.allergensTags.joinToString(" ") { normalize(it) })
append(product.tracesTags.joinToString(" ") { normalize(it) }) append(' ')
append(' ') append(product.tracesTags.joinToString(" ") { normalize(it) })
append(product.labels.joinToString(" ") { normalize(it) }) append(' ')
append(' ') append(product.labels.joinToString(" ") { normalize(it) })
append(product.categories.joinToString(" ") { normalize(it) }) append(' ')
append(' ') append(product.categories.joinToString(" ") { normalize(it) })
append(normalize(product.name.orEmpty())) append(' ')
} append(normalize(product.name.orEmpty()))
}
val customDetections = detectCustomItems(searchable, profiles) val customDetections = detectCustomItems(searchable, profiles)
val status = computeStatus(detected, severeSet, customDetections) val status = computeStatus(detected, severeSet, customDetections)
val confidence = computeConfidence(product, source, hasAnyData = detected.isNotEmpty() || normalizedIngredients.isNotBlank()) val confidence = computeConfidence(product, source, hasAnyData = detected.isNotEmpty() || normalizedIngredients.isNotBlank())
val unhealthyCustomNames = customDetections val unhealthyCustomNames =
.filter { it.item.tag == CustomItemTag.UNHEALTHY } customDetections
.map { it.item.name } .filter { it.item.tag == CustomItemTag.UNHEALTHY }
.map { it.item.name }
val health = HealthClassifier.classify(product, unhealthyCustomNames, healthStrictness) val health = HealthClassifier.classify(product, unhealthyCustomNames, healthStrictness)
return ScanResult( return ScanResult(
product = product, product = product,
safetyStatus = status, safetyStatus = status,
detectedAllergens = detected.sortedWith( detectedAllergens =
compareByDescending<DetectedAllergen> { it.detectionLevel.ordinal == DetectionLevel.CONFIRMED.ordinal } detected.sortedWith(
.thenByDescending { it.severe } compareByDescending<DetectedAllergen> { it.detectionLevel.ordinal == DetectionLevel.CONFIRMED.ordinal }
), .thenByDescending { it.severe },
),
detectedCustomItems = customDetections, detectedCustomItems = customDetections,
health = health, health = health,
analyzedProfiles = profiles, analyzedProfiles = profiles,
confidence = confidence, confidence = confidence,
source = source source = source,
) )
} }
@ -186,7 +198,10 @@ object AllergenAnalysisEngine {
* Match each profile's custom items against the pre-normalized [searchable] text * Match each profile's custom items against the pre-normalized [searchable] text
* (ingredients + tags + labels + categories + product name). * (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() if (searchable.isBlank()) return emptyList()
// Group by (name, tag) so the same item across two profiles becomes a single detection // Group by (name, tag) so the same item across two profiles becomes a single detection
// with the union of profile IDs. // with the union of profile IDs.
@ -207,8 +222,8 @@ object AllergenAnalysisEngine {
DetectedCustomItem( DetectedCustomItem(
item = first, item = first,
matchedKeywords = hits, 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. */ /** Lower-case, strip accents, expand common ligatures, collapse punctuation. */
fun normalize(raw: String): String { fun normalize(raw: String): String {
if (raw.isBlank()) return "" if (raw.isBlank()) return ""
val lowered = raw.lowercase() val lowered =
.replace("œ", "oe") raw.lowercase()
.replace("æ", "ae") .replace("œ", "oe")
.replace("æ", "ae")
val decomposed = Normalizer.normalize(lowered, Normalizer.Form.NFD) val decomposed = Normalizer.normalize(lowered, Normalizer.Form.NFD)
val withoutAccents = decomposed.replace(Regex("\\p{Mn}+"), "") val withoutAccents = decomposed.replace(Regex("\\p{Mn}+"), "")
// Replace various apostrophe/quote styles with a space, normalize whitespace. // Replace various apostrophe/quote styles with a space, normalize whitespace.
@ -233,14 +249,20 @@ object AllergenAnalysisEngine {
.trim() .trim()
} }
private fun keywordsFor(allergen: AllergenType, language: DetectionLanguage): List<String> = private fun keywordsFor(
allergen: AllergenType,
language: DetectionLanguage,
): List<String> =
when (language) { when (language) {
DetectionLanguage.FR -> allergen.keywordsFr DetectionLanguage.FR -> allergen.keywordsFr
DetectionLanguage.EN -> allergen.keywordsEn DetectionLanguage.EN -> allergen.keywordsEn
DetectionLanguage.BOTH -> allergen.keywordsFr + allergen.keywordsEn DetectionLanguage.BOTH -> allergen.keywordsFr + allergen.keywordsEn
}.map { normalize(it) }.filter { it.isNotBlank() }.distinct() }.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() if (productTags.isEmpty()) return emptyList()
val lowered = productTags.map { it.lowercase() } val lowered = productTags.map { it.lowercase() }
return allergenTags.filter { tag -> lowered.any { it == tag.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 * Word-boundary aware keyword matcher. Handles plurals by also matching the keyword
* followed by an "s". Supports multi-word keywords. * 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() if (normalized.isBlank()) return emptyList()
val hits = mutableListOf<String>() val hits = mutableListOf<String>()
for (kw in keywords) { 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 var result = text
for (region in regions) { for (region in regions) {
result = result.replace(region, " ") result = result.replace(region, " ")
@ -283,7 +311,10 @@ object AllergenAnalysisEngine {
// endregion // endregion
private fun merge(existing: DetectedAllergen?, incoming: DetectedAllergen): DetectedAllergen { private fun merge(
existing: DetectedAllergen?,
incoming: DetectedAllergen,
): DetectedAllergen {
if (existing == null) return incoming if (existing == null) return incoming
// Keep the most severe detection level: CONFIRMED > TRACE > SUSPECTED. // Keep the most severe detection level: CONFIRMED > TRACE > SUSPECTED.
val priority: (DetectionLevel) -> Int = { val priority: (DetectionLevel) -> Int = {
@ -297,28 +328,34 @@ object AllergenAnalysisEngine {
return best.copy( return best.copy(
matchedKeywords = (existing.matchedKeywords + incoming.matchedKeywords).distinct(), matchedKeywords = (existing.matchedKeywords + incoming.matchedKeywords).distinct(),
source = if (best == existing) existing.source else incoming.source, source = if (best == existing) existing.source else incoming.source,
profileIds = (existing.profileIds + incoming.profileIds).distinct() profileIds = (existing.profileIds + incoming.profileIds).distinct(),
) )
} }
private fun computeStatus( private fun computeStatus(
detected: List<DetectedAllergen>, detected: List<DetectedAllergen>,
severeSet: Set<AllergenType>, severeSet: Set<AllergenType>,
customDetections: List<DetectedCustomItem> customDetections: List<DetectedCustomItem>,
): SafetyStatus { ): SafetyStatus {
val hasSevereConfirmed = detected.any { val hasSevereConfirmed =
it.detectionLevel != DetectionLevel.TRACE && it.allergenType in severeSet detected.any {
} it.detectionLevel != DetectionLevel.TRACE && it.allergenType in severeSet
}
val customHasAllergy = customDetections.any { it.item.tag == CustomItemTag.ALLERGY } val customHasAllergy = customDetections.any { it.item.tag == CustomItemTag.ALLERGY }
if (hasSevereConfirmed || customHasAllergy) return SafetyStatus.DANGER if (hasSevereConfirmed || customHasAllergy) return SafetyStatus.DANGER
val customTriggersWarning = customDetections.any { val customTriggersWarning =
it.item.tag == CustomItemTag.INTOLERANCE || it.item.tag == CustomItemTag.DIET customDetections.any {
} it.item.tag == CustomItemTag.INTOLERANCE || it.item.tag == CustomItemTag.DIET
}
if (detected.isEmpty() && !customTriggersWarning) return SafetyStatus.SAFE if (detected.isEmpty() && !customTriggersWarning) return SafetyStatus.SAFE
return SafetyStatus.WARNING 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) { return when (source) {
DataSource.OCR -> AnalysisConfidence.LOW DataSource.OCR -> AnalysisConfidence.LOW
DataSource.API, DataSource.CACHE -> { DataSource.API, DataSource.CACHE -> {

View File

@ -5,75 +5,86 @@ import javax.inject.Singleton
/** /**
* Moteur de catégorisation automatique des produits par rayon (Phase 2.4). * Moteur de catégorisation automatique des produits par rayon (Phase 2.4).
* *
* Associe des mots-clés à des catégories de magasin pour organiser les listes de courses. * Associe des mots-clés à des catégories de magasin pour organiser les listes de courses.
*/ */
@Singleton @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()
/** return when {
* Détecte le rayon d'un produit basé sur son nom et ses catégories. text.containsAny(freshKeywords) -> "Frais"
*/ text.containsAny(fruitKeywords) -> "Fruits & Légumes"
fun detectCategory(productName: String, categories: List<String> = emptyList()): String { text.containsAny(bakeryKeywords) -> "Boulangerie"
val text = (listOf(productName) + categories).joinToString(" ").lowercase() text.containsAny(meatKeywords) -> "Boucherie"
text.containsAny(dairyKeywords) -> "Produits laitiers"
return when { text.containsAny(groceryKeywords) -> "Épicerie"
text.containsAny(freshKeywords) -> "Frais" text.containsAny(beverageKeywords) -> "Boissons"
text.containsAny(fruitKeywords) -> "Fruits & Légumes" text.containsAny(frozenKeywords) -> "Surgelés"
text.containsAny(bakeryKeywords) -> "Boulangerie" text.containsAny(hygieneKeywords) -> "Hygiène"
text.containsAny(meatKeywords) -> "Boucherie" text.containsAny(babyKeywords) -> "Bébé"
text.containsAny(dairyKeywords) -> "Produits laitiers" text.containsAny(petKeywords) -> "Animaux"
text.containsAny(groceryKeywords) -> "Épicerie" text.containsAny(cleaningKeywords) -> "Entretien"
text.containsAny(beverageKeywords) -> "Boissons" else -> "Autre"
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. * - STRICT: only A + Nova 2 HEALTHY; B MODERATE; C/D/E or Nova 3 UNHEALTHY.
*/ */
object HealthClassifier { object HealthClassifier {
fun classify( fun classify(
product: Product, product: Product,
unhealthyCustomHits: List<String>, unhealthyCustomHits: List<String>,
strictness: HealthStrictness strictness: HealthStrictness,
): HealthAssessment { ): HealthAssessment {
val nutri = product.nutriScore?.lowercase()?.takeIf { it in listOf("a", "b", "c", "d", "e") } val nutri = product.nutriScore?.lowercase()?.takeIf { it in listOf("a", "b", "c", "d", "e") }
val nova = product.novaGroup?.takeIf { it in 1..4 } val nova = product.novaGroup?.takeIf { it in 1..4 }
@ -30,13 +29,15 @@ object HealthClassifier {
if (unhealthyCustomHits.isNotEmpty()) { if (unhealthyCustomHits.isNotEmpty()) {
reasons += "Contient: ${unhealthyCustomHits.joinToString()}" reasons += "Contient: ${unhealthyCustomHits.joinToString()}"
rating = when (strictness) { rating =
HealthStrictness.LENIENT -> when (rating) { when (strictness) {
HealthRating.HEALTHY, HealthRating.UNKNOWN -> HealthRating.MODERATE HealthStrictness.LENIENT ->
else -> rating 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. // If we truly have nothing to judge on, keep UNKNOWN.
@ -49,7 +50,7 @@ object HealthClassifier {
reasons = reasons, reasons = reasons,
nutriScore = nutri, nutriScore = nutri,
novaGroup = nova, novaGroup = nova,
ecoScore = eco ecoScore = eco,
) )
} }
@ -57,7 +58,7 @@ object HealthClassifier {
nutri: String?, nutri: String?,
nova: Int?, nova: Int?,
strictness: HealthStrictness, strictness: HealthStrictness,
reasons: MutableList<String> reasons: MutableList<String>,
): HealthRating { ): HealthRating {
// Score nutri (a=0 best, e=4 worst) // Score nutri (a=0 best, e=4 worst)
val nutriScoreValue = nutri?.let { it[0].code - 'a'.code } val nutriScoreValue = nutri?.let { it[0].code - 'a'.code }
@ -67,35 +68,38 @@ object HealthClassifier {
if (nova != null) reasons += "NOVA $nova" if (nova != null) reasons += "NOVA $nova"
return when (strictness) { return when (strictness) {
HealthStrictness.LENIENT -> when { HealthStrictness.LENIENT ->
nutriScoreValue != null && nutriScoreValue >= 3 && novaValue == 4 -> HealthRating.UNHEALTHY when {
nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.MODERATE nutriScoreValue != null && nutriScoreValue >= 3 && novaValue == 4 -> HealthRating.UNHEALTHY
novaValue == 4 -> HealthRating.MODERATE nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.MODERATE
nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.HEALTHY novaValue == 4 -> HealthRating.MODERATE
nutriScoreValue != null -> HealthRating.MODERATE nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.HEALTHY
novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY nutriScoreValue != null -> HealthRating.MODERATE
else -> HealthRating.UNKNOWN novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY
} else -> HealthRating.UNKNOWN
HealthStrictness.NORMAL -> when { }
nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.UNHEALTHY HealthStrictness.NORMAL ->
novaValue != null && novaValue >= 4 -> HealthRating.UNHEALTHY when {
nutriScoreValue == 2 && (novaValue ?: 1) >= 3 -> HealthRating.UNHEALTHY nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.UNHEALTHY
nutriScoreValue == 2 -> HealthRating.MODERATE novaValue != null && novaValue >= 4 -> HealthRating.UNHEALTHY
novaValue == 3 && nutriScoreValue == null -> HealthRating.MODERATE nutriScoreValue == 2 && (novaValue ?: 1) >= 3 -> HealthRating.UNHEALTHY
nutriScoreValue != null && nutriScoreValue <= 1 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY nutriScoreValue == 2 -> HealthRating.MODERATE
nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.MODERATE novaValue == 3 && nutriScoreValue == null -> HealthRating.MODERATE
novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY nutriScoreValue != null && nutriScoreValue <= 1 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY
else -> HealthRating.UNKNOWN nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.MODERATE
} novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY
HealthStrictness.STRICT -> when { else -> HealthRating.UNKNOWN
nutriScoreValue != null && nutriScoreValue >= 2 -> HealthRating.UNHEALTHY }
novaValue != null && novaValue >= 3 -> HealthRating.UNHEALTHY HealthStrictness.STRICT ->
nutriScoreValue == 1 -> HealthRating.MODERATE when {
nutriScoreValue == 0 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY nutriScoreValue != null && nutriScoreValue >= 2 -> HealthRating.UNHEALTHY
nutriScoreValue == 0 -> HealthRating.MODERATE novaValue != null && novaValue >= 3 -> HealthRating.UNHEALTHY
novaValue != null && novaValue <= 2 -> HealthRating.MODERATE nutriScoreValue == 1 -> HealthRating.MODERATE
else -> HealthRating.UNKNOWN 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 icon: String,
val openFoodFactsTags: List<String>, val openFoodFactsTags: List<String>,
val keywordsFr: List<String>, val keywordsFr: List<String>,
val keywordsEn: List<String> val keywordsEn: List<String>,
) { ) {
GLUTEN( GLUTEN(
"Gluten", "Gluten", "🌾", "Gluten",
"Gluten",
"🌾",
listOf("en:gluten"), listOf("en:gluten"),
listOf( listOf(
"gluten", "blé", "froment", "seigle", "orge", "avoine", "gluten", "blé", "froment", "seigle", "orge", "avoine",
"épeautre", "kamut", "triticale", "malt", "amidon de blé", "épeautre", "kamut", "triticale", "malt", "amidon de blé",
"farine de blé", "farine d'orge", "farine de seigle", "farine de blé", "farine d'orge", "farine de seigle",
"protéine de blé", "seitan" "protéine de blé", "seitan",
), ),
listOf( listOf(
"gluten", "wheat", "rye", "barley", "oats", "spelt", "gluten", "wheat", "rye", "barley", "oats", "spelt",
"kamut", "triticale", "malt", "wheat starch", "wheat flour" "kamut", "triticale", "malt", "wheat starch", "wheat flour",
) ),
), ),
PEANUTS( PEANUTS(
"Arachides", "Peanuts", "🥜", "Arachides",
"Peanuts",
"🥜",
listOf("en:peanuts"), listOf("en:peanuts"),
listOf( listOf(
"arachide", "arachides", "cacahuète", "cacahuètes", "arachide",
"beurre d'arachide", "huile d'arachide" "arachides",
"cacahuète",
"cacahuètes",
"beurre d'arachide",
"huile d'arachide",
), ),
listOf( listOf(
"peanut", "peanuts", "peanut butter", "peanut oil", "peanut",
"groundnut", "groundnuts" "peanuts",
) "peanut butter",
"peanut oil",
"groundnut",
"groundnuts",
),
), ),
TREE_NUTS( TREE_NUTS(
"Noix", "Tree Nuts", "🌰", "Noix",
"Tree Nuts",
"🌰",
listOf("en:nuts", "en:tree-nuts"), listOf("en:nuts", "en:tree-nuts"),
listOf( listOf(
"noix", "amande", "amandes", "noisette", "noisettes", "noix", "amande", "amandes", "noisette", "noisettes",
"cajou", "noix de cajou", "pistache", "pistaches", "cajou", "noix de cajou", "pistache", "pistaches",
"noix de pécan", "pécan", "noix du brésil", "macadamia", "noix de pécan", "pécan", "noix du brésil", "macadamia",
"noix de macadamia", "pralin", "praliné", "massepain", "noix de macadamia", "pralin", "praliné", "massepain",
"pâte d'amande", "poudre d'amande" "pâte d'amande", "poudre d'amande",
), ),
listOf( listOf(
"nut", "nuts", "almond", "almonds", "hazelnut", "hazelnuts", "nut", "nuts", "almond", "almonds", "hazelnut", "hazelnuts",
"cashew", "cashews", "pistachio", "pecan", "pecans", "cashew", "cashews", "pistachio", "pecan", "pecans",
"brazil nut", "macadamia", "walnut", "walnuts", "praline", "brazil nut", "macadamia", "walnut", "walnuts", "praline",
"marzipan", "almond paste" "marzipan", "almond paste",
) ),
), ),
MILK( MILK(
"Lait", "Milk", "🥛", "Lait",
"Milk",
"🥛",
listOf("en:milk"), listOf("en:milk"),
listOf( listOf(
"lait", "lactose", "caséine", "caséinate", "lactosérum", "lait", "lactose", "caséine", "caséinate", "lactosérum",
"petit-lait", "beurre", "crème", "fromage", "yogourt", "petit-lait", "beurre", "crème", "fromage", "yogourt",
"babeurre", "ghee", "lactalbumine", "lactoglobuline", "babeurre", "ghee", "lactalbumine", "lactoglobuline",
"protéine de lait", "poudre de lait", "lait écrémé", "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( listOf(
"milk", "lactose", "casein", "caseinate", "whey", "milk", "lactose", "casein", "caseinate", "whey",
"butter", "cream", "cheese", "yogurt", "buttermilk", "butter", "cream", "cheese", "yogurt", "buttermilk",
"ghee", "lactalbumin", "lactoglobulin", "milk protein", "ghee", "lactalbumin", "lactoglobulin", "milk protein",
"milk powder", "skim milk", "whole milk" "milk powder", "skim milk", "whole milk",
) ),
), ),
EGGS( EGGS(
"Œufs", "Eggs", "🥚", "Œufs",
"Eggs",
"🥚",
listOf("en:eggs"), listOf("en:eggs"),
listOf( listOf(
"œuf", "oeuf", "œufs", "oeufs", "albumine", "ovomucine", "œuf", "oeuf", "œufs", "oeufs", "albumine", "ovomucine",
"ovomucoïde", "ovalbumine", "lécithine d'œuf", "ovomucoïde", "ovalbumine", "lécithine d'œuf",
"lysozyme", "jaune d'œuf", "blanc d'œuf", "poudre d'œuf", "lysozyme", "jaune d'œuf", "blanc d'œuf", "poudre d'œuf",
"œuf entier" "œuf entier",
), ),
listOf( listOf(
"egg", "eggs", "albumin", "ovomucin", "ovomucoid", "egg", "eggs", "albumin", "ovomucin", "ovomucoid",
"ovalbumin", "egg lecithin", "lysozyme", "egg yolk", "ovalbumin", "egg lecithin", "lysozyme", "egg yolk",
"egg white", "egg powder", "whole egg" "egg white", "egg powder", "whole egg",
) ),
), ),
SOY( SOY(
"Soja", "Soy", "🫘", "Soja",
"Soy",
"🫘",
listOf("en:soybeans"), listOf("en:soybeans"),
listOf( listOf(
"soja", "soya", "lécithine de soja", "protéine de soja", "soja", "soya", "lécithine de soja", "protéine de soja",
"tofu", "tempeh", "edamame", "fève de soja", "tofu", "tempeh", "edamame", "fève de soja",
"huile de soja", "sauce soja", "miso" "huile de soja", "sauce soja", "miso",
), ),
listOf( listOf(
"soy", "soya", "soybean", "soybeans", "soy lecithin", "soy", "soya", "soybean", "soybeans", "soy lecithin",
"soy protein", "tofu", "tempeh", "edamame", "soy protein", "tofu", "tempeh", "edamame",
"soybean oil", "soy sauce", "miso" "soybean oil", "soy sauce", "miso",
) ),
), ),
FISH( FISH(
"Poisson", "Fish", "🐟", "Poisson",
"Fish",
"🐟",
listOf("en:fish"), listOf("en:fish"),
listOf( listOf(
"poisson", "anchois", "bar", "cabillaud", "colin", "poisson", "anchois", "bar", "cabillaud", "colin",
"dorade", "flétan", "hareng", "maquereau", "merlu", "dorade", "flétan", "hareng", "maquereau", "merlu",
"morue", "perche", "sardine", "saumon", "sole", "morue", "perche", "sardine", "saumon", "sole",
"thon", "truite", "huile de poisson", "sauce de poisson", "thon", "truite", "huile de poisson", "sauce de poisson",
"surimi", "gélatine de poisson" "surimi", "gélatine de poisson",
), ),
listOf( listOf(
"fish", "anchovy", "anchovies", "bass", "cod", "haddock", "fish", "anchovy", "anchovies", "bass", "cod", "haddock",
"halibut", "herring", "mackerel", "perch", "salmon", "halibut", "herring", "mackerel", "perch", "salmon",
"sardine", "sole", "trout", "tuna", "fish oil", "sardine", "sole", "trout", "tuna", "fish oil",
"fish sauce", "surimi", "fish gelatin" "fish sauce", "surimi", "fish gelatin",
) ),
), ),
CRUSTACEANS( CRUSTACEANS(
"Crustacés", "Crustaceans", "🦐", "Crustacés",
"Crustaceans",
"🦐",
listOf("en:crustaceans"), listOf("en:crustaceans"),
listOf( listOf(
"crustacé", "crustacés", "crevette", "crevettes", "crustacé", "crustacés", "crevette", "crevettes",
"homard", "crabe", "langouste", "langoustine", "homard", "crabe", "langouste", "langoustine",
"écrevisse", "fruits de mer" "écrevisse", "fruits de mer",
), ),
listOf( listOf(
"crustacean", "crustaceans", "shrimp", "lobster", "crustacean", "crustaceans", "shrimp", "lobster",
"crab", "crayfish", "prawn", "langoustine", "seafood" "crab", "crayfish", "prawn", "langoustine", "seafood",
) ),
), ),
SESAME( SESAME(
"Sésame", "Sesame", "", "Sésame",
"Sesame",
"",
listOf("en:sesame-seeds"), listOf("en:sesame-seeds"),
listOf( listOf(
"sésame", "graines de sésame", "huile de sésame", "sésame",
"tahini", "tahina", "halva" "graines de sésame",
"huile de sésame",
"tahini",
"tahina",
"halva",
), ),
listOf( listOf(
"sesame", "sesame seeds", "sesame oil", "tahini", "sesame",
"tahina", "halva" "sesame seeds",
) "sesame oil",
"tahini",
"tahina",
"halva",
),
), ),
MUSTARD( MUSTARD(
"Moutarde", "Mustard", "🟡", "Moutarde",
"Mustard",
"🟡",
listOf("en:mustard"), listOf("en:mustard"),
listOf( listOf(
"moutarde", "graines de moutarde", "huile de moutarde", "moutarde",
"farine de 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( SULPHITES(
"Sulfites", "Sulphites", "🟣", "Sulfites",
"Sulphites",
"🟣",
listOf("en:sulphur-dioxide-and-sulphites"), listOf("en:sulphur-dioxide-and-sulphites"),
listOf( listOf(
"sulfite", "sulfites", "dioxyde de soufre", "bisulfite", "sulfite", "sulfites", "dioxyde de soufre", "bisulfite",
"métabisulfite", "anhydride sulfureux", "métabisulfite", "anhydride sulfureux",
"e220", "e221", "e222", "e223", "e224", "e225", "e226", "e228" "e220", "e221", "e222", "e223", "e224", "e225", "e226", "e228",
), ),
listOf( listOf(
"sulphite", "sulphites", "sulfite", "sulfites", "sulphite",
"sulphur dioxide", "bisulphite", "metabisulphite" "sulphites",
) "sulfite",
"sulfites",
"sulphur dioxide",
"bisulphite",
"metabisulphite",
),
), ),
LUPIN( LUPIN(
"Lupin", "Lupin", "💐", "Lupin",
"Lupin",
"💐",
listOf("en:lupin"), listOf("en:lupin"),
listOf("lupin", "lupins", "farine de lupin"), listOf("lupin", "lupins", "farine de lupin"),
listOf("lupin", "lupine", "lupin flour") listOf("lupin", "lupine", "lupin flour"),
), ),
MOLLUSCS( MOLLUSCS(
"Mollusques", "Molluscs", "🐚", "Mollusques",
"Molluscs",
"🐚",
listOf("en:molluscs"), listOf("en:molluscs"),
listOf( listOf(
"mollusque", "mollusques", "huître", "moule", "moules", "mollusque", "mollusques", "huître", "moule", "moules",
"palourde", "pétoncle", "calmar", "calamar", "pieuvre", "palourde", "pétoncle", "calmar", "calamar", "pieuvre",
"poulpe", "escargot", "coquille saint-jacques" "poulpe", "escargot", "coquille saint-jacques",
), ),
listOf( listOf(
"mollusc", "molluscs", "mollusk", "oyster", "mussel", "mollusc", "molluscs", "mollusk", "oyster", "mussel",
"clam", "scallop", "squid", "octopus", "snail" "clam", "scallop", "squid", "octopus", "snail",
) ),
), ),
CELERY( CELERY(
"Céleri", "Celery", "🥬", "Céleri",
"Celery",
"🥬",
listOf("en:celery"), listOf("en:celery"),
listOf( listOf(
"céleri", "celeri", "sel de céleri", "graines de céleri", "céleri",
"celeriac", "céleri-rave" "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 { companion object {
fun fromName(name: String): AllergenType? = fun fromName(name: String): AllergenType? = values().firstOrNull { it.name.equals(name, ignoreCase = true) }
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"), VEGETARIAN("Végétarien", "Vegetarian"),
HALAL("Halal", "Halal"), HALAL("Halal", "Halal"),
KOSHER("Casher", "Kosher"), KOSHER("Casher", "Kosher"),
NO_PORK("Sans porc", "No pork") NO_PORK("Sans porc", "No pork"),
} }
enum class DetectionLanguage { FR, EN, BOTH } enum class DetectionLanguage { FR, EN, BOTH }
@ -31,7 +31,7 @@ enum class CustomItemTag(val displayFr: String, val displayEn: String) {
ALLERGY("Allergie", "Allergy"), ALLERGY("Allergie", "Allergy"),
INTOLERANCE("Intolérance", "Intolerance"), INTOLERANCE("Intolérance", "Intolerance"),
DIET("Diète", "Diet"), 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"). */ /** A user-defined ingredient/substance to watch for (e.g. "huile de palme"). */
@ -39,10 +39,9 @@ data class CustomDietItem(
val name: String, val name: String,
val tag: CustomItemTag, val tag: CustomItemTag,
/** Optional additional keywords; if empty, [name] is used. */ /** Optional additional keywords; if empty, [name] is used. */
val keywords: List<String> = emptyList() val keywords: List<String> = emptyList(),
) { ) {
fun allKeywords(): List<String> = fun allKeywords(): List<String> = (listOf(name) + keywords).filter { it.isNotBlank() }.distinct()
(listOf(name) + keywords).filter { it.isNotBlank() }.distinct()
} }
/** A user's allergy profile. */ /** A user's allergy profile. */
@ -54,7 +53,7 @@ data class UserProfile(
val moderateIntolerances: Set<AllergenType> = emptySet(), val moderateIntolerances: Set<AllergenType> = emptySet(),
val dietaryRestrictions: Set<DietaryRestriction> = emptySet(), val dietaryRestrictions: Set<DietaryRestriction> = emptySet(),
val customItems: List<CustomDietItem> = emptyList(), val customItems: List<CustomDietItem> = emptyList(),
val isDefault: Boolean = false val isDefault: Boolean = false,
) { ) {
/** Returns every allergen (severe + moderate) referenced by this profile. */ /** Returns every allergen (severe + moderate) referenced by this profile. */
fun allAllergens(): Set<AllergenType> = severeAllergens + moderateIntolerances fun allAllergens(): Set<AllergenType> = severeAllergens + moderateIntolerances
@ -71,12 +70,13 @@ data class Nutriments(
val sodium100g: Double? = null, val sodium100g: Double? = null,
val fiber100g: Double? = null, val fiber100g: Double? = null,
val proteins100g: Double? = null, val proteins100g: Double? = null,
val carbohydrates100g: Double? = null val carbohydrates100g: Double? = null,
) { ) {
fun isEmpty(): Boolean = listOf( fun isEmpty(): Boolean =
energyKcal100g, energyKcalServing, fat100g, saturatedFat100g, listOf(
sugars100g, salt100g, sodium100g, fiber100g, proteins100g, carbohydrates100g energyKcal100g, energyKcalServing, fat100g, saturatedFat100g,
).all { it == null } sugars100g, salt100g, sodium100g, fiber100g, proteins100g, carbohydrates100g,
).all { it == null }
} }
/** A product fetched from Open Food Facts (or reconstructed from OCR). */ /** A product fetched from Open Food Facts (or reconstructed from OCR). */
@ -94,7 +94,7 @@ data class Product(
val servingSize: String? = null, val servingSize: String? = null,
val nutriments: Nutriments = Nutriments(), val nutriments: Nutriments = Nutriments(),
val labels: List<String> = emptyList(), val labels: List<String> = emptyList(),
val categories: List<String> = emptyList() val categories: List<String> = emptyList(),
) { ) {
/** Public Open Food Facts product page URL. */ /** Public Open Food Facts product page URL. */
fun openFoodFactsUrl(): String = "https://world.openfoodfacts.org/product/$barcode" fun openFoodFactsUrl(): String = "https://world.openfoodfacts.org/product/$barcode"
@ -104,7 +104,7 @@ data class Product(
data class DetectedCustomItem( data class DetectedCustomItem(
val item: CustomDietItem, val item: CustomDietItem,
val matchedKeywords: List<String>, 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. */ /** 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 reasons: List<String> = emptyList(),
val nutriScore: String? = null, val nutriScore: String? = null,
val novaGroup: Int? = null, val novaGroup: Int? = null,
val ecoScore: String? = null val ecoScore: String? = null,
) )
/** Describes a single allergen that was detected during analysis. */ /** 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). */ /** Which profiles this detection concerns (useful for multi-profile scans). */
val profileIds: List<Long> = emptyList(), val profileIds: List<Long> = emptyList(),
/** True when at least one profile lists this as a *severe* allergy. */ /** True when at least one profile lists this as a *severe* allergy. */
val severe: Boolean = true val severe: Boolean = true,
) )
data class ScanResult( data class ScanResult(
@ -136,7 +136,7 @@ data class ScanResult(
val health: HealthAssessment = HealthAssessment(), val health: HealthAssessment = HealthAssessment(),
val analyzedProfiles: List<UserProfile>, val analyzedProfiles: List<UserProfile>,
val confidence: AnalysisConfidence, val confidence: AnalysisConfidence,
val source: DataSource val source: DataSource,
) )
data class ScanHistoryItem( data class ScanHistoryItem(
@ -148,5 +148,5 @@ data class ScanHistoryItem(
val safetyStatus: SafetyStatus, val safetyStatus: SafetyStatus,
val profileNames: List<String>, val profileNames: List<String>,
val scannedAt: Long, val scannedAt: Long,
val source: DataSource val source: DataSource,
) )

View File

@ -15,38 +15,54 @@ import kotlinx.coroutines.flow.Flow
sealed class ProductFetchResult { sealed class ProductFetchResult {
data class Found(val product: Product, val fromCache: Boolean) : ProductFetchResult() data class Found(val product: Product, val fromCache: Boolean) : ProductFetchResult()
data object NotFound : ProductFetchResult() data object NotFound : ProductFetchResult()
data class Error(val message: String, val offline: Boolean = false) : ProductFetchResult() data class Error(val message: String, val offline: Boolean = false) : ProductFetchResult()
} }
interface ProductRepository { interface ProductRepository {
suspend fun fetchProduct(barcode: String): ProductFetchResult suspend fun fetchProduct(barcode: String): ProductFetchResult
suspend fun cacheProduct(product: Product) suspend fun cacheProduct(product: Product)
suspend fun getCachedProduct(barcode: String): Product? suspend fun getCachedProduct(barcode: String): Product?
suspend fun clearCache() suspend fun clearCache()
/** Search for products in the same category without specific allergens. */ /** Search for products in the same category without specific allergens. */
suspend fun searchAlternatives( suspend fun searchAlternatives(
category: String, category: String,
excludeAllergens: Set<String>, excludeAllergens: Set<String>,
limit: Int = 5 limit: Int = 5,
): List<Product> ): List<Product>
} }
interface UserProfileRepository { interface UserProfileRepository {
fun observeProfiles(): Flow<List<UserProfile>> fun observeProfiles(): Flow<List<UserProfile>>
suspend fun getProfile(id: Long): UserProfile? suspend fun getProfile(id: Long): UserProfile?
suspend fun upsert(profile: UserProfile): Long suspend fun upsert(profile: UserProfile): Long
suspend fun delete(profile: UserProfile) suspend fun delete(profile: UserProfile)
suspend fun setDefault(id: Long) suspend fun setDefault(id: Long)
fun observeActiveProfileIds(): Flow<Set<Long>> fun observeActiveProfileIds(): Flow<Set<Long>>
suspend fun setActiveProfileIds(ids: Set<Long>) suspend fun setActiveProfileIds(ids: Set<Long>)
} }
interface ScanHistoryRepository { interface ScanHistoryRepository {
fun observeHistory(): Flow<List<ScanHistoryItem>> fun observeHistory(): Flow<List<ScanHistoryItem>>
suspend fun save(result: ScanResult): Long suspend fun save(result: ScanResult): Long
suspend fun delete(id: Long) suspend fun delete(id: Long)
suspend fun clear() suspend fun clear()
suspend fun getById(id: Long): ScanHistoryItem? suspend fun getById(id: Long): ScanHistoryItem?
} }
@ -61,12 +77,19 @@ interface SettingsRepository {
val splashScreenEnabled: Flow<Boolean> val splashScreenEnabled: Flow<Boolean>
suspend fun setAppLanguage(value: AppLanguage) suspend fun setAppLanguage(value: AppLanguage)
suspend fun setDetectionLanguage(value: DetectionLanguage) suspend fun setDetectionLanguage(value: DetectionLanguage)
suspend fun setHaptics(enabled: Boolean) suspend fun setHaptics(enabled: Boolean)
suspend fun setSound(enabled: Boolean) suspend fun setSound(enabled: Boolean)
suspend fun setTheme(value: ThemePref) suspend fun setTheme(value: ThemePref)
suspend fun setOnboardingCompleted(value: Boolean) suspend fun setOnboardingCompleted(value: Boolean)
suspend fun setHealthStrictness(value: HealthStrictness) suspend fun setHealthStrictness(value: HealthStrictness)
suspend fun setSplashScreenEnabled(enabled: Boolean) suspend fun setSplashScreenEnabled(enabled: Boolean)
} }
@ -77,34 +100,61 @@ interface SettingsRepository {
interface ShoppingListRepository { interface ShoppingListRepository {
// Lists // Lists
fun observeActiveLists(): Flow<List<ShoppingListEntity>> fun observeActiveLists(): Flow<List<ShoppingListEntity>>
fun observeAllLists(): Flow<List<ShoppingListEntity>> fun observeAllLists(): Flow<List<ShoppingListEntity>>
suspend fun getListById(id: Long): 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 updateList(list: ShoppingListEntity)
suspend fun deleteList(list: ShoppingListEntity) suspend fun deleteList(list: ShoppingListEntity)
suspend fun archiveList(id: Long) suspend fun archiveList(id: Long)
// Items // Items
fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>> fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>>
suspend fun getItems(listId: Long): List<ShoppingListItemEntity> suspend fun getItems(listId: Long): List<ShoppingListItemEntity>
suspend fun addItem(item: ShoppingListItemEntity): Long suspend fun addItem(item: ShoppingListItemEntity): Long
suspend fun updateItem(item: ShoppingListItemEntity) suspend fun updateItem(item: ShoppingListItemEntity)
suspend fun deleteItem(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 uncheckAllItems(listId: Long)
suspend fun deleteAllItems(listId: Long) suspend fun deleteAllItems(listId: Long)
// Stats // Stats
fun observeItemCount(listId: Long): Flow<Int> fun observeItemCount(listId: Long): Flow<Int>
fun observeCheckedCount(listId: Long): Flow<Int> fun observeCheckedCount(listId: Long): Flow<Int>
// Members // Members
fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>> fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>>
suspend fun addMember(member: ShoppingListMemberEntity): Long suspend fun addMember(member: ShoppingListMemberEntity): Long
suspend fun updateMember(member: ShoppingListMemberEntity) suspend fun updateMember(member: ShoppingListMemberEntity)
suspend fun removeMember(member: ShoppingListMemberEntity) suspend fun removeMember(member: ShoppingListMemberEntity)
suspend fun deleteAllMembers(listId: Long) suspend fun deleteAllMembers(listId: Long)
// Helpers // 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 * UseCase pour trouver des produits alternatifs dans la même catégorie
* sans les allergènes problématiques. * sans les allergènes problématiques.
*/ */
class GetAlternativesUseCase @Inject constructor( class GetAlternativesUseCase
private val productRepository: ProductRepository @Inject
) { constructor(
suspend operator fun invoke( private val productRepository: ProductRepository,
category: String, ) {
excludeAllergenTags: Set<String>, suspend operator fun invoke(
limit: Int = 5 category: String,
): List<Product> { excludeAllergenTags: Set<String>,
if (category.isBlank()) return emptyList() limit: Int = 5,
return productRepository.searchAlternatives(category, excludeAllergenTags, limit) ): 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.engine.AllergenAnalysisEngine
import com.safebite.app.domain.model.DataSource 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.Product
import com.safebite.app.domain.model.ScanResult import com.safebite.app.domain.model.ScanResult
import com.safebite.app.domain.model.UserProfile import com.safebite.app.domain.model.UserProfile
@ -16,108 +15,163 @@ import kotlinx.coroutines.flow.first
import javax.inject.Inject import javax.inject.Inject
/** Fetch a product by barcode (remote or cache). */ /** Fetch a product by barcode (remote or cache). */
class FetchProductUseCase @Inject constructor( class FetchProductUseCase
private val productRepository: ProductRepository @Inject
) { constructor(
suspend operator fun invoke(barcode: String): ProductFetchResult = private val productRepository: ProductRepository,
productRepository.fetchProduct(barcode) ) {
} suspend operator fun invoke(barcode: String): ProductFetchResult = productRepository.fetchProduct(barcode)
}
/** Analyze a product against a list of profiles using the engine. */ /** Analyze a product against a list of profiles using the engine. */
class AnalyzeProductUseCase @Inject constructor( class AnalyzeProductUseCase
private val settingsRepository: SettingsRepository @Inject
) { constructor(
suspend operator fun invoke( private val settingsRepository: SettingsRepository,
product: Product, ) {
profiles: List<UserProfile>, suspend operator fun invoke(
source: DataSource product: Product,
): ScanResult { profiles: List<UserProfile>,
val lang = settingsRepository.detectionLanguage.first() source: DataSource,
val strictness = settingsRepository.healthStrictness.first() ): ScanResult {
return AllergenAnalysisEngine.analyze(product, profiles, source, lang, strictness) 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). */ /** Analyze free-form ingredients text (OCR path). */
class AnalyzeIngredientsTextUseCase @Inject constructor( class AnalyzeIngredientsTextUseCase
private val analyzeProductUseCase: AnalyzeProductUseCase @Inject
) { constructor(
suspend operator fun invoke( private val analyzeProductUseCase: AnalyzeProductUseCase,
text: String, ) {
profiles: List<UserProfile>, suspend operator fun invoke(
barcode: String? = null, text: String,
productName: String? = null profiles: List<UserProfile>,
): ScanResult { barcode: String? = null,
val product = Product( productName: String? = null,
barcode = barcode ?: "ocr-${System.currentTimeMillis()}", ): ScanResult {
name = productName, val product =
brand = null, Product(
imageUrl = null, barcode = barcode ?: "ocr-${System.currentTimeMillis()}",
ingredientsText = text, name = productName,
allergensTags = emptyList(), brand = null,
tracesTags = emptyList() imageUrl = null,
) ingredientsText = text,
return analyzeProductUseCase(product, profiles, DataSource.OCR) allergensTags = emptyList(),
tracesTags = emptyList(),
)
return analyzeProductUseCase(product, profiles, DataSource.OCR)
}
} }
}
class ManageProfileUseCase @Inject constructor( class ManageProfileUseCase
private val repo: UserProfileRepository @Inject
) { constructor(
fun observe(): Flow<List<UserProfile>> = repo.observeProfiles() private val repo: UserProfileRepository,
suspend fun get(id: Long) = repo.getProfile(id) ) {
suspend fun save(profile: UserProfile): Long = repo.upsert(profile) fun observe(): Flow<List<UserProfile>> = repo.observeProfiles()
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( suspend fun get(id: Long) = repo.getProfile(id)
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( suspend fun save(profile: UserProfile): Long = repo.upsert(profile)
private val repo: ScanHistoryRepository
) { suspend fun delete(profile: UserProfile) = repo.delete(profile)
suspend operator fun invoke(result: ScanResult): Long = repo.save(result)
} 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) // Shopping List UseCases (Phase 2)
// ============================================================================= // =============================================================================
class GetShoppingListsUseCase @Inject constructor( class GetShoppingListsUseCase
private val repo: com.safebite.app.domain.repository.ShoppingListRepository @Inject
) { constructor(
fun observeActive() = repo.observeActiveLists() private val repo: com.safebite.app.domain.repository.ShoppingListRepository,
fun observeAll() = repo.observeAllLists() ) {
suspend fun getList(id: Long) = repo.getListById(id) fun observeActive() = repo.observeActiveLists()
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( fun observeAll() = repo.observeAllLists()
private val repo: com.safebite.app.domain.repository.ShoppingListRepository
) { suspend fun getList(id: Long) = repo.getListById(id)
fun observeItems(listId: Long) = repo.observeItems(listId)
suspend fun getItems(listId: Long) = repo.getItems(listId) suspend fun createList(
suspend fun addItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.addItem(item) name: String,
suspend fun updateItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.updateItem(item) backgroundResName: String? = null,
suspend fun deleteItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.deleteItem(item) ) = repo.createList(name, backgroundResName)
suspend fun setItemChecked(id: Long, checked: Boolean) = repo.setItemChecked(id, checked)
suspend fun uncheckAllItems(listId: Long) = repo.uncheckAllItems(listId) suspend fun updateList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.updateList(list)
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) 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.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.runtime.getValue
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.model.ThemePref
import com.safebite.app.domain.repository.SettingsRepository import com.safebite.app.domain.repository.SettingsRepository
import com.safebite.app.presentation.navigation.SafeBiteNavGraph import com.safebite.app.presentation.navigation.SafeBiteNavGraph
@ -25,30 +25,32 @@ data class RootUi(
val onboardingDone: Boolean = false, val onboardingDone: Boolean = false,
val theme: ThemePref = ThemePref.SYSTEM, val theme: ThemePref = ThemePref.SYSTEM,
val showSplash: Boolean = false, val showSplash: Boolean = false,
val ready: Boolean = false val ready: Boolean = false,
) )
@HiltViewModel @HiltViewModel
class RootViewModel @Inject constructor( class RootViewModel
settings: SettingsRepository @Inject
) : ViewModel() { constructor(
val state: StateFlow<RootUi> = combine( settings: SettingsRepository,
settings.onboardingCompleted, ) : ViewModel() {
settings.theme, val state: StateFlow<RootUi> =
settings.splashScreenEnabled combine(
) { done, theme, splashEnabled -> settings.onboardingCompleted,
RootUi( settings.theme,
onboardingDone = done, settings.splashScreenEnabled,
theme = theme, ) { done, theme, splashEnabled ->
showSplash = splashEnabled && done, RootUi(
ready = true onboardingDone = done,
) theme = theme,
}.stateIn(viewModelScope, SharingStarted.Eagerly, RootUi()) showSplash = splashEnabled && done,
} ready = true,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, RootUi())
}
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val rootViewModel: RootViewModel by viewModels() private val rootViewModel: RootViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -56,16 +58,17 @@ class MainActivity : ComponentActivity() {
WindowCompat.setDecorFitsSystemWindows(window, true) WindowCompat.setDecorFitsSystemWindows(window, true)
setContent { setContent {
val ui by rootViewModel.state.collectAsStateWithLifecycle() val ui by rootViewModel.state.collectAsStateWithLifecycle()
val dark = when (ui.theme) { val dark =
ThemePref.LIGHT -> false when (ui.theme) {
ThemePref.DARK -> true ThemePref.LIGHT -> false
ThemePref.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme() ThemePref.DARK -> true
} ThemePref.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme()
}
SafeBiteTheme(darkTheme = dark) { SafeBiteTheme(darkTheme = dark) {
if (ui.ready) { if (ui.ready) {
SafeBiteNavGraph( SafeBiteNavGraph(
onboardingCompleted = ui.onboardingDone, 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) { enum class AllergenLevel(val label: String, val emoji: String) {
NONE("Aucun", ""), NONE("Aucun", ""),
TRACE("Traces", "⚠️"), TRACE("Traces", "⚠️"),
SEVERE("Sévère", "") SEVERE("Sévère", ""),
} }
/** /**
* Couleurs de fond par état d'allergie (spec UX §4.2). * Couleurs de fond par état d'allergie (spec UX §4.2).
*/ */
fun AllergenLevel.backgroundColor(): Color = when (this) { fun AllergenLevel.backgroundColor(): Color =
AllergenLevel.NONE -> Color.Transparent when (this) {
AllergenLevel.TRACE -> Color(0xFFFEF5E7) // Orange clair AllergenLevel.NONE -> Color.Transparent
AllergenLevel.SEVERE -> Color(0xFFFDEDEC) // Rouge clair AllergenLevel.TRACE -> Color(0xFFFEF5E7) // Orange clair
} AllergenLevel.SEVERE -> Color(0xFFFDEDEC) // Rouge clair
}
/** /**
* Couleur de bordure par état. * Couleur de bordure par état.
*/ */
@Composable @Composable
fun AllergenLevel.borderColor(): Color = when (this) { fun AllergenLevel.borderColor(): Color =
AllergenLevel.NONE -> MaterialTheme.colorScheme.outlineVariant when (this) {
AllergenLevel.TRACE -> Color(0xFFF39C12) AllergenLevel.NONE -> MaterialTheme.colorScheme.outlineVariant
AllergenLevel.SEVERE -> Color(0xFFE74C3C) AllergenLevel.TRACE -> Color(0xFFF39C12)
} AllergenLevel.SEVERE -> Color(0xFFE74C3C)
}
/** /**
* Grille de sélection d'allergènes avec 3 états par tap. * Grille de sélection d'allergènes avec 3 états par tap.
@ -66,7 +68,7 @@ fun AllergenLevel.borderColor(): Color = when (this) {
fun AllergenSelectionGrid( fun AllergenSelectionGrid(
selectedAllergens: Map<AllergenType, AllergenLevel>, selectedAllergens: Map<AllergenType, AllergenLevel>,
onLevelChanged: (AllergenType, AllergenLevel) -> Unit, onLevelChanged: (AllergenType, AllergenLevel) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
val columns = 3 val columns = 3
@ -74,12 +76,12 @@ fun AllergenSelectionGrid(
Column( Column(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm) verticalArrangement = Arrangement.spacedBy(dimens.spacingSm),
) { ) {
rows.forEach { row -> rows.forEach { row ->
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm) horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm),
) { ) {
row.forEach { allergen -> row.forEach { allergen ->
val currentLevel = selectedAllergens[allergen] ?: AllergenLevel.NONE val currentLevel = selectedAllergens[allergen] ?: AllergenLevel.NONE
@ -88,13 +90,14 @@ fun AllergenSelectionGrid(
allergen = allergen, allergen = allergen,
level = currentLevel, level = currentLevel,
onClick = { onClick = {
val nextLevel = when (currentLevel) { val nextLevel =
AllergenLevel.NONE -> AllergenLevel.TRACE when (currentLevel) {
AllergenLevel.TRACE -> AllergenLevel.SEVERE AllergenLevel.NONE -> AllergenLevel.TRACE
AllergenLevel.SEVERE -> AllergenLevel.NONE AllergenLevel.TRACE -> AllergenLevel.SEVERE
} AllergenLevel.SEVERE -> AllergenLevel.NONE
}
onLevelChanged(allergen, nextLevel) onLevelChanged(allergen, nextLevel)
} },
) )
} }
repeat(columns - row.size) { repeat(columns - row.size) {
@ -113,33 +116,37 @@ fun AllergenSelectionChip(
allergen: AllergenType, allergen: AllergenType,
level: AllergenLevel, level: AllergenLevel,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
Card( Card(
modifier = modifier modifier =
.clickable(onClick = onClick), modifier
.clickable(onClick = onClick),
shape = RoundedCornerShape(dimens.radiusMd), shape = RoundedCornerShape(dimens.radiusMd),
colors = CardDefaults.cardColors( colors =
containerColor = level.backgroundColor() CardDefaults.cardColors(
), containerColor = level.backgroundColor(),
border = androidx.compose.foundation.BorderStroke( ),
width = 2.dp, border =
color = level.borderColor() androidx.compose.foundation.BorderStroke(
) width = 2.dp,
color = level.borderColor(),
),
) { ) {
Column( Column(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(vertical = dimens.spacingSm, horizontal = dimens.spacingXs), .fillMaxWidth()
horizontalAlignment = Alignment.CenterHorizontally .padding(vertical = dimens.spacingSm, horizontal = dimens.spacingXs),
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
// Emoji allergène // Emoji allergène
Text( Text(
text = allergen.icon, text = allergen.icon,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center textAlign = TextAlign.Center,
) )
// Nom court // Nom court
@ -148,7 +155,7 @@ fun AllergenSelectionChip(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 1 maxLines = 1,
) )
// Indicateur d'état // Indicateur d'état
@ -156,7 +163,7 @@ fun AllergenSelectionChip(
Text( Text(
text = level.emoji, text = level.emoji,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center textAlign = TextAlign.Center,
) )
} }
} }
@ -171,7 +178,7 @@ fun AllergenSelectionChip(
fun AllergenDisplayGrid( fun AllergenDisplayGrid(
severeAllergens: Set<AllergenType>, severeAllergens: Set<AllergenType>,
moderateIntolerances: Set<AllergenType>, moderateIntolerances: Set<AllergenType>,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
@ -180,29 +187,29 @@ fun AllergenDisplayGrid(
text = "Aucune allergie détectée ✅", text = "Aucune allergie détectée ✅",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier.padding(dimens.spacingSm) modifier = modifier.padding(dimens.spacingSm),
) )
return return
} }
Column( Column(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm) verticalArrangement = Arrangement.spacedBy(dimens.spacingSm),
) { ) {
// Allergènes sévères // Allergènes sévères
if (severeAllergens.isNotEmpty()) { if (severeAllergens.isNotEmpty()) {
Text( Text(
text = "❌ Allergies sévères :", text = "❌ Allergies sévères :",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = Color(0xFFE74C3C) color = Color(0xFFE74C3C),
) )
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
severeAllergens.forEach { allergen -> severeAllergens.forEach { allergen ->
AllergenBadge( AllergenBadge(
allergen = allergen, allergen = allergen,
level = AllergenLevel.SEVERE level = AllergenLevel.SEVERE,
) )
} }
} }
@ -213,15 +220,15 @@ fun AllergenDisplayGrid(
Text( Text(
text = "⚠️ Intolérances :", text = "⚠️ Intolérances :",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = Color(0xFFF39C12) color = Color(0xFFF39C12),
) )
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
moderateIntolerances.forEach { allergen -> moderateIntolerances.forEach { allergen ->
AllergenBadge( AllergenBadge(
allergen = allergen, allergen = allergen,
level = AllergenLevel.TRACE level = AllergenLevel.TRACE,
) )
} }
} }
@ -236,30 +243,31 @@ fun AllergenDisplayGrid(
fun AllergenBadge( fun AllergenBadge(
allergen: AllergenType, allergen: AllergenType,
level: AllergenLevel, level: AllergenLevel,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
Box( Box(
modifier = modifier modifier =
.background( modifier
color = level.backgroundColor(), .background(
shape = RoundedCornerShape(12.dp) color = level.backgroundColor(),
) shape = RoundedCornerShape(12.dp),
.border( )
width = 1.dp, .border(
color = level.borderColor(), width = 1.dp,
shape = RoundedCornerShape(12.dp) color = level.borderColor(),
) shape = RoundedCornerShape(12.dp),
.padding(horizontal = 8.dp, vertical = 4.dp) )
.padding(horizontal = 8.dp, vertical = 4.dp),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text(text = allergen.icon, style = MaterialTheme.typography.bodySmall) Text(text = allergen.icon, style = MaterialTheme.typography.bodySmall)
Text( Text(
text = allergen.displayNameFr, text = allergen.displayNameFr,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface,
) )
} }
} }

View File

@ -32,21 +32,23 @@ fun SafeBiteTopAppBar(
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior? = null, scrollBehavior: TopAppBarScrollBehavior? = null,
) { ) {
val colors = TopAppBarDefaults.topAppBarColors( val colors =
containerColor = MaterialTheme.colorScheme.surface, TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant, containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface, scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface, titleContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
) actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
val titleComposable: @Composable () -> Unit = { val titleComposable: @Composable () -> Unit = {
Text( Text(
title, title,
style = when (variant) { style =
AppBarVariant.Large -> MaterialTheme.typography.headlineMedium when (variant) {
AppBarVariant.CenterAligned -> MaterialTheme.typography.titleLarge AppBarVariant.Large -> MaterialTheme.typography.headlineMedium
AppBarVariant.Small -> MaterialTheme.typography.titleLarge AppBarVariant.CenterAligned -> MaterialTheme.typography.titleLarge
} AppBarVariant.Small -> MaterialTheme.typography.titleLarge
},
) )
} }
val navIcon: @Composable () -> Unit = { val navIcon: @Composable () -> Unit = {
@ -57,35 +59,39 @@ fun SafeBiteTopAppBar(
} }
} }
when (variant) { when (variant) {
AppBarVariant.Small -> TopAppBar( AppBarVariant.Small ->
title = titleComposable, TopAppBar(
modifier = modifier, title = titleComposable,
navigationIcon = navIcon, modifier = modifier,
actions = actions, navigationIcon = navIcon,
colors = colors, actions = actions,
scrollBehavior = scrollBehavior, colors = colors,
) scrollBehavior = scrollBehavior,
AppBarVariant.CenterAligned -> CenterAlignedTopAppBar( )
title = titleComposable, AppBarVariant.CenterAligned ->
modifier = modifier, CenterAlignedTopAppBar(
navigationIcon = navIcon, title = titleComposable,
actions = actions, modifier = modifier,
colors = colors, navigationIcon = navIcon,
scrollBehavior = scrollBehavior, actions = actions,
) colors = colors,
AppBarVariant.Large -> LargeTopAppBar( scrollBehavior = scrollBehavior,
title = titleComposable, )
modifier = modifier, AppBarVariant.Large ->
navigationIcon = navIcon, LargeTopAppBar(
actions = actions, title = titleComposable,
colors = TopAppBarDefaults.largeTopAppBarColors( modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface, navigationIcon = navIcon,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant, actions = actions,
titleContentColor = MaterialTheme.colorScheme.onSurface, colors =
navigationIconContentColor = MaterialTheme.colorScheme.onSurface, TopAppBarDefaults.largeTopAppBarColors(
actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, containerColor = MaterialTheme.colorScheme.surface,
), scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
scrollBehavior = scrollBehavior, 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( val scale by animateFloatAsState(
targetValue = if (pressed) 0.96f else 1f, targetValue = if (pressed) 0.96f else 1f,
animationSpec = tween(durationMillis = 120), animationSpec = tween(durationMillis = 120),
label = "buttonPressScale" label = "buttonPressScale",
) )
return scale return scale
} }
@ -76,10 +76,11 @@ fun PrimaryButton(
Button( Button(
onClick = { if (!loading) onClick() }, onClick = { if (!loading) onClick() },
enabled = enabled && !loading, enabled = enabled && !loading,
modifier = modifier modifier =
.scale(scale) modifier
.heightIn(min = if (large) ButtonTokens.MinHeightLarge else ButtonTokens.MinHeight) .scale(scale)
.defaultMinSize(minHeight = if (large) ButtonTokens.MinHeightLarge else ButtonTokens.MinHeight), .heightIn(min = if (large) ButtonTokens.MinHeightLarge else ButtonTokens.MinHeight)
.defaultMinSize(minHeight = if (large) ButtonTokens.MinHeightLarge else ButtonTokens.MinHeight),
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm), contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm),
interactionSource = interaction, interactionSource = interaction,
@ -104,10 +105,11 @@ fun SecondaryButton(
FilledTonalButton( FilledTonalButton(
onClick = { if (!loading) onClick() }, onClick = { if (!loading) onClick() },
enabled = enabled && !loading, enabled = enabled && !loading,
modifier = modifier modifier =
.scale(scale) modifier
.heightIn(min = ButtonTokens.MinHeight) .scale(scale)
.defaultMinSize(minHeight = ButtonTokens.MinHeight), .heightIn(min = ButtonTokens.MinHeight)
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm), contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm),
interactionSource = interaction, interactionSource = interaction,
@ -132,10 +134,11 @@ fun OutlinedActionButton(
OutlinedButton( OutlinedButton(
onClick = { if (!loading) onClick() }, onClick = { if (!loading) onClick() },
enabled = enabled && !loading, enabled = enabled && !loading,
modifier = modifier modifier =
.scale(scale) modifier
.heightIn(min = ButtonTokens.MinHeight) .scale(scale)
.defaultMinSize(minHeight = ButtonTokens.MinHeight), .heightIn(min = ButtonTokens.MinHeight)
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm), contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm),
interactionSource = interaction, interactionSource = interaction,
@ -158,10 +161,11 @@ fun TertiaryButton(
TextButton( TextButton(
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,
modifier = modifier modifier =
.scale(scale) modifier
.heightIn(min = ButtonTokens.MinHeight) .scale(scale)
.defaultMinSize(minHeight = ButtonTokens.MinHeight), .heightIn(min = ButtonTokens.MinHeight)
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
interactionSource = interaction, interactionSource = interaction,
) { ) {
@ -182,17 +186,19 @@ fun DestructiveButton(
val dimens = LocalDimens.current val dimens = LocalDimens.current
val interaction = remember { MutableInteractionSource() } val interaction = remember { MutableInteractionSource() }
val scale = pressedScale(interaction) val scale = pressedScale(interaction)
val colors: ButtonColors = ButtonDefaults.filledTonalButtonColors( val colors: ButtonColors =
containerColor = MaterialTheme.colorScheme.errorContainer, ButtonDefaults.filledTonalButtonColors(
contentColor = MaterialTheme.colorScheme.onErrorContainer, containerColor = MaterialTheme.colorScheme.errorContainer,
) contentColor = MaterialTheme.colorScheme.onErrorContainer,
)
FilledTonalButton( FilledTonalButton(
onClick = { if (!loading) onClick() }, onClick = { if (!loading) onClick() },
enabled = enabled && !loading, enabled = enabled && !loading,
modifier = modifier modifier =
.scale(scale) modifier
.heightIn(min = ButtonTokens.MinHeight) .scale(scale)
.defaultMinSize(minHeight = ButtonTokens.MinHeight), .heightIn(min = ButtonTokens.MinHeight)
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
colors = colors, colors = colors,
contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm), contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm),
@ -203,20 +209,24 @@ fun DestructiveButton(
} }
@Composable @Composable
private fun ButtonContent(text: String, icon: ImageVector?, loading: Boolean) { private fun ButtonContent(
text: String,
icon: ImageVector?,
loading: Boolean,
) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (loading) { if (loading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(ButtonTokens.ProgressSize), modifier = Modifier.size(ButtonTokens.ProgressSize),
strokeWidth = 2.dp, strokeWidth = 2.dp,
color = LocalContentColor.current color = LocalContentColor.current,
) )
Spacer(Modifier.width(ButtonTokens.IconSpacer)) Spacer(Modifier.width(ButtonTokens.IconSpacer))
} else if (icon != null) { } else if (icon != null) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(ButtonTokens.IconSize) modifier = Modifier.size(ButtonTokens.IconSize),
) )
Spacer(Modifier.width(ButtonTokens.IconSpacer)) Spacer(Modifier.width(ButtonTokens.IconSpacer))
} }

View File

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

View File

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

View File

@ -4,7 +4,6 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -24,10 +23,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@ -69,38 +66,39 @@ fun AllergenChip(
allergen: AllergenType, allergen: AllergenType,
selected: Boolean, selected: Boolean,
onToggle: () -> Unit, onToggle: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
val bg = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface val bg = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface
val fg = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface val fg = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
val stateDesc = if (selected) "Sélectionné" else "Non sélectionné" val stateDesc = if (selected) "Sélectionné" else "Non sélectionné"
Surface( Surface(
modifier = modifier modifier =
.semantics { modifier
contentDescription = "${allergen.displayNameFr} - $stateDesc" .semantics {
role = Role.Checkbox contentDescription = "${allergen.displayNameFr} - $stateDesc"
stateDescription = stateDesc role = Role.Checkbox
}, stateDescription = stateDesc
},
shape = RoundedCornerShape(dimens.radiusPill), shape = RoundedCornerShape(dimens.radiusPill),
color = bg, color = bg,
border = if (selected) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outline), border = if (selected) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
onClick = onToggle onClick = onToggle,
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = dimens.spacingMd, vertical = dimens.spacingSm), modifier = Modifier.padding(horizontal = dimens.spacingMd, vertical = dimens.spacingSm),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = allergen.icon, text = allergen.icon,
color = fg, color = fg,
modifier = Modifier.semantics { contentDescription = "" } modifier = Modifier.semantics { contentDescription = "" },
) )
Spacer(Modifier.width(dimens.spacingXs + 2.dp)) Spacer(Modifier.width(dimens.spacingXs + 2.dp))
Text( Text(
text = allergen.displayNameFr, text = allergen.displayNameFr,
color = fg, color = fg,
style = MaterialTheme.typography.labelLarge style = MaterialTheme.typography.labelLarge,
) )
} }
} }
@ -120,112 +118,118 @@ fun SafetyStatusBanner(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
profileName: String? = null, profileName: String? = null,
allergenName: String? = null, allergenName: String? = null,
severity: String? = null severity: String? = null,
) { ) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
val colors = LocalStatusColors.current val colors = LocalStatusColors.current
val a11yDescription = when (status) { val a11yDescription =
SafetyStatus.SAFE -> stringResource(R.string.a11y_verdict_safe) when (status) {
SafetyStatus.WARNING -> stringResource(R.string.a11y_verdict_warning) SafetyStatus.SAFE -> stringResource(R.string.a11y_verdict_safe)
SafetyStatus.DANGER -> if (profileName != null) SafetyStatus.WARNING -> stringResource(R.string.a11y_verdict_warning)
stringResource(R.string.a11y_verdict_danger, profileName) SafetyStatus.DANGER ->
else if (profileName != null) {
stringResource(R.string.a11y_danger_status, "") 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
)
} }
SafetyStatus.WARNING -> {
VerdictBannerData( val (titleRes, icon, shapeIcon, containerColor, onContainerColor) =
titleRes = R.string.result_warning_headline, when (status) {
icon = "⚠️", SafetyStatus.SAFE -> {
shapeIcon = "🔺", VerdictBannerData(
containerColor = colors.warning, titleRes = R.string.result_safe_headline,
onContainerColor = colors.onWarning 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.DANGER -> {
VerdictBannerData(
titleRes = R.string.result_danger_headline,
icon = "",
shapeIcon = "🔷",
containerColor = colors.danger,
onContainerColor = colors.onDanger
)
}
}
Surface( Surface(
modifier = modifier modifier =
.fillMaxWidth() modifier
.semantics { .fillMaxWidth()
contentDescription = a11yDescription .semantics {
}, contentDescription = a11yDescription
},
color = containerColor, color = containerColor,
contentColor = onContainerColor contentColor = onContainerColor,
) { ) {
Column( Column(
modifier = Modifier.padding(dimens.spacingLg) modifier = Modifier.padding(dimens.spacingLg),
) { ) {
// Ligne supérieure : forme daltonienne + icône + titre // Ligne supérieure : forme daltonienne + icône + titre
// Système daltonien : forme géométrique + couleur + icône // Système daltonien : forme géométrique + couleur + icône
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center,
) { ) {
// Forme daltonienne (jamais couleur seule) // Forme daltonienne (jamais couleur seule)
DaltonianShape( DaltonianShape(
status = status, status = status,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp),
) )
Spacer(Modifier.width(dimens.spacingXs)) Spacer(Modifier.width(dimens.spacingXs))
Text( Text(
text = icon, text = icon,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.semantics { contentDescription = "" } modifier = Modifier.semantics { contentDescription = "" },
) )
Spacer(Modifier.width(dimens.spacingSm)) Spacer(Modifier.width(dimens.spacingSm))
Text( Text(
text = stringResource(titleRes), text = stringResource(titleRes),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
) )
} }
// Sous-titre contextuel (si allergène et profil) // Sous-titre contextuel (si allergène et profil)
if (allergenName != null && profileName != null) { if (allergenName != null && profileName != null) {
Spacer(Modifier.height(dimens.spacingXs)) Spacer(Modifier.height(dimens.spacingXs))
val subtitle = when (status) { val subtitle =
SafetyStatus.WARNING -> "⚠️ Attention pour $profileName : $allergenName" when (status) {
SafetyStatus.DANGER -> "❌ Interdit pour $profileName : $allergenName${if (severity == "anaphylaxis") " (anaphylaxie)" else ""}" SafetyStatus.WARNING -> "⚠️ Attention pour $profileName : $allergenName"
else -> "" SafetyStatus.DANGER -> "❌ Interdit pour $profileName : $allergenName${if (severity == "anaphylaxis") " (anaphylaxie)" else ""}"
} else -> ""
}
if (subtitle.isNotEmpty()) { if (subtitle.isNotEmpty()) {
Text( Text(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold,
) )
} }
} }
// Message supplémentaire pour danger // Message supplémentaire pour danger
if (status == SafetyStatus.DANGER) { if (status == SafetyStatus.DANGER) {
Spacer(Modifier.height(dimens.spacingXs)) Spacer(Modifier.height(dimens.spacingXs))
Text( Text(
text = "Ne pas consommer", text = "Ne pas consommer",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
) )
} }
} }
@ -242,46 +246,50 @@ fun SafetyStatusBanner(
@Composable @Composable
fun DaltonianShape( fun DaltonianShape(
status: SafetyStatus, status: SafetyStatus,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
val colors = LocalStatusColors.current val colors = LocalStatusColors.current
val color = when (status) { val color =
SafetyStatus.SAFE -> colors.safe when (status) {
SafetyStatus.WARNING -> colors.warning SafetyStatus.SAFE -> colors.safe
SafetyStatus.DANGER -> colors.danger SafetyStatus.WARNING -> colors.warning
} SafetyStatus.DANGER -> colors.danger
}
when (status) { when (status) {
SafetyStatus.SAFE -> { SafetyStatus.SAFE -> {
// Cercle pour SAFE // Cercle pour SAFE
Box( Box(
modifier = modifier modifier =
.background(color, CircleShape) modifier
.semantics { contentDescription = "" } .background(color, CircleShape)
.semantics { contentDescription = "" },
) )
} }
SafetyStatus.WARNING -> { SafetyStatus.WARNING -> {
// Triangle pour WARNING (dessiné avec Canvas) // Triangle pour WARNING (dessiné avec Canvas)
Canvas(modifier = modifier) { Canvas(modifier = modifier) {
val path = androidx.compose.ui.graphics.Path().apply { val path =
moveTo(size.width / 2, 0f) androidx.compose.ui.graphics.Path().apply {
lineTo(size.width, size.height) moveTo(size.width / 2, 0f)
lineTo(0f, size.height) lineTo(size.width, size.height)
close() lineTo(0f, size.height)
} close()
}
drawPath(path, color = color) drawPath(path, color = color)
} }
} }
SafetyStatus.DANGER -> { SafetyStatus.DANGER -> {
// Losange pour DANGER // Losange pour DANGER
Canvas(modifier = modifier) { Canvas(modifier = modifier) {
val path = androidx.compose.ui.graphics.Path().apply { val path =
moveTo(size.width / 2, 0f) androidx.compose.ui.graphics.Path().apply {
lineTo(size.width, size.height / 2) moveTo(size.width / 2, 0f)
lineTo(size.width / 2, size.height) lineTo(size.width, size.height / 2)
lineTo(0f, size.height / 2) lineTo(size.width / 2, size.height)
close() lineTo(0f, size.height / 2)
} close()
}
drawPath(path, color = color) drawPath(path, color = color)
} }
} }
@ -294,7 +302,7 @@ private data class VerdictBannerData(
val icon: String, val icon: String,
val shapeIcon: String, val shapeIcon: String,
val containerColor: androidx.compose.ui.graphics.Color, val containerColor: androidx.compose.ui.graphics.Color,
val onContainerColor: androidx.compose.ui.graphics.Color val onContainerColor: androidx.compose.ui.graphics.Color,
) )
@Composable @Composable
@ -303,7 +311,7 @@ fun ProductCard(
subtitle: String?, subtitle: String?,
imageUrl: String?, imageUrl: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageContentDescription: String? = null imageContentDescription: String? = null,
) { ) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
val imgDesc = imageContentDescription ?: "Image du produit" val imgDesc = imageContentDescription ?: "Image du produit"
@ -317,26 +325,28 @@ fun ProductCard(
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = imageContentDescription, contentDescription = imageContentDescription,
modifier = Modifier modifier =
.size(64.dp) Modifier
.background( .size(64.dp)
MaterialTheme.colorScheme.surfaceVariant, .background(
RoundedCornerShape(dimens.radiusMd) MaterialTheme.colorScheme.surfaceVariant,
) RoundedCornerShape(dimens.radiusMd),
),
) )
} else { } else {
Box( Box(
modifier = Modifier modifier =
.size(64.dp) Modifier
.background( .size(64.dp)
MaterialTheme.colorScheme.surfaceVariant, .background(
RoundedCornerShape(dimens.radiusMd) MaterialTheme.colorScheme.surfaceVariant,
), RoundedCornerShape(dimens.radiusMd),
contentAlignment = Alignment.Center ),
contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = "🛒", text = "🛒",
modifier = Modifier.semantics { contentDescription = imgDesc } modifier = Modifier.semantics { contentDescription = imgDesc },
) )
} }
} }
@ -346,13 +356,13 @@ fun ProductCard(
title, title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 2 maxLines = 2,
) )
if (!subtitle.isNullOrBlank()) { if (!subtitle.isNullOrBlank()) {
Text( Text(
subtitle, subtitle,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@ -361,18 +371,23 @@ fun ProductCard(
} }
@Composable @Composable
fun AvatarBubble(avatar: String, modifier: Modifier = Modifier, size: Dp = 40.dp) { fun AvatarBubble(
avatar: String,
modifier: Modifier = Modifier,
size: Dp = 40.dp,
) {
Box( Box(
modifier = modifier modifier =
.size(size) modifier
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape) .size(size)
.border(1.dp, MaterialTheme.colorScheme.primary, CircleShape), .background(MaterialTheme.colorScheme.primaryContainer, CircleShape)
contentAlignment = Alignment.Center .border(1.dp, MaterialTheme.colorScheme.primary, CircleShape),
contentAlignment = Alignment.Center,
) { ) {
Text( Text(
avatar, avatar,
style = MaterialTheme.typography.titleLarge, 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( val progress by transition.animateFloat(
initialValue = 0f, initialValue = 0f,
targetValue = 1f, targetValue = 1f,
animationSpec = infiniteRepeatable( animationSpec =
animation = tween(durationMillis = 1200, easing = FastOutSlowInEasing), infiniteRepeatable(
repeatMode = RepeatMode.Restart, animation = tween(durationMillis = 1200, easing = FastOutSlowInEasing),
), repeatMode = RepeatMode.Restart,
label = "shimmerProgress" ),
label = "shimmerProgress",
) )
val base = MaterialTheme.colorScheme.surfaceVariant val base = MaterialTheme.colorScheme.surfaceVariant
val highlight = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f) val highlight = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
val colors = listOf(base, highlight, base) val colors = listOf(base, highlight, base)
val offset = 1000f * progress val offset = 1000f * progress
val brush = Brush.linearGradient( val brush =
colors = colors, Brush.linearGradient(
start = Offset(offset - 500f, 0f), colors = colors,
end = Offset(offset, 0f), start = Offset(offset - 500f, 0f),
) end = Offset(offset, 0f),
)
Box( Box(
modifier = modifier modifier =
.clip(RoundedCornerShape(cornerRadius)) modifier
.background(brush) .clip(RoundedCornerShape(cornerRadius))
.background(brush),
) )
} }
@ -80,7 +83,7 @@ fun ShimmerListItem(modifier: Modifier = Modifier) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
ShimmerBox(modifier = Modifier.size(64.dp), cornerRadius = dimens.radiusMd) ShimmerBox(modifier = Modifier.size(64.dp), cornerRadius = dimens.radiusMd)
Spacer(Modifier.width(dimens.spacingMd)) Spacer(Modifier.width(dimens.spacingMd))
@ -109,54 +112,61 @@ fun ShimmerListItem(modifier: Modifier = Modifier) {
fun ProductSkeleton(modifier: Modifier = Modifier) { fun ProductSkeleton(modifier: Modifier = Modifier) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
Column( Column(
modifier = modifier modifier =
.fillMaxWidth() modifier
.padding(dimens.spacingMd), .fillMaxWidth()
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd) .padding(dimens.spacingMd),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
) { ) {
// Image produit // Image produit
ShimmerBox( ShimmerBox(
modifier = Modifier modifier =
.size(120.dp) Modifier
.align(Alignment.CenterHorizontally), .size(120.dp)
cornerRadius = dimens.radiusMd .align(Alignment.CenterHorizontally),
cornerRadius = dimens.radiusMd,
) )
// Nom produit // Nom produit
ShimmerBox( ShimmerBox(
modifier = Modifier modifier =
.fillMaxWidth(0.8f) Modifier
.height(20.dp) .fillMaxWidth(0.8f)
.height(20.dp),
) )
// Marque // Marque
ShimmerBox( ShimmerBox(
modifier = Modifier modifier =
.fillMaxWidth(0.5f) Modifier
.height(14.dp) .fillMaxWidth(0.5f)
.height(14.dp),
) )
// Verdict banner (zone colorée) // Verdict banner (zone colorée)
ShimmerBox( ShimmerBox(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.height(56.dp), .fillMaxWidth()
cornerRadius = dimens.radiusMd .height(56.dp),
cornerRadius = dimens.radiusMd,
) )
// Actions // Actions
ShimmerBox( ShimmerBox(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.height(48.dp), .fillMaxWidth()
cornerRadius = dimens.radiusPill .height(48.dp),
cornerRadius = dimens.radiusPill,
) )
ShimmerBox( ShimmerBox(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.height(48.dp), .fillMaxWidth()
cornerRadius = dimens.radiusPill .height(48.dp),
cornerRadius = dimens.radiusPill,
) )
} }
} }
@ -172,9 +182,10 @@ fun EmptyState(
) { ) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
Column( Column(
modifier = modifier modifier =
.fillMaxWidth() modifier
.padding(dimens.spacingXl), .fillMaxWidth()
.padding(dimens.spacingXl),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text(emoji, style = MaterialTheme.typography.displaySmall) Text(emoji, style = MaterialTheme.typography.displaySmall)
@ -182,7 +193,7 @@ fun EmptyState(
Text( Text(
title, title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface,
) )
if (message != null) { if (message != null) {
Spacer(Modifier.height(dimens.spacingSm)) Spacer(Modifier.height(dimens.spacingSm))
@ -212,17 +223,18 @@ fun LoadingIndicator(modifier: Modifier = Modifier) {
fun OfflineIndicator(modifier: Modifier = Modifier) { fun OfflineIndicator(modifier: Modifier = Modifier) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
Row( Row(
modifier = modifier modifier =
.clip(RoundedCornerShape(dimens.radiusLg)) modifier
.background(MaterialTheme.colorScheme.errorContainer) .clip(RoundedCornerShape(dimens.radiusLg))
.padding(horizontal = dimens.spacingMd, vertical = dimens.spacingXs), .background(MaterialTheme.colorScheme.errorContainer)
verticalAlignment = Alignment.CenterVertically .padding(horizontal = dimens.spacingMd, vertical = dimens.spacingXs),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(
imageVector = Icons.Filled.CloudOff, imageVector = Icons.Filled.CloudOff,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer, tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp),
) )
Spacer(Modifier.width(dimens.spacingXs)) Spacer(Modifier.width(dimens.spacingXs))
Text( Text(
@ -238,27 +250,28 @@ fun OfflineIndicator(modifier: Modifier = Modifier) {
fun ErrorView( fun ErrorView(
message: String, message: String,
onRetry: (() -> Unit)? = null, onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
Column( Column(
modifier = modifier modifier =
.fillMaxSize() modifier
.padding(dimens.spacingXl), .fillMaxSize()
.padding(dimens.spacingXl),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center,
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Warning, imageVector = Icons.Filled.Warning,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp) modifier = Modifier.size(48.dp),
) )
Spacer(Modifier.height(dimens.spacingMd)) Spacer(Modifier.height(dimens.spacingMd))
Text( Text(
message, message,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface,
) )
if (onRetry != null) { if (onRetry != null) {
Spacer(Modifier.height(dimens.spacingLg)) Spacer(Modifier.height(dimens.spacingLg))

View File

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

View File

@ -1,11 +1,11 @@
package com.safebite.app.presentation.navigation package com.safebite.app.presentation.navigation
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@ -13,29 +13,28 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.safebite.app.presentation.screen.catalog.CatalogScreen 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.CatalogSearchScreen
import com.safebite.app.presentation.screen.catalog.CategoryItemsScreen
import com.safebite.app.presentation.screen.catalog.DomainCategoriesScreen 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.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.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.ListSettingsScreen
import com.safebite.app.presentation.screen.lists.settings.ListSortScreen 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.main.MainScreen
import com.safebite.app.presentation.screen.ocr.OcrCaptureScreen import com.safebite.app.presentation.screen.ocr.OcrCaptureScreen
import com.safebite.app.presentation.screen.ocr.OcrReviewScreen import com.safebite.app.presentation.screen.ocr.OcrReviewScreen
import com.safebite.app.presentation.screen.onboarding.OnboardingScreen 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.ProfileEditScreen
import com.safebite.app.presentation.screen.profile.ProfileListScreen import com.safebite.app.presentation.screen.profile.ProfileListScreen
import com.safebite.app.presentation.screen.result.ResultScreen import com.safebite.app.presentation.screen.result.ResultScreen
import com.safebite.app.presentation.screen.scanner.ScannerScreen import com.safebite.app.presentation.screen.scanner.ScannerScreen
import com.safebite.app.presentation.screen.settings.SettingsScreen import com.safebite.app.presentation.screen.settings.SettingsScreen
import com.safebite.app.presentation.screen.splash.SplashScreen import com.safebite.app.presentation.screen.splash.SplashScreen
import com.safebite.app.presentation.screen.tracking.TrackingScreen
/** /**
* Graph de navigation principal de l'application SafeBite. * 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. * - Écrans de navigation : Scanner, Result, OCR, Settings, etc.
*/ */
@Composable @Composable
fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) { fun SafeBiteNavGraph(
onboardingCompleted: Boolean,
showSplash: Boolean = false,
) {
val navController = rememberNavController() val navController = rememberNavController()
val startDestination = when { val startDestination =
showSplash -> Screen.Splash.route when {
onboardingCompleted -> Screen.Dashboard.route showSplash -> Screen.Splash.route
else -> Screen.Onboarding.route onboardingCompleted -> Screen.Dashboard.route
} else -> Screen.Onboarding.route
}
val enterAnim = fadeIn(animationSpec = tween(250)) + val enterAnim =
slideInHorizontally(animationSpec = tween(250)) { it / 24 } fadeIn(animationSpec = tween(250)) +
slideInHorizontally(animationSpec = tween(250)) { it / 24 }
val exitAnim = fadeOut(animationSpec = tween(200)) val exitAnim = fadeOut(animationSpec = tween(200))
val popEnterAnim = fadeIn(animationSpec = tween(250)) val popEnterAnim = fadeIn(animationSpec = tween(250))
val popExitAnim = fadeOut(animationSpec = tween(200)) + val popExitAnim =
slideOutHorizontally(animationSpec = tween(250)) { it / 24 } fadeOut(animationSpec = tween(200)) +
slideOutHorizontally(animationSpec = tween(250)) { it / 24 }
NavHost( NavHost(
navController = navController, navController = navController,
@ -76,7 +81,7 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
navController.navigate(Screen.Dashboard.route) { navController.navigate(Screen.Dashboard.route) {
popUpTo(Screen.Splash.route) { inclusive = true } 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)) }, onOpenListDetail = { id, name -> navController.navigate(Screen.ListDetail.build(id, name)) },
onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) }, onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) },
onOpenListCreate = { navController.navigate(Screen.ListCreate.route) }, 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)) { navController.navigate(Screen.Result.fromBarcode(code)) {
popUpTo(Screen.Dashboard.route) popUpTo(Screen.Dashboard.route)
} }
} },
) )
} }
@ -118,14 +123,14 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
composable(Screen.OcrCapture.route) { composable(Screen.OcrCapture.route) {
OcrCaptureScreen( OcrCaptureScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onCaptured = { text -> navController.navigate(Screen.OcrReview.build(text)) } onCaptured = { text -> navController.navigate(Screen.OcrReview.build(text)) },
) )
} }
// ── OCR Review ── // ── OCR Review ──
composable( composable(
route = Screen.OcrReview.route, route = Screen.OcrReview.route,
arguments = listOf(navArgument("text") { type = NavType.StringType }) arguments = listOf(navArgument("text") { type = NavType.StringType }),
) { entry -> ) { entry ->
val text = entry.arguments?.getString("text").orEmpty() val text = entry.arguments?.getString("text").orEmpty()
OcrReviewScreen( OcrReviewScreen(
@ -135,18 +140,26 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
navController.navigate(Screen.Result.fromOcr(edited)) { navController.navigate(Screen.Result.fromOcr(edited)) {
popUpTo(Screen.Dashboard.route) popUpTo(Screen.Dashboard.route)
} }
} },
) )
} }
// ── Result ── // ── Result ──
composable( composable(
route = Screen.Result.route, route = Screen.Result.route,
arguments = listOf( arguments =
navArgument("barcode") { type = NavType.StringType }, listOf(
navArgument("fromOcr") { type = NavType.BoolType; defaultValue = false }, navArgument("barcode") { type = NavType.StringType },
navArgument("ocrText") { type = NavType.StringType; nullable = true; defaultValue = null } navArgument("fromOcr") {
) type = NavType.BoolType
defaultValue = false
},
navArgument("ocrText") {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { entry -> ) { entry ->
val barcode = entry.arguments?.getString("barcode") val barcode = entry.arguments?.getString("barcode")
val fromOcr = entry.arguments?.getBoolean("fromOcr") == true val fromOcr = entry.arguments?.getBoolean("fromOcr") == true
@ -165,7 +178,12 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
navController.navigate(Screen.OcrCapture.route) { navController.navigate(Screen.OcrCapture.route) {
popUpTo(Screen.Dashboard.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( ProfileListScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onNew = { navController.navigate(Screen.ProfileEdit.new()) }, onNew = { navController.navigate(Screen.ProfileEdit.new()) },
onEdit = { id -> navController.navigate(Screen.ProfileEdit.edit(id)) } onEdit = { id -> navController.navigate(Screen.ProfileEdit.edit(id)) },
) )
} }
composable( composable(
route = Screen.ProfileEdit.route, route = Screen.ProfileEdit.route,
arguments = listOf(navArgument("id") { type = NavType.LongType }) arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry -> ) { entry ->
val id = entry.arguments?.getLong("id") ?: 0L val id = entry.arguments?.getLong("id") ?: 0L
ProfileEditScreen( ProfileEditScreen(
id = id, id = id,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onSaved = { navController.popBackStack() } onSaved = { navController.popBackStack() },
) )
} }
composable(Screen.Tracking.route) { composable(Screen.Tracking.route) {
TrackingScreen( TrackingScreen(
onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) }, onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) },
onOpenScanner = { navController.navigate(Screen.Scanner.route) } onOpenScanner = { navController.navigate(Screen.Scanner.route) },
) )
} }
composable(Screen.Settings.route) { composable(Screen.Settings.route) {
@ -202,10 +220,14 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
composable( composable(
route = Screen.ListDetail.route, route = Screen.ListDetail.route,
arguments = listOf( arguments =
navArgument("id") { type = NavType.LongType }, listOf(
navArgument("name") { type = NavType.StringType; defaultValue = "Ma liste" } navArgument("id") { type = NavType.LongType },
) navArgument("name") {
type = NavType.StringType
defaultValue = "Ma liste"
},
),
) { entry -> ) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L val listId = entry.arguments?.getLong("id") ?: 0L
val listName = entry.arguments?.getString("name") ?: "Ma liste" val listName = entry.arguments?.getString("name") ?: "Ma liste"
@ -215,14 +237,14 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onOpenScanner = { navController.navigate(Screen.Scanner.route) }, onOpenScanner = { navController.navigate(Screen.Scanner.route) },
onOpenProduct = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) }, 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) ── // ── Catalogue (refonte) ──
composable( composable(
route = Screen.Catalog.route, route = Screen.Catalog.route,
arguments = listOf(navArgument("listId") { type = NavType.LongType }) arguments = listOf(navArgument("listId") { type = NavType.LongType }),
) { entry -> ) { entry ->
val listId = entry.arguments?.getLong("listId") ?: 0L val listId = entry.arguments?.getLong("listId") ?: 0L
CatalogScreen( CatalogScreen(
@ -233,15 +255,16 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
}, },
onOpenSearch = { onOpenSearch = {
navController.navigate(Screen.CatalogSearch.build(listId)) navController.navigate(Screen.CatalogSearch.build(listId))
} },
) )
} }
composable( composable(
route = Screen.CatalogDomain.route, route = Screen.CatalogDomain.route,
arguments = listOf( arguments =
navArgument("listId") { type = NavType.LongType }, listOf(
navArgument("domainId") { type = NavType.StringType } navArgument("listId") { type = NavType.LongType },
) navArgument("domainId") { type = NavType.StringType },
),
) { entry -> ) { entry ->
val listId = entry.arguments?.getLong("listId") ?: 0L val listId = entry.arguments?.getLong("listId") ?: 0L
val domainId = entry.arguments?.getString("domainId").orEmpty() val domainId = entry.arguments?.getString("domainId").orEmpty()
@ -250,45 +273,46 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onOpenCategory = { categoryId -> onOpenCategory = { categoryId ->
navController.navigate(Screen.CatalogCategory.build(listId, categoryId)) navController.navigate(Screen.CatalogCategory.build(listId, categoryId))
} },
) )
} }
composable( composable(
route = Screen.CatalogCategory.route, route = Screen.CatalogCategory.route,
arguments = listOf( arguments =
navArgument("listId") { type = NavType.LongType }, listOf(
navArgument("categoryId") { type = NavType.StringType } navArgument("listId") { type = NavType.LongType },
) navArgument("categoryId") { type = NavType.StringType },
),
) { entry -> ) { entry ->
val listId = entry.arguments?.getLong("listId") ?: 0L val listId = entry.arguments?.getLong("listId") ?: 0L
val categoryId = entry.arguments?.getString("categoryId").orEmpty() val categoryId = entry.arguments?.getString("categoryId").orEmpty()
CategoryItemsScreen( CategoryItemsScreen(
categoryId = categoryId, categoryId = categoryId,
listId = listId, listId = listId,
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() },
) )
} }
composable( composable(
route = Screen.CatalogSearch.route, route = Screen.CatalogSearch.route,
arguments = listOf(navArgument("listId") { type = NavType.LongType }) arguments = listOf(navArgument("listId") { type = NavType.LongType }),
) { entry -> ) { entry ->
val listId = entry.arguments?.getLong("listId") ?: 0L val listId = entry.arguments?.getLong("listId") ?: 0L
CatalogSearchScreen( CatalogSearchScreen(
listId = listId, listId = listId,
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() },
) )
} }
// ── Product Detail (Phase 5) ── // ── Product Detail (Phase 5) ──
composable( composable(
route = Screen.ProductDetail.route, route = Screen.ProductDetail.route,
arguments = listOf(navArgument("barcode") { type = NavType.StringType }) arguments = listOf(navArgument("barcode") { type = NavType.StringType }),
) { entry -> ) { entry ->
val barcode = entry.arguments?.getString("barcode").orEmpty() val barcode = entry.arguments?.getString("barcode").orEmpty()
ProductDetailScreen( ProductDetailScreen(
barcode = barcode, barcode = barcode,
onBack = { navController.popBackStack() }, 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) { composable(Screen.ListCreate.route) {
CreateListScreen( CreateListScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onListCreated = { navController.popBackStack() } onListCreated = { navController.popBackStack() },
) )
} }
composable( composable(
route = Screen.ListSettings.route, route = Screen.ListSettings.route,
arguments = listOf(navArgument("id") { type = NavType.LongType }) arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry -> ) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L val listId = entry.arguments?.getLong("id") ?: 0L
ListSettingsScreen( ListSettingsScreen(
@ -310,47 +334,47 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
onOpenSort = { navController.navigate(Screen.ListSort.build(listId)) }, onOpenSort = { navController.navigate(Screen.ListSort.build(listId)) },
onOpenRegion = { navController.navigate(Screen.ListRegion.build(listId)) }, onOpenRegion = { navController.navigate(Screen.ListRegion.build(listId)) },
onOpenNameImage = { navController.navigate(Screen.ListNameImage.build(listId)) }, onOpenNameImage = { navController.navigate(Screen.ListNameImage.build(listId)) },
onOpenMembers = { navController.navigate(Screen.ListMembers.build(listId)) } onOpenMembers = { navController.navigate(Screen.ListMembers.build(listId)) },
) )
} }
composable( composable(
route = Screen.ListSort.route, route = Screen.ListSort.route,
arguments = listOf(navArgument("id") { type = NavType.LongType }) arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry -> ) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L val listId = entry.arguments?.getLong("id") ?: 0L
ListSortScreen( ListSortScreen(
listId = listId, listId = listId,
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() },
) )
} }
composable( composable(
route = Screen.ListRegion.route, route = Screen.ListRegion.route,
arguments = listOf(navArgument("id") { type = NavType.LongType }) arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry -> ) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L val listId = entry.arguments?.getLong("id") ?: 0L
ListRegionScreen( ListRegionScreen(
listId = listId, listId = listId,
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() },
) )
} }
composable( composable(
route = Screen.ListNameImage.route, route = Screen.ListNameImage.route,
arguments = listOf(navArgument("id") { type = NavType.LongType }) arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry -> ) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L val listId = entry.arguments?.getLong("id") ?: 0L
ListNameImageScreen( ListNameImageScreen(
listId = listId, listId = listId,
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() },
) )
} }
composable( composable(
route = Screen.ListMembers.route, route = Screen.ListMembers.route,
arguments = listOf(navArgument("id") { type = NavType.LongType }) arguments = listOf(navArgument("id") { type = NavType.LongType }),
) { entry -> ) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L val listId = entry.arguments?.getLong("id") ?: 0L
ListMembersScreen( ListMembersScreen(
listId = listId, 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) { sealed class Screen(val route: String) {
// ── Onglets principaux (Bottom Navigation) ── // ── Onglets principaux (Bottom Navigation) ──
data object Dashboard : Screen("dashboard") data object Dashboard : Screen("dashboard")
data object Lists : Screen("lists") data object Lists : Screen("lists")
data object Tracking : Screen("tracking") data object Tracking : Screen("tracking")
data object Family : Screen("family") data object Family : Screen("family")
// ── Écrans de navigation (non dans bottom nav) ── // ── Écrans de navigation (non dans bottom nav) ──
data object Scanner : Screen("scanner") data object Scanner : Screen("scanner")
data object OcrCapture : Screen("ocr/capture") data object OcrCapture : Screen("ocr/capture")
data object OcrReview : Screen("ocr/review/{text}") { data object OcrReview : Screen("ocr/review/{text}") {
fun build(text: String) = "ocr/review/${android.net.Uri.encode(text)}" fun build(text: String) = "ocr/review/${android.net.Uri.encode(text)}"
} }
data object Result : Screen("result/{barcode}?fromOcr={fromOcr}&ocrText={ocrText}") { data object Result : Screen("result/{barcode}?fromOcr={fromOcr}&ocrText={ocrText}") {
fun fromBarcode(barcode: String) = "result/$barcode?fromOcr=false&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 fromOcr(text: String) = "result/ocr?fromOcr=true&ocrText=${android.net.Uri.encode(text)}"
fun fromHistory(barcode: String) = "result/$barcode?fromOcr=false&ocrText=" fun fromHistory(barcode: String) = "result/$barcode?fromOcr=false&ocrText="
} }
data object Onboarding : Screen("onboarding") data object Onboarding : Screen("onboarding")
data object Settings : Screen("settings") data object Settings : Screen("settings")
data object Splash : Screen("splash") data object Splash : Screen("splash")
// ── Sous-écrans ── // ── Sous-écrans ──
data object ProfileList : Screen("profiles") data object ProfileList : Screen("profiles")
data object ProfileEdit : Screen("profile/edit/{id}") { data object ProfileEdit : Screen("profile/edit/{id}") {
fun new() = "profile/edit/0" fun new() = "profile/edit/0"
fun edit(id: Long) = "profile/edit/$id" fun edit(id: Long) = "profile/edit/$id"
} }
data object ProductDetail : Screen("product/{barcode}") { data object ProductDetail : Screen("product/{barcode}") {
fun build(barcode: String) = "product/$barcode" fun build(barcode: String) = "product/$barcode"
} }
data object ListDetail : Screen("list/{id}?name={name}") { 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}") { data object ListEdit : Screen("list/edit?id={id}") {
fun new() = "list/edit?id=0" fun new() = "list/edit?id=0"
fun edit(id: Long) = "list/edit?id=$id" fun edit(id: Long) = "list/edit?id=$id"
} }
// ── List management (refonte) ── // ── List management (refonte) ──
data object ListCreate : Screen("list/create") data object ListCreate : Screen("list/create")
data object ListSettings : Screen("list/settings/{id}") { data object ListSettings : Screen("list/settings/{id}") {
fun build(id: Long) = "list/settings/$id" fun build(id: Long) = "list/settings/$id"
} }
data object ListSort : Screen("list/sort/{id}") { data object ListSort : Screen("list/sort/{id}") {
fun build(id: Long) = "list/sort/$id" fun build(id: Long) = "list/sort/$id"
} }
data object ListRegion : Screen("list/region/{id}") { data object ListRegion : Screen("list/region/{id}") {
fun build(id: Long) = "list/region/$id" fun build(id: Long) = "list/region/$id"
} }
data object ListNameImage : Screen("list/nameimage/{id}") { data object ListNameImage : Screen("list/nameimage/{id}") {
fun build(id: Long) = "list/nameimage/$id" fun build(id: Long) = "list/nameimage/$id"
} }
data object ListMembers : Screen("list/members/{id}") { data object ListMembers : Screen("list/members/{id}") {
fun build(id: Long) = "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}") { data object Catalog : Screen("catalog/{listId}") {
fun build(listId: Long) = "catalog/$listId" fun build(listId: Long) = "catalog/$listId"
} }
data object CatalogDomain : Screen("catalog/{listId}/domain/{domainId}") { 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}") { 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") { data object CatalogSearch : Screen("catalog/{listId}/search") {
fun build(listId: Long) = "catalog/$listId/search" fun build(listId: Long) = "catalog/$listId/search"
} }
@ -100,36 +134,37 @@ data class BottomNavItem(
val iconUnselected: ImageVector, val iconUnselected: ImageVector,
val label: String, val label: String,
val contentDescription: String, val contentDescription: String,
val badgeCount: Int = 0 val badgeCount: Int = 0,
) )
val bottomNavItems = listOf( val bottomNavItems =
BottomNavItem( listOf(
screen = Screen.Dashboard, BottomNavItem(
iconSelected = Icons.Filled.Home, screen = Screen.Dashboard,
iconUnselected = Icons.Outlined.Home, iconSelected = Icons.Filled.Home,
label = "Accueil", iconUnselected = Icons.Outlined.Home,
contentDescription = "Tableau de bord" label = "Accueil",
), contentDescription = "Tableau de bord",
BottomNavItem( ),
screen = Screen.Lists, BottomNavItem(
iconSelected = Icons.Filled.List, screen = Screen.Lists,
iconUnselected = Icons.Outlined.List, iconSelected = Icons.Filled.List,
label = "Listes", iconUnselected = Icons.Outlined.List,
contentDescription = "Mes listes de courses" label = "Listes",
), contentDescription = "Mes listes de courses",
BottomNavItem( ),
screen = Screen.Tracking, BottomNavItem(
iconSelected = Icons.Filled.ShowChart, screen = Screen.Tracking,
iconUnselected = Icons.Outlined.ShowChart, iconSelected = Icons.Filled.ShowChart,
label = "Suivi", iconUnselected = Icons.Outlined.ShowChart,
contentDescription = "Statistiques et historique" label = "Suivi",
), contentDescription = "Statistiques et historique",
BottomNavItem( ),
screen = Screen.Family, BottomNavItem(
iconSelected = Icons.Filled.People, screen = Screen.Family,
iconUnselected = Icons.Outlined.People, iconSelected = Icons.Filled.People,
label = "Famille", iconUnselected = Icons.Outlined.People,
contentDescription = "Profils et réglages" 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 com.safebite.app.data.local.database.relation.DomainWithCategoriesAndItems
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private fun parseColor(hex: String?): Color? = runCatching { private fun parseColor(hex: String?): Color? =
hex?.takeIf { it.startsWith("#") }?.let { Color(android.graphics.Color.parseColor(it)) } runCatching {
}.getOrNull() hex?.takeIf { it.startsWith("#") }?.let { Color(android.graphics.Color.parseColor(it)) }
}.getOrNull()
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -73,7 +74,7 @@ fun CatalogScreen(
onBack: () -> Unit, onBack: () -> Unit,
onOpenDomain: (String) -> Unit, onOpenDomain: (String) -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
viewModel: CatalogViewModel = hiltViewModel() viewModel: CatalogViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(listId) { viewModel.setActiveList(listId) } LaunchedEffect(listId) { viewModel.setActiveList(listId) }
val domains by viewModel.domains.collectAsStateWithLifecycle() val domains by viewModel.domains.collectAsStateWithLifecycle()
@ -91,9 +92,9 @@ fun CatalogScreen(
IconButton(onClick = onOpenSearch) { IconButton(onClick = onOpenSearch) {
Icon(Icons.Filled.Search, contentDescription = "Rechercher") Icon(Icons.Filled.Search, contentDescription = "Rechercher")
} }
} },
) )
} },
) { padding -> ) { padding ->
if (domains.isEmpty()) { if (domains.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
@ -106,12 +107,12 @@ fun CatalogScreen(
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items(domains, key = { it.domain.domainId }) { domain -> items(domains, key = { it.domain.domainId }) { domain ->
DomainCard( DomainCard(
domain = domain, domain = domain,
onClick = { onOpenDomain(domain.domain.domainId) } onClick = { onOpenDomain(domain.domain.domainId) },
) )
} }
} }
@ -121,22 +122,22 @@ fun CatalogScreen(
@Composable @Composable
private fun DomainCard( private fun DomainCard(
domain: DomainWithCategoriesAndItems, domain: DomainWithCategoriesAndItems,
onClick: () -> Unit onClick: () -> Unit,
) { ) {
val color = parseColor(domain.domain.color) ?: MaterialTheme.colorScheme.primaryContainer val color = parseColor(domain.domain.color) ?: MaterialTheme.colorScheme.primaryContainer
val itemCount = domain.categoriesWithItems.sumOf { it.items.size } val itemCount = domain.categoriesWithItems.sumOf { it.items.size }
Card( Card(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clickable(onClick = onClick), modifier = Modifier.fillMaxWidth().aspectRatio(1f).clickable(onClick = onClick),
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.18f)) colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.18f)),
) { ) {
Column( Column(
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween verticalArrangement = Arrangement.SpaceBetween,
) { ) {
Text( Text(
text = domain.domain.emoji, text = domain.domain.emoji,
style = MaterialTheme.typography.displayMedium style = MaterialTheme.typography.displayMedium,
) )
Column { Column {
Text( Text(
@ -144,13 +145,13 @@ private fun DomainCard(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
text = "${domain.categoriesWithItems.size} catégories • $itemCount articles", text = "${domain.categoriesWithItems.size} catégories • $itemCount articles",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@ -163,7 +164,7 @@ fun DomainCategoriesScreen(
domainId: String, domainId: String,
onBack: () -> Unit, onBack: () -> Unit,
onOpenCategory: (String) -> Unit, onOpenCategory: (String) -> Unit,
viewModel: CatalogViewModel = hiltViewModel() viewModel: CatalogViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(domainId) { viewModel.selectDomain(domainId) } LaunchedEffect(domainId) { viewModel.selectDomain(domainId) }
val categories by viewModel.categoriesForSelectedDomain.collectAsStateWithLifecycle() val categories by viewModel.categoriesForSelectedDomain.collectAsStateWithLifecycle()
@ -178,14 +179,14 @@ fun DomainCategoriesScreen(
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
} }
} },
) )
} },
) { padding -> ) { padding ->
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(categories, key = { it.categoryId }) { cat -> items(categories, key = { it.categoryId }) { cat ->
CategoryRow(category = cat, onClick = { onOpenCategory(cat.categoryId) }) CategoryRow(category = cat, onClick = { onOpenCategory(cat.categoryId) })
@ -195,23 +196,27 @@ fun DomainCategoriesScreen(
} }
@Composable @Composable
private fun CategoryRow(category: CategoryEntity, onClick: () -> Unit) { private fun CategoryRow(
category: CategoryEntity,
onClick: () -> Unit,
) {
val color = parseColor(category.color) ?: MaterialTheme.colorScheme.surfaceVariant val color = parseColor(category.color) ?: MaterialTheme.colorScheme.surfaceVariant
Card( Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.20f)) colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.20f)),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(16.dp), modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( Box(
modifier = Modifier modifier =
.size(48.dp) Modifier
.clip(CircleShape) .size(48.dp)
.background(color.copy(alpha = 0.4f)), .clip(CircleShape)
contentAlignment = Alignment.Center .background(color.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center,
) { ) {
Text(text = category.emoji, style = MaterialTheme.typography.headlineSmall) Text(text = category.emoji, style = MaterialTheme.typography.headlineSmall)
} }
@ -220,7 +225,7 @@ private fun CategoryRow(category: CategoryEntity, onClick: () -> Unit) {
text = category.name, text = category.name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
) )
} }
} }
@ -232,7 +237,7 @@ fun CategoryItemsScreen(
categoryId: String, categoryId: String,
listId: Long, listId: Long,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: CatalogViewModel = hiltViewModel() viewModel: CatalogViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(categoryId, listId) { LaunchedEffect(categoryId, listId) {
viewModel.setActiveList(listId) viewModel.setActiveList(listId)
@ -250,17 +255,17 @@ fun CategoryItemsScreen(
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
} }
} },
) )
}, },
snackbarHost = { SnackbarHost(snackbar) } snackbarHost = { SnackbarHost(snackbar) },
) { padding -> ) { padding ->
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(3), columns = GridCells.Fixed(3),
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items(items, key = { it.itemId }) { item -> items(items, key = { it.itemId }) { item ->
ItemTile(item = item, onClick = { ItemTile(item = item, onClick = {
@ -273,19 +278,22 @@ fun CategoryItemsScreen(
} }
@Composable @Composable
private fun ItemTile(item: CatalogItemEntity, onClick: () -> Unit) { private fun ItemTile(
item: CatalogItemEntity,
onClick: () -> Unit,
) {
Card( Card(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clickable(onClick = onClick), modifier = Modifier.fillMaxWidth().aspectRatio(1f).clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize().padding(8.dp), modifier = Modifier.fillMaxSize().padding(8.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center,
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center,
) { ) {
Text(text = item.emoji, style = MaterialTheme.typography.displayMedium) Text(text = item.emoji, style = MaterialTheme.typography.displayMedium)
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
@ -295,14 +303,14 @@ private fun ItemTile(item: CatalogItemEntity, onClick: () -> Unit) {
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
) )
} }
Icon( Icon(
imageVector = Icons.Filled.Add, imageVector = Icons.Filled.Add,
contentDescription = "Ajouter", contentDescription = "Ajouter",
tint = MaterialTheme.colorScheme.primary, 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( fun CatalogSearchScreen(
listId: Long, listId: Long,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: CatalogViewModel = hiltViewModel() viewModel: CatalogViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(listId) { viewModel.setActiveList(listId) } LaunchedEffect(listId) { viewModel.setActiveList(listId) }
val query by viewModel.searchQuery.collectAsStateWithLifecycle() val query by viewModel.searchQuery.collectAsStateWithLifecycle()
@ -329,10 +337,10 @@ fun CatalogSearchScreen(
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
} }
} },
) )
}, },
snackbarHost = { SnackbarHost(snackbar) } snackbarHost = { SnackbarHost(snackbar) },
) { padding -> ) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) { Column(modifier = Modifier.fillMaxSize().padding(padding)) {
OutlinedTextField( OutlinedTextField(
@ -349,12 +357,12 @@ fun CatalogSearchScreen(
} }
}, },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search) keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
) )
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(results, key = { it.itemId }) { item -> items(results, key = { it.itemId }) { item ->
SearchResultRow(item = item, onAdd = { SearchResultRow(item = item, onAdd = {
@ -368,15 +376,18 @@ fun CatalogSearchScreen(
} }
@Composable @Composable
private fun SearchResultRow(item: CatalogItemEntity, onAdd: () -> Unit) { private fun SearchResultRow(
item: CatalogItemEntity,
onAdd: () -> Unit,
) {
Card( Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onAdd), modifier = Modifier.fillMaxWidth().clickable(onClick = onAdd),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(12.dp), modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
Text(text = item.emoji, style = MaterialTheme.typography.headlineMedium) Text(text = item.emoji, style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
@ -384,7 +395,7 @@ private fun SearchResultRow(item: CatalogItemEntity, onAdd: () -> Unit) {
Text( Text(
text = item.name, text = item.name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold,
) )
if (item.aliases.isNotBlank()) { if (item.aliases.isNotBlank()) {
Text( Text(
@ -392,7 +403,7 @@ private fun SearchResultRow(item: CatalogItemEntity, onAdd: () -> Unit) {
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
) )
} }
} }

View File

@ -27,95 +27,112 @@ import javax.inject.Inject
*/ */
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class CatalogViewModel @Inject constructor( class CatalogViewModel
private val repository: CatalogRepository, @Inject
private val manageListUseCase: ManageShoppingListUseCase constructor(
) : ViewModel() { 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 domains: StateFlow<List<DomainWithCategoriesAndItems>> =
val activeListId: StateFlow<Long?> = _activeListId.asStateFlow() repository.observeDomainsWithCategoriesAndItems().stateIn(
viewModelScope,
val domains: StateFlow<List<DomainWithCategoriesAndItems>> = SharingStarted.WhileSubscribed(5_000),
repository.observeDomainsWithCategoriesAndItems().stateIn( emptyList(),
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
)
) )
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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -27,127 +32,374 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.safebite.app.R 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.PrimaryButton
import com.safebite.app.presentation.common.components.StandardCard 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). * Dashboard contextuel (spec UX §5.3).
* *
* Trois modes : * Trois modes :
* - first_time : aucun scan dans l'historique * - FIRST_TIME : aucun scan CTA "Commencer"
* - store_mode : détecté via géolocalisation/heure * - STORE : créneau magasin ou liste active scan prominent + liste en cours
* - home_mode : mode par défaut * - HOME : soirée/weekend résumé hebdomadaire + derniers scans
*/ */
@Composable @Composable
fun DashboardScreen( fun DashboardScreen(
onScan: () -> Unit, onScan: () -> Unit,
onOpenList: (Long, String) -> Unit, onOpenList: (Long, String) -> Unit,
onOpenHistoryItem: (String) -> Unit, onOpenHistoryItem: (String) -> Unit,
viewModel: DashboardViewModel = hiltViewModel() viewModel: DashboardViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background containerColor = MaterialTheme.colorScheme.background,
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxSize()
.verticalScroll(rememberScrollState()) .padding(padding)
.padding(16.dp), .verticalScroll(rememberScrollState())
verticalArrangement = Arrangement.spacedBy(16.dp) .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(
text = stringResource(R.string.dashboard_greeting, state.greetingName), text = stringResource(R.string.dashboard_current_list),
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold,
) )
state.lists.forEach { list ->
// Quick actions StandardCard(
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(), 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 -> Row(
StandardCard( modifier = Modifier.fillMaxWidth(),
modifier = Modifier verticalAlignment = Alignment.CenterVertically,
.weight(1f) ) {
.height(72.dp), Column(modifier = Modifier.weight(1f)) {
variant = CardVariant.Filled, Text(
onClick = { onOpenList(list.id, list.name) }, text = list.name,
contentPadding = PaddingValues(8.dp) style = MaterialTheme.typography.bodyLarge,
) { fontWeight = FontWeight.Medium,
Column( )
modifier = Modifier.fillMaxSize(), Text(
horizontalAlignment = Alignment.CenterHorizontally, text = stringResource(R.string.dashboard_remaining, list.remaining),
verticalArrangement = Arrangement.Center style = MaterialTheme.typography.bodySmall,
) { color = MaterialTheme.colorScheme.onSurfaceVariant,
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
)
}
} }
Icon(
Icons.Filled.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
} }
} }
} }
}
}
}
// Weekly stats placeholder // ─── HOME ────────────────────────────────────────────────────────────────────
Card(
modifier = Modifier.fillMaxWidth(), @Composable
colors = CardDefaults.cardColors( private fun HomeContent(
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f) state: DashboardUiState,
) onScan: () -> Unit,
) { onOpenList: (Long, String) -> Unit,
Column(modifier = Modifier.padding(16.dp)) { onOpenHistoryItem: (String) -> Unit,
Text( ) {
text = stringResource(R.string.dashboard_weekly_title), val statusColors = LocalStatusColors.current
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold // Greeting
) Text(
Spacer(Modifier.height(8.dp)) text =
Text( if (state.greetingName.isNotEmpty()) {
text = "78% produits OK", stringResource(R.string.dashboard_greeting, state.greetingName)
style = MaterialTheme.typography.bodyLarge, } else {
color = MaterialTheme.colorScheme.onSurfaceVariant 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 // Weekly stats
Text( if (state.weeklyStats != null) {
text = stringResource(R.string.dashboard_recent_scans), WeeklyStatsCard(state.weeklyStats!!)
style = MaterialTheme.typography.titleMedium, }
fontWeight = FontWeight.SemiBold
// 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(
text = stringResource(R.string.dashboard_no_scans), text = stringResource(R.string.dashboard_weekly_title),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant 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.ViewModel
import androidx.lifecycle.viewModelScope 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.model.UserProfile
import com.safebite.app.domain.usecase.GetScanHistoryUseCase
import com.safebite.app.domain.usecase.GetShoppingListsUseCase import com.safebite.app.domain.usecase.GetShoppingListsUseCase
import com.safebite.app.domain.usecase.ManageProfileUseCase import com.safebite.app.domain.usecase.ManageProfileUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -15,66 +17,158 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import java.util.Calendar
import javax.inject.Inject 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( data class DashboardUiState(
val greetingName: String = "", 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( data class ListSummary(
val id: Long, val id: Long,
val name: String, val name: String,
val remaining: Int val remaining: Int,
) )
@HiltViewModel @HiltViewModel
class DashboardViewModel @Inject constructor( class DashboardViewModel
private val manageProfile: ManageProfileUseCase, @Inject
private val getShoppingLists: GetShoppingListsUseCase constructor(
) : ViewModel() { private val manageProfile: ManageProfileUseCase,
private val getShoppingLists: GetShoppingListsUseCase,
@OptIn(ExperimentalCoroutinesApi::class) private val getScanHistory: GetScanHistoryUseCase,
val state: StateFlow<DashboardUiState> = combine( ) : ViewModel() {
manageProfile.observe(), @OptIn(ExperimentalCoroutinesApi::class)
manageProfile.observeActiveIds() val state: StateFlow<DashboardUiState> =
) { profiles, activeIds -> combine(
profiles to activeIds manageProfile.observe(),
}.flatMapLatest { (profiles, activeIds) -> manageProfile.observeActiveIds(),
val greetingName = resolveGreetingName(profiles, activeIds) ) { profiles, activeIds ->
observeListsWithStats(greetingName) profiles to activeIds
}.stateIn( }.flatMapLatest { (profiles, activeIds) ->
scope = viewModelScope, val greetingName = resolveGreetingName(profiles, activeIds)
started = SharingStarted.WhileSubscribed(5000), combine(
initialValue = DashboardUiState() observeListsWithStats(greetingName),
) observeHistory(),
) { dashboard, history ->
@OptIn(ExperimentalCoroutinesApi::class) val weeklyStats = computeWeeklyStats(history)
private fun observeListsWithStats(greetingName: String): Flow<DashboardUiState> { val contextMode = detectContextMode(history, dashboard.lists)
return getShoppingLists.observeActive().flatMapLatest { lists -> dashboard.copy(
val sortedLists = lists.sortedBy { it.createdAt }.take(4) contextMode = contextMode,
if (sortedLists.isEmpty()) { recentScans = history.take(5),
flowOf(DashboardUiState(greetingName = greetingName, lists = emptyList())) weeklyStats = weeklyStats,
} 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 -> }.stateIn(
DashboardUiState(greetingName, array.toList()) 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 { private fun observeHistory(): Flow<List<ScanHistoryItem>> = getScanHistory.observe()
return when {
activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }.firstOrNull()?.name /**
else -> profiles.filter { it.isDefault }.firstOrNull()?.name ?: profiles.firstOrNull()?.name * 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 package com.safebite.app.presentation.screen.family
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.Star
import androidx.compose.material.icons.filled.StarBorder import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@ -43,13 +41,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -65,7 +59,7 @@ import com.safebite.app.presentation.theme.LocalDimens
fun FamilyScreen( fun FamilyScreen(
onOpenProfile: (Long) -> Unit, onOpenProfile: (Long) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
viewModel: FamilyViewModel = hiltViewModel() viewModel: FamilyViewModel = hiltViewModel(),
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val activeProfileIds by viewModel.activeProfileIds.collectAsStateWithLifecycle() val activeProfileIds by viewModel.activeProfileIds.collectAsStateWithLifecycle()
@ -78,7 +72,7 @@ fun FamilyScreen(
SafeBiteTopAppBar( SafeBiteTopAppBar(
title = stringResource(R.string.family_title), title = stringResource(R.string.family_title),
onBack = null, onBack = null,
backContentDescription = null backContentDescription = null,
) )
}, },
floatingActionButton = { floatingActionButton = {
@ -86,39 +80,42 @@ fun FamilyScreen(
FloatingActionButton( FloatingActionButton(
onClick = { onOpenProfile(0L) }, onClick = { onOpenProfile(0L) },
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.semantics { modifier =
contentDescription = addContentDesc Modifier.semantics {
} contentDescription = addContentDesc
},
) { ) {
Icon( Icon(
Icons.Filled.Add, Icons.Filled.Add,
contentDescription = null contentDescription = null,
) )
} }
} },
) { padding -> ) { padding ->
if (uiState.profiles.isEmpty()) { if (uiState.profiles.isEmpty()) {
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding), .fillMaxSize()
contentAlignment = Alignment.Center .padding(padding),
contentAlignment = Alignment.Center,
) { ) {
EmptyState( EmptyState(
title = stringResource(R.string.family_no_profiles), title = stringResource(R.string.family_no_profiles),
message = stringResource(R.string.family_no_profiles_body), message = stringResource(R.string.family_no_profiles_body),
emoji = "👨‍👩‍👧‍👦" emoji = "👨‍👩‍👧‍👦",
) )
} }
} else { } else {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxSize()
.padding(horizontal = LocalDimens.current.spacingMd), .padding(padding)
.padding(horizontal = LocalDimens.current.spacingMd),
horizontalArrangement = Arrangement.spacedBy(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 -> items(uiState.profiles, key = { it.id }) { profile ->
val isActive = profile.id in activeProfileIds val isActive = profile.id in activeProfileIds
@ -128,7 +125,7 @@ fun FamilyScreen(
onToggleActive = { viewModel.toggleProfileActive(profile.id) }, onToggleActive = { viewModel.toggleProfileActive(profile.id) },
onEdit = { onOpenProfile(profile.id) }, onEdit = { onOpenProfile(profile.id) },
onDelete = { showDeleteDialog = profile.id }, onDelete = { showDeleteDialog = profile.id },
onSetDefault = { viewModel.setDefaultProfile(profile.id) } onSetDefault = { viewModel.setDefaultProfile(profile.id) },
) )
} }
} }
@ -148,7 +145,7 @@ fun FamilyScreen(
onClick = { onClick = {
viewModel.deleteProfile(profile) viewModel.deleteProfile(profile)
showDeleteDialog = null showDeleteDialog = null
} },
) { ) {
Text("Supprimer", color = MaterialTheme.colorScheme.error) Text("Supprimer", color = MaterialTheme.colorScheme.error)
} }
@ -157,7 +154,7 @@ fun FamilyScreen(
TextButton(onClick = { showDeleteDialog = null }) { TextButton(onClick = { showDeleteDialog = null }) {
Text("Annuler") Text("Annuler")
} }
} },
) )
} }
} }
@ -174,82 +171,96 @@ fun ProfileCard(
onEdit: () -> Unit, onEdit: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onSetDefault: () -> Unit, onSetDefault: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
Card( Card(
modifier = modifier modifier =
.clickable(onClick = onEdit), modifier
.clickable(onClick = onEdit),
shape = RoundedCornerShape(dimens.radiusMd), shape = RoundedCornerShape(dimens.radiusMd),
colors = CardDefaults.cardColors( colors =
containerColor = MaterialTheme.colorScheme.surface CardDefaults.cardColors(
), containerColor = MaterialTheme.colorScheme.surface,
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm) ),
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
) { ) {
Column( Column(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(dimens.spacingMd) .fillMaxWidth()
.padding(dimens.spacingMd),
) { ) {
// En-tête avec avatar et nom // En-tête avec avatar et nom
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
// Avatar // Avatar
Box( Box(
modifier = Modifier modifier =
.size(48.dp) Modifier
.clip(CircleShape) .size(48.dp)
.background( .clip(CircleShape)
if (isActive) MaterialTheme.colorScheme.primaryContainer .background(
else MaterialTheme.colorScheme.surfaceVariant if (isActive) {
), MaterialTheme.colorScheme.primaryContainer
contentAlignment = Alignment.Center } else {
MaterialTheme.colorScheme.surfaceVariant
},
),
contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = profile.avatar, text = profile.avatar,
style = MaterialTheme.typography.headlineMedium style = MaterialTheme.typography.headlineMedium,
) )
} }
// Nom et badge // Nom et badge
Column( Column(
modifier = Modifier.weight(1f).padding(horizontal = dimens.spacingSm) modifier = Modifier.weight(1f).padding(horizontal = dimens.spacingSm),
) { ) {
Text( Text(
text = profile.name, text = profile.name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1 maxLines = 1,
) )
if (profile.isDefault) { if (profile.isDefault) {
Text( Text(
text = stringResource(R.string.profile_default_badge), text = stringResource(R.string.profile_default_badge),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary,
) )
} }
} }
// Bouton actif // Bouton actif
val a11yDesc = if (isActive) val a11yDesc =
stringResource(R.string.a11y_profile_inactive, profile.name) if (isActive) {
else stringResource(R.string.a11y_profile_inactive, profile.name)
stringResource(R.string.a11y_profile_active, profile.name) } else {
stringResource(R.string.a11y_profile_active, profile.name)
}
IconButton( IconButton(
onClick = onToggleActive, onClick = onToggleActive,
modifier = Modifier.semantics { modifier =
contentDescription = a11yDesc Modifier.semantics {
} contentDescription = a11yDesc
},
) { ) {
Icon( Icon(
imageVector = if (isActive) Icons.Filled.Star else Icons.Filled.StarBorder, imageVector = if (isActive) Icons.Filled.Star else Icons.Filled.StarBorder,
contentDescription = null, contentDescription = null,
tint = if (isActive) MaterialTheme.colorScheme.primary tint =
else MaterialTheme.colorScheme.onSurfaceVariant if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
) )
} }
} }
@ -260,7 +271,7 @@ fun ProfileCard(
AllergenDisplayGrid( AllergenDisplayGrid(
severeAllergens = profile.severeAllergens, severeAllergens = profile.severeAllergens,
moderateIntolerances = profile.moderateIntolerances, moderateIntolerances = profile.moderateIntolerances,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
Spacer(modifier = Modifier.height(dimens.spacingSm)) Spacer(modifier = Modifier.height(dimens.spacingSm))
@ -271,7 +282,7 @@ fun ProfileCard(
text = profile.dietaryRestrictions.joinToString(", ") { it.displayFr }, text = profile.dietaryRestrictions.joinToString(", ") { it.displayFr },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2 maxLines = 2,
) )
} }
} }

View File

@ -19,45 +19,51 @@ import javax.inject.Inject
data class FamilyUiState( data class FamilyUiState(
val profiles: List<UserProfile> = emptyList(), val profiles: List<UserProfile> = emptyList(),
val activeProfileIds: Set<Long> = emptySet(), val activeProfileIds: Set<Long> = emptySet(),
val isLoading: Boolean = true val isLoading: Boolean = true,
) )
@HiltViewModel @HiltViewModel
class FamilyViewModel @Inject constructor( class FamilyViewModel
private val manageProfileUseCase: ManageProfileUseCase @Inject
) : ViewModel() { 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() val activeProfileIds: StateFlow<Set<Long>> =
.map { profiles -> manageProfileUseCase.observeActiveIds()
FamilyUiState( .stateIn(
profiles = profiles, viewModelScope,
isLoading = false SharingStarted.WhileSubscribed(5_000),
) emptySet(),
} )
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
FamilyUiState()
)
val activeProfileIds: StateFlow<Set<Long>> = manageProfileUseCase.observeActiveIds() fun toggleProfileActive(id: Long) =
.stateIn( viewModelScope.launch {
viewModelScope, val current = manageProfileUseCase.observeActiveIds().first()
SharingStarted.WhileSubscribed(5_000), val newIds = if (id in current) current - id else current + id
emptySet() manageProfileUseCase.setActive(newIds)
) }
fun toggleProfileActive(id: Long) = viewModelScope.launch { fun deleteProfile(profile: UserProfile) =
val current = manageProfileUseCase.observeActiveIds().first() viewModelScope.launch {
val newIds = if (id in current) current - id else current + id manageProfileUseCase.delete(profile)
manageProfileUseCase.setActive(newIds) }
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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.safebite.app.R import com.safebite.app.R
import com.safebite.app.domain.model.SafetyStatus
import com.safebite.app.domain.model.UserProfile import com.safebite.app.domain.model.UserProfile
import com.safebite.app.presentation.common.components.AvatarBubble import com.safebite.app.presentation.common.components.AvatarBubble
import com.safebite.app.presentation.common.components.OutlinedActionButton import com.safebite.app.presentation.common.components.OutlinedActionButton
@ -60,7 +59,7 @@ fun HomeScreen(
onHistory: () -> Unit, onHistory: () -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
onOpenHistoryItem: (String) -> Unit, onOpenHistoryItem: (String) -> Unit,
viewModel: HomeViewModel = hiltViewModel() viewModel: HomeViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
@ -71,29 +70,36 @@ fun HomeScreen(
SafeBiteTopAppBar( SafeBiteTopAppBar(
title = stringResource(R.string.app_name), title = stringResource(R.string.app_name),
actions = { actions = {
IconButton(onClick = onProfiles) { Icon(Icons.Filled.Person, contentDescription = stringResource(R.string.nav_profiles)) } IconButton(
IconButton(onClick = onHistory) { Icon(Icons.Filled.History, contentDescription = stringResource(R.string.nav_history)) } onClick = onProfiles,
IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.nav_settings)) } ) { 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 -> ) { padding ->
if (state.profiles.isEmpty()) { if (state.profiles.isEmpty()) {
NoProfileBlock(modifier = Modifier.padding(padding), onCreate = onCreateProfile) NoProfileBlock(modifier = Modifier.padding(padding), onCreate = onCreateProfile)
return@Scaffold return@Scaffold
} }
Column( Column(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxSize()
.padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg), .padding(padding)
verticalArrangement = Arrangement.spacedBy(dimens.spacingLg) .padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
verticalArrangement = Arrangement.spacedBy(dimens.spacingLg),
) { ) {
ActiveProfilesRow( ActiveProfilesRow(
profiles = state.profiles, profiles = state.profiles,
active = state.activeProfiles, active = state.activeProfiles,
onToggle = viewModel::toggleActive, onToggle = viewModel::toggleActive,
onManage = onProfiles onManage = onProfiles,
) )
ScanButton(onClick = onScan) ScanButton(onClick = onScan)
@ -102,35 +108,38 @@ fun HomeScreen(
text = stringResource(R.string.home_ocr_button), text = stringResource(R.string.home_ocr_button),
onClick = onOcr, onClick = onOcr,
icon = Icons.Filled.TextFields, icon = Icons.Filled.TextFields,
modifier = Modifier.fillMaxWidth().height(dimens.buttonHeightLg) modifier = Modifier.fillMaxWidth().height(dimens.buttonHeightLg),
) )
Text( Text(
stringResource(R.string.home_recent_scans), stringResource(R.string.home_recent_scans),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground color = MaterialTheme.colorScheme.onBackground,
) )
if (state.recent.isEmpty()) { if (state.recent.isEmpty()) {
Text( Text(
stringResource(R.string.home_no_recent), stringResource(R.string.home_no_recent),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
state.recent.forEach { item -> state.recent.forEach { item ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier Row(
.fillMaxWidth() verticalAlignment = Alignment.CenterVertically,
.clickable { onOpenHistoryItem(item.barcode) } modifier =
Modifier
.fillMaxWidth()
.clickable { onOpenHistoryItem(item.barcode) },
) { ) {
Box( 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)) Spacer(Modifier.size(8.dp))
ProductCard( ProductCard(
title = item.productName ?: item.barcode, title = item.productName ?: item.barcode,
subtitle = item.brand, subtitle = item.brand,
imageUrl = item.imageUrl, imageUrl = item.imageUrl,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
) )
} }
} }
@ -144,7 +153,7 @@ private fun ActiveProfilesRow(
profiles: List<UserProfile>, profiles: List<UserProfile>,
active: List<UserProfile>, active: List<UserProfile>,
onToggle: (UserProfile) -> Unit, onToggle: (UserProfile) -> Unit,
onManage: () -> Unit onManage: () -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@ -159,7 +168,7 @@ private fun ActiveProfilesRow(
onClick = { onToggle(p) }, onClick = { onToggle(p) },
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, 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) { Row(modifier = Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
AvatarBubble(avatar = p.avatar, size = 32.dp) AvatarBubble(avatar = p.avatar, size = 32.dp)
@ -180,37 +189,40 @@ private fun ScanButton(onClick: () -> Unit) {
onClick = onClick, onClick = onClick,
icon = Icons.Filled.QrCodeScanner, icon = Icons.Filled.QrCodeScanner,
large = true, large = true,
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.height(dimens.buttonHeightHero) .fillMaxWidth()
.semantics { contentDescription = "Scan a product" }, .height(dimens.buttonHeightHero)
.semantics { contentDescription = "Scan a product" },
) )
} }
@Composable @Composable
private fun NoProfileBlock(modifier: Modifier, onCreate: () -> Unit) { private fun NoProfileBlock(
modifier: Modifier,
onCreate: () -> Unit,
) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
Column( Column(
modifier = modifier.fillMaxSize().padding(dimens.spacingXl), modifier = modifier.fillMaxSize().padding(dimens.spacingXl),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center,
) { ) {
Text( Text(
stringResource(R.string.home_no_profile_title), stringResource(R.string.home_no_profile_title),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground color = MaterialTheme.colorScheme.onBackground,
) )
Spacer(Modifier.size(dimens.spacingSm)) Spacer(Modifier.size(dimens.spacingSm))
Text( Text(
stringResource(R.string.home_no_profile_body), stringResource(R.string.home_no_profile_body),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.size(dimens.spacingLg)) Spacer(Modifier.size(dimens.spacingLg))
PrimaryButton( PrimaryButton(
text = stringResource(R.string.home_create_profile), text = stringResource(R.string.home_create_profile),
onClick = onCreate onClick = onCreate,
) )
} }
} }

View File

@ -17,34 +17,39 @@ import javax.inject.Inject
data class HomeUi( data class HomeUi(
val profiles: List<UserProfile> = emptyList(), val profiles: List<UserProfile> = emptyList(),
val activeProfiles: List<UserProfile> = emptyList(), val activeProfiles: List<UserProfile> = emptyList(),
val recent: List<ScanHistoryItem> = emptyList() val recent: List<ScanHistoryItem> = emptyList(),
) )
@HiltViewModel @HiltViewModel
class HomeViewModel @Inject constructor( class HomeViewModel
private val manageProfile: ManageProfileUseCase, @Inject
private val history: GetScanHistoryUseCase constructor(
) : ViewModel() { 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( fun toggleActive(profile: UserProfile) =
manageProfile.observe(), viewModelScope.launch {
manageProfile.observeActiveIds(), val current = state.value.activeProfiles.map { it.id }.toMutableSet()
history.observe() if (profile.id in current) current.remove(profile.id) else current.add(profile.id)
) { profiles, activeIds, scans -> manageProfile.setActive(current)
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 { fun setActiveOnly(profile: UserProfile) =
val current = state.value.activeProfiles.map { it.id }.toMutableSet() viewModelScope.launch {
if (profile.id in current) current.remove(profile.id) else current.add(profile.id) manageProfile.setActive(setOf(profile.id))
manageProfile.setActive(current) }
} }
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.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -52,13 +50,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.safebite.app.domain.engine.CatalogProvider import com.safebite.app.domain.engine.CatalogProvider
import javax.inject.Inject
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -67,7 +63,7 @@ fun IconPickerSheet(
categories: List<String>, categories: List<String>,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSelectIcon: (String) -> Unit, onSelectIcon: (String) -> Unit,
catalogProvider: CatalogProvider = hiltViewModel<ListDetailViewModel>().catalog catalogProvider: CatalogProvider = hiltViewModel<ListDetailViewModel>().catalog,
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var searchQuery by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") }
@ -75,18 +71,19 @@ fun IconPickerSheet(
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState sheetState = sheetState,
) { ) {
Column( Column(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.navigationBarsPadding() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp) .navigationBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp),
) { ) {
// Header // Header
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton(onClick = onDismiss) { IconButton(onClick = onDismiss) {
Icon(Icons.Filled.Close, contentDescription = "Fermer") Icon(Icons.Filled.Close, contentDescription = "Fermer")
@ -95,34 +92,36 @@ fun IconPickerSheet(
text = "Choisir une icône", text = "Choisir une icône",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
) )
IconButton(onClick = { onSelectIcon("") }) { IconButton(onClick = { onSelectIcon("") }) {
Icon( Icon(
Icons.Filled.Delete, Icons.Filled.Delete,
contentDescription = "Supprimer l'icône", contentDescription = "Supprimer l'icône",
tint = MaterialTheme.colorScheme.error tint = MaterialTheme.colorScheme.error,
) )
} }
} }
// Current icon display // Current icon display
Box( Box(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(vertical = 12.dp), .fillMaxWidth()
contentAlignment = Alignment.Center .padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) { ) {
Box( Box(
modifier = Modifier modifier =
.size(120.dp) Modifier
.clip(CircleShape) .size(120.dp)
.background(MaterialTheme.colorScheme.primaryContainer), .clip(CircleShape)
contentAlignment = Alignment.Center .background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = currentEmoji, text = currentEmoji,
style = MaterialTheme.typography.displayLarge style = MaterialTheme.typography.displayLarge,
) )
} }
} }
@ -131,9 +130,10 @@ fun IconPickerSheet(
OutlinedTextField( OutlinedTextField(
value = searchQuery, value = searchQuery,
onValueChange = { searchQuery = it }, onValueChange = { searchQuery = it },
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(bottom = 12.dp), .fillMaxWidth()
.padding(bottom = 12.dp),
placeholder = { Text("Chercher une icône") }, placeholder = { Text("Chercher une icône") },
leadingIcon = { leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null) Icon(Icons.Filled.Search, contentDescription = null)
@ -146,23 +146,26 @@ fun IconPickerSheet(
} }
}, },
singleLine = true, 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( LazyColumn(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
// 1. Catégories du catalogue alimentaire
categories.forEach { category -> categories.forEach { category ->
val categoryItems = catalogProvider.itemsForCategory(category) val categoryItems = catalogProvider.itemsForCategory(category)
val filteredItems = if (searchQuery.isNotBlank()) { val filteredItems =
categoryItems.filter { if (searchQuery.isNotBlank()) {
it.name.contains(searchQuery, ignoreCase = true) categoryItems.filter {
it.name.contains(searchQuery, ignoreCase = true)
}
} else {
categoryItems
} }
} else {
categoryItems
}
if (filteredItems.isNotEmpty()) { if (filteredItems.isNotEmpty()) {
val expanded = expandedCategories[category] ?: (searchQuery.isNotBlank()) val expanded = expandedCategories[category] ?: (searchQuery.isNotBlank())
@ -174,7 +177,7 @@ fun IconPickerSheet(
expanded = expanded, expanded = expanded,
onToggle = { onToggle = {
expandedCategories[category] = !expanded expandedCategories[category] = !expanded
} },
) )
} }
@ -183,7 +186,51 @@ fun IconPickerSheet(
IconGrid( IconGrid(
items = filteredItems, items = filteredItems,
currentEmoji = currentEmoji, 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, title: String,
count: Int, count: Int,
expanded: Boolean, expanded: Boolean,
onToggle: () -> Unit onToggle: () -> Unit,
) { ) {
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.clip(RoundedCornerShape(8.dp)) .fillMaxWidth()
.clickable(onClick = onToggle) .clip(RoundedCornerShape(8.dp))
.padding(vertical = 12.dp, horizontal = 4.dp), .clickable(onClick = onToggle)
verticalAlignment = Alignment.CenterVertically .padding(vertical = 12.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(
imageVector = Icons.Filled.KeyboardArrowRight, imageVector = Icons.Filled.KeyboardArrowRight,
contentDescription = null, contentDescription = null,
modifier = Modifier.rotate(if (expanded) 90f else 0f), modifier = Modifier.rotate(if (expanded) 90f else 0f),
tint = MaterialTheme.colorScheme.onSurface tint = MaterialTheme.colorScheme.onSurface,
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
) )
Icon( Icon(
imageVector = Icons.Filled.KeyboardArrowDown, imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp),
) )
} }
} }
@ -237,23 +285,24 @@ private fun CategoryHeader(
private fun IconGrid( private fun IconGrid(
items: List<CatalogProvider.CatalogItem>, items: List<CatalogProvider.CatalogItem>,
currentEmoji: String, currentEmoji: String,
onSelectIcon: (String) -> Unit onSelectIcon: (String) -> Unit,
) { ) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(3), columns = GridCells.Fixed(3),
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.height(((items.size + 2) / 3 * 100).dp.coerceAtMost(400.dp)), .fillMaxWidth()
.height(((items.size + 2) / 3 * 100).dp.coerceAtMost(400.dp)),
contentPadding = PaddingValues(4.dp), contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(items) { item -> items(items) { item ->
IconCard( IconCard(
emoji = item.emoji, emoji = item.emoji,
label = item.name, label = item.name,
isSelected = item.emoji == currentEmoji, isSelected = item.emoji == currentEmoji,
onClick = { onSelectIcon(item.emoji) } onClick = { onSelectIcon(item.emoji) },
) )
} }
} }
@ -264,39 +313,42 @@ private fun IconCard(
emoji: String, emoji: String,
label: String, label: String,
isSelected: Boolean, isSelected: Boolean,
onClick: () -> Unit onClick: () -> Unit,
) { ) {
val backgroundColor = if (isSelected) { val backgroundColor =
MaterialTheme.colorScheme.primaryContainer if (isSelected) {
} else { MaterialTheme.colorScheme.primaryContainer
MaterialTheme.colorScheme.surfaceVariant } else {
} MaterialTheme.colorScheme.surfaceVariant
val contentColor = if (isSelected) { }
MaterialTheme.colorScheme.onPrimaryContainer val contentColor =
} else { if (isSelected) {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onPrimaryContainer
} } else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Card( Card(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.aspectRatio(1f) .fillMaxWidth()
.clickable(onClick = onClick), .aspectRatio(1f)
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = backgroundColor) colors = CardDefaults.cardColors(containerColor = backgroundColor),
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center,
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(8.dp) modifier = Modifier.padding(8.dp),
) { ) {
Text( Text(
text = emoji, text = emoji,
style = MaterialTheme.typography.headlineMedium style = MaterialTheme.typography.headlineMedium,
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
@ -305,7 +357,7 @@ private fun IconCard(
color = contentColor, color = contentColor,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 2, maxLines = 2,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
) )
} }
if (isSelected) { if (isSelected) {
@ -313,12 +365,150 @@ private fun IconCard(
imageVector = Icons.Filled.Check, imageVector = Icons.Filled.Check,
contentDescription = "Sélectionné", contentDescription = "Sélectionné",
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier modifier =
.align(Alignment.TopEnd) Modifier
.padding(4.dp) .align(Alignment.TopEnd)
.size(20.dp) .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 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -25,9 +23,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.DragHandle
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults 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.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.common.components.PrimaryButton
import com.safebite.app.presentation.screen.lists.util.backgroundByResName import com.safebite.app.presentation.screen.lists.util.backgroundByResName
import com.safebite.app.presentation.theme.LocalDimens import com.safebite.app.presentation.theme.LocalDimens
import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -82,7 +76,7 @@ fun ListsScreen(
onOpenScanner: () -> Unit, onOpenScanner: () -> Unit,
onOpenListCreate: () -> Unit, onOpenListCreate: () -> Unit,
onOpenListSettings: (Long) -> Unit, onOpenListSettings: (Long) -> Unit,
viewModel: ListsViewModel = hiltViewModel() viewModel: ListsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val isEditMode by viewModel.isEditMode.collectAsStateWithLifecycle() val isEditMode by viewModel.isEditMode.collectAsStateWithLifecycle()
@ -108,7 +102,7 @@ fun ListsScreen(
} }
else -> {} else -> {}
} }
} },
) )
}, },
floatingActionButton = { floatingActionButton = {
@ -116,16 +110,17 @@ fun ListsScreen(
onClick = onOpenListCreate, onClick = onOpenListCreate,
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary, contentColor = MaterialTheme.colorScheme.onPrimary,
shape = MaterialTheme.shapes.medium shape = MaterialTheme.shapes.medium,
) { ) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.lists_new)) Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.lists_new))
} }
} },
) { padding -> ) { padding ->
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxSize()
.padding(padding),
) { ) {
when (val s = state) { when (val s = state) {
is ListsViewModel.UiState.Loading -> { is ListsViewModel.UiState.Loading -> {
@ -139,9 +134,9 @@ fun ListsScreen(
action = { action = {
PrimaryButton( PrimaryButton(
text = stringResource(R.string.lists_new), text = stringResource(R.string.lists_new),
onClick = onOpenListCreate onClick = onOpenListCreate,
) )
} },
) )
} }
is ListsViewModel.UiState.Success -> { is ListsViewModel.UiState.Success -> {
@ -150,14 +145,14 @@ fun ListsScreen(
isEditMode = isEditMode, isEditMode = isEditMode,
onItemClick = { item -> onOpenList(item.list.id, item.list.name) }, onItemClick = { item -> onOpenList(item.list.id, item.list.name) },
onSettingsClick = { item -> onOpenListSettings(item.list.id) }, onSettingsClick = { item -> onOpenListSettings(item.list.id) },
onReorder = { from, to -> viewModel.reorderLists(from, to) } onReorder = { from, to -> viewModel.reorderLists(from, to) },
) )
} }
is ListsViewModel.UiState.Error -> { is ListsViewModel.UiState.Error -> {
EmptyState( EmptyState(
title = "Erreur", title = "Erreur",
message = s.message, message = s.message,
emoji = "" emoji = "",
) )
} }
} }
@ -171,7 +166,7 @@ private fun ReorderableList(
isEditMode: Boolean, isEditMode: Boolean,
onItemClick: (ListsViewModel.ShoppingListWithStats) -> Unit, onItemClick: (ListsViewModel.ShoppingListWithStats) -> Unit,
onSettingsClick: (ListsViewModel.ShoppingListWithStats) -> Unit, onSettingsClick: (ListsViewModel.ShoppingListWithStats) -> Unit,
onReorder: (Int, Int) -> Unit onReorder: (Int, Int) -> Unit,
) { ) {
var draggedIndex by remember { mutableStateOf<Int?>(null) } var draggedIndex by remember { mutableStateOf<Int?>(null) }
var dragOffsetY by remember { mutableFloatStateOf(0f) } var dragOffsetY by remember { mutableFloatStateOf(0f) }
@ -182,11 +177,11 @@ private fun ReorderableList(
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
itemsIndexed( itemsIndexed(
items = items, items = items,
key = { _, item -> item.list.id } key = { _, item -> item.list.id },
) { index, item -> ) { index, item ->
val isDragged = draggedIndex == index val isDragged = draggedIndex == index
val zIndex = if (isDragged) 1f else 0f val zIndex = if (isDragged) 1f else 0f
@ -194,45 +189,49 @@ private fun ReorderableList(
val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0 val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0
Box( Box(
modifier = Modifier modifier =
.zIndex(zIndex) Modifier
.offset { IntOffset(0, offsetY) } .zIndex(zIndex)
.graphicsLayer { .offset { IntOffset(0, offsetY) }
scaleX = if (isDragged) 1.02f else 1f .graphicsLayer {
scaleY = if (isDragged) 1.02f else 1f scaleX = if (isDragged) 1.02f else 1f
} scaleY = if (isDragged) 1.02f else 1f
.then( }
if (isEditMode) { .then(
Modifier.pointerInput(Unit) { if (isEditMode) {
detectDragGesturesAfterLongPress( Modifier.pointerInput(Unit) {
onDragStart = { draggedIndex = index }, detectDragGesturesAfterLongPress(
onDragEnd = { onDragStart = { draggedIndex = index },
draggedIndex?.let { from -> onDragEnd = {
val to = (from + (dragOffsetY / itemPx).roundToInt()) draggedIndex?.let { from ->
.coerceIn(0, items.size - 1) val to =
if (from != to) onReorder(from, to) (from + (dragOffsetY / itemPx).roundToInt())
} .coerceIn(0, items.size - 1)
draggedIndex = null if (from != to) onReorder(from, to)
dragOffsetY = 0f }
}, draggedIndex = null
onDragCancel = { dragOffsetY = 0f
draggedIndex = null },
dragOffsetY = 0f onDragCancel = {
}, draggedIndex = null
onDrag = { change, dragAmount -> dragOffsetY = 0f
change.consume() },
dragOffsetY += dragAmount.y onDrag = { change, dragAmount ->
} change.consume()
) dragOffsetY += dragAmount.y
} },
} else Modifier )
) }
} else {
Modifier
},
),
) { ) {
ShoppingListCard( ShoppingListCard(
item = item, item = item,
isEditMode = isEditMode, isEditMode = isEditMode,
onClick = { onItemClick(item) }, onClick = { onItemClick(item) },
onSettingsClick = { onSettingsClick(item) } onSettingsClick = { onSettingsClick(item) },
) )
} }
} }
@ -244,18 +243,19 @@ private fun ShoppingListCard(
item: ListsViewModel.ShoppingListWithStats, item: ListsViewModel.ShoppingListWithStats,
isEditMode: Boolean, isEditMode: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onSettingsClick: () -> Unit onSettingsClick: () -> Unit,
) { ) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
val bg = backgroundByResName(item.list.backgroundResName) val bg = backgroundByResName(item.list.backgroundResName)
Card( Card(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.height(160.dp) .fillMaxWidth()
.then(if (!isEditMode) Modifier.clickable(onClick = onClick) else Modifier), .height(160.dp)
.then(if (!isEditMode) Modifier.clickable(onClick = onClick) else Modifier),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Background image // Background image
@ -264,37 +264,40 @@ private fun ShoppingListCard(
painter = painterResource(id = bg.drawableRes), painter = painterResource(id = bg.drawableRes),
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop,
) )
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.background(Color.Black.copy(alpha = 0.35f)) .fillMaxSize()
.background(Color.Black.copy(alpha = 0.35f)),
) )
} else { } else {
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.background(MaterialTheme.colorScheme.primaryContainer) .fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer),
) )
} }
Column( Column(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(dimens.spacingMd) .fillMaxSize()
.padding(dimens.spacingMd),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top,
) { ) {
if (isEditMode) { if (isEditMode) {
Icon( Icon(
imageVector = Icons.Filled.DragHandle, imageVector = Icons.Filled.DragHandle,
contentDescription = "Reorder", contentDescription = "Reorder",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp),
) )
} else { } else {
Spacer(modifier = Modifier.width(24.dp)) Spacer(modifier = Modifier.width(24.dp))
@ -302,13 +305,13 @@ private fun ShoppingListCard(
IconButton( IconButton(
onClick = onSettingsClick, onClick = onSettingsClick,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp),
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Settings, imageVector = Icons.Filled.Settings,
contentDescription = stringResource(R.string.lists_settings), contentDescription = stringResource(R.string.lists_settings),
tint = Color.White, 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)) Spacer(modifier = Modifier.weight(1f))
// List name // List name
val regionFlagEmoji = item.list.region?.let { code -> val regionFlagEmoji =
when (code) { item.list.region?.let { code ->
"de" -> "🇩🇪" when (code) {
"au" -> "🇦🇺" "de" -> "🇩🇪"
"at" -> "🇦🇹" "au" -> "🇦🇺"
"ca" -> "🇨🇦" "at" -> "🇦🇹"
"es" -> "🇪🇸" "ca" -> "🇨🇦"
"fr" -> "🇫🇷" "es" -> "🇪🇸"
"hu" -> "🇭🇺" "fr" -> "🇫🇷"
"it" -> "🇮🇹" "hu" -> "🇭🇺"
"no" -> "🇳🇴" "it" -> "🇮🇹"
"nl" -> "🇳🇱" "no" -> "🇳🇴"
"pl" -> "🇵🇱" "nl" -> "🇳🇱"
"pt" -> "🇵🇹" "pl" -> "🇵🇱"
"gb" -> "🇬🇧" "pt" -> "🇵🇹"
"ru" -> "🇷🇺" "gb" -> "🇬🇧"
"ch_de", "ch_fr" -> "🇨🇭" "ru" -> "🇷🇺"
else -> "" "ch_de", "ch_fr" -> "🇨🇭"
} else -> ""
} ?: "" }
} ?: ""
Text( Text(
text = "$regionFlagEmoji ${item.list.name}".trim(), text = "$regionFlagEmoji ${item.list.name}".trim(),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = Color.White, color = Color.White,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
// Item count badge // Item count badge
val remaining = item.itemCount - item.checkedCount val remaining = item.itemCount - item.checkedCount
@ -358,16 +362,17 @@ private fun ShoppingListCard(
text = "$remaining articles", text = "$remaining articles",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = Color.White, color = Color.White,
modifier = Modifier modifier =
.background(badgeColor.copy(alpha = 0.85f), RoundedCornerShape(12.dp)) Modifier
.padding(horizontal = 8.dp, vertical = 2.dp) .background(badgeColor.copy(alpha = 0.85f), RoundedCornerShape(12.dp))
.padding(horizontal = 8.dp, vertical = 2.dp),
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
// Member avatars // Member avatars
Row( Row(
horizontalArrangement = Arrangement.spacedBy((-8).dp) horizontalArrangement = Arrangement.spacedBy((-8).dp),
) { ) {
item.members.take(3).forEach { member -> item.members.take(3).forEach { member ->
MemberAvatar(member = member) MemberAvatar(member = member)
@ -382,17 +387,18 @@ private fun ShoppingListCard(
@Composable @Composable
private fun MemberAvatar(member: ShoppingListMemberEntity) { private fun MemberAvatar(member: ShoppingListMemberEntity) {
Box( Box(
modifier = Modifier modifier =
.size(32.dp) Modifier
.clip(CircleShape) .size(32.dp)
.background(MaterialTheme.colorScheme.surfaceVariant), .clip(CircleShape)
contentAlignment = Alignment.Center .background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = member.name.take(1).uppercase(), text = member.name.take(1).uppercase(),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold, 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 com.safebite.app.domain.usecase.GetShoppingListsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@ -21,88 +21,100 @@ import javax.inject.Inject
* ViewModel pour l'écran Listes (Phase 2). * ViewModel pour l'écran Listes (Phase 2).
*/ */
@HiltViewModel @HiltViewModel
class ListsViewModel @Inject constructor( class ListsViewModel
private val getShoppingListsUseCase: GetShoppingListsUseCase @Inject
) : ViewModel() { constructor(
private val getShoppingListsUseCase: GetShoppingListsUseCase,
) : ViewModel() {
sealed class UiState {
object Loading : UiState()
sealed class UiState { data class Success(
object Loading : UiState() val lists: List<ShoppingListWithStats>,
data class Success( ) : UiState()
val lists: List<ShoppingListWithStats>
) : UiState()
data class Empty(val message: String = "") : UiState()
data class Error(val message: String) : UiState()
}
data class ShoppingListWithStats( data class Empty(val message: String = "") : UiState()
val list: ShoppingListEntity,
val itemCount: Int,
val checkedCount: Int,
val members: List<ShoppingListMemberEntity> = emptyList()
)
private val _isEditMode = MutableStateFlow(false) data class Error(val message: String) : UiState()
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 })
}
}
} }
.stateIn(
scope = viewModelScope, data class ShoppingListWithStats(
started = SharingStarted.WhileSubscribed(5000), val list: ShoppingListEntity,
initialValue = UiState.Loading val itemCount: Int,
val checkedCount: Int,
val members: List<ShoppingListMemberEntity> = emptyList(),
) )
fun createList(name: String, backgroundResName: String? = null) { private val _isEditMode = MutableStateFlow(false)
viewModelScope.launch { val isEditMode: StateFlow<Boolean> = _isEditMode
getShoppingListsUseCase.createList(name, backgroundResName)
}
}
fun deleteList(list: ShoppingListEntity) { @OptIn(ExperimentalCoroutinesApi::class)
viewModelScope.launch { val state: StateFlow<UiState> =
getShoppingListsUseCase.deleteList(list) getShoppingListsUseCase.observeActive()
} .flatMapLatest { lists ->
} if (lists.isEmpty()) {
flowOf(UiState.Empty("Aucune liste de courses. Créez votre première liste !"))
fun toggleEditMode() { } else {
_isEditMode.value = !_isEditMode.value val statsFlows =
} lists.sortedBy { it.displayOrder }.map { list ->
combine(
fun updateList(list: ShoppingListEntity) { getShoppingListsUseCase.observeItemCount(list.id),
viewModelScope.launch { getShoppingListsUseCase.observeCheckedCount(list.id),
getShoppingListsUseCase.updateList(list) getShoppingListsUseCase.observeMembers(list.id),
} ) { itemCount, checkedCount, members ->
} ShoppingListWithStats(list, itemCount, checkedCount, members.take(3))
}
fun reorderLists(fromIndex: Int, toIndex: Int) { }
viewModelScope.launch { combine(statsFlows) { array ->
val current = state.value as? UiState.Success ?: return@launch UiState.Success(array.toList().sortedBy { it.list.displayOrder })
val mutable = current.lists.toMutableList() }
val moved = mutable.removeAt(fromIndex) }
mutable.add(toIndex.coerceIn(0, mutable.size), moved) }
mutable.forEachIndexed { index, item -> .stateIn(
getShoppingListsUseCase.updateList( scope = viewModelScope,
item.list.copy(displayOrder = index) 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 package com.safebite.app.presentation.screen.lists.create
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize 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.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -57,7 +54,7 @@ import com.safebite.app.presentation.screen.lists.util.allListBackgrounds
fun CreateListScreen( fun CreateListScreen(
onBack: () -> Unit, onBack: () -> Unit,
onListCreated: () -> Unit, onListCreated: () -> Unit,
viewModel: ListsViewModel = hiltViewModel() viewModel: ListsViewModel = hiltViewModel(),
) { ) {
var listName by remember { mutableStateOf("") } var listName by remember { mutableStateOf("") }
var selectedBg by remember { mutableStateOf(allListBackgrounds.firstOrNull()?.resName) } var selectedBg by remember { mutableStateOf(allListBackgrounds.firstOrNull()?.resName) }
@ -79,26 +76,27 @@ fun CreateListScreen(
onListCreated() onListCreated()
} }
}, },
enabled = listName.isNotBlank() enabled = listName.isNotBlank(),
) { ) {
Text(stringResource(R.string.list_create_next)) Text(stringResource(R.string.list_create_next))
} }
} },
) )
} },
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxSize()
.padding(16.dp) .padding(padding)
.padding(16.dp),
) { ) {
OutlinedTextField( OutlinedTextField(
value = listName, value = listName,
onValueChange = { listName = it }, onValueChange = { listName = it },
label = { Text(stringResource(R.string.list_name_hint)) }, label = { Text(stringResource(R.string.list_name_hint)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true singleLine = true,
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -106,7 +104,7 @@ fun CreateListScreen(
Text( Text(
text = stringResource(R.string.list_choose_background), text = stringResource(R.string.list_choose_background),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -116,43 +114,46 @@ fun CreateListScreen(
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) { ) {
items(allListBackgrounds) { bg -> items(allListBackgrounds) { bg ->
val isSelected = selectedBg == bg.resName val isSelected = selectedBg == bg.resName
Card( Card(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.aspectRatio(1.5f), .fillMaxWidth()
.aspectRatio(1.5f),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null, border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
onClick = { selectedBg = bg.resName } onClick = { selectedBg = bg.resName },
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Image( Image(
painter = painterResource(id = bg.drawableRes), painter = painterResource(id = bg.drawableRes),
contentDescription = bg.label, contentDescription = bg.label,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop,
) )
if (isSelected) { if (isSelected) {
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(8.dp), .fillMaxSize()
contentAlignment = Alignment.TopEnd .padding(8.dp),
contentAlignment = Alignment.TopEnd,
) { ) {
Box( Box(
modifier = Modifier modifier =
.size(28.dp) Modifier
.background(Color.White, RoundedCornerShape(14.dp)), .size(28.dp)
contentAlignment = Alignment.Center .background(Color.White, RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Check, imageVector = Icons.Filled.Check,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, 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.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -38,7 +37,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -53,7 +51,7 @@ import com.safebite.app.presentation.screen.lists.ListsViewModel
fun ListMembersScreen( fun ListMembersScreen(
listId: Long, listId: Long,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: ListsViewModel = hiltViewModel() viewModel: ListsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId } val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
@ -67,15 +65,16 @@ fun ListMembersScreen(
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
} }
} },
) )
} },
) { padding -> ) { padding ->
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxSize()
.padding(horizontal = 16.dp) .padding(padding)
.padding(horizontal = 16.dp),
) { ) {
if (listData == null) { if (listData == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
@ -85,19 +84,19 @@ fun ListMembersScreen(
text = stringResource(R.string.list_members_count, members.size), text = stringResource(R.string.list_members_count, members.size),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp) modifier = Modifier.padding(vertical = 8.dp),
) )
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) { ) {
items(members) { member -> items(members) { member ->
MemberRow( MemberRow(
member = member, member = member,
onRemove = { onRemove = {
// TODO: remove member via viewmodel/usecase // TODO: remove member via viewmodel/usecase
} },
) )
} }
} }
@ -108,15 +107,16 @@ fun ListMembersScreen(
onClick = { /* TODO: invite UI placeholder */ }, onClick = { /* TODO: invite UI placeholder */ },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors( colors =
containerColor = MaterialTheme.colorScheme.primary, ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colorScheme.onPrimary containerColor = MaterialTheme.colorScheme.primary,
) contentColor = MaterialTheme.colorScheme.onPrimary,
),
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Add, imageVector = Icons.Filled.Add,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp),
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.list_invite_member)) Text(stringResource(R.string.list_invite_member))
@ -132,33 +132,36 @@ fun ListMembersScreen(
@Composable @Composable
private fun MemberRow( private fun MemberRow(
member: ShoppingListMemberEntity, member: ShoppingListMemberEntity,
onRemove: () -> Unit onRemove: () -> Unit,
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors( colors =
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) CardDefaults.cardColors(
) containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
) { ) {
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(horizontal = 16.dp, vertical = 12.dp), .fillMaxWidth()
verticalAlignment = Alignment.CenterVertically .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( Box(
modifier = Modifier modifier =
.size(40.dp) Modifier
.clip(CircleShape) .size(40.dp)
.background(MaterialTheme.colorScheme.primaryContainer), .clip(CircleShape)
contentAlignment = Alignment.Center .background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = member.name.take(1).uppercase(), text = member.name.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer color = MaterialTheme.colorScheme.onPrimaryContainer,
) )
} }
@ -168,12 +171,12 @@ private fun MemberRow(
Text( Text(
text = member.name, text = member.name,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold,
) )
Text( Text(
text = member.email, text = member.email,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
@ -181,7 +184,7 @@ private fun MemberRow(
Icon( Icon(
imageVector = Icons.Filled.RemoveCircleOutline, imageVector = Icons.Filled.RemoveCircleOutline,
contentDescription = stringResource(R.string.list_remove_member), 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.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -57,7 +56,7 @@ import com.safebite.app.presentation.screen.lists.util.backgroundByResName
fun ListNameImageScreen( fun ListNameImageScreen(
listId: Long, listId: Long,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: ListsViewModel = hiltViewModel() viewModel: ListsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId } val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
@ -68,10 +67,11 @@ fun ListNameImageScreen(
val onSave = { val onSave = {
listData?.let { listData?.let {
val updated = it.list.copy( val updated =
name = listName.ifBlank { it.list.name }, it.list.copy(
backgroundResName = selectedBg name = listName.ifBlank { it.list.name },
) backgroundResName = selectedBg,
)
viewModel.updateList(updated) viewModel.updateList(updated)
} }
onBack() onBack()
@ -90,18 +90,19 @@ fun ListNameImageScreen(
IconButton(onClick = onSave) { IconButton(onClick = onSave) {
Icon( Icon(
imageVector = Icons.Filled.Check, imageVector = Icons.Filled.Check,
contentDescription = stringResource(R.string.action_save) contentDescription = stringResource(R.string.action_save),
) )
} }
} },
) )
} },
) { padding -> ) { padding ->
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxSize()
.padding(horizontal = 16.dp) .padding(padding)
.padding(horizontal = 16.dp),
) { ) {
if (listData == null) { if (listData == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
@ -110,10 +111,11 @@ fun ListNameImageScreen(
// Preview // Preview
val bg = backgroundByResName(selectedBg) val bg = backgroundByResName(selectedBg)
Card( Card(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.height(120.dp), .fillMaxWidth()
shape = RoundedCornerShape(16.dp) .height(120.dp),
shape = RoundedCornerShape(16.dp),
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
if (bg != null) { if (bg != null) {
@ -121,18 +123,20 @@ fun ListNameImageScreen(
painter = painterResource(id = bg.drawableRes), painter = painterResource(id = bg.drawableRes),
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop,
) )
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.background(Color.Black.copy(alpha = 0.35f)) .fillMaxSize()
.background(Color.Black.copy(alpha = 0.35f)),
) )
} else { } else {
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.background(MaterialTheme.colorScheme.primaryContainer) .fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer),
) )
} }
Text( Text(
@ -140,9 +144,10 @@ fun ListNameImageScreen(
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = Color.White, color = Color.White,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier modifier =
.align(Alignment.BottomStart) Modifier
.padding(16.dp) .align(Alignment.BottomStart)
.padding(16.dp),
) )
} }
} }
@ -154,7 +159,7 @@ fun ListNameImageScreen(
onValueChange = { listName = it }, onValueChange = { listName = it },
label = { Text(stringResource(R.string.list_name_hint)) }, label = { Text(stringResource(R.string.list_name_hint)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true singleLine = true,
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -162,7 +167,7 @@ fun ListNameImageScreen(
Text( Text(
text = stringResource(R.string.list_choose_background), text = stringResource(R.string.list_choose_background),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -172,45 +177,49 @@ fun ListNameImageScreen(
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.weight(1f) .fillMaxWidth()
.weight(1f),
) { ) {
items(allListBackgrounds) { bg -> items(allListBackgrounds) { bg ->
val isSelected = selectedBg == bg.resName val isSelected = selectedBg == bg.resName
Card( Card(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.aspectRatio(1.5f), .fillMaxWidth()
.aspectRatio(1.5f),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null, border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
onClick = { selectedBg = bg.resName } onClick = { selectedBg = bg.resName },
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Image( Image(
painter = painterResource(id = bg.drawableRes), painter = painterResource(id = bg.drawableRes),
contentDescription = bg.label, contentDescription = bg.label,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop,
) )
if (isSelected) { if (isSelected) {
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(8.dp), .fillMaxSize()
contentAlignment = Alignment.TopEnd .padding(8.dp),
contentAlignment = Alignment.TopEnd,
) { ) {
Box( Box(
modifier = Modifier modifier =
.size(28.dp) Modifier
.background(Color.White, RoundedCornerShape(14.dp)), .size(28.dp)
contentAlignment = Alignment.Center .background(Color.White, RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Check, imageVector = Icons.Filled.Check,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, 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.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -35,7 +34,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 data class Region(val name: String, val code: String, val flag: String)
private val availableRegions = listOf( private val availableRegions =
Region("Allemagne", "de", "🇩🇪"), listOf(
Region("Australie", "au", "🇦🇺"), Region("Allemagne", "de", "🇩🇪"),
Region("Autriche", "at", "🇦🇹"), Region("Australie", "au", "🇦🇺"),
Region("Canada", "ca", "🇨🇦"), Region("Autriche", "at", "🇦🇹"),
Region("Espagne", "es", "🇪🇸"), Region("Canada", "ca", "🇨🇦"),
Region("France", "fr", "🇫🇷"), Region("Espagne", "es", "🇪🇸"),
Region("Hongrie", "hu", "🇭🇺"), Region("France", "fr", "🇫🇷"),
Region("Italie", "it", "🇮🇹"), Region("Hongrie", "hu", "🇭🇺"),
Region("Norvège", "no", "🇳🇴"), Region("Italie", "it", "🇮🇹"),
Region("Pays-Bas", "nl", "🇳🇱"), Region("Norvège", "no", "🇳🇴"),
Region("Pologne", "pl", "🇵🇱"), Region("Pays-Bas", "nl", "🇳🇱"),
Region("Portugal", "pt", "🇵🇹"), Region("Pologne", "pl", "🇵🇱"),
Region("Royaume-Uni", "gb", "🇬🇧"), Region("Portugal", "pt", "🇵🇹"),
Region("Russie", "ru", "🇷🇺"), Region("Royaume-Uni", "gb", "🇬🇧"),
Region("Suisse (Allemand)", "ch_de", "🇨🇭"), Region("Russie", "ru", "🇷🇺"),
Region("Suisse (français)", "ch_fr", "🇨🇭") Region("Suisse (Allemand)", "ch_de", "🇨🇭"),
) Region("Suisse (français)", "ch_fr", "🇨🇭"),
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ListRegionScreen( fun ListRegionScreen(
listId: Long, listId: Long,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: ListsViewModel = hiltViewModel() viewModel: ListsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId } val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
@ -96,59 +95,63 @@ fun ListRegionScreen(
IconButton(onClick = onSave) { IconButton(onClick = onSave) {
Icon( Icon(
imageVector = Icons.Filled.Check, imageVector = Icons.Filled.Check,
contentDescription = stringResource(R.string.action_save) contentDescription = stringResource(R.string.action_save),
) )
} }
} },
) )
} },
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxSize()
.padding(horizontal = 16.dp) .padding(padding)
.padding(horizontal = 16.dp),
) { ) {
Text( Text(
text = stringResource(R.string.list_region_description), text = stringResource(R.string.list_region_description),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp) modifier = Modifier.padding(vertical = 8.dp),
) )
LazyColumn( LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.weight(1f) .fillMaxWidth()
.weight(1f),
) { ) {
items(availableRegions) { region -> items(availableRegions) { region ->
val isSelected = selectedRegion == region.code val isSelected = selectedRegion == region.code
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.clickable { selectedRegion = region.code } .fillMaxWidth()
.padding(vertical = 14.dp, horizontal = 8.dp), .clickable { selectedRegion = region.code }
verticalAlignment = Alignment.CenterVertically .padding(vertical = 14.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "${region.flag} ${region.name}", text = "${region.flag} ${region.name}",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
) )
if (isSelected) { if (isSelected) {
Box( Box(
modifier = Modifier modifier =
.size(24.dp) Modifier
.clip(CircleShape) .size(24.dp)
.background(MaterialTheme.colorScheme.primary), .clip(CircleShape)
contentAlignment = Alignment.Center .background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Check, imageVector = Icons.Filled.Check,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary, 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize 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.automirrored.filled.Sort
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Brush 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.Language
import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.People
import androidx.compose.material3.Button import androidx.compose.material3.Button
@ -62,7 +59,7 @@ fun ListSettingsScreen(
onOpenRegion: () -> Unit, onOpenRegion: () -> Unit,
onOpenNameImage: () -> Unit, onOpenNameImage: () -> Unit,
onOpenMembers: () -> Unit, onOpenMembers: () -> Unit,
viewModel: ListsViewModel = hiltViewModel() viewModel: ListsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId } val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
@ -75,15 +72,16 @@ fun ListSettingsScreen(
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
} }
} },
) )
} },
) { padding -> ) { padding ->
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxSize()
.padding(horizontal = 16.dp) .padding(padding)
.padding(horizontal = 16.dp),
) { ) {
if (listData == null) { if (listData == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
@ -92,10 +90,11 @@ fun ListSettingsScreen(
// Header card with list preview // Header card with list preview
val bg = backgroundByResName(listData.list.backgroundResName) val bg = backgroundByResName(listData.list.backgroundResName)
Card( Card(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.height(140.dp), .fillMaxWidth()
shape = RoundedCornerShape(16.dp) .height(140.dp),
shape = RoundedCornerShape(16.dp),
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
if (bg != null) { if (bg != null) {
@ -103,18 +102,20 @@ fun ListSettingsScreen(
painter = painterResource(id = bg.drawableRes), painter = painterResource(id = bg.drawableRes),
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop,
) )
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.background(Color.Black.copy(alpha = 0.35f)) .fillMaxSize()
.background(Color.Black.copy(alpha = 0.35f)),
) )
} else { } else {
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.background(MaterialTheme.colorScheme.primaryContainer) .fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer),
) )
} }
Text( Text(
@ -122,9 +123,10 @@ fun ListSettingsScreen(
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = Color.White, color = Color.White,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier modifier =
.align(Alignment.BottomStart) Modifier
.padding(16.dp) .align(Alignment.BottomStart)
.padding(16.dp),
) )
} }
} }
@ -135,7 +137,7 @@ fun ListSettingsScreen(
Text( Text(
text = stringResource(R.string.list_personalize), text = stringResource(R.string.list_personalize),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold,
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@ -144,40 +146,43 @@ fun ListSettingsScreen(
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) { ) {
item { item {
SettingsTile( SettingsTile(
icon = Icons.AutoMirrored.Filled.Sort, icon = Icons.AutoMirrored.Filled.Sort,
label = stringResource(R.string.list_sort), label = stringResource(R.string.list_sort),
onClick = onOpenSort onClick = onOpenSort,
) )
} }
item { item {
val regionCode = listData.list.region val regionCode = listData.list.region
val regionSubtitle = if (regionCode != null) { val regionSubtitle =
val (flag, name) = regionFlagAndName(regionCode) if (regionCode != null) {
"$flag $name" val (flag, name) = regionFlagAndName(regionCode)
} else null "$flag $name"
} else {
null
}
SettingsTile( SettingsTile(
icon = Icons.Filled.Language, icon = Icons.Filled.Language,
label = stringResource(R.string.list_region_language), label = stringResource(R.string.list_region_language),
subtitle = regionSubtitle, subtitle = regionSubtitle,
onClick = onOpenRegion onClick = onOpenRegion,
) )
} }
item { item {
SettingsTile( SettingsTile(
icon = Icons.Filled.People, icon = Icons.Filled.People,
label = stringResource(R.string.list_members), label = stringResource(R.string.list_members),
onClick = onOpenMembers onClick = onOpenMembers,
) )
} }
item { item {
SettingsTile( SettingsTile(
icon = Icons.Filled.Brush, icon = Icons.Filled.Brush,
label = stringResource(R.string.list_name_image), label = stringResource(R.string.list_name_image),
onClick = onOpenNameImage onClick = onOpenNameImage,
) )
} }
} }
@ -192,10 +197,11 @@ fun ListSettingsScreen(
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors( colors =
containerColor = MaterialTheme.colorScheme.error, ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colorScheme.onError containerColor = MaterialTheme.colorScheme.error,
) contentColor = MaterialTheme.colorScheme.onError,
),
) { ) {
Text(stringResource(R.string.list_leave)) Text(stringResource(R.string.list_leave))
} }
@ -207,55 +213,59 @@ fun ListSettingsScreen(
} }
} }
private fun regionFlagAndName(code: String): Pair<String, String> = when (code) { private fun regionFlagAndName(code: String): Pair<String, String> =
"de" -> "🇩🇪" to "Allemagne" when (code) {
"au" -> "🇦🇺" to "Australie" "de" -> "🇩🇪" to "Allemagne"
"at" -> "🇦🇹" to "Autriche" "au" -> "🇦🇺" to "Australie"
"ca" -> "🇨🇦" to "Canada" "at" -> "🇦🇹" to "Autriche"
"es" -> "🇪🇸" to "Espagne" "ca" -> "🇨🇦" to "Canada"
"fr" -> "🇫🇷" to "France" "es" -> "🇪🇸" to "Espagne"
"hu" -> "🇭🇺" to "Hongrie" "fr" -> "🇫🇷" to "France"
"it" -> "🇮🇹" to "Italie" "hu" -> "🇭🇺" to "Hongrie"
"no" -> "🇳🇴" to "Norvège" "it" -> "🇮🇹" to "Italie"
"nl" -> "🇳🇱" to "Pays-Bas" "no" -> "🇳🇴" to "Norvège"
"pl" -> "🇵🇱" to "Pologne" "nl" -> "🇳🇱" to "Pays-Bas"
"pt" -> "🇵🇹" to "Portugal" "pl" -> "🇵🇱" to "Pologne"
"gb" -> "🇬🇧" to "Royaume-Uni" "pt" -> "🇵🇹" to "Portugal"
"ru" -> "🇷🇺" to "Russie" "gb" -> "🇬🇧" to "Royaume-Uni"
"ch_de" -> "🇨🇭" to "Suisse (Allemand)" "ru" -> "🇷🇺" to "Russie"
"ch_fr" -> "🇨🇭" to "Suisse (français)" "ch_de" -> "🇨🇭" to "Suisse (Allemand)"
else -> "" to code "ch_fr" -> "🇨🇭" to "Suisse (français)"
} else -> "" to code
}
@Composable @Composable
private fun SettingsTile( private fun SettingsTile(
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String, label: String,
subtitle: String? = null, subtitle: String? = null,
onClick: () -> Unit onClick: () -> Unit,
) { ) {
Card( Card(
onClick = onClick, onClick = onClick,
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.aspectRatio(1.2f), .fillMaxWidth()
.aspectRatio(1.2f),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors( colors =
containerColor = MaterialTheme.colorScheme.surfaceVariant CardDefaults.cardColors(
) containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) { ) {
Column( Column(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(16.dp), .fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center,
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
@ -263,7 +273,7 @@ private fun SettingsTile(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
) )
if (subtitle != null) { if (subtitle != null) {
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
@ -272,7 +282,7 @@ private fun SettingsTile(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
) )
} }
} }

View File

@ -70,18 +70,19 @@ import kotlin.math.roundToInt
fun ListSortScreen( fun ListSortScreen(
listId: Long, listId: Long,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: ListsViewModel = hiltViewModel() viewModel: ListsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId } val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
val catalog = remember { CatalogProvider() } val catalog = remember { CatalogProvider() }
val savedOrder = listData?.list?.categoryOrder?.split(",")?.filter { it.isNotBlank() } val savedOrder = listData?.list?.categoryOrder?.split(",")?.filter { it.isNotBlank() }
val orderedCategories = remember(listData?.list?.categoryOrder) { val orderedCategories =
mutableStateListOf<String>().apply { remember(listData?.list?.categoryOrder) {
addAll(savedOrder ?: catalog.categories) mutableStateListOf<String>().apply {
addAll(savedOrder ?: catalog.categories)
}
} }
}
val savedVisible = listData?.list?.visibleCategories?.split(",")?.filter { it.isNotBlank() }?.toSet() val savedVisible = listData?.list?.visibleCategories?.split(",")?.filter { it.isNotBlank() }?.toSet()
var visibleCategories by remember(listData?.list?.visibleCategories) { var visibleCategories by remember(listData?.list?.visibleCategories) {
@ -109,99 +110,105 @@ fun ListSortScreen(
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier modifier =
.padding(end = 16.dp) Modifier
.clickable { .padding(end = 16.dp)
listData?.let { .clickable {
viewModel.updateList( listData?.let {
it.list.copy( viewModel.updateList(
visibleCategories = visibleCategories.joinToString(","), it.list.copy(
categoryOrder = orderedCategories.joinToString(",") visibleCategories = visibleCategories.joinToString(","),
categoryOrder = orderedCategories.joinToString(","),
),
) )
) }
} onBack()
onBack() },
}
) )
} },
) )
} },
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxSize()
.padding(horizontal = 16.dp) .padding(padding)
.padding(horizontal = 16.dp),
) { ) {
Text( Text(
text = stringResource(R.string.list_sort_description), text = stringResource(R.string.list_sort_description),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp) modifier = Modifier.padding(vertical = 8.dp),
) )
// Preview card (collapsible) // Preview card (collapsible)
Card( Card(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(vertical = 8.dp) .fillMaxWidth()
.clickable { previewExpanded = !previewExpanded }, .padding(vertical = 8.dp)
.clickable { previewExpanded = !previewExpanded },
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors( colors =
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) CardDefaults.cardColors(
) containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
) { ) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(16.dp), .fillMaxWidth()
verticalAlignment = Alignment.CenterVertically .padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Sort, imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp),
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text( Text(
text = stringResource(R.string.list_sort_preview), text = stringResource(R.string.list_sort_preview),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
) )
Icon( Icon(
imageVector = if (previewExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, 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( AnimatedVisibility(
visible = previewExpanded, visible = previewExpanded,
enter = expandVertically(), enter = expandVertically(),
exit = shrinkVertically() exit = shrinkVertically(),
) { ) {
Column( Column(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), .fillMaxWidth()
verticalArrangement = Arrangement.spacedBy(4.dp) .padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
orderedCategories.forEach { category -> orderedCategories.forEach { category ->
val isVisible = category in visibleCategories val isVisible = category in visibleCategories
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = if (isVisible) "" else "", text = if (isVisible) "" else "",
style = MaterialTheme.typography.bodySmall, 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)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = category, text = category,
style = MaterialTheme.typography.bodySmall, 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( LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 8.dp) contentPadding = PaddingValues(vertical = 8.dp),
) { ) {
itemsIndexed( itemsIndexed(
items = orderedCategories, items = orderedCategories,
key = { _, item -> item } key = { _, item -> item },
) { index, category -> ) { index, category ->
val isDragged = draggedIndex == index val isDragged = draggedIndex == index
val zIndex = if (isDragged) 1f else 0f val zIndex = if (isDragged) 1f else 0f
val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0 val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0
Box( Box(
modifier = Modifier modifier =
.zIndex(zIndex) Modifier
.offset { IntOffset(0, offsetY) } .zIndex(zIndex)
.graphicsLayer { .offset { IntOffset(0, offsetY) }
scaleX = if (isDragged) 1.02f else 1f .graphicsLayer {
scaleY = if (isDragged) 1.02f else 1f scaleX = if (isDragged) 1.02f else 1f
} scaleY = if (isDragged) 1.02f else 1f
.pointerInput(Unit) { }
detectDragGesturesAfterLongPress( .pointerInput(Unit) {
onDragStart = { draggedIndex = index }, detectDragGesturesAfterLongPress(
onDragEnd = { onDragStart = { draggedIndex = index },
draggedIndex?.let { from -> onDragEnd = {
val to = (from + (dragOffsetY / itemPx).roundToInt()) draggedIndex?.let { from ->
.coerceIn(0, orderedCategories.size - 1) val to =
if (from != to) { (from + (dragOffsetY / itemPx).roundToInt())
val moved = orderedCategories.removeAt(from) .coerceIn(0, orderedCategories.size - 1)
orderedCategories.add(to, moved) if (from != to) {
val moved = orderedCategories.removeAt(from)
orderedCategories.add(to, moved)
}
} }
} draggedIndex = null
draggedIndex = null dragOffsetY = 0f
dragOffsetY = 0f },
}, onDragCancel = {
onDragCancel = { draggedIndex = null
draggedIndex = null dragOffsetY = 0f
dragOffsetY = 0f },
}, onDrag = { change, dragAmount ->
onDrag = { change, dragAmount -> change.consume()
change.consume() dragOffsetY += dragAmount.y
dragOffsetY += dragAmount.y },
} )
) },
}
) { ) {
val isVisible = category in visibleCategories val isVisible = category in visibleCategories
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.clip(RoundedCornerShape(8.dp)) .fillMaxWidth()
.background( .clip(RoundedCornerShape(8.dp))
if (isDragged) MaterialTheme.colorScheme.primaryContainer .background(
else MaterialTheme.colorScheme.surface if (isDragged) {
) MaterialTheme.colorScheme.primaryContainer
.clickable { } else {
visibleCategories = if (isVisible) { MaterialTheme.colorScheme.surface
visibleCategories - category },
} else { )
visibleCategories + category .clickable {
visibleCategories =
if (isVisible) {
visibleCategories - category
} else {
visibleCategories + category
}
} }
} .padding(vertical = 12.dp, horizontal = 8.dp),
.padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = category, text = category,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
) )
IconButton(onClick = { IconButton(onClick = {
visibleCategories = if (isVisible) { visibleCategories =
visibleCategories - category if (isVisible) {
} else { visibleCategories - category
visibleCategories + category } else {
} visibleCategories + category
}
}) { }) {
Icon( Icon(
imageVector = if (isVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, imageVector = if (isVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = if (isVisible) "Masquer" else "Afficher", 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( Icon(
imageVector = Icons.Filled.DragHandle, imageVector = Icons.Filled.DragHandle,
contentDescription = "Réordonner", contentDescription = "Réordonner",
modifier = Modifier.padding(start = 8.dp), 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( data class ListBackground(
val resName: String, val resName: String,
val label: String, val label: String,
val drawableRes: Int val drawableRes: Int,
) )
val allListBackgrounds: List<ListBackground> = listOf( val allListBackgrounds: List<ListBackground> =
ListBackground("bg_animaux", "Animaux", R.drawable.bg_animaux), listOf(
ListBackground("bg_baby", "Bébé", R.drawable.bg_baby), ListBackground("bg_animaux", "Animaux", R.drawable.bg_animaux),
ListBackground("bg_epicerie", "Épicerie", R.drawable.bg_epicerie), ListBackground("bg_baby", "Bébé", R.drawable.bg_baby),
ListBackground("bg_epicerie2", "Épicerie 2", R.drawable.bg_epicerie2), ListBackground("bg_epicerie", "Épicerie", R.drawable.bg_epicerie),
ListBackground("bg_jardinage", "Maison & Jardin", R.drawable.bg_jardinage), ListBackground("bg_epicerie2", "Épicerie 2", R.drawable.bg_epicerie2),
ListBackground("bg_office", "Bureau", R.drawable.bg_office), ListBackground("bg_jardinage", "Maison & Jardin", R.drawable.bg_jardinage),
ListBackground("bg_party", "Fête", R.drawable.bg_party), ListBackground("bg_office", "Bureau", R.drawable.bg_office),
ListBackground("bg_pharmacie", "Pharmacie", R.drawable.bg_pharmacie), ListBackground("bg_party", "Fête", R.drawable.bg_party),
ListBackground("bg_plage", "Plage", R.drawable.bg_plage), ListBackground("bg_pharmacie", "Pharmacie", R.drawable.bg_pharmacie),
ListBackground("bg_renovation", "Rénovation", R.drawable.bg_renovation) ListBackground("bg_plage", "Plage", R.drawable.bg_plage),
) ListBackground("bg_renovation", "Rénovation", R.drawable.bg_renovation),
)
fun backgroundByResName(name: String?): ListBackground? = fun backgroundByResName(name: String?): ListBackground? = allListBackgrounds.firstOrNull { it.resName == name }
allListBackgrounds.firstOrNull { it.resName == name }
fun backgroundLabel(name: String?): String = fun backgroundLabel(name: String?): String = backgroundByResName(name)?.label ?: ""
backgroundByResName(name)?.label ?: ""

View File

@ -1,5 +1,9 @@
package com.safebite.app.presentation.screen.main 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.AnimatedVisibility
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@ -31,11 +35,12 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -58,7 +63,7 @@ import com.safebite.app.presentation.screen.tracking.TrackingScreen
/** /**
* Écran principal de l'application avec Bottom Navigation et FAB Scanner. * Écran principal de l'application avec Bottom Navigation et FAB Scanner.
* *
* Architecture (spec UX §3.1) : * Architecture (spec UX §3.1) :
* - 4 onglets : Dashboard, Listes, Suivi, Famille * - 4 onglets : Dashboard, Listes, Suivi, Famille
* - FAB Scanner central (56dp, chevauchant la bottom bar) * - FAB Scanner central (56dp, chevauchant la bottom bar)
@ -80,12 +85,14 @@ fun MainScreen(
val currentDestination = currentBackStackEntry?.destination val currentDestination = currentBackStackEntry?.destination
// Déterminer si le FAB doit être visible // Déterminer si le FAB doit être visible
val fabVisible = currentDestination?.route in listOf( val fabVisible =
Screen.Dashboard.route, currentDestination?.route in
Screen.Lists.route, listOf(
Screen.Tracking.route, Screen.Dashboard.route,
Screen.Family.route Screen.Lists.route,
) Screen.Tracking.route,
Screen.Family.route,
)
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
@ -96,12 +103,12 @@ fun MainScreen(
Image( Image(
painter = painterResource(id = R.drawable.safebite_logo_nobg), painter = painterResource(id = R.drawable.safebite_logo_nobg),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp),
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(
text = stringResource(R.string.app_name), text = stringResource(R.string.app_name),
color = Color.White color = Color.White,
) )
} }
}, },
@ -110,47 +117,49 @@ fun MainScreen(
Icon( Icon(
imageVector = Icons.Filled.Settings, imageVector = Icons.Filled.Settings,
contentDescription = stringResource(R.string.nav_settings), contentDescription = stringResource(R.string.nav_settings),
tint = Color.White tint = Color.White,
) )
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors =
containerColor = MaterialTheme.colorScheme.primary, TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = Color.White, scrolledContainerColor = MaterialTheme.colorScheme.primary,
navigationIconContentColor = Color.White, titleContentColor = Color.White,
actionIconContentColor = Color.White, navigationIconContentColor = Color.White,
) actionIconContentColor = Color.White,
),
) )
}, },
bottomBar = { bottomBar = {
SafeBiteBottomNavigation( SafeBiteBottomNavigation(
navController = navController, navController = navController,
currentDestination = currentDestination, currentDestination = currentDestination,
items = bottomNavItems items = bottomNavItems,
) )
}, },
floatingActionButton = { floatingActionButton = {
SafeBiteFab( SafeBiteFab(
visible = fabVisible, visible = fabVisible,
onClick = onOpenScanner onClick = onOpenScanner,
) )
}, },
floatingActionButtonPosition = FabPosition.Center floatingActionButtonPosition = FabPosition.Center,
) { paddingValues -> ) { paddingValues ->
// NavHost pour les 4 onglets principaux // NavHost pour les 4 onglets principaux
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Screen.Dashboard.route, startDestination = Screen.Dashboard.route,
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(paddingValues) .fillMaxSize()
.padding(paddingValues),
) { ) {
composable(Screen.Dashboard.route) { composable(Screen.Dashboard.route) {
DashboardScreen( DashboardScreen(
onScan = onOpenScanner, onScan = onOpenScanner,
onOpenList = onOpenListDetail, onOpenList = onOpenListDetail,
onOpenHistoryItem = onOpenHistoryItem onOpenHistoryItem = onOpenHistoryItem,
) )
} }
composable(Screen.Lists.route) { composable(Screen.Lists.route) {
@ -158,19 +167,19 @@ fun MainScreen(
onOpenList = { id, name -> onOpenListDetail(id, name) }, onOpenList = { id, name -> onOpenListDetail(id, name) },
onOpenScanner = onOpenScanner, onOpenScanner = onOpenScanner,
onOpenListCreate = onOpenListCreate, onOpenListCreate = onOpenListCreate,
onOpenListSettings = onOpenListSettings onOpenListSettings = onOpenListSettings,
) )
} }
composable(Screen.Tracking.route) { composable(Screen.Tracking.route) {
TrackingScreen( TrackingScreen(
onOpenHistoryItem = onOpenHistoryItem, onOpenHistoryItem = onOpenHistoryItem,
onOpenScanner = onOpenScanner onOpenScanner = onOpenScanner,
) )
} }
composable(Screen.Family.route) { composable(Screen.Family.route) {
FamilyScreen( FamilyScreen(
onOpenProfile = onOpenProfile, onOpenProfile = onOpenProfile,
onOpenSettings = onOpenSettings onOpenSettings = onOpenSettings,
) )
} }
} }
@ -179,7 +188,7 @@ fun MainScreen(
/** /**
* Bottom Navigation Bar SafeBite (spec UX §3.2). * Bottom Navigation Bar SafeBite (spec UX §3.2).
* *
* - 4 items avec icônes selected/unselected * - 4 items avec icônes selected/unselected
* - Badge pour notifications non lues * - Badge pour notifications non lues
* - Labels toujours visibles * - Labels toujours visibles
@ -188,11 +197,11 @@ fun MainScreen(
private fun SafeBiteBottomNavigation( private fun SafeBiteBottomNavigation(
navController: NavHostController, navController: NavHostController,
currentDestination: NavDestination?, currentDestination: NavDestination?,
items: List<BottomNavItem> items: List<BottomNavItem>,
) { ) {
NavigationBar( NavigationBar(
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
tonalElevation = 2.dp tonalElevation = 2.dp,
) { ) {
items.forEach { item -> items.forEach { item ->
val selected = currentDestination?.hierarchy?.any { it.route == item.screen.route } == true 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 val icon = if (selected) item.iconSelected else item.iconUnselected
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null contentDescription = null,
) )
}, },
label = { label = {
Text( Text(
text = item.label, text = item.label,
style = MaterialTheme.typography.labelMedium style = MaterialTheme.typography.labelMedium,
) )
}, },
alwaysShowLabel = true, alwaysShowLabel = true,
colors = androidx.compose.material3.NavigationBarItemDefaults.colors( colors =
selectedIconColor = Color.White, androidx.compose.material3.NavigationBarItemDefaults.colors(
selectedTextColor = Color.White, selectedIconColor = Color.White,
unselectedIconColor = Color.White.copy(alpha = 0.7f), selectedTextColor = Color.White,
unselectedTextColor = Color.White.copy(alpha = 0.7f), unselectedIconColor = Color.White.copy(alpha = 0.7f),
indicatorColor = Color.White.copy(alpha = 0.2f) unselectedTextColor = Color.White.copy(alpha = 0.7f),
) indicatorColor = Color.White.copy(alpha = 0.2f),
),
) )
} }
} }
@ -235,7 +245,7 @@ private fun SafeBiteBottomNavigation(
/** /**
* FAB Scanner SafeBite (spec UX §3.3). * FAB Scanner SafeBite (spec UX §3.3).
* *
* - 56dp, centré, chevauchant la bottom bar * - 56dp, centré, chevauchant la bottom bar
* - Animation scale + fade pour apparition/disparition * - Animation scale + fade pour apparition/disparition
* - Haptic feedback au tap * - Haptic feedback au tap
@ -243,38 +253,60 @@ private fun SafeBiteBottomNavigation(
@Composable @Composable
private fun SafeBiteFab( private fun SafeBiteFab(
visible: Boolean, visible: Boolean,
onClick: () -> Unit onClick: () -> Unit,
) { ) {
val context = LocalContext.current
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(animationSpec = tween(200)) + enter =
fadeIn(animationSpec = tween(200)) +
scaleIn(initialScale = 0.8f, animationSpec = tween(200)) + scaleIn(initialScale = 0.8f, animationSpec = tween(200)) +
slideInVertically( slideInVertically(
initialOffsetY = { it }, 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( FloatingActionButton(
onClick = onClick, onClick = {
triggerFabHaptic(context)
onClick()
},
containerColor = MaterialTheme.colorScheme.onSurface, containerColor = MaterialTheme.colorScheme.onSurface,
contentColor = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
elevation = androidx.compose.material3.FloatingActionButtonDefaults.elevation( elevation =
defaultElevation = 6.dp androidx.compose.material3.FloatingActionButtonDefaults.elevation(
) defaultElevation = 6.dp,
),
) { ) {
Icon( Icon(
imageVector = Icons.Filled.QrCodeScanner, imageVector = Icons.Filled.QrCodeScanner,
contentDescription = stringResource(R.string.fab_scan), 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.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
@ -50,7 +50,7 @@ import java.util.concurrent.Executors
@Composable @Composable
fun OcrCaptureScreen( fun OcrCaptureScreen(
onBack: () -> Unit, onBack: () -> Unit,
onCaptured: (String) -> Unit onCaptured: (String) -> Unit,
) { ) {
val permission = rememberPermissionState(android.Manifest.permission.CAMERA) val permission = rememberPermissionState(android.Manifest.permission.CAMERA)
LaunchedEffect(Unit) { if (!permission.status.isGranted) permission.launchPermissionRequest() } LaunchedEffect(Unit) { if (!permission.status.isGranted) permission.launchPermissionRequest() }
@ -65,41 +65,44 @@ fun OcrCaptureScreen(
onBack = onBack, onBack = onBack,
backContentDescription = stringResource(R.string.action_back), backContentDescription = stringResource(R.string.action_back),
) )
} },
) { padding -> ) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) { Box(Modifier.fillMaxSize().padding(padding)) {
if (!permission.status.isGranted) { if (!permission.status.isGranted) {
ErrorView( ErrorView(
message = stringResource(R.string.scanner_camera_denied), message = stringResource(R.string.scanner_camera_denied),
onRetry = { permission.launchPermissionRequest() } onRetry = { permission.launchPermissionRequest() },
) )
} else { } else {
OcrCameraView( OcrCameraView(
onTextUpdate = { livePreviewText = it }, onTextUpdate = { livePreviewText = it },
onCapture = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) } onCapture = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) },
) )
Column( Column(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.align(Alignment.BottomCenter) .fillMaxWidth()
.padding(16.dp) .align(Alignment.BottomCenter)
.padding(16.dp),
) { ) {
if (livePreviewText.isNotBlank()) { if (livePreviewText.isNotBlank()) {
Text( Text(
livePreviewText.take(300), livePreviewText.take(300),
color = Color.White, color = Color.White,
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.background(Color(0xAA000000), RoundedCornerShape(8.dp)) .fillMaxWidth()
.padding(8.dp) .background(Color(0xAA000000), RoundedCornerShape(8.dp))
.padding(8.dp),
) )
} else { } else {
Text( Text(
stringResource(R.string.ocr_capture_hint), stringResource(R.string.ocr_capture_hint),
color = Color.White, color = Color.White,
modifier = Modifier modifier =
.background(Color(0xAA000000), RoundedCornerShape(8.dp)) Modifier
.padding(8.dp) .background(Color(0xAA000000), RoundedCornerShape(8.dp))
.padding(8.dp),
) )
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
@ -108,7 +111,7 @@ fun OcrCaptureScreen(
onClick = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) }, onClick = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) },
enabled = livePreviewText.isNotBlank(), enabled = livePreviewText.isNotBlank(),
large = true, large = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
} }
} }
@ -117,7 +120,10 @@ fun OcrCaptureScreen(
} }
@Composable @Composable
private fun OcrCameraView(onTextUpdate: (String) -> Unit, onCapture: () -> Unit) { private fun OcrCameraView(
onTextUpdate: (String) -> Unit,
onCapture: () -> Unit,
) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val executor = remember { Executors.newSingleThreadExecutor() } val executor = remember { Executors.newSingleThreadExecutor() }
@ -137,13 +143,17 @@ private fun OcrCameraView(onTextUpdate: (String) -> Unit, onCapture: () -> Unit)
providerFuture.addListener({ providerFuture.addListener({
val provider = providerFuture.get() val provider = providerFuture.get()
val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) } val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) }
val analysis = ImageAnalysis.Builder() val analysis =
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) ImageAnalysis.Builder()
.build() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
analysis.setAnalyzer(executor) { proxy: ImageProxy -> analysis.setAnalyzer(executor) { proxy: ImageProxy ->
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class) @androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
val media = proxy.image 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) val input = InputImage.fromMediaImage(media, proxy.imageInfo.rotationDegrees)
recognizer.process(input) recognizer.process(input)
.addOnSuccessListener { result -> onTextUpdate(result.text) } .addOnSuccessListener { result -> onTextUpdate(result.text) }
@ -152,11 +162,15 @@ private fun OcrCameraView(onTextUpdate: (String) -> Unit, onCapture: () -> Unit)
try { try {
provider.unbindAll() provider.unbindAll()
provider.bindToLifecycle( provider.bindToLifecycle(
lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis,
) )
} catch (_: Throwable) {} } catch (_: Throwable) {
}
}, ContextCompat.getMainExecutor(ctx)) }, ContextCompat.getMainExecutor(ctx))
previewView previewView
} },
) )
} }

View File

@ -20,19 +20,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.safebite.app.R 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.engine.AllergenAnalysisEngine
import com.safebite.app.domain.model.AllergenType 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun OcrReviewScreen( fun OcrReviewScreen(
initialText: String, initialText: String,
onBack: () -> Unit, onBack: () -> Unit,
onAnalyze: (String) -> Unit onAnalyze: (String) -> Unit,
) { ) {
var text by remember { mutableStateOf(initialText) } var text by remember { mutableStateOf(initialText) }
Scaffold( Scaffold(
@ -43,11 +42,11 @@ fun OcrReviewScreen(
onBack = onBack, onBack = onBack,
backContentDescription = stringResource(R.string.action_back), backContentDescription = stringResource(R.string.action_back),
) )
} },
) { padding -> ) { padding ->
Column( Column(
Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), 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) Text(stringResource(R.string.ocr_review_hint), color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedTextField( OutlinedTextField(
@ -55,7 +54,7 @@ fun OcrReviewScreen(
onValueChange = { text = it }, onValueChange = { text = it },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.result_ingredients)) }, label = { Text(stringResource(R.string.result_ingredients)) },
minLines = 8 minLines = 8,
) )
val highlights = remember(text) { findHighlights(text) } val highlights = remember(text) { findHighlights(text) }
if (highlights.isNotEmpty()) { if (highlights.isNotEmpty()) {
@ -66,7 +65,7 @@ fun OcrReviewScreen(
onClick = { onAnalyze(text) }, onClick = { onAnalyze(text) },
enabled = text.isNotBlank(), enabled = text.isNotBlank(),
large = true, large = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
} }
} }

View File

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

View File

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

View File

@ -21,49 +21,57 @@ import javax.inject.Inject
*/ */
sealed class ProductDetailUiState { sealed class ProductDetailUiState {
data object Loading : ProductDetailUiState() data object Loading : ProductDetailUiState()
data class Success( data class Success(
val product: Product, val product: Product,
val scanResult: ScanResult? val scanResult: ScanResult?,
) : ProductDetailUiState() ) : ProductDetailUiState()
data class Error(val message: String) : ProductDetailUiState() data class Error(val message: String) : ProductDetailUiState()
} }
@HiltViewModel @HiltViewModel
class ProductDetailViewModel @Inject constructor( class ProductDetailViewModel
private val fetchProduct: FetchProductUseCase, @Inject
private val analyzeProduct: AnalyzeProductUseCase, constructor(
private val manageProfile: ManageProfileUseCase private val fetchProduct: FetchProductUseCase,
) : ViewModel() { 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) fun loadProduct(barcode: String) =
val uiState: StateFlow<ProductDetailUiState> = _uiState.asStateFlow() viewModelScope.launch {
_uiState.value = ProductDetailUiState.Loading
fun loadProduct(barcode: String) = viewModelScope.launch { when (val result = fetchProduct(barcode)) {
_uiState.value = ProductDetailUiState.Loading 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)) { private suspend fun resolveProfiles() =
is ProductFetchResult.Found -> { run {
val profiles = resolveProfiles() val all = manageProfile.observe().first()
val scanResult = if (profiles.isNotEmpty()) { val activeIds = manageProfile.observeActiveIds().first()
analyzeProduct(result.product, profiles, com.safebite.app.domain.model.DataSource.API) when {
} else null activeIds.isNotEmpty() -> all.filter { it.id in activeIds }
_uiState.value = ProductDetailUiState.Success(result.product, scanResult) 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.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -40,7 +39,10 @@ import com.safebite.app.domain.model.CustomItemTag
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun AllergenGrid(selected: Set<AllergenType>, onToggle: (AllergenType) -> Unit) { fun AllergenGrid(
selected: Set<AllergenType>,
onToggle: (AllergenType) -> Unit,
) {
FlowRow { FlowRow {
AllergenType.entries.forEach { a -> AllergenType.entries.forEach { a ->
FilterChip( FilterChip(
@ -48,7 +50,7 @@ fun AllergenGrid(selected: Set<AllergenType>, onToggle: (AllergenType) -> Unit)
onClick = { onToggle(a) }, onClick = { onToggle(a) },
leadingIcon = { Text(a.icon) }, leadingIcon = { Text(a.icon) },
label = { Text(a.displayNameFr) }, 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 }, onValueChange = { name = it },
label = { Text(stringResource(R.string.profile_custom_name)) }, label = { Text(stringResource(R.string.profile_custom_name)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true singleLine = true,
) )
Text(stringResource(R.string.profile_custom_tag), style = MaterialTheme.typography.labelLarge) Text(stringResource(R.string.profile_custom_tag), style = MaterialTheme.typography.labelLarge)
FlowRow { FlowRow {
@ -75,7 +77,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
selected = tag == t, selected = tag == t,
onClick = { tag = t }, onClick = { tag = t },
label = { Text(tagLabel(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 = "" name = ""
}, },
enabled = name.isNotBlank(), enabled = name.isNotBlank(),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) { ) {
Icon(Icons.Filled.Add, null) Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
@ -97,7 +99,10 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun CustomItemsList(items: List<CustomDietItem>, onRemove: (CustomDietItem) -> Unit) { fun CustomItemsList(
items: List<CustomDietItem>,
onRemove: (CustomDietItem) -> Unit,
) {
if (items.isEmpty()) { if (items.isEmpty()) {
Text(stringResource(R.string.profile_custom_empty), color = MaterialTheme.colorScheme.onSurfaceVariant) Text(stringResource(R.string.profile_custom_empty), color = MaterialTheme.colorScheme.onSurfaceVariant)
return return
@ -109,38 +114,42 @@ fun CustomItemsList(items: List<CustomDietItem>, onRemove: (CustomDietItem) -> U
label = { label = {
Text( Text(
"${tagIcon(item.tag)} ${item.name}", "${tagIcon(item.tag)} ${item.name}",
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium,
) )
}, },
trailingIcon = { Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(16.dp)) }, trailingIcon = { Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(16.dp)) },
colors = AssistChipDefaults.assistChipColors( colors =
containerColor = tagColor(item.tag).copy(alpha = 0.18f) AssistChipDefaults.assistChipColors(
), containerColor = tagColor(item.tag).copy(alpha = 0.18f),
modifier = Modifier.padding(4.dp) ),
modifier = Modifier.padding(4.dp),
) )
} }
} }
} }
@Composable @Composable
fun tagLabel(tag: CustomItemTag): String = when (tag) { fun tagLabel(tag: CustomItemTag): String =
CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy) when (tag) {
CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance) CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy)
CustomItemTag.DIET -> stringResource(R.string.profile_custom_tag_diet) CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance)
CustomItemTag.UNHEALTHY -> stringResource(R.string.profile_custom_tag_unhealthy) 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) { fun tagIcon(tag: CustomItemTag): String =
CustomItemTag.ALLERGY -> "" when (tag) {
CustomItemTag.INTOLERANCE -> "⚠️" CustomItemTag.ALLERGY -> ""
CustomItemTag.DIET -> "🥗" CustomItemTag.INTOLERANCE -> "⚠️"
CustomItemTag.UNHEALTHY -> "🍩" CustomItemTag.DIET -> "🥗"
} CustomItemTag.UNHEALTHY -> "🍩"
}
@Composable @Composable
fun tagColor(tag: CustomItemTag): Color = when (tag) { fun tagColor(tag: CustomItemTag): Color =
CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.error when (tag) {
CustomItemTag.INTOLERANCE -> Color(0xFFFFA000) CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.error
CustomItemTag.DIET -> MaterialTheme.colorScheme.tertiary CustomItemTag.INTOLERANCE -> Color(0xFFFFA000)
CustomItemTag.UNHEALTHY -> Color(0xFF9575CD) 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -28,20 +27,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.safebite.app.R 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.AllergenSelectionGrid
import com.safebite.app.presentation.common.components.PrimaryButton import com.safebite.app.presentation.common.components.PrimaryButton
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
import com.safebite.app.presentation.common.components.StandardTextField import com.safebite.app.presentation.common.components.StandardTextField
import com.safebite.app.presentation.theme.LocalDimens 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) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
@ -49,7 +44,7 @@ fun ProfileEditScreen(
id: Long, id: Long,
onBack: () -> Unit, onBack: () -> Unit,
onSaved: () -> Unit, onSaved: () -> Unit,
viewModel: ProfileViewModel = hiltViewModel() viewModel: ProfileViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(id) { viewModel.load(id) } LaunchedEffect(id) { viewModel.load(id) }
val ui by viewModel.edit.collectAsStateWithLifecycle() val ui by viewModel.edit.collectAsStateWithLifecycle()
@ -64,13 +59,13 @@ fun ProfileEditScreen(
onBack = onBack, onBack = onBack,
backContentDescription = stringResource(R.string.action_back), backContentDescription = stringResource(R.string.action_back),
) )
} },
) { padding -> ) { padding ->
if (!ui.loaded) return@Scaffold if (!ui.loaded) return@Scaffold
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg), modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd) verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
) { ) {
item { item {
StandardTextField( StandardTextField(
@ -89,8 +84,16 @@ fun ProfileEditScreen(
onClick = { viewModel.setAvatar(a) }, onClick = { viewModel.setAvatar(a) },
shape = CircleShape, shape = CircleShape,
color = bg, color = bg,
border = if (selected) androidx.compose.foundation.BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null, border =
modifier = Modifier.size(72.dp) if (selected) {
androidx.compose.foundation.BorderStroke(
3.dp,
MaterialTheme.colorScheme.primary,
)
} else {
null
},
modifier = Modifier.size(72.dp),
) { ) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(a, fontSize = MaterialTheme.typography.displaySmall.fontSize) Text(a, fontSize = MaterialTheme.typography.displaySmall.fontSize)
@ -114,7 +117,7 @@ fun ProfileEditScreen(
item { item {
AllergenSelectionGrid( AllergenSelectionGrid(
selectedAllergens = ui.allergenLevels, selectedAllergens = ui.allergenLevels,
onLevelChanged = viewModel::setAllergenLevel onLevelChanged = viewModel::setAllergenLevel,
) )
} }
@ -126,7 +129,7 @@ fun ProfileEditScreen(
selected = r in ui.restrictions, selected = r in ui.restrictions,
onClick = { viewModel.toggleRestriction(r) }, onClick = { viewModel.toggleRestriction(r) },
label = { Text(r.displayFr) }, label = { Text(r.displayFr) },
modifier = Modifier.padding(4.dp) modifier = Modifier.padding(4.dp),
) )
} }
} }
@ -147,11 +150,9 @@ fun ProfileEditScreen(
PrimaryButton( PrimaryButton(
text = stringResource(R.string.action_save), text = stringResource(R.string.action_save),
onClick = { viewModel.save(onSaved) }, onClick = { viewModel.save(onSaved) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
} }
} }
} }
} }

View File

@ -42,7 +42,7 @@ fun ProfileListScreen(
onBack: () -> Unit, onBack: () -> Unit,
onNew: () -> Unit, onNew: () -> Unit,
onEdit: (Long) -> Unit, onEdit: (Long) -> Unit,
viewModel: ProfileViewModel = hiltViewModel() viewModel: ProfileViewModel = hiltViewModel(),
) { ) {
val profiles by viewModel.profiles.collectAsStateWithLifecycle() val profiles by viewModel.profiles.collectAsStateWithLifecycle()
val dimens = LocalDimens.current val dimens = LocalDimens.current
@ -61,14 +61,15 @@ fun ProfileListScreen(
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary, contentColor = MaterialTheme.colorScheme.onPrimary,
) { Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.action_save)) } ) { Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.action_save)) }
} },
) { padding -> ) { padding ->
LazyColumn( LazyColumn(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxSize()
.padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg), .padding(padding)
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd) .padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
) { ) {
items(profiles, key = { it.id }) { profile -> items(profiles, key = { it.id }) { profile ->
StandardCard( StandardCard(
@ -78,7 +79,7 @@ fun ProfileListScreen(
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
AvatarBubble(avatar = profile.avatar) AvatarBubble(avatar = profile.avatar)
Spacer(Modifier.size(dimens.spacingMd)) Spacer(Modifier.size(dimens.spacingMd))
@ -87,34 +88,34 @@ fun ProfileListScreen(
Text( Text(
profile.name, profile.name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface,
) )
if (profile.isDefault) { if (profile.isDefault) {
Spacer(Modifier.size(dimens.spacingXs + 2.dp)) Spacer(Modifier.size(dimens.spacingXs + 2.dp))
androidx.compose.material3.AssistChip( androidx.compose.material3.AssistChip(
onClick = {}, onClick = {},
label = { Text(stringResource(R.string.profile_default_badge)) } label = { Text(stringResource(R.string.profile_default_badge)) },
) )
} }
} }
Text( Text(
"${profile.severeAllergens.size + profile.moderateIntolerances.size} allergènes", "${profile.severeAllergens.size + profile.moderateIntolerances.size} allergènes",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
IconButton(onClick = { onEdit(profile.id) }) { IconButton(onClick = { onEdit(profile.id) }) {
Icon( Icon(
Icons.Filled.Edit, Icons.Filled.Edit,
contentDescription = stringResource(R.string.action_save), contentDescription = stringResource(R.string.action_save),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
IconButton(onClick = { viewModel.delete(profile) }) { IconButton(onClick = { viewModel.delete(profile) }) {
Icon( Icon(
Icons.Filled.Delete, Icons.Filled.Delete,
contentDescription = stringResource(R.string.action_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 restrictions: Set<DietaryRestriction> = emptySet(),
val customItems: List<CustomDietItem> = emptyList(), val customItems: List<CustomDietItem> = emptyList(),
val isDefault: Boolean = false, val isDefault: Boolean = false,
val loaded: Boolean = false val loaded: Boolean = false,
) { ) {
// Propriétés calculées pour la compatibilité // Propriétés calculées pour la compatibilité
val severe: Set<AllergenType> val severe: Set<AllergenType>
@ -38,110 +38,134 @@ data class ProfileEditUi(
} }
@HiltViewModel @HiltViewModel
class ProfileViewModel @Inject constructor( class ProfileViewModel
private val manage: ManageProfileUseCase @Inject
) : ViewModel() { 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() private val _edit = MutableStateFlow(ProfileEditUi())
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) val edit: StateFlow<ProfileEditUi> = _edit.asStateFlow()
private val _edit = MutableStateFlow(ProfileEditUi()) fun load(id: Long) =
val edit: StateFlow<ProfileEditUi> = _edit.asStateFlow() 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 { _edit.value =
if (id == 0L) { ProfileEditUi(
_edit.value = ProfileEditUi(loaded = true) id = p.id,
} else { name = p.name,
val p = manage.get(id) avatar = p.avatar,
if (p != null) { allergenLevels = allergenLevels,
// Construire la map des niveaux d'allergènes restrictions = p.dietaryRestrictions,
val allergenLevels = mutableMapOf<AllergenType, AllergenLevel>() customItems = p.customItems,
p.severeAllergens.forEach { allergenLevels[it] = AllergenLevel.SEVERE } isDefault = p.isDefault,
p.moderateIntolerances.forEach { allergenLevels[it] = AllergenLevel.TRACE } loaded = true,
)
}
}
}
_edit.value = ProfileEditUi( fun setName(v: String) = _edit.update { it.copy(name = v) }
id = p.id,
name = p.name, fun setAvatar(v: String) = _edit.update { it.copy(avatar = v) }
avatar = p.avatar,
allergenLevels = allergenLevels, /** Met à jour le niveau d'un allergène (cycle : NONE → TRACE → SEVERE → NONE) */
restrictions = p.dietaryRestrictions, fun setAllergenLevel(
customItems = p.customItems, allergen: AllergenType,
isDefault = p.isDefault, level: AllergenLevel,
loaded = true ) = _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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CameraAlt 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.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.safebite.app.R import com.safebite.app.R
import com.safebite.app.presentation.common.components.PrimaryButton import com.safebite.app.presentation.common.components.PrimaryButton
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
@ -58,7 +54,7 @@ fun ProductNotFoundScreen(
onBack: () -> Unit, onBack: () -> Unit,
onOpenOcr: () -> Unit, onOpenOcr: () -> Unit,
onManualSubmit: (String) -> Unit, onManualSubmit: (String) -> Unit,
onScanAgain: () -> Unit onScanAgain: () -> Unit,
) { ) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
var manualBarcode by remember { mutableStateOf("") } var manualBarcode by remember { mutableStateOf("") }
@ -71,19 +67,20 @@ fun ProductNotFoundScreen(
SafeBiteTopAppBar( SafeBiteTopAppBar(
title = stringResource(R.string.result_product_not_found), title = stringResource(R.string.result_product_not_found),
onBack = onBack, onBack = onBack,
backContentDescription = stringResource(R.string.a11y_back) backContentDescription = stringResource(R.string.a11y_back),
) )
} },
) { padding -> ) { padding ->
if (submitted) { if (submitted) {
// Message de confirmation // Message de confirmation
Column( Column(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxSize()
.padding(dimens.spacingXl), .padding(padding)
.padding(dimens.spacingXl),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center,
) { ) {
Text("", style = MaterialTheme.typography.displayMedium) Text("", style = MaterialTheme.typography.displayMedium)
Spacer(Modifier.height(dimens.spacingMd)) Spacer(Modifier.height(dimens.spacingMd))
@ -91,55 +88,57 @@ fun ProductNotFoundScreen(
text = "Merci pour votre contribution !", text = "Merci pour votre contribution !",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(dimens.spacingSm)) Spacer(Modifier.height(dimens.spacingSm))
Text( Text(
text = "Le produit sera analysé sous 24h. Vous recevrez une notification quand le résultat sera disponible.", text = "Le produit sera analysé sous 24h. Vous recevrez une notification quand le résultat sera disponible.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(dimens.spacingLg)) Spacer(Modifier.height(dimens.spacingLg))
PrimaryButton( PrimaryButton(
text = "Scanner un autre produit", text = "Scanner un autre produit",
onClick = onScanAgain, onClick = onScanAgain,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
} }
} else { } else {
Column( Column(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxSize()
.verticalScroll(rememberScrollState()) .padding(padding)
.padding(dimens.spacingLg), .verticalScroll(rememberScrollState())
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd) .padding(dimens.spacingLg),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
) { ) {
// Message principal // Message principal
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors =
containerColor = MaterialTheme.colorScheme.surfaceVariant CardDefaults.cardColors(
) containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) { ) {
Column( Column(
modifier = Modifier.padding(dimens.spacingMd), modifier = Modifier.padding(dimens.spacingMd),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text("🔍", style = MaterialTheme.typography.displayMedium) Text("🔍", style = MaterialTheme.typography.displayMedium)
Spacer(Modifier.height(dimens.spacingSm)) Spacer(Modifier.height(dimens.spacingSm))
Text( Text(
text = "Produit non reconnu", text = "Produit non reconnu",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
) )
Spacer(Modifier.height(dimens.spacingSm)) Spacer(Modifier.height(dimens.spacingSm))
Text( Text(
text = "Ce produit (code: $barcode) n'est pas dans notre base de données.", text = "Ce produit (code: $barcode) n'est pas dans notre base de données.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center,
) )
} }
} }
@ -148,13 +147,14 @@ fun ProductNotFoundScreen(
Text( Text(
text = "Option 1 : Photographier les ingrédients", text = "Option 1 : Photographier les ingrédients",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
) )
OutlinedButton( OutlinedButton(
onClick = onOpenOcr, onClick = onOpenOcr,
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.semantics { contentDescription = "Prendre une photo des ingrédients" } .fillMaxWidth()
.semantics { contentDescription = "Prendre une photo des ingrédients" },
) { ) {
Icon(Icons.Filled.CameraAlt, contentDescription = null) Icon(Icons.Filled.CameraAlt, contentDescription = null)
Spacer(Modifier.size(dimens.spacingSm)) Spacer(Modifier.size(dimens.spacingSm))
@ -165,7 +165,7 @@ fun ProductNotFoundScreen(
Text( Text(
text = "Option 2 : Saisie manuelle", text = "Option 2 : Saisie manuelle",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
) )
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(dimens.spacingMd)) { Column(modifier = Modifier.padding(dimens.spacingMd)) {
@ -173,7 +173,7 @@ fun ProductNotFoundScreen(
value = productName, value = productName,
onValueChange = { productName = it }, onValueChange = { productName = it },
label = "Nom du produit (optionnel)", label = "Nom du produit (optionnel)",
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(dimens.spacingSm)) Spacer(Modifier.height(dimens.spacingSm))
StandardTextField( StandardTextField(
@ -181,7 +181,7 @@ fun ProductNotFoundScreen(
onValueChange = { manualBarcode = it }, onValueChange = { manualBarcode = it },
label = "Code-barres", label = "Code-barres",
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) }, leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(dimens.spacingMd)) Spacer(Modifier.height(dimens.spacingMd))
PrimaryButton( PrimaryButton(
@ -193,7 +193,7 @@ fun ProductNotFoundScreen(
} }
}, },
enabled = manualBarcode.isNotBlank(), 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.", text = "💡 Vous pouvez aussi scanner un autre produit similaire en magasin.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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.content.Intent
import android.net.Uri import android.net.Uri
import androidx.compose.animation.AnimatedVisibility 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.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics 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.Nutriments
import com.safebite.app.domain.model.ScanResult import com.safebite.app.domain.model.ScanResult
import com.safebite.app.presentation.common.components.ErrorView 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.OutlinedActionButton
import com.safebite.app.presentation.common.components.PrimaryButton import com.safebite.app.presentation.common.components.PrimaryButton
import com.safebite.app.presentation.common.components.ProductCard 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.SafeBiteTopAppBar
import com.safebite.app.presentation.common.components.SafetyStatusBanner import com.safebite.app.presentation.common.components.SafetyStatusBanner
import com.safebite.app.presentation.common.util.UiState import com.safebite.app.presentation.common.util.UiState
import com.safebite.app.presentation.theme.LocalDimens
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -93,7 +92,8 @@ fun ResultScreen(
onBack: () -> Unit, onBack: () -> Unit,
onScanAgain: () -> Unit, onScanAgain: () -> Unit,
onOcr: () -> Unit, onOcr: () -> Unit,
viewModel: ResultViewModel = hiltViewModel() onOpenAlternatives: () -> Unit,
viewModel: ResultViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val lists by viewModel.lists.collectAsStateWithLifecycle() val lists by viewModel.lists.collectAsStateWithLifecycle()
@ -114,46 +114,50 @@ fun ResultScreen(
onBack = onBack, onBack = onBack,
backContentDescription = stringResource(R.string.a11y_back), backContentDescription = stringResource(R.string.a11y_back),
) )
} },
) { padding -> ) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) { Box(Modifier.fillMaxSize().padding(padding)) {
when (val s = state) { when (val s = state) {
UiState.Idle, UiState.Loading -> ProductSkeleton() UiState.Idle, UiState.Loading -> ProductSkeleton()
is UiState.Error -> { is UiState.Error -> {
val msg = when { val msg =
s.offline -> stringResource(R.string.error_no_connection) when {
s.message == "not_found" -> stringResource(R.string.result_product_not_found) s.offline -> stringResource(R.string.error_no_connection)
else -> stringResource(R.string.error_product_unavailable) 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) val errorContentDesc = stringResource(R.string.a11y_error, msg)
Column( Column(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(16.dp) .fillMaxSize()
.semantics { .padding(16.dp)
contentDescription = errorContentDesc .semantics {
}, contentDescription = errorContentDesc
verticalArrangement = Arrangement.spacedBy(12.dp) },
verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
ErrorView(message = msg, modifier = Modifier.weight(1f)) ErrorView(message = msg, modifier = Modifier.weight(1f))
OutlinedActionButton( OutlinedActionButton(
text = stringResource(R.string.action_read_ingredients), text = stringResource(R.string.action_read_ingredients),
onClick = onOcr, onClick = onOcr,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
PrimaryButton( PrimaryButton(
text = stringResource(R.string.action_scan_again), text = stringResource(R.string.action_scan_again),
onClick = onScanAgain, onClick = onScanAgain,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
} }
} }
is UiState.Success -> ResultContent( is UiState.Success ->
result = s.data, ResultContent(
onScanAgain = onScanAgain, result = s.data,
onOcr = onOcr, onScanAgain = onScanAgain,
onAddToList = { showListPicker = true } onOcr = onOcr,
) onAddToList = { showListPicker = true },
onOpenAlternatives = onOpenAlternatives,
)
} }
if (showListPicker) { if (showListPicker) {
@ -163,7 +167,7 @@ fun ResultScreen(
viewModel.addToList(listId) viewModel.addToList(listId)
showListPicker = false showListPicker = false
}, },
onDismiss = { showListPicker = false } onDismiss = { showListPicker = false },
) )
} }
} }
@ -176,167 +180,207 @@ private fun ResultContent(
result: ScanResult, result: ScanResult,
onScanAgain: () -> Unit, onScanAgain: () -> Unit,
onOcr: () -> Unit, onOcr: () -> Unit,
onAddToList: () -> Unit onAddToList: () -> Unit,
onOpenAlternatives: () -> Unit,
) { ) {
var ingredientsExpanded by remember { mutableStateOf(false) } var ingredientsExpanded by remember { mutableStateOf(false) }
var actionsVisible by remember { mutableStateOf(false) }
var contentVisible by remember { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
Column( LaunchedEffect(Unit) {
modifier = Modifier contentVisible = true
.fillMaxSize() actionsVisible = true
.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)) { AnimatedVisibility(
ProductCard( visible = contentVisible,
title = result.product.name ?: result.product.barcode, enter = fadeIn(tween(250)) + slideInVertically(tween(250)) { it / 8 },
subtitle = result.product.brand, ) {
imageUrl = result.product.imageUrl, Column(
imageContentDescription = stringResource(R.string.a11y_product_image) 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
},
) )
// Open on OFF (only when we have a real barcode, not an OCR synthetic one). SafetyStatusBanner(
if (result.source != DataSource.OCR) { status = result.safetyStatus,
OutlinedActionButton( profileName = result.analyzedProfiles.firstOrNull()?.name,
text = stringResource(R.string.result_open_in_off), allergenName = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr,
onClick = { severity = if (result.detectedAllergens.any { it.severe }) "anaphylaxis" else null,
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(result.product.openFoodFactsUrl())) )
ContextCompat.startActivity(context, intent, null)
}, Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
icon = Icons.AutoMirrored.Filled.OpenInNew, ProductCard(
modifier = Modifier.fillMaxWidth() 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) // Open on OFF (only when we have a real barcode, not an OCR synthetic one).
if (result.source != DataSource.OCR) {
if (result.analyzedProfiles.isNotEmpty()) { StaggeredAction(visible = actionsVisible, delayMs = 0) {
Text( OutlinedActionButton(
stringResource(R.string.result_profiles_checked) + ": " + text = stringResource(R.string.result_open_in_off),
result.analyzedProfiles.joinToString { it.name }, onClick = {
style = MaterialTheme.typography.bodyMedium, val intent = Intent(Intent.ACTION_VIEW, Uri.parse(result.product.openFoodFactsUrl()))
color = MaterialTheme.colorScheme.onSurfaceVariant ContextCompat.startActivity(context, intent, null)
) },
} icon = Icons.AutoMirrored.Filled.OpenInNew,
modifier = Modifier.fillMaxWidth(),
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) ConfidenceRow(result.confidence, result.source)
stringResource(R.string.a11y_collapse)
else if (result.analyzedProfiles.isNotEmpty()) {
stringResource(R.string.a11y_expand) 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 Spacer(Modifier.height(4.dp))
?: stringResource(R.string.result_ingredients_unavailable), Text(
style = MaterialTheme.typography.bodyMedium 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 @Composable
private fun ConfidenceRow(confidence: AnalysisConfidence, source: DataSource) { private fun ConfidenceRow(
val label = when (confidence) { confidence: AnalysisConfidence,
AnalysisConfidence.HIGH -> R.string.result_confidence_high source: DataSource,
AnalysisConfidence.MEDIUM -> R.string.result_confidence_medium ) {
AnalysisConfidence.LOW -> R.string.result_confidence_low val label =
} when (confidence) {
val src = when (source) { AnalysisConfidence.HIGH -> R.string.result_confidence_high
DataSource.API -> R.string.result_source_api AnalysisConfidence.MEDIUM -> R.string.result_confidence_medium
DataSource.CACHE -> R.string.result_source_cache AnalysisConfidence.LOW -> R.string.result_confidence_low
DataSource.OCR -> R.string.result_source_ocr }
} 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)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = {}, label = { AssistChip(onClick = {}, label = {
Text(stringResource(R.string.result_confidence) + ": " + stringResource(label)) Text(stringResource(R.string.result_confidence) + ": " + stringResource(label))
@ -347,18 +391,23 @@ private fun ConfidenceRow(confidence: AnalysisConfidence, source: DataSource) {
@Composable @Composable
private fun AllergenRow(d: DetectedAllergen) { private fun AllergenRow(d: DetectedAllergen) {
val levelText = when (d.detectionLevel) { val levelText =
DetectionLevel.CONFIRMED -> stringResource(R.string.result_level_confirmed) when (d.detectionLevel) {
DetectionLevel.TRACE -> stringResource(R.string.result_level_trace) DetectionLevel.CONFIRMED -> stringResource(R.string.result_level_confirmed)
DetectionLevel.SUSPECTED -> stringResource(R.string.result_level_suspected) DetectionLevel.TRACE -> stringResource(R.string.result_level_trace)
} DetectionLevel.SUSPECTED -> stringResource(R.string.result_level_suspected)
}
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors =
containerColor = if (d.severe && d.detectionLevel == DetectionLevel.CONFIRMED) CardDefaults.cardColors(
MaterialTheme.colorScheme.errorContainer containerColor =
else MaterialTheme.colorScheme.surfaceVariant if (d.severe && d.detectionLevel == DetectionLevel.CONFIRMED) {
) MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) { ) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text(d.allergenType.icon, style = MaterialTheme.typography.headlineMedium) Text(d.allergenType.icon, style = MaterialTheme.typography.headlineMedium)
@ -367,14 +416,14 @@ private fun AllergenRow(d: DetectedAllergen) {
Text( Text(
d.allergenType.displayNameFr, d.allergenType.displayNameFr,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold,
) )
Text("$levelText · ${d.source}", style = MaterialTheme.typography.bodySmall) Text("$levelText · ${d.source}", style = MaterialTheme.typography.bodySmall)
if (d.matchedKeywords.isNotEmpty()) { if (d.matchedKeywords.isNotEmpty()) {
Text( Text(
d.matchedKeywords.joinToString(), d.matchedKeywords.joinToString(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@ -384,25 +433,28 @@ private fun AllergenRow(d: DetectedAllergen) {
@Composable @Composable
private fun CustomItemRow(d: DetectedCustomItem) { private fun CustomItemRow(d: DetectedCustomItem) {
val tagLabel = when (d.item.tag) { val tagLabel =
CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy) when (d.item.tag) {
CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance) CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy)
CustomItemTag.DIET -> stringResource(R.string.profile_custom_tag_diet) CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance)
CustomItemTag.UNHEALTHY -> stringResource(R.string.profile_custom_tag_unhealthy) 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 -> "" val icon =
CustomItemTag.INTOLERANCE -> "⚠️" when (d.item.tag) {
CustomItemTag.DIET -> "🥗" CustomItemTag.ALLERGY -> ""
CustomItemTag.UNHEALTHY -> "🍩" CustomItemTag.INTOLERANCE -> "⚠️"
} CustomItemTag.DIET -> "🥗"
val bg = when (d.item.tag) { CustomItemTag.UNHEALTHY -> "🍩"
CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.errorContainer }
else -> MaterialTheme.colorScheme.surfaceVariant val bg =
} when (d.item.tag) {
CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.errorContainer
else -> MaterialTheme.colorScheme.surfaceVariant
}
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = bg) colors = CardDefaults.cardColors(containerColor = bg),
) { ) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text(icon, style = MaterialTheme.typography.headlineMedium) Text(icon, style = MaterialTheme.typography.headlineMedium)
@ -414,7 +466,7 @@ private fun CustomItemRow(d: DetectedCustomItem) {
Text( Text(
d.matchedKeywords.joinToString(), d.matchedKeywords.joinToString(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@ -424,15 +476,16 @@ private fun CustomItemRow(d: DetectedCustomItem) {
@Composable @Composable
private fun HealthSection(health: HealthAssessment) { private fun HealthSection(health: HealthAssessment) {
val (ratingText, ratingColor, emoji) = when (health.rating) { val (ratingText, ratingColor, emoji) =
HealthRating.HEALTHY -> Triple(stringResource(R.string.result_health_healthy), Color(0xFF2E7D32), "💪") when (health.rating) {
HealthRating.MODERATE -> Triple(stringResource(R.string.result_health_moderate), Color(0xFFF57C00), "🙂") HealthRating.HEALTHY -> Triple(stringResource(R.string.result_health_healthy), Color(0xFF2E7D32), "💪")
HealthRating.UNHEALTHY -> Triple(stringResource(R.string.result_health_unhealthy), Color(0xFFC62828), "🚫") HealthRating.MODERATE -> Triple(stringResource(R.string.result_health_moderate), Color(0xFFF57C00), "🙂")
HealthRating.UNKNOWN -> Triple(stringResource(R.string.result_health_unknown), Color(0xFF757575), "") HealthRating.UNHEALTHY -> Triple(stringResource(R.string.result_health_unhealthy), Color(0xFFC62828), "🚫")
} HealthRating.UNKNOWN -> Triple(stringResource(R.string.result_health_unknown), Color(0xFF757575), "")
}
Card( Card(
modifier = Modifier.fillMaxWidth(), 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) { Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Text(emoji, style = MaterialTheme.typography.displaySmall) Text(emoji, style = MaterialTheme.typography.displaySmall)
@ -441,14 +494,14 @@ private fun HealthSection(health: HealthAssessment) {
Text( Text(
stringResource(R.string.result_health_verdict), stringResource(R.string.result_health_verdict),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text(ratingText, style = MaterialTheme.typography.titleLarge, color = ratingColor, fontWeight = FontWeight.Bold) Text(ratingText, style = MaterialTheme.typography.titleLarge, color = ratingColor, fontWeight = FontWeight.Bold)
if (health.reasons.isNotEmpty()) { if (health.reasons.isNotEmpty()) {
Text( Text(
health.reasons.joinToString(" · "), health.reasons.joinToString(" · "),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@ -457,7 +510,10 @@ private fun HealthSection(health: HealthAssessment) {
} }
@Composable @Composable
private fun NutritionSection(n: Nutriments, servingSize: String?) { private fun NutritionSection(
n: Nutriments,
servingSize: String?,
) {
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) { Column(Modifier.padding(12.dp)) {
Text(stringResource(R.string.result_nutrition), style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.result_nutrition), style = MaterialTheme.typography.titleMedium)
@ -465,7 +521,7 @@ private fun NutritionSection(n: Nutriments, servingSize: String?) {
Text( Text(
stringResource(R.string.result_nutrition_unavailable), stringResource(R.string.result_nutrition_unavailable),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
return@Card return@Card
} }
@ -473,22 +529,38 @@ private fun NutritionSection(n: Nutriments, servingSize: String?) {
Text( Text(
stringResource(R.string.result_nutrition_serving_size, servingSize), stringResource(R.string.result_nutrition_serving_size, servingSize),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row { Row {
Text("", modifier = Modifier.weight(1f)) 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) { 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( HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp), 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_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_saturated_fat)}", n.saturatedFat100g, null, unit = "g")
NutritionRow(stringResource(R.string.result_nutrition_carbs), n.carbohydrates100g, null, unit = "g") NutritionRow(stringResource(R.string.result_nutrition_carbs), n.carbohydrates100g, null, unit = "g")
@ -501,7 +573,13 @@ private fun NutritionSection(n: Nutriments, servingSize: String?) {
} }
@Composable @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 if (per100 == null && perServing == null) return
val style = if (emphasize) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium val style = if (emphasize) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { 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), modifier = Modifier.width(80.dp),
textAlign = TextAlign.End, textAlign = TextAlign.End,
style = style, style = style,
fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal,
) )
if (perServing != null) { if (perServing != null) {
Text( Text(
@ -519,16 +597,20 @@ private fun NutritionRow(label: String, per100: Double?, perServing: Double?, un
modifier = Modifier.width(80.dp), modifier = Modifier.width(80.dp),
textAlign = TextAlign.End, textAlign = TextAlign.End,
style = style, style = style,
fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal,
) )
} }
} }
} }
private fun formatNumber(d: Double): String { private fun formatNumber(d: Double): String {
return if (d >= 100) d.toInt().toString() return if (d >= 100) {
else if (d >= 10) "%.1f".format(d) d.toInt().toString()
else "%.2f".format(d).trimEnd('0').trimEnd('.', ',') } else if (d >= 10) {
"%.1f".format(d)
} else {
"%.2f".format(d).trimEnd('0').trimEnd('.', ',')
}
} }
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@ -542,28 +624,29 @@ private fun ScoresSection(health: HealthAssessment) {
ScoreRow( ScoreRow(
title = stringResource(R.string.result_nutriscore), title = stringResource(R.string.result_nutriscore),
details = stringResource(R.string.result_nutriscore_details), details = stringResource(R.string.result_nutriscore_details),
badge = { NutriScoreBadge(health.nutriScore) } badge = { NutriScoreBadge(health.nutriScore) },
) )
} }
if (health.novaGroup != null) { if (health.novaGroup != null) {
val desc = when (health.novaGroup) { val desc =
1 -> stringResource(R.string.result_nova_1) when (health.novaGroup) {
2 -> stringResource(R.string.result_nova_2) 1 -> stringResource(R.string.result_nova_1)
3 -> stringResource(R.string.result_nova_3) 2 -> stringResource(R.string.result_nova_2)
4 -> stringResource(R.string.result_nova_4) 3 -> stringResource(R.string.result_nova_3)
else -> stringResource(R.string.result_nova_details) 4 -> stringResource(R.string.result_nova_4)
} else -> stringResource(R.string.result_nova_details)
}
ScoreRow( ScoreRow(
title = stringResource(R.string.result_nova), title = stringResource(R.string.result_nova),
details = desc, details = desc,
badge = { NovaBadge(health.novaGroup) } badge = { NovaBadge(health.novaGroup) },
) )
} }
if (health.ecoScore != null) { if (health.ecoScore != null) {
ScoreRow( ScoreRow(
title = stringResource(R.string.result_ecoscore), title = stringResource(R.string.result_ecoscore),
details = stringResource(R.string.result_ecoscore_details), 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 @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) { Row(verticalAlignment = Alignment.CenterVertically) {
badge() badge()
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
@ -585,20 +672,22 @@ private fun ScoreRow(title: String, details: String, badge: @Composable () -> Un
@Composable @Composable
private fun NutriScoreBadge(grade: String) { private fun NutriScoreBadge(grade: String) {
val upper = grade.uppercase() val upper = grade.uppercase()
val color = when (upper) { val color =
"A" -> Color(0xFF1E8E3E) when (upper) {
"B" -> Color(0xFF7CB342) "A" -> Color(0xFF1E8E3E)
"C" -> Color(0xFFFBC02D) "B" -> Color(0xFF7CB342)
"D" -> Color(0xFFEF6C00) "C" -> Color(0xFFFBC02D)
"E" -> Color(0xFFC62828) "D" -> Color(0xFFEF6C00)
else -> Color(0xFF757575) "E" -> Color(0xFFC62828)
} else -> Color(0xFF757575)
}
Box( Box(
modifier = Modifier modifier =
.size(56.dp) Modifier
.background(color, RoundedCornerShape(12.dp)) .size(56.dp)
.border(2.dp, color.copy(alpha = 0.8f), RoundedCornerShape(12.dp)), .background(color, RoundedCornerShape(12.dp))
contentAlignment = Alignment.Center .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) Text(upper, color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineMedium)
} }
@ -606,18 +695,20 @@ private fun NutriScoreBadge(grade: String) {
@Composable @Composable
private fun NovaBadge(group: Int) { private fun NovaBadge(group: Int) {
val color = when (group) { val color =
1 -> Color(0xFF1E8E3E) when (group) {
2 -> Color(0xFF7CB342) 1 -> Color(0xFF1E8E3E)
3 -> Color(0xFFEF6C00) 2 -> Color(0xFF7CB342)
4 -> Color(0xFFC62828) 3 -> Color(0xFFEF6C00)
else -> Color(0xFF757575) 4 -> Color(0xFFC62828)
} else -> Color(0xFF757575)
}
Box( Box(
modifier = Modifier modifier =
.size(56.dp) Modifier
.background(color, CircleShape), .size(56.dp)
contentAlignment = Alignment.Center .background(color, CircleShape),
contentAlignment = Alignment.Center,
) { ) {
Text(group.toString(), color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineMedium) Text(group.toString(), color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineMedium)
} }
@ -626,19 +717,21 @@ private fun NovaBadge(group: Int) {
@Composable @Composable
private fun EcoScoreBadge(grade: String) { private fun EcoScoreBadge(grade: String) {
val upper = grade.uppercase() val upper = grade.uppercase()
val color = when (upper) { val color =
"A" -> Color(0xFF2E7D32) when (upper) {
"B" -> Color(0xFF558B2F) "A" -> Color(0xFF2E7D32)
"C" -> Color(0xFFFBC02D) "B" -> Color(0xFF558B2F)
"D" -> Color(0xFFEF6C00) "C" -> Color(0xFFFBC02D)
"E" -> Color(0xFFC62828) "D" -> Color(0xFFEF6C00)
else -> Color(0xFF757575) "E" -> Color(0xFFC62828)
} else -> Color(0xFF757575)
}
Box( Box(
modifier = Modifier modifier =
.size(56.dp) Modifier
.background(color, RoundedCornerShape(28.dp)), .size(56.dp)
contentAlignment = Alignment.Center .background(color, RoundedCornerShape(28.dp)),
contentAlignment = Alignment.Center,
) { ) {
Text("🌿$upper", color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) Text("🌿$upper", color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
} }
@ -649,48 +742,50 @@ private fun EcoScoreBadge(grade: String) {
private fun ListPickerBottomSheet( private fun ListPickerBottomSheet(
lists: List<com.safebite.app.data.local.database.entity.ShoppingListEntity>, lists: List<com.safebite.app.data.local.database.entity.ShoppingListEntity>,
onSelect: (Long) -> Unit, onSelect: (Long) -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit,
) { ) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) { ) {
Column( Column(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.padding(horizontal = 20.dp, vertical = 12.dp) .fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp),
) { ) {
Text( Text(
text = stringResource(R.string.result_choose_list), text = stringResource(R.string.result_choose_list),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
if (lists.isEmpty()) { if (lists.isEmpty()) {
Text( Text(
text = "Aucune liste disponible", text = "Aucune liste disponible",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
lists.forEach { list -> lists.forEach { list ->
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.clip(RoundedCornerShape(8.dp)) .fillMaxWidth()
.clickable { onSelect(list.id) } .clip(RoundedCornerShape(8.dp))
.padding(vertical = 12.dp, horizontal = 8.dp), .clickable { onSelect(list.id) }
verticalAlignment = Alignment.CenterVertically .padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = list.name, text = list.name,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
) )
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew, imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null, 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.ViewModel
import androidx.lifecycle.viewModelScope 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.DataSource
import com.safebite.app.domain.model.ScanResult import com.safebite.app.domain.model.ScanResult
import com.safebite.app.domain.model.UserProfile import com.safebite.app.domain.model.UserProfile
import com.safebite.app.domain.repository.ProductFetchResult import com.safebite.app.domain.repository.ProductFetchResult
import com.safebite.app.domain.usecase.AnalyzeIngredientsTextUseCase import com.safebite.app.domain.usecase.AnalyzeIngredientsTextUseCase
import com.safebite.app.domain.usecase.AnalyzeProductUseCase 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.FetchProductUseCase
import com.safebite.app.domain.usecase.GetShoppingListsUseCase import com.safebite.app.domain.usecase.GetShoppingListsUseCase
import com.safebite.app.domain.usecase.ManageProfileUseCase import com.safebite.app.domain.usecase.ManageProfileUseCase
@ -26,76 +26,82 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ResultViewModel @Inject constructor( class ResultViewModel
private val fetchProduct: FetchProductUseCase, @Inject
private val analyzeProduct: AnalyzeProductUseCase, constructor(
private val analyzeText: AnalyzeIngredientsTextUseCase, private val fetchProduct: FetchProductUseCase,
private val manageProfile: ManageProfileUseCase, private val analyzeProduct: AnalyzeProductUseCase,
private val saveScan: SaveScanUseCase, private val analyzeText: AnalyzeIngredientsTextUseCase,
private val getLists: GetShoppingListsUseCase, private val manageProfile: ManageProfileUseCase,
private val manageList: ManageShoppingListUseCase private val saveScan: SaveScanUseCase,
) : ViewModel() { 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 lists =
val state: StateFlow<UiState<ScanResult>> = _state.asStateFlow() getLists.observeActive()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val lists = getLists.observeActive() fun analyzeBarcode(barcode: String) =
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) 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 { fun analyzeOcrText(text: String) =
_state.value = UiState.Loading viewModelScope.launch {
val profiles = resolveProfiles() _state.value = UiState.Loading
if (profiles.isEmpty()) { val profiles = resolveProfiles()
_state.value = UiState.Error("No profile configured") if (profiles.isEmpty()) {
return@launch _state.value = UiState.Error("No profile configured")
} return@launch
when (val fetched = fetchProduct(barcode)) { }
is ProductFetchResult.Found -> { val result = analyzeText(text, profiles)
val source = if (fetched.fromCache) DataSource.CACHE else DataSource.API
val result = analyzeProduct(fetched.product, profiles, source)
_state.value = UiState.Success(result) _state.value = UiState.Success(result)
saveScan(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 { private suspend fun resolveProfiles(): List<UserProfile> {
_state.value = UiState.Loading val all = manageProfile.observe().first()
val profiles = resolveProfiles() val activeIds = manageProfile.observeActiveIds().first()
if (profiles.isEmpty()) { return when {
_state.value = UiState.Error("No profile configured") activeIds.isNotEmpty() -> all.filter { it.id in activeIds }
return@launch 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> { fun addToList(listId: Long) =
val all = manageProfile.observe().first() viewModelScope.launch {
val activeIds = manageProfile.observeActiveIds().first() val currentState = _state.value
return when { if (currentState !is UiState.Success) return@launch
activeIds.isNotEmpty() -> all.filter { it.id in activeIds } val result = currentState.data
else -> all.filter { it.isDefault }.ifEmpty { all.take(1) } 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 import java.util.concurrent.atomic.AtomicBoolean
class BarcodeAnalyzer( class BarcodeAnalyzer(
private val onBarcode: (String) -> Unit private val onBarcode: (String) -> Unit,
) : ImageAnalysis.Analyzer { ) : ImageAnalysis.Analyzer {
private val scanner: BarcodeScanner =
private val scanner: BarcodeScanner = BarcodeScanning.getClient( BarcodeScanning.getClient(
BarcodeScannerOptions.Builder() BarcodeScannerOptions.Builder()
.setBarcodeFormats( .setBarcodeFormats(
Barcode.FORMAT_EAN_13, Barcode.FORMAT_EAN_13,
Barcode.FORMAT_EAN_8, Barcode.FORMAT_EAN_8,
Barcode.FORMAT_UPC_A, Barcode.FORMAT_UPC_A,
Barcode.FORMAT_UPC_E, Barcode.FORMAT_UPC_E,
Barcode.FORMAT_QR_CODE Barcode.FORMAT_QR_CODE,
).build() ).build(),
) )
private val consumed = AtomicBoolean(false) private val consumed = AtomicBoolean(false)
@OptIn(ExperimentalGetImage::class) @OptIn(ExperimentalGetImage::class)
override fun analyze(image: ImageProxy) { override fun analyze(image: ImageProxy) {
if (consumed.get()) { image.close(); return } if (consumed.get()) {
image.close()
return
}
val mediaImage = image.image val mediaImage = image.image
if (mediaImage == null) { image.close(); return } if (mediaImage == null) {
image.close()
return
}
val input = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees) val input = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees)
scanner.process(input) scanner.process(input)
.addOnSuccessListener { barcodes -> .addOnSuccessListener { barcodes ->

View File

@ -20,21 +20,27 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons 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.FlashOff
import androidx.compose.material.icons.filled.FlashOn import androidx.compose.material.icons.filled.FlashOn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect 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.Color
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
@ -68,11 +75,15 @@ import java.util.concurrent.Executors
@Composable @Composable
fun ScannerScreen( fun ScannerScreen(
onBack: () -> Unit, onBack: () -> Unit,
onBarcode: (String) -> Unit onBarcode: (String) -> Unit,
) { ) {
val permission = rememberPermissionState(android.Manifest.permission.CAMERA) val permission = rememberPermissionState(android.Manifest.permission.CAMERA)
LaunchedEffect(Unit) { if (!permission.status.isGranted) permission.launchPermissionRequest() } 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( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
topBar = { topBar = {
@ -81,7 +92,7 @@ fun ScannerScreen(
onBack = onBack, onBack = onBack,
backContentDescription = stringResource(R.string.a11y_back), backContentDescription = stringResource(R.string.a11y_back),
) )
} },
) { padding -> ) { padding ->
val scanAreaDesc = stringResource(R.string.a11y_scan_area) val scanAreaDesc = stringResource(R.string.a11y_scan_area)
Box( Box(
@ -90,22 +101,98 @@ fun ScannerScreen(
.padding(padding) .padding(padding)
.semantics { .semantics {
contentDescription = scanAreaDesc contentDescription = scanAreaDesc
} },
) { ) {
if (!permission.status.isGranted) { if (!permission.status.isGranted) {
ErrorView( Column(
message = stringResource(R.string.scanner_camera_denied), modifier = Modifier.fillMaxSize(),
onRetry = { permission.launchPermissionRequest() } 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 { } 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 @Composable
private fun CameraView(onBarcode: (String) -> Unit) { private fun CameraView(
onBarcode: (String) -> Unit,
onManualEntry: () -> Unit,
) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
var torch by remember { mutableStateOf(false) } var torch by remember { mutableStateOf(false) }
@ -119,66 +206,99 @@ private fun CameraView(onBarcode: (String) -> Unit) {
androidx.compose.ui.viewinterop.AndroidView( androidx.compose.ui.viewinterop.AndroidView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
factory = { ctx -> factory = { ctx ->
val previewView = PreviewView(ctx).apply { val previewView =
scaleType = PreviewView.ScaleType.FILL_CENTER PreviewView(ctx).apply {
} scaleType = PreviewView.ScaleType.FILL_CENTER
}
val providerFuture = ProcessCameraProvider.getInstance(ctx) val providerFuture = ProcessCameraProvider.getInstance(ctx)
providerFuture.addListener({ providerFuture.addListener({
val provider = providerFuture.get() val provider = providerFuture.get()
val preview = Preview.Builder().build().also { val preview =
it.setSurfaceProvider(previewView.surfaceProvider) Preview.Builder().build().also {
} it.setSurfaceProvider(previewView.surfaceProvider)
val analysis = ImageAnalysis.Builder() }
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) val analysis =
.build() ImageAnalysis.Builder()
.also { it.setAnalyzer(executor, BarcodeAnalyzer { code -> .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
if (!detected) { .build()
detected = true .also {
triggerHaptic(ctx) it.setAnalyzer(
onBarcode(code) executor,
BarcodeAnalyzer { code ->
if (!detected) {
detected = true
triggerHaptic(ctx)
onBarcode(code)
}
},
)
} }
}) }
try { try {
provider.unbindAll() provider.unbindAll()
val camera = provider.bindToLifecycle( val camera =
lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis provider.bindToLifecycle(
) lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis,
)
cameraControl = camera.cameraControl cameraControl = camera.cameraControl
} catch (t: Throwable) { /* ignore */ } } catch (t: Throwable) {
// ignore
}
}, ContextCompat.getMainExecutor(ctx)) }, ContextCompat.getMainExecutor(ctx))
previewView previewView
} },
) )
ScanOverlay(modifier = Modifier.fillMaxSize()) ScanOverlay(modifier = Modifier.fillMaxSize())
Column( Column(
modifier = Modifier modifier =
.fillMaxWidth() Modifier
.align(Alignment.BottomCenter) .fillMaxWidth()
.padding(24.dp), .align(Alignment.BottomCenter)
horizontalAlignment = Alignment.CenterHorizontally .padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
text = stringResource(R.string.scanner_hint), text = stringResource(R.string.scanner_hint),
color = Color.White, color = Color.White,
modifier = Modifier modifier =
.background(Color(0x99000000), RoundedCornerShape(12.dp)) Modifier
.padding(horizontal = 12.dp, vertical = 6.dp) .background(Color(0x99000000), RoundedCornerShape(12.dp))
.padding(horizontal = 12.dp, vertical = 6.dp),
) )
Spacer(Modifier.size(12.dp)) Spacer(Modifier.size(12.dp))
IconButton( Row(
onClick = { horizontalArrangement = Arrangement.spacedBy(12.dp),
torch = !torch verticalAlignment = Alignment.CenterVertically,
cameraControl?.enableTorch(torch)
},
modifier = Modifier.size(48.dp)
) { ) {
Icon( TextButton(onClick = onManualEntry) {
if (torch) Icons.Filled.FlashOn else Icons.Filled.FlashOff, Icon(
contentDescription = stringResource(R.string.a11y_torch), Icons.Filled.Edit,
tint = Color.White 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( val y by transition.animateFloat(
initialValue = 0f, initialValue = 0f,
targetValue = 1f, targetValue = 1f,
animationSpec = infiniteRepeatable( animationSpec =
animation = tween(1800, easing = LinearEasing), infiniteRepeatable(
repeatMode = RepeatMode.Reverse animation = tween(1800, easing = LinearEasing),
), repeatMode = RepeatMode.Reverse,
label = "scanY" ),
label = "scanY",
) )
Canvas(modifier = modifier) { Canvas(modifier = modifier) {
val w = size.width 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) val topLeft = Offset((w - boxSize.width) / 2f, (h - boxSize.height) / 2f)
drawRect( drawRect(
color = Color(0xB3000000), color = Color(0xB3000000),
size = Size(w, topLeft.y) size = Size(w, topLeft.y),
) )
drawRect( drawRect(
color = Color(0xB3000000), color = Color(0xB3000000),
topLeft = Offset(0f, topLeft.y + boxSize.height), topLeft = Offset(0f, topLeft.y + boxSize.height),
size = Size(w, h - topLeft.y - boxSize.height) size = Size(w, h - topLeft.y - boxSize.height),
) )
drawRect( drawRect(
color = Color(0xB3000000), color = Color(0xB3000000),
topLeft = Offset(0f, topLeft.y), topLeft = Offset(0f, topLeft.y),
size = Size(topLeft.x, boxSize.height) size = Size(topLeft.x, boxSize.height),
) )
drawRect( drawRect(
color = Color(0xB3000000), color = Color(0xB3000000),
topLeft = Offset(topLeft.x + boxSize.width, topLeft.y), 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( drawRect(
color = Color.White, color = Color.White,
topLeft = topLeft, topLeft = topLeft,
size = boxSize, size = boxSize,
style = Stroke(width = 4f) style = Stroke(width = 4f),
) )
val lineY = topLeft.y + boxSize.height * y val lineY = topLeft.y + boxSize.height * y
drawLine( drawLine(
color = Color(0xFF00E676), color = Color(0xFF00E676),
start = Offset(topLeft.x, lineY), start = Offset(topLeft.x, lineY),
end = Offset(topLeft.x + boxSize.width, 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 val v = context.getSystemService(android.content.Context.VIBRATOR_SERVICE) as? Vibrator
v?.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE)) 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 @Composable
fun SettingsScreen( fun SettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel() viewModel: SettingsViewModel = hiltViewModel(),
) { ) {
val ui by viewModel.state.collectAsStateWithLifecycle() val ui by viewModel.state.collectAsStateWithLifecycle()
@ -51,27 +51,42 @@ fun SettingsScreen(
onBack = onBack, onBack = onBack,
backContentDescription = stringResource(R.string.action_back), backContentDescription = stringResource(R.string.action_back),
) )
} },
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxSize()
.verticalScroll(rememberScrollState()) .padding(padding)
.padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg), .verticalScroll(rememberScrollState())
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd) .padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
) { ) {
Section(stringResource(R.string.settings_language)) { Section(stringResource(R.string.settings_language)) {
Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) { Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
FilterChip(selected = ui.appLanguage == AppLanguage.FR, onClick = { viewModel.setAppLanguage(AppLanguage.FR) }, label = { Text("FR") }) FilterChip(
FilterChip(selected = ui.appLanguage == AppLanguage.EN, onClick = { viewModel.setAppLanguage(AppLanguage.EN) }, label = { Text("EN") }) 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)) { Section(stringResource(R.string.settings_detection_language)) {
Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) { 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.FR, onClick = {
FilterChip(selected = ui.detectionLanguage == DetectionLanguage.EN, onClick = { viewModel.setDetectionLanguage(DetectionLanguage.EN) }, label = { Text(stringResource(R.string.settings_detection_en)) }) viewModel.setDetectionLanguage(DetectionLanguage.FR)
FilterChip(selected = ui.detectionLanguage == DetectionLanguage.BOTH, onClick = { viewModel.setDetectionLanguage(DetectionLanguage.BOTH) }, label = { Text(stringResource(R.string.settings_detection_both)) }) }, 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) { StandardCard(variant = CardVariant.Filled) {
@ -84,17 +99,29 @@ fun SettingsScreen(
Section(stringResource(R.string.settings_health_strictness)) { Section(stringResource(R.string.settings_health_strictness)) {
Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) { 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.LENIENT, onClick = {
FilterChip(selected = ui.healthStrictness == HealthStrictness.NORMAL, onClick = { viewModel.setHealthStrictness(HealthStrictness.NORMAL) }, label = { Text(stringResource(R.string.settings_health_normal)) }) viewModel.setHealthStrictness(HealthStrictness.LENIENT)
FilterChip(selected = ui.healthStrictness == HealthStrictness.STRICT, onClick = { viewModel.setHealthStrictness(HealthStrictness.STRICT) }, label = { Text(stringResource(R.string.settings_health_strict)) }) }, 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)) { Section(stringResource(R.string.settings_theme)) {
Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) { 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.LIGHT, onClick = {
FilterChip(selected = ui.theme == ThemePref.DARK, onClick = { viewModel.setTheme(ThemePref.DARK) }, label = { Text(stringResource(R.string.settings_theme_dark)) }) viewModel.setTheme(ThemePref.LIGHT)
FilterChip(selected = ui.theme == ThemePref.SYSTEM, onClick = { viewModel.setTheme(ThemePref.SYSTEM) }, label = { Text(stringResource(R.string.settings_theme_system)) }) }, 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( DestructiveButton(
text = stringResource(R.string.settings_clear_cache), text = stringResource(R.string.settings_clear_cache),
onClick = viewModel::clearCache, onClick = viewModel::clearCache,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
DestructiveButton( DestructiveButton(
text = stringResource(R.string.settings_clear_history), text = stringResource(R.string.settings_clear_history),
onClick = viewModel::clearHistory, onClick = viewModel::clearHistory,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
) )
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
@ -115,43 +142,50 @@ fun SettingsScreen(
Text( Text(
stringResource(R.string.settings_version, BuildConfig.VERSION_NAME), stringResource(R.string.settings_version, BuildConfig.VERSION_NAME),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text( Text(
stringResource(R.string.settings_off_attribution), stringResource(R.string.settings_off_attribution),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text( Text(
"https://world.openfoodfacts.org", "https://world.openfoodfacts.org",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary,
) )
} }
} }
} }
@Composable @Composable
private fun Section(title: String, content: @Composable () -> Unit) { private fun Section(
title: String,
content: @Composable () -> Unit,
) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
Column(verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)) { Column(verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
Text( Text(
title, title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground color = MaterialTheme.colorScheme.onBackground,
) )
content() content()
} }
} }
@Composable @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) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text( Text(
label, label,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
) )
Switch(checked = checked, onCheckedChange = onChange) Switch(checked = checked, onCheckedChange = onChange)
} }

View File

@ -24,41 +24,52 @@ data class SettingsUi(
val sound: Boolean = true, val sound: Boolean = true,
val theme: ThemePref = ThemePref.SYSTEM, val theme: ThemePref = ThemePref.SYSTEM,
val healthStrictness: HealthStrictness = HealthStrictness.NORMAL, val healthStrictness: HealthStrictness = HealthStrictness.NORMAL,
val splashScreenEnabled: Boolean = true val splashScreenEnabled: Boolean = true,
) )
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel
private val settings: SettingsRepository, @Inject
private val productRepo: ProductRepository, constructor(
private val historyRepo: ScanHistoryRepository private val settings: SettingsRepository,
) : ViewModel() { 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( val state: StateFlow<SettingsUi> =
settings.appLanguage, combine(
settings.detectionLanguage, coreFlow,
settings.hapticsEnabled, settings.healthStrictness,
settings.soundEnabled, settings.splashScreenEnabled,
settings.theme ) { core, strict, splash ->
) { lang, detection, haptics, sound, theme -> core.copy(healthStrictness = strict, splashScreenEnabled = splash)
SettingsUi(lang, detection, haptics, sound, theme) }.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 @Composable
fun SplashScreen( fun SplashScreen(
onFinished: () -> Unit, onFinished: () -> Unit,
durationMillis: Int = 2500 durationMillis: Int = 2500,
) { ) {
val scale = remember { Animatable(0.6f) } val scale = remember { Animatable(0.6f) }
val alpha = remember { Animatable(0f) } val alpha = remember { Animatable(0f) }
@ -44,39 +44,43 @@ fun SplashScreen(
} }
Box( Box(
modifier = Modifier modifier =
.fillMaxSize() Modifier
.background(ShieldGradient), .fillMaxSize()
contentAlignment = Alignment.Center .background(ShieldGradient),
contentAlignment = Alignment.Center,
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(24.dp) modifier = Modifier.padding(24.dp),
) { ) {
Image( Image(
painter = painterResource(id = R.drawable.safebite_logo_nobg), painter = painterResource(id = R.drawable.safebite_logo_nobg),
contentDescription = null, contentDescription = null,
modifier = Modifier modifier =
.size(160.dp) Modifier
.scale(scale.value) .size(160.dp)
.scale(scale.value),
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Text( Text(
text = stringResource(R.string.app_name), text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge.copy( style =
fontWeight = FontWeight.Bold, MaterialTheme.typography.headlineLarge.copy(
color = androidx.compose.ui.graphics.Color.White fontWeight = FontWeight.Bold,
), color = androidx.compose.ui.graphics.Color.White,
modifier = Modifier.alpha(alpha.value) ),
modifier = Modifier.alpha(alpha.value),
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.onboarding_welcome_subtitle), text = stringResource(R.string.onboarding_welcome_subtitle),
style = MaterialTheme.typography.bodyLarge.copy( style =
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f) MaterialTheme.typography.bodyLarge.copy(
), color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f),
modifier = Modifier.alpha(alpha.value) ),
modifier = Modifier.alpha(alpha.value),
) )
} }
} }

View File

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

View File

@ -34,7 +34,7 @@ data class TrackingStats(
val weeklyScans: Int = 0, val weeklyScans: Int = 0,
val weeklySafePercentage: Float = 0f, val weeklySafePercentage: Float = 0f,
val sparklineData: SparklineData = SparklineData(emptyList()), 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( data class AllergenCount(
val name: String, val name: String,
val count: Int, val count: Int,
val emoji: String = "⚠️" val emoji: String = "⚠️",
) )
/** /**
@ -51,204 +51,224 @@ data class AllergenCount(
*/ */
sealed class TrackingUiState { sealed class TrackingUiState {
data object Loading : TrackingUiState() data object Loading : TrackingUiState()
data class Success( data class Success(
val stats: TrackingStats, val stats: TrackingStats,
val historyItems: List<ScanHistoryItem>, val historyItems: List<ScanHistoryItem>,
val timeFilter: TimeFilter, val timeFilter: TimeFilter,
val statusFilter: SafetyStatus? = null, val statusFilter: SafetyStatus? = null,
val searchQuery: String = "" val searchQuery: String = "",
) : TrackingUiState() ) : TrackingUiState()
data object Empty : TrackingUiState() data object Empty : TrackingUiState()
} }
@HiltViewModel @HiltViewModel
class TrackingViewModel @Inject constructor( class TrackingViewModel
private val getScanHistoryUseCase: GetScanHistoryUseCase @Inject
) : ViewModel() { constructor(
private val getScanHistoryUseCase: GetScanHistoryUseCase,
) : ViewModel() {
private val _timeFilter = MutableStateFlow(TimeFilter.WEEK)
val timeFilter: StateFlow<TimeFilter> = _timeFilter.asStateFlow()
private val _timeFilter = MutableStateFlow(TimeFilter.WEEK) private val _statusFilter = MutableStateFlow<SafetyStatus?>(null)
val timeFilter: StateFlow<TimeFilter> = _timeFilter.asStateFlow() val statusFilter: StateFlow<SafetyStatus?> = _statusFilter.asStateFlow()
private val _statusFilter = MutableStateFlow<SafetyStatus?>(null) private val _searchQuery = MutableStateFlow("")
val statusFilter: StateFlow<SafetyStatus?> = _statusFilter.asStateFlow() val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _searchQuery = MutableStateFlow("") val uiState: StateFlow<TrackingUiState> =
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow() 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( if (items.isEmpty()) {
getScanHistoryUseCase.observe(), TrackingUiState.Empty
_timeFilter, } else {
_statusFilter, val stats = computeStats(items, timeFilter)
_searchQuery TrackingUiState.Success(
) { items, timeFilter, statusFilter, query -> stats = stats,
val filteredItems = items historyItems = filteredItems,
.filterByTime(timeFilter) timeFilter = timeFilter,
.filter { statusFilter == null || it.safetyStatus == statusFilter } statusFilter = statusFilter,
.filter { query.isBlank() || matchesSearch(it, query) } searchQuery = query,
)
if (items.isEmpty()) { }
TrackingUiState.Empty }.stateIn(
} else { viewModelScope,
val stats = computeStats(items, timeFilter) SharingStarted.WhileSubscribed(5_000),
TrackingUiState.Success( TrackingUiState.Loading,
stats = stats,
historyItems = filteredItems,
timeFilter = timeFilter,
statusFilter = statusFilter,
searchQuery = query
) )
fun setTimeFilter(filter: TimeFilter) {
_timeFilter.value = filter
} }
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
TrackingUiState.Loading
)
fun setTimeFilter(filter: TimeFilter) { fun setStatusFilter(status: SafetyStatus?) {
_timeFilter.value = filter _statusFilter.value = status
}
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
} }
return this.filter { it.scannedAt >= cutoffTime }
}
private fun matchesSearch(item: ScanHistoryItem, query: String): Boolean { fun setSearchQuery(query: String) {
return item.productName?.contains(query, ignoreCase = true) == true || _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.brand?.contains(query, ignoreCase = true) == true ||
item.barcode.contains(query) item.barcode.contains(query)
} }
private fun computeStats(allItems: List<ScanHistoryItem>, timeFilter: TimeFilter): TrackingStats { private fun computeStats(
val items = allItems.filterByTime(timeFilter) allItems: List<ScanHistoryItem>,
val total = items.size timeFilter: TimeFilter,
val safeCount = items.count { it.safetyStatus == SafetyStatus.SAFE } ): TrackingStats {
val warningCount = items.count { it.safetyStatus == SafetyStatus.WARNING } val items = allItems.filterByTime(timeFilter)
val dangerCount = items.count { it.safetyStatus == SafetyStatus.DANGER } val total = items.size
val safePercentage = if (total > 0) safeCount.toFloat() / total else 0f 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) // Calcul des allergènes top (simulé à partir des noms de produits)
val topAllergens = computeTopAllergens(items) val topAllergens = computeTopAllergens(items)
// Données sparkline (scans par jour sur la période) // Données sparkline (scans par jour sur la période)
val sparklineData = computeSparklineData(items, timeFilter) val sparklineData = computeSparklineData(items, timeFilter)
// Données bar chart (répartition par statut) // Données bar chart (répartition par statut)
val barChartData = BarChartData( val barChartData =
items = listOf( BarChartData(
BarChartItem("Sûr", safeCount, SemanticColors.Safe), items =
BarChartItem("Attention", warningCount, SemanticColors.Warning), listOf(
BarChartItem("Danger", dangerCount, SemanticColors.Danger) 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>() private fun computeTopAllergens(items: List<ScanHistoryItem>): List<AllergenCount> {
val values = mutableListOf<Float>() // Simulation : on compte les profils associés aux scans danger/warning
val allergenCounts = mutableMapOf<String, Int>()
if (timeFilter == TimeFilter.YEAR) { items.filter { it.safetyStatus != SafetyStatus.SAFE }.forEach { item ->
// Par mois item.profileNames.forEach { profileName ->
for (i in 11 downTo 0) { allergenCounts[profileName] = allergenCounts.getOrDefault(profileName, 0) + 1
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 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. // Ces couleurs sont indépendantes du thème M3 pour cohérence marque.
object SemanticColors { object SemanticColors {
// Light mode // 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 SafeContainer = Color(0xFFE8F5E9) // Fond très clair
val OnSafe = Color(0xFFFFFFFF) val OnSafe = Color(0xFFFFFFFF)
val OnSafeContainer = Color(0xFF1A3A2A) val OnSafeContainer = Color(0xFF1A3A2A)
val Warning = Color(0xFFFFA000) // Orange attention val Warning = Color(0xFFFFA000) // Orange attention
val WarningContainer = Color(0xFFFFF3E0) val WarningContainer = Color(0xFFFFF3E0)
val OnWarning = Color(0xFFFFFFFF) val OnWarning = Color(0xFFFFFFFF)
val OnWarningContainer = Color(0xFF4A2800) val OnWarningContainer = Color(0xFF4A2800)
val Danger = Color(0xFFD32F2F) // Rouge danger val Danger = Color(0xFFD32F2F) // Rouge danger
val DangerContainer = Color(0xFFFFEBEE) val DangerContainer = Color(0xFFFFEBEE)
val OnDanger = Color(0xFFFFFFFF) val OnDanger = Color(0xFFFFFFFF)
val OnDangerContainer = Color(0xFF5C0B0B) val OnDangerContainer = Color(0xFF5C0B0B)
@ -37,137 +37,150 @@ object SemanticColors {
// ---- NEUTRES (spec UX §2.1) ------------------------------------------------ // ---- NEUTRES (spec UX §2.1) ------------------------------------------------
object NeutralColors { object NeutralColors {
val Background = Color(0xFFF1F8E9) // Fond principal light val Background = Color(0xFFF1F8E9) // Fond principal light
val Surface = Color(0xFFFFFFFF) // Blanc pur pour cartes val Surface = Color(0xFFFFFFFF) // Blanc pur pour cartes
val TextPrimary = Color(0xFF212121) // Texte principal val TextPrimary = Color(0xFF212121) // Texte principal
val TextSecondary = Color(0xFF757575) // Texte secondaire val TextSecondary = Color(0xFF757575) // Texte secondaire
val Separator = Color(0xFFBDBDBD) // Séparateurs val Separator = Color(0xFFBDBDBD) // Séparateurs
} }
// ---- Brand anchors (Material 3) -------------------------------------------- // ---- Brand anchors (Material 3) --------------------------------------------
val BrandPrimary = Color(0xFF1B7A2B) val BrandPrimary = Color(0xFF1B7A2B)
val BrandPrimaryDark = Color(0xFF0D5E1A) val BrandPrimaryDark = Color(0xFF0D5E1A)
val BrandPrimaryLight = Color(0xFF4CAF50) val BrandPrimaryLight = Color(0xFF4CAF50)
val BrandSecondary = Color(0xFF2E7D32) val BrandSecondary = Color(0xFF2E7D32)
// ---- Light scheme --------------------------------------------------------- // ---- Light scheme ---------------------------------------------------------
val LightPrimary = Color(0xFF1B7A2B) val LightPrimary = Color(0xFF1B7A2B)
val LightOnPrimary = Color(0xFFFFFFFF) val LightOnPrimary = Color(0xFFFFFFFF)
val LightPrimaryContainer = Color(0xFFA5D6A7) val LightPrimaryContainer = Color(0xFFA5D6A7)
val LightOnPrimaryContainer = Color(0xFF0D3B12) val LightOnPrimaryContainer = Color(0xFF0D3B12)
val LightSecondary = Color(0xFF2E7D32) val LightSecondary = Color(0xFF2E7D32)
val LightOnSecondary = Color(0xFFFFFFFF) val LightOnSecondary = Color(0xFFFFFFFF)
val LightSecondaryContainer = Color(0xFFC8E6C9) val LightSecondaryContainer = Color(0xFFC8E6C9)
val LightOnSecondaryContainer = Color(0xFF1B5E20) val LightOnSecondaryContainer = Color(0xFF1B5E20)
val LightTertiary = Color(0xFF00796B) val LightTertiary = Color(0xFF00796B)
val LightOnTertiary = Color(0xFFFFFFFF) val LightOnTertiary = Color(0xFFFFFFFF)
val LightTertiaryContainer = Color(0xFFB2DFDB) val LightTertiaryContainer = Color(0xFFB2DFDB)
val LightOnTertiaryContainer = Color(0xFF004D40) val LightOnTertiaryContainer = Color(0xFF004D40)
val LightError = Color(0xFFD32F2F) val LightError = Color(0xFFD32F2F)
val LightOnError = Color(0xFFFFFFFF) val LightOnError = Color(0xFFFFFFFF)
val LightErrorContainer = Color(0xFFFFCDD2) val LightErrorContainer = Color(0xFFFFCDD2)
val LightOnErrorContainer = Color(0xFF5C0B0B) val LightOnErrorContainer = Color(0xFF5C0B0B)
val LightBackground = NeutralColors.Background // #F1F8E9 val LightBackground = NeutralColors.Background // #F1F8E9
val LightOnBackground = NeutralColors.TextPrimary // #212121 val LightOnBackground = NeutralColors.TextPrimary // #212121
val LightSurface = NeutralColors.Surface // #FFFFFF val LightSurface = NeutralColors.Surface // #FFFFFF
val LightOnSurface = NeutralColors.TextPrimary // #212121 val LightOnSurface = NeutralColors.TextPrimary // #212121
val LightSurfaceVariant = Color(0xFFE8F5E9) val LightSurfaceVariant = Color(0xFFE8F5E9)
val LightOnSurfaceVariant = NeutralColors.TextSecondary val LightOnSurfaceVariant = NeutralColors.TextSecondary
val LightSurfaceTint = LightPrimary val LightSurfaceTint = LightPrimary
val LightOutline = NeutralColors.Separator val LightOutline = NeutralColors.Separator
val LightOutlineVariant = Color(0xFFE0E0E0) val LightOutlineVariant = Color(0xFFE0E0E0)
val LightInverseSurface = Color(0xFF2F3033) val LightInverseSurface = Color(0xFF2F3033)
val LightInverseOnSurface = Color(0xFFF1F0F4) val LightInverseOnSurface = Color(0xFFF1F0F4)
val LightInversePrimary = Color(0xFF81C784) val LightInversePrimary = Color(0xFF81C784)
val LightScrim = Color(0xFF000000) val LightScrim = Color(0xFF000000)
// ---- Dark scheme (surfaces élevées M3) ------------------------------------ // ---- Dark scheme (surfaces élevées M3) ------------------------------------
val DarkPrimary = Color(0xFF81C784) val DarkPrimary = Color(0xFF81C784)
val DarkOnPrimary = Color(0xFF0D3B12) val DarkOnPrimary = Color(0xFF0D3B12)
val DarkPrimaryContainer = Color(0xFF1B5E20) val DarkPrimaryContainer = Color(0xFF1B5E20)
val DarkOnPrimaryContainer = Color(0xFFA5D6A7) val DarkOnPrimaryContainer = Color(0xFFA5D6A7)
val DarkSecondary = Color(0xFFA5D6A7) val DarkSecondary = Color(0xFFA5D6A7)
val DarkOnSecondary = Color(0xFF1B5E20) val DarkOnSecondary = Color(0xFF1B5E20)
val DarkSecondaryContainer = Color(0xFF2E7D32) val DarkSecondaryContainer = Color(0xFF2E7D32)
val DarkOnSecondaryContainer = Color(0xFFC8E6C9) val DarkOnSecondaryContainer = Color(0xFFC8E6C9)
val DarkTertiary = Color(0xFF4DB6AC) val DarkTertiary = Color(0xFF4DB6AC)
val DarkOnTertiary = Color(0xFF00332C) val DarkOnTertiary = Color(0xFF00332C)
val DarkTertiaryContainer = Color(0xFF00695C) val DarkTertiaryContainer = Color(0xFF00695C)
val DarkOnTertiaryContainer = Color(0xFFB2DFDB) val DarkOnTertiaryContainer = Color(0xFFB2DFDB)
val DarkError = Color(0xFFEF9A9A) val DarkError = Color(0xFFEF9A9A)
val DarkOnError = Color(0xFF690005) val DarkOnError = Color(0xFF690005)
val DarkErrorContainer = Color(0xFF93000A) val DarkErrorContainer = Color(0xFF93000A)
val DarkOnErrorContainer = Color(0xFFFFCDD2) val DarkOnErrorContainer = Color(0xFFFFCDD2)
val DarkBackground = Color(0xFF1A1C1A) val DarkBackground = Color(0xFF1A1C1A)
val DarkOnBackground = Color(0xFFE0E0E0) val DarkOnBackground = Color(0xFFE0E0E0)
val DarkSurface = Color(0xFF2D2F2D) val DarkSurface = Color(0xFF2D2F2D)
val DarkOnSurface = Color(0xFFE0E0E0) val DarkOnSurface = Color(0xFFE0E0E0)
val DarkSurfaceVariant = Color(0xFF3A3F3A) val DarkSurfaceVariant = Color(0xFF3A3F3A)
val DarkOnSurfaceVariant = Color(0xFFBDBDBD) val DarkOnSurfaceVariant = Color(0xFFBDBDBD)
val DarkSurfaceTint = DarkPrimary val DarkSurfaceTint = DarkPrimary
val DarkOutline = Color(0xFF90909A) val DarkOutline = Color(0xFF90909A)
val DarkOutlineVariant = Color(0xFF46464F) val DarkOutlineVariant = Color(0xFF46464F)
val DarkInverseSurface = Color(0xFFE6E1E5) val DarkInverseSurface = Color(0xFFE6E1E5)
val DarkInverseOnSurface = Color(0xFF2F3033) val DarkInverseOnSurface = Color(0xFF2F3033)
val DarkInversePrimary = Color(0xFF1B7A2B) val DarkInversePrimary = Color(0xFF1B7A2B)
val DarkScrim = Color(0xFF000000) val DarkScrim = Color(0xFF000000)
// ---- Dégradé signature (rappel du fond du logo bouclier) -------------------- // ---- Dégradé signature (rappel du fond du logo bouclier) --------------------
val ShieldGradient = androidx.compose.ui.graphics.Brush.linearGradient( val ShieldGradient =
colors = listOf( androidx.compose.ui.graphics.Brush.linearGradient(
Color(0xFF4CAF50), // Vert clair (haut-gauche) colors =
Color(0xFF1B7A2B), // Vert moyen listOf(
Color(0xFF0D5E1A) // Vert foncé (bas-droite) Color(0xFF4CAF50), // Vert clair (haut-gauche)
), Color(0xFF1B7A2B), // Vert moyen
start = androidx.compose.ui.geometry.Offset(0f, 0f), Color(0xFF0D5E1A), // Vert foncé (bas-droite)
end = androidx.compose.ui.geometry.Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY) ),
) 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) ------------------- // ---- Legacy aliases (backward compat pour code existant) -------------------
@Deprecated("Use SemanticColors.Safe", ReplaceWith("SemanticColors.Safe")) @Deprecated("Use SemanticColors.Safe", ReplaceWith("SemanticColors.Safe"))
val StatusSafe get() = SemanticColors.Safe val StatusSafe get() = SemanticColors.Safe
@Deprecated("Use SemanticColors.SafeContainer", ReplaceWith("SemanticColors.SafeContainer")) @Deprecated("Use SemanticColors.SafeContainer", ReplaceWith("SemanticColors.SafeContainer"))
val StatusSafeContainer get() = SemanticColors.SafeContainer val StatusSafeContainer get() = SemanticColors.SafeContainer
@Deprecated("Use SemanticColors.OnSafe", ReplaceWith("SemanticColors.OnSafe")) @Deprecated("Use SemanticColors.OnSafe", ReplaceWith("SemanticColors.OnSafe"))
val OnStatusSafe get() = SemanticColors.OnSafe val OnStatusSafe get() = SemanticColors.OnSafe
@Deprecated("Use SemanticColors.Warning", ReplaceWith("SemanticColors.Warning")) @Deprecated("Use SemanticColors.Warning", ReplaceWith("SemanticColors.Warning"))
val StatusWarning get() = SemanticColors.Warning val StatusWarning get() = SemanticColors.Warning
@Deprecated("Use SemanticColors.WarningContainer", ReplaceWith("SemanticColors.WarningContainer")) @Deprecated("Use SemanticColors.WarningContainer", ReplaceWith("SemanticColors.WarningContainer"))
val StatusWarningContainer get() = SemanticColors.WarningContainer val StatusWarningContainer get() = SemanticColors.WarningContainer
@Deprecated("Use SemanticColors.OnWarning", ReplaceWith("SemanticColors.OnWarning")) @Deprecated("Use SemanticColors.OnWarning", ReplaceWith("SemanticColors.OnWarning"))
val OnStatusWarning get() = SemanticColors.OnWarning val OnStatusWarning get() = SemanticColors.OnWarning
@Deprecated("Use SemanticColors.Danger", ReplaceWith("SemanticColors.Danger")) @Deprecated("Use SemanticColors.Danger", ReplaceWith("SemanticColors.Danger"))
val StatusDanger get() = SemanticColors.Danger val StatusDanger get() = SemanticColors.Danger
@Deprecated("Use SemanticColors.DangerContainer", ReplaceWith("SemanticColors.DangerContainer")) @Deprecated("Use SemanticColors.DangerContainer", ReplaceWith("SemanticColors.DangerContainer"))
val StatusDangerContainer get() = SemanticColors.DangerContainer val StatusDangerContainer get() = SemanticColors.DangerContainer
@Deprecated("Use SemanticColors.OnDanger", ReplaceWith("SemanticColors.OnDanger")) @Deprecated("Use SemanticColors.OnDanger", ReplaceWith("SemanticColors.OnDanger"))
val OnStatusDanger get() = SemanticColors.OnDanger val OnStatusDanger get() = SemanticColors.OnDanger
@Deprecated("Use SemanticColors.SafeDark", ReplaceWith("SemanticColors.SafeDark")) @Deprecated("Use SemanticColors.SafeDark", ReplaceWith("SemanticColors.SafeDark"))
val StatusSafeDark get() = SemanticColors.SafeDark val StatusSafeDark get() = SemanticColors.SafeDark
@Deprecated("Use SemanticColors.SafeContainerDark", ReplaceWith("SemanticColors.SafeContainerDark")) @Deprecated("Use SemanticColors.SafeContainerDark", ReplaceWith("SemanticColors.SafeContainerDark"))
val StatusSafeContainerDark get() = SemanticColors.SafeContainerDark val StatusSafeContainerDark get() = SemanticColors.SafeContainerDark
@Deprecated("Use SemanticColors.WarningDark", ReplaceWith("SemanticColors.WarningDark")) @Deprecated("Use SemanticColors.WarningDark", ReplaceWith("SemanticColors.WarningDark"))
val StatusWarningDark get() = SemanticColors.WarningDark val StatusWarningDark get() = SemanticColors.WarningDark
@Deprecated("Use SemanticColors.WarningContainerDark", ReplaceWith("SemanticColors.WarningContainerDark")) @Deprecated("Use SemanticColors.WarningContainerDark", ReplaceWith("SemanticColors.WarningContainerDark"))
val StatusWarningContainerDark get() = SemanticColors.WarningContainerDark val StatusWarningContainerDark get() = SemanticColors.WarningContainerDark
@Deprecated("Use SemanticColors.DangerDark", ReplaceWith("SemanticColors.DangerDark")) @Deprecated("Use SemanticColors.DangerDark", ReplaceWith("SemanticColors.DangerDark"))
val StatusDangerDark get() = SemanticColors.DangerDark val StatusDangerDark get() = SemanticColors.DangerDark
@Deprecated("Use SemanticColors.DangerContainerDark", ReplaceWith("SemanticColors.DangerContainerDark")) @Deprecated("Use SemanticColors.DangerContainerDark", ReplaceWith("SemanticColors.DangerContainerDark"))
val StatusDangerContainerDark get() = SemanticColors.DangerContainerDark val StatusDangerContainerDark get() = SemanticColors.DangerContainerDark

View File

@ -22,7 +22,6 @@ data class Dimens(
val spacingXl: Dp = 24.dp, val spacingXl: Dp = 24.dp,
val spacingXxl: Dp = 32.dp, val spacingXxl: Dp = 32.dp,
val spacingXxxl: Dp = 48.dp, val spacingXxxl: Dp = 48.dp,
// Corner radius // Corner radius
val radiusSm: Dp = 4.dp, val radiusSm: Dp = 4.dp,
val radiusMd: Dp = 8.dp, val radiusMd: Dp = 8.dp,
@ -30,14 +29,12 @@ data class Dimens(
val radiusXl: Dp = 16.dp, val radiusXl: Dp = 16.dp,
val radiusXxl: Dp = 24.dp, val radiusXxl: Dp = 24.dp,
val radiusPill: Dp = 999.dp, val radiusPill: Dp = 999.dp,
// Elevations // Elevations
val elevationNone: Dp = 0.dp, val elevationNone: Dp = 0.dp,
val elevationSm: Dp = 1.dp, val elevationSm: Dp = 1.dp,
val elevationMd: Dp = 3.dp, val elevationMd: Dp = 3.dp,
val elevationLg: Dp = 6.dp, val elevationLg: Dp = 6.dp,
val elevationXl: Dp = 8.dp, val elevationXl: Dp = 8.dp,
// Component heights // Component heights
val buttonHeightSm: Dp = 40.dp, val buttonHeightSm: Dp = 40.dp,
val buttonHeight: Dp = 48.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.material3.Shapes
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
val SafeBiteShapes = Shapes( val SafeBiteShapes =
extraSmall = RoundedCornerShape(4.dp), Shapes(
small = RoundedCornerShape(8.dp), extraSmall = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(12.dp), small = RoundedCornerShape(8.dp),
large = RoundedCornerShape(16.dp), medium = RoundedCornerShape(12.dp),
extraLarge = RoundedCornerShape(24.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