From c4add0a3fe0d1fa906c2ced1551b46f8a3761110 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 11 May 2026 15:13:18 -0400 Subject: [PATCH] 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. --- .editorconfig | 9 + CHANGELOG.md | 42 + README.md | 2 +- app/build.gradle.kts | 23 + .../com/safebite/app/ExampleComposeTest.kt | 1 - .../com/safebite/app/SafeBiteApplication.kt | 1 - .../app/data/local/database/Converters.kt | 23 +- .../data/local/database/SafeBiteDatabase.kt | 8 +- .../app/data/local/database/dao/CatalogDao.kt | 8 +- .../local/database/dao/ShoppingListDao.kt | 11 +- .../local/database/entity/CatalogEntities.kt | 50 +- .../data/local/database/entity/Entities.kt | 36 +- .../local/database/migration/Migration7To8.kt | 125 +- .../local/database/migration/Migration8To9.kt | 13 +- .../database/relation/CatalogRelations.kt | 19 +- .../data/local/datastore/UserPreferences.kt | 55 +- .../app/data/local/seed/CatalogSeedManager.kt | 177 +- .../app/data/local/seed/CatalogSeedModels.kt | 8 +- .../app/data/remote/api/OpenFoodFactsApi.kt | 4 +- .../app/data/remote/dto/ProductDtos.kt | 6 +- .../app/data/remote/mapper/ProductMapper.kt | 161 +- .../app/data/repository/CatalogRepository.kt | 102 +- .../data/repository/ProductRepositoryImpl.kt | 115 +- .../repository/ScanHistoryRepositoryImpl.kt | 79 +- .../data/repository/SettingsRepositoryImpl.kt | 49 +- .../repository/ShoppingListRepositoryImpl.kt | 175 +- .../repository/UserProfileRepositoryImpl.kt | 106 +- .../app/data/util/ConnectivityObserver.kt | 40 +- .../java/com/safebite/app/di/AppModule.kt | 11 +- .../com/safebite/app/di/DatabaseModule.kt | 9 +- .../java/com/safebite/app/di/EngineModule.kt | 1 - .../java/com/safebite/app/di/NetworkModule.kt | 18 +- .../com/safebite/app/di/RepositoryModule.kt | 1 - .../domain/engine/AllergenAnalysisEngine.kt | 219 +- .../app/domain/engine/CatalogProvider.kt | 3371 +++++++++-------- .../app/domain/engine/CategoryEngine.kt | 143 +- .../app/domain/engine/HealthClassifier.kt | 82 +- .../safebite/app/domain/model/AllergenType.kt | 181 +- .../safebite/app/domain/model/DomainModels.kt | 34 +- .../app/domain/repository/Repositories.kt | 58 +- .../domain/usecase/GetAlternativesUseCase.kt | 24 +- .../safebite/app/domain/usecase/UseCases.kt | 236 +- .../safebite/app/presentation/MainActivity.kt | 55 +- .../common/components/AllergenGrid.kt | 128 +- .../presentation/common/components/AppBars.kt | 90 +- .../presentation/common/components/Buttons.kt | 66 +- .../presentation/common/components/Cards.kt | 23 +- .../presentation/common/components/Charts.kt | 140 +- .../common/components/Components.kt | 257 +- .../common/components/Feedback.kt | 137 +- .../common/components/ImageCropBottomSheet.kt | 159 +- .../common/components/TextFields.kt | 13 +- .../app/presentation/common/util/UiState.kt | 3 + .../app/presentation/navigation/NavGraph.kt | 156 +- .../app/presentation/navigation/Screen.kt | 101 +- .../screen/catalog/CatalogScreens.kt | 105 +- .../screen/catalog/CatalogViewModel.kt | 193 +- .../screen/dashboard/DashboardScreen.kt | 428 ++- .../screen/dashboard/DashboardViewModel.kt | 186 +- .../screen/family/FamilyScreen.kt | 131 +- .../screen/family/FamilyViewModel.kt | 76 +- .../presentation/screen/home/HomeScreen.kt | 76 +- .../presentation/screen/home/HomeViewModel.kt | 55 +- .../screen/lists/IconPickerSheet.kt | 344 +- .../screen/lists/ListDetailScreen.kt | 1183 +++--- .../screen/lists/ListDetailViewModel.kt | 1214 +++--- .../presentation/screen/lists/ListsScreen.kt | 218 +- .../screen/lists/ListsViewModel.kt | 162 +- .../screen/lists/create/CreateListScreen.kt | 59 +- .../lists/settings/ListMembersScreen.kt | 71 +- .../lists/settings/ListNameImageScreen.kt | 101 +- .../screen/lists/settings/ListRegionScreen.kt | 91 +- .../lists/settings/ListSettingsScreen.kt | 152 +- .../screen/lists/settings/ListSortScreen.kt | 219 +- .../screen/lists/util/ListBackgrounds.kt | 33 +- .../presentation/screen/main/MainScreen.kt | 142 +- .../screen/ocr/OcrCaptureScreen.kt | 64 +- .../screen/ocr/OcrReviewScreen.kt | 15 +- .../presentation/screen/ocr/OcrViewModel.kt | 14 +- .../screen/onboarding/OnboardingScreen.kt | 216 +- .../screen/onboarding/OnboardingViewModel.kt | 57 +- .../screen/product/ProductDetailScreen.kt | 209 +- .../screen/product/ProductDetailViewModel.kt | 76 +- .../screen/profile/ProfileComponents.kt | 69 +- .../screen/profile/ProfileEditScreen.kt | 33 +- .../screen/profile/ProfileListScreen.kt | 27 +- .../screen/profile/ProfileViewModel.kt | 226 +- .../screen/result/ProductNotFoundScreen.kt | 72 +- .../screen/result/ResultScreen.kt | 680 ++-- .../screen/result/ResultViewModel.kt | 134 +- .../screen/scanner/BarcodeAnalyzer.kt | 34 +- .../screen/scanner/ScannerScreen.kt | 253 +- .../screen/settings/SettingsScreen.kt | 90 +- .../screen/settings/SettingsViewModel.kt | 77 +- .../screen/splash/SplashScreen.kt | 40 +- .../screen/tracking/TrackingScreen.kt | 277 +- .../screen/tracking/TrackingViewModel.kt | 360 +- .../safebite/app/presentation/theme/Color.kt | 169 +- .../safebite/app/presentation/theme/Dimens.kt | 3 - .../safebite/app/presentation/theme/Shape.kt | 15 +- .../app/presentation/theme/StatusColors.kt | 60 +- .../safebite/app/presentation/theme/Theme.kt | 147 +- .../safebite/app/presentation/theme/Type.kt | 242 +- app/src/main/res/values/strings.xml | 10 + .../repository/ProductRepositoryImplTest.kt | 189 +- .../engine/AllergenAnalysisEngineTest.kt | 339 +- .../app/domain/engine/HealthClassifierTest.kt | 207 +- .../usecase/GetAlternativesUseCaseTest.kt | 78 +- .../screen/result/ResultViewModelTest.kt | 164 +- build.gradle.kts | 2 + build_apks.ps1 | 7 +- config/detekt/baseline.xml | 166 + config/detekt/detekt.yml | 111 + docs/ameliorations.md | 177 + docs/prompt-frigo-recettes.md | 556 +++ docs/roadmap.md | 869 ++--- docs/test-plan.md | 209 + gradle/libs.versions.toml | 4 + version.properties | 4 +- 119 files changed, 11196 insertions(+), 7958 deletions(-) create mode 100644 .editorconfig create mode 100644 config/detekt/baseline.xml create mode 100644 config/detekt/detekt.yml create mode 100644 docs/ameliorations.md create mode 100644 docs/prompt-frigo-recettes.md create mode 100644 docs/test-plan.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d009f86 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e90f5c..9749cb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,48 @@ Tous les changements notables de SafeBite seront documentés dans ce fichier. Le format est basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.28.0] — 2026-05-11 + +### Ajouté +- **Haptic feedback sur le FAB Scanner** (15ms, distinct du scan 60ms) +- **Saisie manuelle du code-barres** dans le Scanner (`AlertDialog` avec `OutlinedTextField`, validation 8-13 chiffres) +- **Bouton "Voir les alternatives"** dans ResultScreen (visible si verdict != SAFE, navigation vers ProductDetail) +- **Dashboard données réelles** : + - Stats hebdomadaires (✅ % safe / ⚠️ warnings / ❌ dangers) + - 5 derniers scans avec verdict, marque, temps relatif + - 3 modes contextuels auto-détectés (FIRST_TIME / STORE / HOME) +- **Animations stagger** sur les actions ResultScreen (fadeIn + slideInVertically, délais 0/50/100/150ms) +- **Animation slide-up** sur le contenu ResultScreen (250ms, ease-out) +- **Validation format code-barres** dans le Scanner (manuel) et BarcodeAnalyzer (ML Kit) + +### Modifié +- `DashboardViewModel` : injecte `GetScanHistoryUseCase`, calcule `WeeklyStats` +- `DashboardScreen` : 3 layouts contextuels distincts (FirstTimeContent, StoreContent, HomeContent) +- `ResultScreen` : nouveau callback `onOpenAlternatives`, composant `StaggeredAction` +- `ScannerScreen` : état `manualCode` + `AlertDialog` saisie manuelle +- `MainScreen` : `SafeBiteFab` avec retour haptique 15ms +- `NavGraph` : navigation `ResultScreen.onOpenAlternatives` → `ProductDetail` + +### Vérifié +- Mode sombre : `StatusColors` light/dark correctement câblés dans `Theme.kt` +- Contraste Material 3 conforme WCAG 2.1 AA + +--- + +## [1.27.0] — 2026-05-10 + +### Ajouté +- **Catalogue** : écrans Catalog, DomainCategories, CategoryItems, CatalogSearch +- **Gestion avancée des listes** : création, tri, région, nom/image, membres +- **Splash screen** configurable +- **Paramètres liste** : ListSettingsScreen, ListSortScreen, ListRegionScreen + +### Modifié +- Navigation enrichie : routes Catalog*, ListCreate, ListSettings* +- `MainScreen` : bottom bar et FAB avec animations scale/fade/slide + +--- + ## [1.2.0] — 2026-04-26 ### Ajouté diff --git a/README.md b/README.md index 088d61c..594579d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8215b09..5719a87 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,6 +7,8 @@ plugins { alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.ksp) alias(libs.plugins.hilt) + alias(libs.plugins.ktlint) + alias(libs.plugins.detekt) } android { @@ -154,3 +156,24 @@ dependencies { // LeakCanary pour détection de fuites mémoire (debug uniquement) debugImplementation(libs.leakcanary.android) } + +// Ktlint — formatage Kotlin +ktlint { + android = true + ignoreFailures = true + reporters { + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) + } +} + +// Detekt — analyse statique +detekt { + buildUponDefaultConfig = true + allRules = false + autoCorrect = false + parallel = true + ignoreFailures = true + config.setFrom(file("$rootDir/config/detekt/detekt.yml")) + baseline = file("$rootDir/config/detekt/baseline.xml") +} diff --git a/app/src/androidTest/java/com/safebite/app/ExampleComposeTest.kt b/app/src/androidTest/java/com/safebite/app/ExampleComposeTest.kt index 69c71c8..8f6666b 100644 --- a/app/src/androidTest/java/com/safebite/app/ExampleComposeTest.kt +++ b/app/src/androidTest/java/com/safebite/app/ExampleComposeTest.kt @@ -12,7 +12,6 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class ExampleComposeTest { - @get:Rule val composeTestRule = createComposeRule() diff --git a/app/src/main/java/com/safebite/app/SafeBiteApplication.kt b/app/src/main/java/com/safebite/app/SafeBiteApplication.kt index f2c0942..9d8f2d8 100644 --- a/app/src/main/java/com/safebite/app/SafeBiteApplication.kt +++ b/app/src/main/java/com/safebite/app/SafeBiteApplication.kt @@ -12,7 +12,6 @@ import javax.inject.Inject @HiltAndroidApp class SafeBiteApplication : Application() { - @Inject lateinit var catalogSeedManager: CatalogSeedManager private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) diff --git a/app/src/main/java/com/safebite/app/data/local/database/Converters.kt b/app/src/main/java/com/safebite/app/data/local/database/Converters.kt index f31b8d9..d3b5039 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/Converters.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/Converters.kt @@ -10,16 +10,14 @@ import com.safebite.app.domain.model.SafetyStatus class Converters { @TypeConverter - fun allergenSetToString(set: Set?): String = - set.orEmpty().joinToString(",") { it.name } + fun allergenSetToString(set: Set?): String = set.orEmpty().joinToString(",") { it.name } @TypeConverter fun stringToAllergenSet(raw: String?): Set = raw.orEmpty().split(',').mapNotNull { AllergenType.fromName(it.trim()) }.toSet() @TypeConverter - fun restrictionSetToString(set: Set?): String = - set.orEmpty().joinToString(",") { it.name } + fun restrictionSetToString(set: Set?): String = set.orEmpty().joinToString(",") { it.name } @TypeConverter fun stringToRestrictionSet(raw: String?): Set = @@ -29,12 +27,10 @@ class Converters { .toSet() @TypeConverter - fun stringListToString(list: List?): String = - list.orEmpty().joinToString("\u0001") + fun stringListToString(list: List?): String = list.orEmpty().joinToString("\u0001") @TypeConverter - fun stringToStringList(raw: String?): List = - if (raw.isNullOrEmpty()) emptyList() else raw.split('\u0001') + fun stringToStringList(raw: String?): List = if (raw.isNullOrEmpty()) emptyList() else raw.split('\u0001') @TypeConverter fun safetyStatusToString(status: SafetyStatus): String = status.name @@ -55,7 +51,7 @@ class Converters { listOf( item.name.replace('|', '/').replace('\u0002', ' '), item.tag.name, - item.keywords.joinToString(";") { it.replace(';', ',').replace('|', '/') } + item.keywords.joinToString(";") { it.replace(';', ',').replace('|', '/') }, ).joinToString("|") } @@ -67,9 +63,12 @@ class Converters { if (parts.size < 2) return@mapNotNull null val name = parts[0].trim() val tag = runCatching { CustomItemTag.valueOf(parts[1].trim()) }.getOrNull() ?: return@mapNotNull null - val keywords = if (parts.size >= 3 && parts[2].isNotBlank()) - parts[2].split(';').map { it.trim() }.filter { it.isNotBlank() } - else emptyList() + val keywords = + if (parts.size >= 3 && parts[2].isNotBlank()) { + parts[2].split(';').map { it.trim() }.filter { it.isNotBlank() } + } else { + emptyList() + } CustomDietItem(name = name, tag = tag, keywords = keywords) } } diff --git a/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt b/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt index 53648b5..7b77885 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt @@ -30,17 +30,21 @@ import com.safebite.app.data.local.database.entity.UserProfileEntity ShoppingDomainEntity::class, CategoryEntity::class, CatalogItemEntity::class, - ItemCategoryCrossRef::class + ItemCategoryCrossRef::class, ], version = 9, - exportSchema = false + exportSchema = false, ) @TypeConverters(Converters::class) abstract class SafeBiteDatabase : RoomDatabase() { abstract fun userProfileDao(): UserProfileDao + abstract fun productCacheDao(): ProductCacheDao + abstract fun scanHistoryDao(): ScanHistoryDao + abstract fun shoppingListDao(): ShoppingListDao + abstract fun catalogDao(): CatalogDao companion object { diff --git a/app/src/main/java/com/safebite/app/data/local/database/dao/CatalogDao.kt b/app/src/main/java/com/safebite/app/data/local/database/dao/CatalogDao.kt index 3851d5e..29741ff 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/dao/CatalogDao.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/dao/CatalogDao.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.Flow @Dao interface CatalogDao { - // ── Domaines ────────────────────────────────────────────────────────────── @Query("SELECT * FROM shopping_domains WHERE isActive = 1 ORDER BY sortOrder") fun getAllDomains(): Flow> @@ -67,9 +66,12 @@ interface CatalogDao { popularity DESC, name ASC LIMIT :limit - """ + """, ) - fun searchItems(query: String, limit: Int = 20): Flow> + fun searchItems( + query: String, + limit: Int = 20, + ): Flow> @Query("SELECT * FROM catalog_items WHERE primaryCategoryId = :categoryId ORDER BY sortOrder, name") fun getItemsForCategory(categoryId: String): Flow> diff --git a/app/src/main/java/com/safebite/app/data/local/database/dao/ShoppingListDao.kt b/app/src/main/java/com/safebite/app/data/local/database/dao/ShoppingListDao.kt index 843848d..4d4ebab 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/dao/ShoppingListDao.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/dao/ShoppingListDao.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.Flow */ @Dao interface ShoppingListDao { - // ── Shopping Lists ────────────────────────────────────────────────────── @Query("SELECT * FROM shopping_lists WHERE isArchived = 0 ORDER BY displayOrder ASC, updatedAt DESC") @@ -65,7 +64,10 @@ interface ShoppingListDao { suspend fun deleteItem(item: ShoppingListItemEntity) @Query("UPDATE shopping_list_items SET isChecked = :checked WHERE id = :id") - suspend fun setItemChecked(id: Long, checked: Boolean) + suspend fun setItemChecked( + id: Long, + checked: Boolean, + ) @Query("UPDATE shopping_list_items SET isChecked = 0 WHERE listId = :listId") suspend fun uncheckAllItems(listId: Long) @@ -101,7 +103,10 @@ interface ShoppingListDao { // ── Transaction: ajouter un produit à une liste ───────────────────────── @Transaction - suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) { + suspend fun addItemToList( + listId: Long, + item: ShoppingListItemEntity, + ) { // S'assurer que le listId est correct val itemWithCorrectList = item.copy(listId = listId) insertItem(itemWithCorrectList) diff --git a/app/src/main/java/com/safebite/app/data/local/database/entity/CatalogEntities.kt b/app/src/main/java/com/safebite/app/data/local/database/entity/CatalogEntities.kt index bb329fd..8eb78da 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/entity/CatalogEntities.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/entity/CatalogEntities.kt @@ -22,18 +22,20 @@ data class ShoppingDomainEntity( val iconResName: String? = null, val color: String? = null, val sortOrder: Int, - val isActive: Boolean = true + val isActive: Boolean = true, ) @Entity( tableName = "categories", - foreignKeys = [ForeignKey( - entity = ShoppingDomainEntity::class, - parentColumns = ["domainId"], - childColumns = ["domainId"], - onDelete = ForeignKey.CASCADE - )], - indices = [Index("domainId"), Index("name")] + foreignKeys = [ + ForeignKey( + entity = ShoppingDomainEntity::class, + parentColumns = ["domainId"], + childColumns = ["domainId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("domainId"), Index("name")], ) data class CategoryEntity( @PrimaryKey val categoryId: String, @@ -43,22 +45,24 @@ data class CategoryEntity( val iconResName: String? = null, val color: String? = null, val sortOrder: Int, - val isActive: Boolean = true + val isActive: Boolean = true, ) @Entity( tableName = "catalog_items", - foreignKeys = [ForeignKey( - entity = CategoryEntity::class, - parentColumns = ["categoryId"], - childColumns = ["primaryCategoryId"], - onDelete = ForeignKey.SET_NULL - )], + foreignKeys = [ + ForeignKey( + entity = CategoryEntity::class, + parentColumns = ["categoryId"], + childColumns = ["primaryCategoryId"], + onDelete = ForeignKey.SET_NULL, + ), + ], indices = [ Index("primaryCategoryId"), Index("name"), - Index("barcode") - ] + Index("barcode"), + ], ) data class CatalogItemEntity( @PrimaryKey val itemId: String, @@ -72,7 +76,7 @@ data class CatalogItemEntity( val variants: String = "", val isUserCreated: Boolean = false, val popularity: Int = 0, - val sortOrder: Int = 0 + val sortOrder: Int = 0, ) @Entity( @@ -83,18 +87,18 @@ data class CatalogItemEntity( entity = CatalogItemEntity::class, parentColumns = ["itemId"], childColumns = ["itemId"], - onDelete = ForeignKey.CASCADE + onDelete = ForeignKey.CASCADE, ), ForeignKey( entity = CategoryEntity::class, parentColumns = ["categoryId"], childColumns = ["categoryId"], - onDelete = ForeignKey.CASCADE - ) + onDelete = ForeignKey.CASCADE, + ), ], - indices = [Index("categoryId")] + indices = [Index("categoryId")], ) data class ItemCategoryCrossRef( val itemId: String, - val categoryId: String + val categoryId: String, ) diff --git a/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt b/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt index 2d0b643..bfa8b01 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt @@ -17,7 +17,7 @@ data class UserProfileEntity( val moderateIntolerances: Set, val dietaryRestrictions: Set, val customItems: List = emptyList(), - val isDefault: Boolean + val isDefault: Boolean, ) @Entity(tableName = "product_cache") @@ -45,7 +45,7 @@ data class ProductCacheEntity( val fiber100g: Double? = null, val proteins100g: Double? = null, val carbohydrates100g: Double? = null, - val cachedAt: Long + val cachedAt: Long, ) @Entity(tableName = "scan_history") @@ -58,7 +58,7 @@ data class ScanHistoryEntity( val safetyStatus: SafetyStatus, val profileNames: List, val scannedAt: Long, - val source: DataSource + val source: DataSource, ) // ============================================================================= @@ -77,7 +77,7 @@ data class ShoppingListEntity( val sortType: String = "category", val displayOrder: Int = 0, val visibleCategories: String? = null, - val categoryOrder: String? = null + val categoryOrder: String? = null, ) @Entity( @@ -87,10 +87,10 @@ data class ShoppingListEntity( entity = ShoppingListEntity::class, parentColumns = ["id"], childColumns = ["listId"], - onDelete = androidx.room.ForeignKey.CASCADE - ) + onDelete = androidx.room.ForeignKey.CASCADE, + ), ], - indices = [androidx.room.Index("listId")] + indices = [androidx.room.Index("listId")], ) data class ShoppingListItemEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0L, @@ -100,13 +100,13 @@ data class ShoppingListItemEntity( val brand: String? = null, val imageUrl: String? = null, val isChecked: Boolean = false, - val category: String? = null, // "Frais", "Épicerie", etc. - val safetyStatus: String? = null, // "SAFE", "WARNING", "DANGER" + val category: String? = null, // "Frais", "Épicerie", etc. + val safetyStatus: String? = null, // "SAFE", "WARNING", "DANGER" val allergenWarning: String? = null, // Allergène détecté pour alerte - val note: String? = null, // Quantité / description libre (ex: "2 kg") - val customEmoji: String? = null, // Emoji personnalisé choisi par l'utilisateur - val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever" - val addedAt: Long = System.currentTimeMillis() + val note: String? = null, // Quantité / description libre (ex: "2 kg") + val customEmoji: String? = null, // Emoji personnalisé choisi par l'utilisateur + val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever" + val addedAt: Long = System.currentTimeMillis(), ) @Entity( @@ -116,10 +116,10 @@ data class ShoppingListItemEntity( entity = ShoppingListEntity::class, parentColumns = ["id"], childColumns = ["listId"], - onDelete = androidx.room.ForeignKey.CASCADE - ) + onDelete = androidx.room.ForeignKey.CASCADE, + ), ], - indices = [androidx.room.Index("listId")] + indices = [androidx.room.Index("listId")], ) data class ShoppingListMemberEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0L, @@ -127,6 +127,6 @@ data class ShoppingListMemberEntity( val name: String, val email: String, val avatarUrl: String? = null, - val role: String = "member", // "owner" | "member" - val joinedAt: Long = System.currentTimeMillis() + val role: String = "member", // "owner" | "member" + val joinedAt: Long = System.currentTimeMillis(), ) diff --git a/app/src/main/java/com/safebite/app/data/local/database/migration/Migration7To8.kt b/app/src/main/java/com/safebite/app/data/local/database/migration/Migration7To8.kt index 9df19d7..99e7fd7 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/migration/Migration7To8.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/migration/Migration7To8.kt @@ -8,73 +8,74 @@ import androidx.sqlite.db.SupportSQLiteDatabase * (domaines, catégories, articles, cross-ref) + leurs index. Aucune * donnée existante n'est touchée. Le seed JSON est appliqué après ouverture. */ -val MIGRATION_7_8: Migration = object : Migration(7, 8) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS shopping_domains ( - domainId TEXT NOT NULL PRIMARY KEY, - name TEXT NOT NULL, - emoji TEXT NOT NULL, - iconResName TEXT, - color TEXT, - sortOrder INTEGER NOT NULL, - isActive INTEGER NOT NULL +val MIGRATION_7_8: Migration = + object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS shopping_domains ( + domainId TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + emoji TEXT NOT NULL, + iconResName TEXT, + color TEXT, + sortOrder INTEGER NOT NULL, + isActive INTEGER NOT NULL + ) + """.trimIndent(), ) - """.trimIndent() - ) - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS categories ( - categoryId TEXT NOT NULL PRIMARY KEY, - domainId TEXT NOT NULL, - name TEXT NOT NULL, - emoji TEXT NOT NULL, - iconResName TEXT, - color TEXT, - sortOrder INTEGER NOT NULL, - isActive INTEGER NOT NULL, - FOREIGN KEY(domainId) REFERENCES shopping_domains(domainId) ON DELETE CASCADE + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS categories ( + categoryId TEXT NOT NULL PRIMARY KEY, + domainId TEXT NOT NULL, + name TEXT NOT NULL, + emoji TEXT NOT NULL, + iconResName TEXT, + color TEXT, + sortOrder INTEGER NOT NULL, + isActive INTEGER NOT NULL, + FOREIGN KEY(domainId) REFERENCES shopping_domains(domainId) ON DELETE CASCADE + ) + """.trimIndent(), ) - """.trimIndent() - ) - db.execSQL("CREATE INDEX IF NOT EXISTS index_categories_domainId ON categories(domainId)") - db.execSQL("CREATE INDEX IF NOT EXISTS index_categories_name ON categories(name)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_categories_domainId ON categories(domainId)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_categories_name ON categories(name)") - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS catalog_items ( - itemId TEXT NOT NULL PRIMARY KEY, - name TEXT NOT NULL, - primaryCategoryId TEXT, - emoji TEXT NOT NULL, - iconUrl TEXT, - barcode TEXT, - aliases TEXT NOT NULL, - tags TEXT NOT NULL, - isUserCreated INTEGER NOT NULL, - popularity INTEGER NOT NULL, - sortOrder INTEGER NOT NULL, - FOREIGN KEY(primaryCategoryId) REFERENCES categories(categoryId) ON DELETE SET NULL + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS catalog_items ( + itemId TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + primaryCategoryId TEXT, + emoji TEXT NOT NULL, + iconUrl TEXT, + barcode TEXT, + aliases TEXT NOT NULL, + tags TEXT NOT NULL, + isUserCreated INTEGER NOT NULL, + popularity INTEGER NOT NULL, + sortOrder INTEGER NOT NULL, + FOREIGN KEY(primaryCategoryId) REFERENCES categories(categoryId) ON DELETE SET NULL + ) + """.trimIndent(), ) - """.trimIndent() - ) - db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_primaryCategoryId ON catalog_items(primaryCategoryId)") - db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_name ON catalog_items(name)") - db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_barcode ON catalog_items(barcode)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_primaryCategoryId ON catalog_items(primaryCategoryId)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_name ON catalog_items(name)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_barcode ON catalog_items(barcode)") - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS item_category_cross_ref ( - itemId TEXT NOT NULL, - categoryId TEXT NOT NULL, - PRIMARY KEY(itemId, categoryId), - FOREIGN KEY(itemId) REFERENCES catalog_items(itemId) ON DELETE CASCADE, - FOREIGN KEY(categoryId) REFERENCES categories(categoryId) ON DELETE CASCADE + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS item_category_cross_ref ( + itemId TEXT NOT NULL, + categoryId TEXT NOT NULL, + PRIMARY KEY(itemId, categoryId), + FOREIGN KEY(itemId) REFERENCES catalog_items(itemId) ON DELETE CASCADE, + FOREIGN KEY(categoryId) REFERENCES categories(categoryId) ON DELETE CASCADE + ) + """.trimIndent(), ) - """.trimIndent() - ) - db.execSQL("CREATE INDEX IF NOT EXISTS index_item_category_cross_ref_categoryId ON item_category_cross_ref(categoryId)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_item_category_cross_ref_categoryId ON item_category_cross_ref(categoryId)") + } } -} diff --git a/app/src/main/java/com/safebite/app/data/local/database/migration/Migration8To9.kt b/app/src/main/java/com/safebite/app/data/local/database/migration/Migration8To9.kt index c9c45ed..5924bbf 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/migration/Migration8To9.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/migration/Migration8To9.kt @@ -7,10 +7,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase * Migration additive : ajoute la colonne `variants` à la table `catalog_items`. * Les données existantes conservent la valeur par défaut (''). */ -val MIGRATION_8_9: Migration = object : Migration(8, 9) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - "ALTER TABLE catalog_items ADD COLUMN variants TEXT NOT NULL DEFAULT ''" - ) +val MIGRATION_8_9: Migration = + object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE catalog_items ADD COLUMN variants TEXT NOT NULL DEFAULT ''", + ) + } } -} diff --git a/app/src/main/java/com/safebite/app/data/local/database/relation/CatalogRelations.kt b/app/src/main/java/com/safebite/app/data/local/database/relation/CatalogRelations.kt index 997be37..f1cabab 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/relation/CatalogRelations.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/relation/CatalogRelations.kt @@ -11,7 +11,7 @@ import com.safebite.app.data.local.database.entity.ShoppingDomainEntity data class DomainWithCategories( @Embedded val domain: ShoppingDomainEntity, @Relation(parentColumn = "domainId", entityColumn = "domainId") - val categories: List + val categories: List, ) data class CategoryWithItems( @@ -19,13 +19,14 @@ data class CategoryWithItems( @Relation( parentColumn = "categoryId", entityColumn = "itemId", - associateBy = Junction( - value = ItemCategoryCrossRef::class, - parentColumn = "categoryId", - entityColumn = "itemId" - ) + associateBy = + Junction( + value = ItemCategoryCrossRef::class, + parentColumn = "categoryId", + entityColumn = "itemId", + ), ) - val items: List + val items: List, ) data class DomainWithCategoriesAndItems( @@ -33,7 +34,7 @@ data class DomainWithCategoriesAndItems( @Relation( entity = CategoryEntity::class, parentColumn = "domainId", - entityColumn = "domainId" + entityColumn = "domainId", ) - val categoriesWithItems: List + val categoriesWithItems: List, ) diff --git a/app/src/main/java/com/safebite/app/data/local/datastore/UserPreferences.kt b/app/src/main/java/com/safebite/app/data/local/datastore/UserPreferences.kt index 195a6e7..c266c59 100644 --- a/app/src/main/java/com/safebite/app/data/local/datastore/UserPreferences.kt +++ b/app/src/main/java/com/safebite/app/data/local/datastore/UserPreferences.kt @@ -30,41 +30,46 @@ object UserPreferencesKeys { } class UserPreferences(private val dataStore: DataStore) { + val appLanguage: Flow = + dataStore.data.map { + runCatching { AppLanguage.valueOf(it[UserPreferencesKeys.APP_LANGUAGE] ?: AppLanguage.FR.name) } + .getOrDefault(AppLanguage.FR) + } - val appLanguage: Flow = dataStore.data.map { - runCatching { AppLanguage.valueOf(it[UserPreferencesKeys.APP_LANGUAGE] ?: AppLanguage.FR.name) } - .getOrDefault(AppLanguage.FR) - } - - val detectionLanguage: Flow = dataStore.data.map { - runCatching { DetectionLanguage.valueOf(it[UserPreferencesKeys.DETECTION_LANGUAGE] ?: DetectionLanguage.BOTH.name) } - .getOrDefault(DetectionLanguage.BOTH) - } + val detectionLanguage: Flow = + dataStore.data.map { + runCatching { DetectionLanguage.valueOf(it[UserPreferencesKeys.DETECTION_LANGUAGE] ?: DetectionLanguage.BOTH.name) } + .getOrDefault(DetectionLanguage.BOTH) + } val haptics: Flow = dataStore.data.map { it[UserPreferencesKeys.HAPTICS] ?: true } val sound: Flow = dataStore.data.map { it[UserPreferencesKeys.SOUND] ?: true } - val theme: Flow = dataStore.data.map { - runCatching { ThemePref.valueOf(it[UserPreferencesKeys.THEME] ?: ThemePref.SYSTEM.name) } - .getOrDefault(ThemePref.SYSTEM) - } + val theme: Flow = + dataStore.data.map { + runCatching { ThemePref.valueOf(it[UserPreferencesKeys.THEME] ?: ThemePref.SYSTEM.name) } + .getOrDefault(ThemePref.SYSTEM) + } val onboardingCompleted: Flow = dataStore.data.map { it[UserPreferencesKeys.ONBOARDING_DONE] ?: false } - val activeProfileIds: Flow> = dataStore.data.map { prefs -> - prefs[UserPreferencesKeys.ACTIVE_PROFILE_IDS].orEmpty() - .mapNotNull { it.toLongOrNull() } - .toSet() - } + val activeProfileIds: Flow> = + dataStore.data.map { prefs -> + prefs[UserPreferencesKeys.ACTIVE_PROFILE_IDS].orEmpty() + .mapNotNull { it.toLongOrNull() } + .toSet() + } - val healthStrictness: Flow = dataStore.data.map { - runCatching { HealthStrictness.valueOf(it[UserPreferencesKeys.HEALTH_STRICTNESS] ?: HealthStrictness.NORMAL.name) } - .getOrDefault(HealthStrictness.NORMAL) - } + val healthStrictness: Flow = + dataStore.data.map { + runCatching { HealthStrictness.valueOf(it[UserPreferencesKeys.HEALTH_STRICTNESS] ?: HealthStrictness.NORMAL.name) } + .getOrDefault(HealthStrictness.NORMAL) + } - val splashScreenEnabled: Flow = dataStore.data.map { - it[UserPreferencesKeys.SPLASH_SCREEN_ENABLED] ?: true - } + val splashScreenEnabled: Flow = + dataStore.data.map { + it[UserPreferencesKeys.SPLASH_SCREEN_ENABLED] ?: true + } suspend fun setAppLanguage(value: AppLanguage) { dataStore.edit { it[UserPreferencesKeys.APP_LANGUAGE] = value.name } diff --git a/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedManager.kt b/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedManager.kt index d336b20..6ff32f5 100644 --- a/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedManager.kt +++ b/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedManager.kt @@ -1,6 +1,7 @@ package com.safebite.app.data.local.seed import android.content.Context +import android.util.Log import androidx.room.withTransaction import com.safebite.app.data.local.database.SafeBiteDatabase import com.safebite.app.data.local.database.dao.CatalogDao @@ -8,7 +9,6 @@ import com.safebite.app.data.local.database.entity.CatalogItemEntity import com.safebite.app.data.local.database.entity.CategoryEntity import com.safebite.app.data.local.database.entity.ItemCategoryCrossRef import com.safebite.app.data.local.database.entity.ShoppingDomainEntity -import android.util.Log import com.squareup.moshi.Moshi import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -22,96 +22,101 @@ import javax.inject.Singleton * lancement (ou après une migration qui a créé les tables vides). */ @Singleton -class CatalogSeedManager @Inject constructor( - @ApplicationContext private val context: Context, - private val database: SafeBiteDatabase, - private val catalogDao: CatalogDao, - private val moshi: Moshi -) { - suspend fun seedIfNeeded() = withContext(Dispatchers.IO) { - val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val storedVersion = prefs.getInt(PREF_SEED_VERSION, 0) - val jsonVersion = runCatching { - val json = context.assets.open(SEED_ASSET).bufferedReader().use { it.readText() } - moshi.adapter(CatalogSeed::class.java).fromJson(json)?.version ?: 0 - }.getOrElse { 0 } - Log.i(TAG, "Catalog seed check: storedVersion=$storedVersion, jsonVersion=$jsonVersion") - if (jsonVersion <= storedVersion) return@withContext - runCatching { seedFromJson() } - .onSuccess { - Log.i(TAG, "Catalog seeded successfully v$jsonVersion") - prefs.edit().putInt(PREF_SEED_VERSION, jsonVersion).apply() - } - .onFailure { - Log.e(TAG, "Catalog seed failed: ${it.message}", it) - Timber.e(it, "Catalog seed failed") - } - } - - private suspend fun seedFromJson() { - val json = context.assets.open(SEED_ASSET).bufferedReader().use { it.readText() } - val adapter = moshi.adapter(CatalogSeed::class.java) - val seed = adapter.fromJson(json) ?: error("catalog_seed.json invalide") - - database.withTransaction { - seed.domains.forEach { domainSeed -> - catalogDao.insertDomains( - listOf( - ShoppingDomainEntity( - domainId = domainSeed.domainId, - name = domainSeed.name, - emoji = domainSeed.emoji, - color = domainSeed.color, - sortOrder = domainSeed.sortOrder, - isActive = true - ) - ) - ) - - domainSeed.categories.forEach { catSeed -> - catalogDao.insertCategories( - listOf( - CategoryEntity( - categoryId = catSeed.categoryId, - domainId = domainSeed.domainId, - name = catSeed.name, - emoji = catSeed.emoji, - color = catSeed.color, - sortOrder = catSeed.sortOrder, - isActive = true - ) - ) - ) - - val items = catSeed.items.mapIndexed { index, itemSeed -> - CatalogItemEntity( - itemId = itemSeed.itemId, - name = itemSeed.name, - primaryCategoryId = catSeed.categoryId, - emoji = itemSeed.emoji, - aliases = itemSeed.aliases.orEmpty(), - tags = itemSeed.tags.orEmpty(), - variants = itemSeed.variants.orEmpty(), - barcode = itemSeed.barcode, - sortOrder = index - ) +class CatalogSeedManager + @Inject + constructor( + @ApplicationContext private val context: Context, + private val database: SafeBiteDatabase, + private val catalogDao: CatalogDao, + private val moshi: Moshi, + ) { + suspend fun seedIfNeeded() = + withContext(Dispatchers.IO) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val storedVersion = prefs.getInt(PREF_SEED_VERSION, 0) + val jsonVersion = + runCatching { + val json = context.assets.open(SEED_ASSET).bufferedReader().use { it.readText() } + moshi.adapter(CatalogSeed::class.java).fromJson(json)?.version ?: 0 + }.getOrElse { 0 } + Log.i(TAG, "Catalog seed check: storedVersion=$storedVersion, jsonVersion=$jsonVersion") + if (jsonVersion <= storedVersion) return@withContext + runCatching { seedFromJson() } + .onSuccess { + Log.i(TAG, "Catalog seeded successfully v$jsonVersion") + prefs.edit().putInt(PREF_SEED_VERSION, jsonVersion).apply() } - if (items.isNotEmpty()) { - catalogDao.insertItems(items) - catalogDao.insertCrossRefs( - items.map { ItemCategoryCrossRef(it.itemId, catSeed.categoryId) } + .onFailure { + Log.e(TAG, "Catalog seed failed: ${it.message}", it) + Timber.e(it, "Catalog seed failed") + } + } + + private suspend fun seedFromJson() { + val json = context.assets.open(SEED_ASSET).bufferedReader().use { it.readText() } + val adapter = moshi.adapter(CatalogSeed::class.java) + val seed = adapter.fromJson(json) ?: error("catalog_seed.json invalide") + + database.withTransaction { + seed.domains.forEach { domainSeed -> + catalogDao.insertDomains( + listOf( + ShoppingDomainEntity( + domainId = domainSeed.domainId, + name = domainSeed.name, + emoji = domainSeed.emoji, + color = domainSeed.color, + sortOrder = domainSeed.sortOrder, + isActive = true, + ), + ), + ) + + domainSeed.categories.forEach { catSeed -> + catalogDao.insertCategories( + listOf( + CategoryEntity( + categoryId = catSeed.categoryId, + domainId = domainSeed.domainId, + name = catSeed.name, + emoji = catSeed.emoji, + color = catSeed.color, + sortOrder = catSeed.sortOrder, + isActive = true, + ), + ), ) + + val items = + catSeed.items.mapIndexed { index, itemSeed -> + CatalogItemEntity( + itemId = itemSeed.itemId, + name = itemSeed.name, + primaryCategoryId = catSeed.categoryId, + emoji = itemSeed.emoji, + aliases = itemSeed.aliases.orEmpty(), + tags = itemSeed.tags.orEmpty(), + variants = itemSeed.variants.orEmpty(), + barcode = itemSeed.barcode, + sortOrder = index, + ) + } + if (items.isNotEmpty()) { + catalogDao.insertItems(items) + catalogDao.insertCrossRefs( + items.map { ItemCategoryCrossRef(it.itemId, catSeed.categoryId) }, + ) + } } } } + Timber.i("Catalog seeded: %d domains", seed.domains.size) } - Timber.i("Catalog seeded: %d domains", seed.domains.size) - } - companion object { - private const val SEED_ASSET = "catalog_seed.json" - private const val TAG = "CatalogSeedManager" - private const val PREFS_NAME = "catalog_seed_prefs" - private const val PREF_SEED_VERSION = "seed_version" + companion object { + private const val SEED_ASSET = "catalog_seed.json" + private const val TAG = "CatalogSeedManager" + private const val PREFS_NAME = "catalog_seed_prefs" + private const val PREF_SEED_VERSION = "seed_version" + } } -} diff --git a/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedModels.kt b/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedModels.kt index e3781ee..16f3f12 100644 --- a/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedModels.kt +++ b/app/src/main/java/com/safebite/app/data/local/seed/CatalogSeedModels.kt @@ -10,7 +10,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class CatalogSeed( val version: Int, - val domains: List + val domains: List, ) @JsonClass(generateAdapter = true) @@ -20,7 +20,7 @@ data class DomainSeed( val emoji: String, val color: String? = null, val sortOrder: Int, - val categories: List + val categories: List, ) @JsonClass(generateAdapter = true) @@ -30,7 +30,7 @@ data class CategorySeed( val emoji: String, val color: String? = null, val sortOrder: Int, - val items: List + val items: List, ) @JsonClass(generateAdapter = true) @@ -41,5 +41,5 @@ data class ItemSeed( val aliases: String? = null, val tags: String? = null, val variants: String? = null, - val barcode: String? = null + val barcode: String? = null, ) diff --git a/app/src/main/java/com/safebite/app/data/remote/api/OpenFoodFactsApi.kt b/app/src/main/java/com/safebite/app/data/remote/api/OpenFoodFactsApi.kt index 567266d..6e6dd1b 100644 --- a/app/src/main/java/com/safebite/app/data/remote/api/OpenFoodFactsApi.kt +++ b/app/src/main/java/com/safebite/app/data/remote/api/OpenFoodFactsApi.kt @@ -7,7 +7,9 @@ import retrofit2.http.Path interface OpenFoodFactsApi { @GET("api/v2/product/{barcode}.json") - suspend fun getProduct(@Path("barcode") barcode: String): Response + suspend fun getProduct( + @Path("barcode") barcode: String, + ): Response companion object { const val BASE_URL = "https://world.openfoodfacts.org/" diff --git a/app/src/main/java/com/safebite/app/data/remote/dto/ProductDtos.kt b/app/src/main/java/com/safebite/app/data/remote/dto/ProductDtos.kt index 32737af..052b134 100644 --- a/app/src/main/java/com/safebite/app/data/remote/dto/ProductDtos.kt +++ b/app/src/main/java/com/safebite/app/data/remote/dto/ProductDtos.kt @@ -8,7 +8,7 @@ data class ProductResponse( @Json(name = "code") val code: String? = null, @Json(name = "status") val status: Int? = null, @Json(name = "status_verbose") val statusVerbose: String? = null, - @Json(name = "product") val product: ProductDto? = null + @Json(name = "product") val product: ProductDto? = null, ) @JsonClass(generateAdapter = true) @@ -30,7 +30,7 @@ data class ProductDto( @Json(name = "serving_size") val servingSize: String? = null, @Json(name = "labels_tags") val labelsTags: List? = null, @Json(name = "categories_tags") val categoriesTags: List? = null, - @Json(name = "nutriments") val nutriments: NutrimentsDto? = null + @Json(name = "nutriments") val nutriments: NutrimentsDto? = null, ) @JsonClass(generateAdapter = true) @@ -44,5 +44,5 @@ data class NutrimentsDto( @Json(name = "sodium_100g") val sodium100g: Double? = null, @Json(name = "fiber_100g") val fiber100g: Double? = null, @Json(name = "proteins_100g") val proteins100g: Double? = null, - @Json(name = "carbohydrates_100g") val carbohydrates100g: Double? = null + @Json(name = "carbohydrates_100g") val carbohydrates100g: Double? = null, ) diff --git a/app/src/main/java/com/safebite/app/data/remote/mapper/ProductMapper.kt b/app/src/main/java/com/safebite/app/data/remote/mapper/ProductMapper.kt index 098e4a3..3869773 100644 --- a/app/src/main/java/com/safebite/app/data/remote/mapper/ProductMapper.kt +++ b/app/src/main/java/com/safebite/app/data/remote/mapper/ProductMapper.kt @@ -6,82 +6,32 @@ import com.safebite.app.data.remote.dto.ProductDto import com.safebite.app.domain.model.Nutriments import com.safebite.app.domain.model.Product -fun ProductDto.toDomain(barcode: String): Product = Product( - barcode = barcode, - name = productNameFr?.takeIf { it.isNotBlank() } - ?: productNameEn?.takeIf { it.isNotBlank() } - ?: productName?.takeIf { it.isNotBlank() }, - brand = brands?.takeIf { it.isNotBlank() }, - imageUrl = imageFrontUrl ?: imageUrl, - ingredientsText = ingredientsTextFr?.takeIf { it.isNotBlank() } - ?: ingredientsTextEn?.takeIf { it.isNotBlank() } - ?: ingredientsText, - allergensTags = allergensTags.orEmpty(), - tracesTags = tracesTags.orEmpty(), - nutriScore = nutriScoreGrade, - novaGroup = novaGroup, - ecoScore = ecoScoreGrade, - servingSize = servingSize, - nutriments = nutriments?.toDomain() ?: Nutriments(), - labels = labelsTags.orEmpty(), - categories = categoriesTags.orEmpty() -) +fun ProductDto.toDomain(barcode: String): Product = + Product( + barcode = barcode, + name = + productNameFr?.takeIf { it.isNotBlank() } + ?: productNameEn?.takeIf { it.isNotBlank() } + ?: productName?.takeIf { it.isNotBlank() }, + brand = brands?.takeIf { it.isNotBlank() }, + imageUrl = imageFrontUrl ?: imageUrl, + ingredientsText = + ingredientsTextFr?.takeIf { it.isNotBlank() } + ?: ingredientsTextEn?.takeIf { it.isNotBlank() } + ?: ingredientsText, + allergensTags = allergensTags.orEmpty(), + tracesTags = tracesTags.orEmpty(), + nutriScore = nutriScoreGrade, + novaGroup = novaGroup, + ecoScore = ecoScoreGrade, + servingSize = servingSize, + nutriments = nutriments?.toDomain() ?: Nutriments(), + labels = labelsTags.orEmpty(), + categories = categoriesTags.orEmpty(), + ) -fun NutrimentsDto.toDomain(): Nutriments = Nutriments( - energyKcal100g = energyKcal100g, - energyKcalServing = energyKcalServing, - fat100g = fat100g, - saturatedFat100g = saturatedFat100g, - sugars100g = sugars100g, - salt100g = salt100g, - sodium100g = sodium100g, - fiber100g = fiber100g, - proteins100g = proteins100g, - carbohydrates100g = carbohydrates100g -) - -fun Product.toCacheEntity(): ProductCacheEntity = ProductCacheEntity( - barcode = barcode, - name = name, - brand = brand, - imageUrl = imageUrl, - ingredientsText = ingredientsText, - allergensTags = allergensTags, - tracesTags = tracesTags, - nutriScore = nutriScore, - novaGroup = novaGroup, - ecoScore = ecoScore, - servingSize = servingSize, - labels = labels, - categories = categories, - energyKcal100g = nutriments.energyKcal100g, - energyKcalServing = nutriments.energyKcalServing, - fat100g = nutriments.fat100g, - saturatedFat100g = nutriments.saturatedFat100g, - sugars100g = nutriments.sugars100g, - salt100g = nutriments.salt100g, - sodium100g = nutriments.sodium100g, - fiber100g = nutriments.fiber100g, - proteins100g = nutriments.proteins100g, - carbohydrates100g = nutriments.carbohydrates100g, - cachedAt = System.currentTimeMillis() -) - -fun ProductCacheEntity.toDomain(): Product = Product( - barcode = barcode, - name = name, - brand = brand, - imageUrl = imageUrl, - ingredientsText = ingredientsText, - allergensTags = allergensTags, - tracesTags = tracesTags, - nutriScore = nutriScore, - novaGroup = novaGroup, - ecoScore = ecoScore, - servingSize = servingSize, - labels = labels, - categories = categories, - nutriments = Nutriments( +fun NutrimentsDto.toDomain(): Nutriments = + Nutriments( energyKcal100g = energyKcal100g, energyKcalServing = energyKcalServing, fat100g = fat100g, @@ -91,6 +41,63 @@ fun ProductCacheEntity.toDomain(): Product = Product( sodium100g = sodium100g, fiber100g = fiber100g, proteins100g = proteins100g, - carbohydrates100g = carbohydrates100g + carbohydrates100g = carbohydrates100g, + ) + +fun Product.toCacheEntity(): ProductCacheEntity = + ProductCacheEntity( + barcode = barcode, + name = name, + brand = brand, + imageUrl = imageUrl, + ingredientsText = ingredientsText, + allergensTags = allergensTags, + tracesTags = tracesTags, + nutriScore = nutriScore, + novaGroup = novaGroup, + ecoScore = ecoScore, + servingSize = servingSize, + labels = labels, + categories = categories, + energyKcal100g = nutriments.energyKcal100g, + energyKcalServing = nutriments.energyKcalServing, + fat100g = nutriments.fat100g, + saturatedFat100g = nutriments.saturatedFat100g, + sugars100g = nutriments.sugars100g, + salt100g = nutriments.salt100g, + sodium100g = nutriments.sodium100g, + fiber100g = nutriments.fiber100g, + proteins100g = nutriments.proteins100g, + carbohydrates100g = nutriments.carbohydrates100g, + cachedAt = System.currentTimeMillis(), + ) + +fun ProductCacheEntity.toDomain(): Product = + Product( + barcode = barcode, + name = name, + brand = brand, + imageUrl = imageUrl, + ingredientsText = ingredientsText, + allergensTags = allergensTags, + tracesTags = tracesTags, + nutriScore = nutriScore, + novaGroup = novaGroup, + ecoScore = ecoScore, + servingSize = servingSize, + labels = labels, + categories = categories, + nutriments = + Nutriments( + energyKcal100g = energyKcal100g, + energyKcalServing = energyKcalServing, + fat100g = fat100g, + saturatedFat100g = saturatedFat100g, + sugars100g = sugars100g, + salt100g = salt100g, + sodium100g = sodium100g, + fiber100g = fiber100g, + proteins100g = proteins100g, + carbohydrates100g = carbohydrates100g, + ), ) -) diff --git a/app/src/main/java/com/safebite/app/data/repository/CatalogRepository.kt b/app/src/main/java/com/safebite/app/data/repository/CatalogRepository.kt index b390db2..0ad1fa0 100644 --- a/app/src/main/java/com/safebite/app/data/repository/CatalogRepository.kt +++ b/app/src/main/java/com/safebite/app/data/repository/CatalogRepository.kt @@ -18,65 +18,67 @@ import javax.inject.Singleton * aux écrans Catalogue, Catégories, Articles et Recherche. */ @Singleton -class CatalogRepository @Inject constructor( - private val dao: CatalogDao -) { +class CatalogRepository + @Inject + constructor( + private val dao: CatalogDao, + ) { + fun observeDomains(): Flow> = dao.getAllDomains() - fun observeDomains(): Flow> = dao.getAllDomains() + fun observeDomainsWithCategories(): Flow> = dao.getDomainsWithCategories() - fun observeDomainsWithCategories(): Flow> = - dao.getDomainsWithCategories() + fun observeDomainsWithCategoriesAndItems(): Flow> = dao.getDomainsWithCategoriesAndItems() - fun observeDomainsWithCategoriesAndItems(): Flow> = - dao.getDomainsWithCategoriesAndItems() + fun observeCategoriesForDomain(domainId: String): Flow> = dao.getCategoriesForDomain(domainId) - fun observeCategoriesForDomain(domainId: String): Flow> = - dao.getCategoriesForDomain(domainId) + fun observeCategoryWithItems(categoryId: String): Flow = dao.getCategoryWithItems(categoryId) - fun observeCategoryWithItems(categoryId: String): Flow = - dao.getCategoryWithItems(categoryId) + fun observeItemsForCategory(categoryId: String): Flow> = dao.getItemsForCategory(categoryId) - fun observeItemsForCategory(categoryId: String): Flow> = - dao.getItemsForCategory(categoryId) + fun observePopularItems(limit: Int = 15): Flow> = dao.getPopularItems(limit) - fun observePopularItems(limit: Int = 15): Flow> = - dao.getPopularItems(limit) + fun search( + query: String, + limit: Int = 20, + ): Flow> = dao.searchItems(query.trim(), limit) - fun search(query: String, limit: Int = 20): Flow> = - dao.searchItems(query.trim(), limit) + suspend fun getDomain(domainId: String): ShoppingDomainEntity? = dao.getDomainById(domainId) - suspend fun getDomain(domainId: String): ShoppingDomainEntity? = dao.getDomainById(domainId) - suspend fun getCategory(categoryId: String): CategoryEntity? = dao.getCategoryById(categoryId) - suspend fun getItem(itemId: String): CatalogItemEntity? = dao.getItemById(itemId) - suspend fun getItemByBarcode(barcode: String): CatalogItemEntity? = dao.getItemByBarcode(barcode) - suspend fun incrementPopularity(itemId: String) = dao.incrementPopularity(itemId) + suspend fun getCategory(categoryId: String): CategoryEntity? = dao.getCategoryById(categoryId) - /** - * Crée un article personnalisé (généré par l'utilisateur), persisté avec - * `isUserCreated = true` afin de pouvoir filtrer / exporter ces ajouts. - */ - suspend fun createUserItem( - name: String, - emoji: String, - primaryCategoryId: String?, - aliases: String = "", - tags: String = "" - ): CatalogItemEntity { - val item = CatalogItemEntity( - itemId = "user_${UUID.randomUUID()}", - name = name, - primaryCategoryId = primaryCategoryId, - emoji = emoji, - aliases = aliases, - tags = tags, - isUserCreated = true, - popularity = 0, - sortOrder = 0 - ) - dao.insertItem(item) - if (primaryCategoryId != null) { - dao.insertCrossRefs(listOf(ItemCategoryCrossRef(item.itemId, primaryCategoryId))) + suspend fun getItem(itemId: String): CatalogItemEntity? = dao.getItemById(itemId) + + suspend fun getItemByBarcode(barcode: String): CatalogItemEntity? = dao.getItemByBarcode(barcode) + + suspend fun incrementPopularity(itemId: String) = dao.incrementPopularity(itemId) + + /** + * Crée un article personnalisé (généré par l'utilisateur), persisté avec + * `isUserCreated = true` afin de pouvoir filtrer / exporter ces ajouts. + */ + suspend fun createUserItem( + name: String, + emoji: String, + primaryCategoryId: String?, + aliases: String = "", + tags: String = "", + ): CatalogItemEntity { + val item = + CatalogItemEntity( + itemId = "user_${UUID.randomUUID()}", + name = name, + primaryCategoryId = primaryCategoryId, + emoji = emoji, + aliases = aliases, + tags = tags, + isUserCreated = true, + popularity = 0, + sortOrder = 0, + ) + dao.insertItem(item) + if (primaryCategoryId != null) { + dao.insertCrossRefs(listOf(ItemCategoryCrossRef(item.itemId, primaryCategoryId))) + } + return item } - return item } -} diff --git a/app/src/main/java/com/safebite/app/data/repository/ProductRepositoryImpl.kt b/app/src/main/java/com/safebite/app/data/repository/ProductRepositoryImpl.kt index c2f6c98..41a1a42 100644 --- a/app/src/main/java/com/safebite/app/data/repository/ProductRepositoryImpl.kt +++ b/app/src/main/java/com/safebite/app/data/repository/ProductRepositoryImpl.kt @@ -16,67 +16,72 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class ProductRepositoryImpl @Inject constructor( - private val api: OpenFoodFactsApi, - private val cacheDao: ProductCacheDao, - private val connectivity: ConnectivityObserver -) : ProductRepository { +class ProductRepositoryImpl + @Inject + constructor( + private val api: OpenFoodFactsApi, + private val cacheDao: ProductCacheDao, + private val connectivity: ConnectivityObserver, + ) : ProductRepository { + override suspend fun fetchProduct(barcode: String): ProductFetchResult = + withContext(Dispatchers.IO) { + val cached = cacheDao.getByBarcode(barcode)?.toDomain() + val online = connectivity.isOnline() - override suspend fun fetchProduct(barcode: String): ProductFetchResult = withContext(Dispatchers.IO) { - val cached = cacheDao.getByBarcode(barcode)?.toDomain() - val online = connectivity.isOnline() + if (!online) { + return@withContext if (cached != null) { + ProductFetchResult.Found(cached, fromCache = true) + } else { + ProductFetchResult.Error("offline", offline = true) + } + } - if (!online) { - return@withContext if (cached != null) { - ProductFetchResult.Found(cached, fromCache = true) - } else { - ProductFetchResult.Error("offline", offline = true) + try { + val response = api.getProduct(barcode) + if (!response.isSuccessful) { + Timber.w("OFF returned HTTP ${response.code()} for $barcode") + return@withContext cached?.let { ProductFetchResult.Found(it, fromCache = true) } + ?: ProductFetchResult.Error("http_${response.code()}") + } + val body = response.body() + val status = body?.status ?: 0 + val dto = body?.product + if (status != 1 || dto == null) { + return@withContext ProductFetchResult.NotFound + } + val product = dto.toDomain(barcode) + cacheDao.upsert(product.toCacheEntity()) + ProductFetchResult.Found(product, fromCache = false) + } catch (io: IOException) { + Timber.w(io, "Network error fetching $barcode") + cached?.let { ProductFetchResult.Found(it, fromCache = true) } + ?: ProductFetchResult.Error(io.message ?: "network_error", offline = true) + } catch (t: Throwable) { + Timber.e(t, "Unexpected error fetching $barcode") + cached?.let { ProductFetchResult.Found(it, fromCache = true) } + ?: ProductFetchResult.Error(t.message ?: "unknown_error") + } } - } - try { - val response = api.getProduct(barcode) - if (!response.isSuccessful) { - Timber.w("OFF returned HTTP ${response.code()} for $barcode") - return@withContext cached?.let { ProductFetchResult.Found(it, fromCache = true) } - ?: ProductFetchResult.Error("http_${response.code()}") + override suspend fun cacheProduct(product: Product) = + withContext(Dispatchers.IO) { + cacheDao.upsert(product.toCacheEntity()) } - val body = response.body() - val status = body?.status ?: 0 - val dto = body?.product - if (status != 1 || dto == null) { - return@withContext ProductFetchResult.NotFound + + override suspend fun getCachedProduct(barcode: String): Product? = + withContext(Dispatchers.IO) { + cacheDao.getByBarcode(barcode)?.toDomain() } - val product = dto.toDomain(barcode) - cacheDao.upsert(product.toCacheEntity()) - ProductFetchResult.Found(product, fromCache = false) - } catch (io: IOException) { - Timber.w(io, "Network error fetching $barcode") - cached?.let { ProductFetchResult.Found(it, fromCache = true) } - ?: ProductFetchResult.Error(io.message ?: "network_error", offline = true) - } catch (t: Throwable) { - Timber.e(t, "Unexpected error fetching $barcode") - cached?.let { ProductFetchResult.Found(it, fromCache = true) } - ?: ProductFetchResult.Error(t.message ?: "unknown_error") - } - } - override suspend fun cacheProduct(product: Product) = withContext(Dispatchers.IO) { - cacheDao.upsert(product.toCacheEntity()) - } + override suspend fun clearCache() = withContext(Dispatchers.IO) { cacheDao.clear() } - override suspend fun getCachedProduct(barcode: String): Product? = withContext(Dispatchers.IO) { - cacheDao.getByBarcode(barcode)?.toDomain() + override suspend fun searchAlternatives( + category: String, + excludeAllergens: Set, + limit: Int, + ): List = + 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, - limit: Int - ): List = withContext(Dispatchers.IO) { - // TODO: Implémenter la recherche d'alternatives via l'API OFF - emptyList() - } -} diff --git a/app/src/main/java/com/safebite/app/data/repository/ScanHistoryRepositoryImpl.kt b/app/src/main/java/com/safebite/app/data/repository/ScanHistoryRepositoryImpl.kt index c865944..bd5e32a 100644 --- a/app/src/main/java/com/safebite/app/data/repository/ScanHistoryRepositoryImpl.kt +++ b/app/src/main/java/com/safebite/app/data/repository/ScanHistoryRepositoryImpl.kt @@ -13,45 +13,48 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class ScanHistoryRepositoryImpl @Inject constructor( - private val dao: ScanHistoryDao -) : ScanHistoryRepository { +class ScanHistoryRepositoryImpl + @Inject + constructor( + private val dao: ScanHistoryDao, + ) : ScanHistoryRepository { + override fun observeHistory(): Flow> = dao.observeAll().map { list -> list.map { it.toDomain() } } - override fun observeHistory(): Flow> = - dao.observeAll().map { list -> list.map { it.toDomain() } } + override suspend fun save(result: ScanResult): Long = + withContext(Dispatchers.IO) { + dao.insert( + ScanHistoryEntity( + barcode = result.product.barcode, + productName = result.product.name, + brand = result.product.brand, + imageUrl = result.product.imageUrl, + safetyStatus = result.safetyStatus, + profileNames = result.analyzedProfiles.map { it.name }, + scannedAt = System.currentTimeMillis(), + source = result.source, + ), + ) + } - override suspend fun save(result: ScanResult): Long = withContext(Dispatchers.IO) { - dao.insert( - ScanHistoryEntity( - barcode = result.product.barcode, - productName = result.product.name, - brand = result.product.brand, - imageUrl = result.product.imageUrl, - safetyStatus = result.safetyStatus, - profileNames = result.analyzedProfiles.map { it.name }, - scannedAt = System.currentTimeMillis(), - source = result.source - ) - ) + override suspend fun delete(id: Long) = withContext(Dispatchers.IO) { dao.deleteById(id) } + + override suspend fun clear() = withContext(Dispatchers.IO) { dao.clear() } + + override suspend fun getById(id: Long): ScanHistoryItem? = + withContext(Dispatchers.IO) { + dao.getById(id)?.toDomain() + } } - override suspend fun delete(id: Long) = withContext(Dispatchers.IO) { dao.deleteById(id) } - - override suspend fun clear() = withContext(Dispatchers.IO) { dao.clear() } - - override suspend fun getById(id: Long): ScanHistoryItem? = withContext(Dispatchers.IO) { - dao.getById(id)?.toDomain() - } -} - -private fun ScanHistoryEntity.toDomain() = ScanHistoryItem( - id = id, - barcode = barcode, - productName = productName, - brand = brand, - imageUrl = imageUrl, - safetyStatus = safetyStatus, - profileNames = profileNames, - scannedAt = scannedAt, - source = source -) +private fun ScanHistoryEntity.toDomain() = + ScanHistoryItem( + id = id, + barcode = barcode, + productName = productName, + brand = brand, + imageUrl = imageUrl, + safetyStatus = safetyStatus, + profileNames = profileNames, + scannedAt = scannedAt, + source = source, + ) diff --git a/app/src/main/java/com/safebite/app/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/safebite/app/data/repository/SettingsRepositoryImpl.kt index 98c74a1..20fc4da 100644 --- a/app/src/main/java/com/safebite/app/data/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/safebite/app/data/repository/SettingsRepositoryImpl.kt @@ -10,24 +10,33 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class SettingsRepositoryImpl @Inject constructor( - private val prefs: UserPreferences -) : SettingsRepository { - override val appLanguage = prefs.appLanguage - override val detectionLanguage = prefs.detectionLanguage - override val hapticsEnabled = prefs.haptics - override val soundEnabled = prefs.sound - override val theme = prefs.theme - override val onboardingCompleted = prefs.onboardingCompleted - override val healthStrictness = prefs.healthStrictness - override val splashScreenEnabled = prefs.splashScreenEnabled +class SettingsRepositoryImpl + @Inject + constructor( + private val prefs: UserPreferences, + ) : SettingsRepository { + override val appLanguage = prefs.appLanguage + override val detectionLanguage = prefs.detectionLanguage + override val hapticsEnabled = prefs.haptics + override val soundEnabled = prefs.sound + override val theme = prefs.theme + override val onboardingCompleted = prefs.onboardingCompleted + override val healthStrictness = prefs.healthStrictness + override val splashScreenEnabled = prefs.splashScreenEnabled - override suspend fun setAppLanguage(value: AppLanguage) = prefs.setAppLanguage(value) - override suspend fun setDetectionLanguage(value: DetectionLanguage) = prefs.setDetectionLanguage(value) - override suspend fun setHaptics(enabled: Boolean) = prefs.setHaptics(enabled) - override suspend fun setSound(enabled: Boolean) = prefs.setSound(enabled) - override suspend fun setTheme(value: ThemePref) = prefs.setTheme(value) - override suspend fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value) - override suspend fun setHealthStrictness(value: HealthStrictness) = prefs.setHealthStrictness(value) - override suspend fun setSplashScreenEnabled(enabled: Boolean) = prefs.setSplashScreenEnabled(enabled) -} + override suspend fun setAppLanguage(value: AppLanguage) = prefs.setAppLanguage(value) + + override suspend fun setDetectionLanguage(value: DetectionLanguage) = prefs.setDetectionLanguage(value) + + override suspend fun setHaptics(enabled: Boolean) = prefs.setHaptics(enabled) + + override suspend fun setSound(enabled: Boolean) = prefs.setSound(enabled) + + override suspend fun setTheme(value: ThemePref) = prefs.setTheme(value) + + override suspend fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value) + + override suspend fun setHealthStrictness(value: HealthStrictness) = prefs.setHealthStrictness(value) + + override suspend fun setSplashScreenEnabled(enabled: Boolean) = prefs.setSplashScreenEnabled(enabled) + } diff --git a/app/src/main/java/com/safebite/app/data/repository/ShoppingListRepositoryImpl.kt b/app/src/main/java/com/safebite/app/data/repository/ShoppingListRepositoryImpl.kt index 3bc4a81..ce55347 100644 --- a/app/src/main/java/com/safebite/app/data/repository/ShoppingListRepositoryImpl.kt +++ b/app/src/main/java/com/safebite/app/data/repository/ShoppingListRepositoryImpl.kt @@ -10,95 +10,96 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class ShoppingListRepositoryImpl @Inject constructor( - private val dao: ShoppingListDao -) : ShoppingListRepository { +class ShoppingListRepositoryImpl + @Inject + constructor( + private val dao: ShoppingListDao, + ) : ShoppingListRepository { + override fun observeActiveLists(): Flow> = dao.observeActiveLists() - override fun observeActiveLists(): Flow> = - dao.observeActiveLists() + override fun observeAllLists(): Flow> = dao.observeAllLists() - override fun observeAllLists(): Flow> = - dao.observeAllLists() + override suspend fun getListById(id: Long): ShoppingListEntity? = dao.getListById(id) - override suspend fun getListById(id: Long): ShoppingListEntity? = - dao.getListById(id) + override suspend fun createList( + name: String, + backgroundResName: String?, + ): Long { + val list = + ShoppingListEntity( + name = name, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis(), + backgroundResName = backgroundResName, + ) + return dao.insertList(list) + } - override suspend fun createList(name: String, backgroundResName: String?): Long { - val list = ShoppingListEntity( - name = name, - createdAt = System.currentTimeMillis(), - updatedAt = System.currentTimeMillis(), - backgroundResName = backgroundResName - ) - return dao.insertList(list) + override suspend fun updateList(list: ShoppingListEntity) { + dao.updateList(list.copy(updatedAt = System.currentTimeMillis())) + } + + override suspend fun deleteList(list: ShoppingListEntity) { + dao.deleteList(list) + } + + override suspend fun archiveList(id: Long) { + dao.archiveList(id) + } + + override fun observeItems(listId: Long): Flow> = dao.observeItems(listId) + + override suspend fun getItems(listId: Long): List = 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 = dao.observeItemCount(listId) + + override fun observeCheckedCount(listId: Long): Flow = dao.observeCheckedCount(listId) + + override suspend fun addItemToList( + listId: Long, + item: ShoppingListItemEntity, + ) { + dao.addItemToList(listId, item) + } + + override fun observeMembers(listId: Long): Flow> = 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> = - dao.observeItems(listId) - - override suspend fun getItems(listId: Long): List = - 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 = - dao.observeItemCount(listId) - - override fun observeCheckedCount(listId: Long): Flow = - dao.observeCheckedCount(listId) - - override suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) { - dao.addItemToList(listId, item) - } - - override fun observeMembers(listId: Long): Flow> = - 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) - } -} diff --git a/app/src/main/java/com/safebite/app/data/repository/UserProfileRepositoryImpl.kt b/app/src/main/java/com/safebite/app/data/repository/UserProfileRepositoryImpl.kt index b21291a..3147642 100644 --- a/app/src/main/java/com/safebite/app/data/repository/UserProfileRepositoryImpl.kt +++ b/app/src/main/java/com/safebite/app/data/repository/UserProfileRepositoryImpl.kt @@ -13,58 +13,68 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class UserProfileRepositoryImpl @Inject constructor( - private val dao: UserProfileDao, - private val prefs: UserPreferences -) : UserProfileRepository { +class UserProfileRepositoryImpl + @Inject + constructor( + private val dao: UserProfileDao, + private val prefs: UserPreferences, + ) : UserProfileRepository { + override fun observeProfiles(): Flow> = dao.observeAll().map { list -> list.map { it.toDomain() } } - override fun observeProfiles(): Flow> = - dao.observeAll().map { list -> list.map { it.toDomain() } } + override suspend fun getProfile(id: Long): UserProfile? = + withContext(Dispatchers.IO) { + dao.getById(id)?.toDomain() + } - override suspend fun getProfile(id: Long): UserProfile? = withContext(Dispatchers.IO) { - dao.getById(id)?.toDomain() - } + override suspend fun upsert(profile: UserProfile): Long = + withContext(Dispatchers.IO) { + val entity = profile.toEntity() + if (profile.id == 0L) { + dao.insert(entity) + } else { + dao.update(entity) + profile.id + } + } - override suspend fun upsert(profile: UserProfile): Long = withContext(Dispatchers.IO) { - val entity = profile.toEntity() - if (profile.id == 0L) dao.insert(entity) else { - dao.update(entity) - profile.id + override suspend fun delete(profile: UserProfile) = + withContext(Dispatchers.IO) { + dao.delete(profile.toEntity()) + } + + override suspend fun setDefault(id: Long) = + withContext(Dispatchers.IO) { + dao.clearDefault() + dao.markDefault(id) + } + + override fun observeActiveProfileIds(): Flow> = prefs.activeProfileIds + + override suspend fun setActiveProfileIds(ids: Set) { + prefs.setActiveProfileIds(ids) } } - override suspend fun delete(profile: UserProfile) = withContext(Dispatchers.IO) { - dao.delete(profile.toEntity()) - } +private fun UserProfileEntity.toDomain(): UserProfile = + UserProfile( + id = id, + name = name, + avatar = avatar, + severeAllergens = severeAllergens, + moderateIntolerances = moderateIntolerances, + dietaryRestrictions = dietaryRestrictions, + customItems = customItems, + isDefault = isDefault, + ) - override suspend fun setDefault(id: Long) = withContext(Dispatchers.IO) { - dao.clearDefault() - dao.markDefault(id) - } - - override fun observeActiveProfileIds(): Flow> = prefs.activeProfileIds - - override suspend fun setActiveProfileIds(ids: Set) { prefs.setActiveProfileIds(ids) } -} - -private fun UserProfileEntity.toDomain(): UserProfile = UserProfile( - id = id, - name = name, - avatar = avatar, - severeAllergens = severeAllergens, - moderateIntolerances = moderateIntolerances, - dietaryRestrictions = dietaryRestrictions, - customItems = customItems, - isDefault = isDefault -) - -private fun UserProfile.toEntity(): UserProfileEntity = UserProfileEntity( - id = id, - name = name, - avatar = avatar, - severeAllergens = severeAllergens, - moderateIntolerances = moderateIntolerances, - dietaryRestrictions = dietaryRestrictions, - customItems = customItems, - isDefault = isDefault -) +private fun UserProfile.toEntity(): UserProfileEntity = + UserProfileEntity( + id = id, + name = name, + avatar = avatar, + severeAllergens = severeAllergens, + moderateIntolerances = moderateIntolerances, + dietaryRestrictions = dietaryRestrictions, + customItems = customItems, + isDefault = isDefault, + ) diff --git a/app/src/main/java/com/safebite/app/data/util/ConnectivityObserver.kt b/app/src/main/java/com/safebite/app/data/util/ConnectivityObserver.kt index 158024a..3172a55 100644 --- a/app/src/main/java/com/safebite/app/data/util/ConnectivityObserver.kt +++ b/app/src/main/java/com/safebite/app/data/util/ConnectivityObserver.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged class ConnectivityObserver(private val context: Context) { - fun isOnline(): Boolean { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false @@ -19,18 +18,29 @@ class ConnectivityObserver(private val context: Context) { caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } - fun observe(): Flow = callbackFlow { - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val callback = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { trySend(true) } - override fun onLost(network: Network) { trySend(false) } - override fun onUnavailable() { trySend(false) } - } - val request = NetworkRequest.Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .build() - cm.registerNetworkCallback(request, callback) - trySend(isOnline()) - awaitClose { cm.unregisterNetworkCallback(callback) } - }.distinctUntilChanged() + fun observe(): Flow = + callbackFlow { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val callback = + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(true) + } + + override fun onLost(network: Network) { + trySend(false) + } + + override fun onUnavailable() { + trySend(false) + } + } + val request = + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + cm.registerNetworkCallback(request, callback) + trySend(isOnline()) + awaitClose { cm.unregisterNetworkCallback(callback) } + }.distinctUntilChanged() } diff --git a/app/src/main/java/com/safebite/app/di/AppModule.kt b/app/src/main/java/com/safebite/app/di/AppModule.kt index 34fb7e0..d1b1246 100644 --- a/app/src/main/java/com/safebite/app/di/AppModule.kt +++ b/app/src/main/java/com/safebite/app/di/AppModule.kt @@ -16,18 +16,19 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object AppModule { - @Provides @Singleton fun provideMoshi(): Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() @Provides @Singleton - fun provideUserPreferences(@ApplicationContext context: Context): UserPreferences = - UserPreferences(context.safeBiteDataStore) + fun provideUserPreferences( + @ApplicationContext context: Context, + ): UserPreferences = UserPreferences(context.safeBiteDataStore) @Provides @Singleton - fun provideConnectivity(@ApplicationContext context: Context): ConnectivityObserver = - ConnectivityObserver(context) + fun provideConnectivity( + @ApplicationContext context: Context, + ): ConnectivityObserver = ConnectivityObserver(context) } diff --git a/app/src/main/java/com/safebite/app/di/DatabaseModule.kt b/app/src/main/java/com/safebite/app/di/DatabaseModule.kt index ee9e176..a4cecd6 100644 --- a/app/src/main/java/com/safebite/app/di/DatabaseModule.kt +++ b/app/src/main/java/com/safebite/app/di/DatabaseModule.kt @@ -20,18 +20,23 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object DatabaseModule { - @Provides @Singleton - fun provideDatabase(@ApplicationContext context: Context): SafeBiteDatabase = + fun provideDatabase( + @ApplicationContext context: Context, + ): SafeBiteDatabase = Room.databaseBuilder(context, SafeBiteDatabase::class.java, SafeBiteDatabase.NAME) .addMigrations(MIGRATION_7_8, MIGRATION_8_9) .fallbackToDestructiveMigration() .build() @Provides fun provideUserProfileDao(db: SafeBiteDatabase): UserProfileDao = db.userProfileDao() + @Provides fun provideProductCacheDao(db: SafeBiteDatabase): ProductCacheDao = db.productCacheDao() + @Provides fun provideScanHistoryDao(db: SafeBiteDatabase): ScanHistoryDao = db.scanHistoryDao() + @Provides fun provideShoppingListDao(db: SafeBiteDatabase): ShoppingListDao = db.shoppingListDao() + @Provides fun provideCatalogDao(db: SafeBiteDatabase): CatalogDao = db.catalogDao() } diff --git a/app/src/main/java/com/safebite/app/di/EngineModule.kt b/app/src/main/java/com/safebite/app/di/EngineModule.kt index 1cb2189..7ef399d 100644 --- a/app/src/main/java/com/safebite/app/di/EngineModule.kt +++ b/app/src/main/java/com/safebite/app/di/EngineModule.kt @@ -10,7 +10,6 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object EngineModule { - @Provides @Singleton fun provideCategoryEngine(): CategoryEngine = CategoryEngine() diff --git a/app/src/main/java/com/safebite/app/di/NetworkModule.kt b/app/src/main/java/com/safebite/app/di/NetworkModule.kt index 9bbb01b..5eb81e8 100644 --- a/app/src/main/java/com/safebite/app/di/NetworkModule.kt +++ b/app/src/main/java/com/safebite/app/di/NetworkModule.kt @@ -17,17 +17,17 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object NetworkModule { - private const val USER_AGENT = "SafeBite/1.0 (Android; contact@safebite.app)" @Provides @Singleton fun provideOkHttp(): OkHttpClient { val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } - val uaInterceptor = Interceptor { chain -> - val req = chain.request().newBuilder().header("User-Agent", USER_AGENT).build() - chain.proceed(req) - } + val uaInterceptor = + Interceptor { chain -> + val req = chain.request().newBuilder().header("User-Agent", USER_AGENT).build() + chain.proceed(req) + } return OkHttpClient.Builder() .addInterceptor(uaInterceptor) .addInterceptor(logging) @@ -39,7 +39,10 @@ object NetworkModule { @Provides @Singleton - fun provideRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit = + fun provideRetrofit( + client: OkHttpClient, + moshi: Moshi, + ): Retrofit = Retrofit.Builder() .baseUrl(OpenFoodFactsApi.BASE_URL) .client(client) @@ -48,6 +51,5 @@ object NetworkModule { @Provides @Singleton - fun provideOpenFoodFactsApi(retrofit: Retrofit): OpenFoodFactsApi = - retrofit.create(OpenFoodFactsApi::class.java) + fun provideOpenFoodFactsApi(retrofit: Retrofit): OpenFoodFactsApi = retrofit.create(OpenFoodFactsApi::class.java) } diff --git a/app/src/main/java/com/safebite/app/di/RepositoryModule.kt b/app/src/main/java/com/safebite/app/di/RepositoryModule.kt index 9e54fe3..f40c7a1 100644 --- a/app/src/main/java/com/safebite/app/di/RepositoryModule.kt +++ b/app/src/main/java/com/safebite/app/di/RepositoryModule.kt @@ -19,7 +19,6 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { - @Binds @Singleton abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository diff --git a/app/src/main/java/com/safebite/app/domain/engine/AllergenAnalysisEngine.kt b/app/src/main/java/com/safebite/app/domain/engine/AllergenAnalysisEngine.kt index faf01aa..00c66f2 100644 --- a/app/src/main/java/com/safebite/app/domain/engine/AllergenAnalysisEngine.kt +++ b/app/src/main/java/com/safebite/app/domain/engine/AllergenAnalysisEngine.kt @@ -9,8 +9,6 @@ import com.safebite.app.domain.model.DetectedAllergen import com.safebite.app.domain.model.DetectedCustomItem import com.safebite.app.domain.model.DetectionLanguage import com.safebite.app.domain.model.DetectionLevel -import com.safebite.app.domain.model.HealthAssessment -import com.safebite.app.domain.model.HealthRating import com.safebite.app.domain.model.HealthStrictness import com.safebite.app.domain.model.Product import com.safebite.app.domain.model.SafetyStatus @@ -27,16 +25,22 @@ import java.text.Normalizer * 3. "May contain / traces" pattern extraction from the ingredients text. */ object AllergenAnalysisEngine { - /** Regexes for "may contain" disclosures, in French and English. */ - private val MAY_CONTAIN_PATTERNS = listOf( - Regex("peut contenir(?:\\s+des\\s+traces\\s+de)?\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE), - Regex("traces?\\s+possibles?\\s+de\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE), - Regex("fabriqué\\s+dans\\s+un\\s+(?:atelier|environnement|établissement)\\s+(?:contenant|utilisant|qui\\s+utilise)[^.]{1,200}", RegexOption.IGNORE_CASE), - Regex("may\\s+contain\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE), - Regex("manufactured\\s+in\\s+a\\s+facility\\s+that\\s+(?:processes|also\\s+processes|handles)[^.]{1,200}", RegexOption.IGNORE_CASE), - Regex("produced\\s+in\\s+a\\s+plant\\s+that\\s+also\\s+handles[^.]{1,200}", RegexOption.IGNORE_CASE) - ) + private val MAY_CONTAIN_PATTERNS = + listOf( + Regex("peut contenir(?:\\s+des\\s+traces\\s+de)?\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE), + Regex("traces?\\s+possibles?\\s+de\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE), + Regex( + "fabriqué\\s+dans\\s+un\\s+(?:atelier|environnement|établissement)\\s+(?:contenant|utilisant|qui\\s+utilise)[^.]{1,200}", + RegexOption.IGNORE_CASE, + ), + Regex("may\\s+contain\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE), + Regex( + "manufactured\\s+in\\s+a\\s+facility\\s+that\\s+(?:processes|also\\s+processes|handles)[^.]{1,200}", + RegexOption.IGNORE_CASE, + ), + Regex("produced\\s+in\\s+a\\s+plant\\s+that\\s+also\\s+handles[^.]{1,200}", RegexOption.IGNORE_CASE), + ) /** * Analyze a product against the given profiles. @@ -48,7 +52,7 @@ object AllergenAnalysisEngine { profiles: List, source: DataSource, language: DetectionLanguage = DetectionLanguage.BOTH, - healthStrictness: HealthStrictness = HealthStrictness.NORMAL + healthStrictness: HealthStrictness = HealthStrictness.NORMAL, ): ScanResult { if (profiles.isEmpty()) { return ScanResult( @@ -59,7 +63,7 @@ object AllergenAnalysisEngine { health = HealthClassifier.classify(product, emptyList(), healthStrictness), analyzedProfiles = emptyList(), confidence = AnalysisConfidence.LOW, - source = source + source = source, ) } @@ -73,26 +77,28 @@ object AllergenAnalysisEngine { for (allergen in watched) { val tagHits = matchTags(product.allergensTags, allergen.openFoodFactsTags) if (tagHits.isNotEmpty()) { - detections[allergen] = DetectedAllergen( - allergenType = allergen, - detectionLevel = DetectionLevel.CONFIRMED, - matchedKeywords = tagHits, - source = "API allergens_tags", - profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id }, - severe = allergen in severeSet - ) + detections[allergen] = + DetectedAllergen( + allergenType = allergen, + detectionLevel = DetectionLevel.CONFIRMED, + matchedKeywords = tagHits, + source = "API allergens_tags", + profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id }, + severe = allergen in severeSet, + ) } val traceTagHits = matchTags(product.tracesTags, allergen.openFoodFactsTags) if (traceTagHits.isNotEmpty()) { detections.compute(allergen) { _, existing -> - val hit = DetectedAllergen( - allergenType = allergen, - detectionLevel = DetectionLevel.TRACE, - matchedKeywords = traceTagHits, - source = "API traces_tags", - profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id }, - severe = allergen in severeSet - ) + val hit = + DetectedAllergen( + allergenType = allergen, + detectionLevel = DetectionLevel.TRACE, + matchedKeywords = traceTagHits, + source = "API traces_tags", + profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id }, + severe = allergen in severeSet, + ) merge(existing, hit) } } @@ -110,32 +116,35 @@ object AllergenAnalysisEngine { val ingMatches = findKeywordMatches(ingredientsOnly, keywords) if (ingMatches.isNotEmpty()) { - val level = if (detections[allergen]?.detectionLevel == DetectionLevel.CONFIRMED) { - DetectionLevel.CONFIRMED - } else { - DetectionLevel.SUSPECTED - } - val hit = DetectedAllergen( - allergenType = allergen, - detectionLevel = level, - matchedKeywords = ingMatches, - source = "Ingredients text", - profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id }, - severe = allergen in severeSet - ) + val level = + if (detections[allergen]?.detectionLevel == DetectionLevel.CONFIRMED) { + DetectionLevel.CONFIRMED + } else { + DetectionLevel.SUSPECTED + } + val hit = + DetectedAllergen( + allergenType = allergen, + detectionLevel = level, + matchedKeywords = ingMatches, + source = "Ingredients text", + profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id }, + severe = allergen in severeSet, + ) detections.compute(allergen) { _, existing -> merge(existing, hit) } } val traceMatches = traceRegions.flatMap { region -> findKeywordMatches(region, keywords) } if (traceMatches.isNotEmpty()) { - val hit = DetectedAllergen( - allergenType = allergen, - detectionLevel = DetectionLevel.TRACE, - matchedKeywords = traceMatches.distinct(), - source = "May-contain mention", - profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id }, - severe = allergen in severeSet - ) + val hit = + DetectedAllergen( + allergenType = allergen, + detectionLevel = DetectionLevel.TRACE, + matchedKeywords = traceMatches.distinct(), + source = "May-contain mention", + profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id }, + severe = allergen in severeSet, + ) detections.compute(allergen) { _, existing -> merge(existing, hit) } } } @@ -144,41 +153,44 @@ object AllergenAnalysisEngine { val detected = detections.values.toList() // Custom items: per profile, scan against ingredients text + OFF tags / labels / categories. - val searchable = buildString { - append(normalizedIngredients) - append(' ') - append(product.allergensTags.joinToString(" ") { normalize(it) }) - append(' ') - append(product.tracesTags.joinToString(" ") { normalize(it) }) - append(' ') - append(product.labels.joinToString(" ") { normalize(it) }) - append(' ') - append(product.categories.joinToString(" ") { normalize(it) }) - append(' ') - append(normalize(product.name.orEmpty())) - } + val searchable = + buildString { + append(normalizedIngredients) + append(' ') + append(product.allergensTags.joinToString(" ") { normalize(it) }) + append(' ') + append(product.tracesTags.joinToString(" ") { normalize(it) }) + append(' ') + append(product.labels.joinToString(" ") { normalize(it) }) + append(' ') + append(product.categories.joinToString(" ") { normalize(it) }) + append(' ') + append(normalize(product.name.orEmpty())) + } val customDetections = detectCustomItems(searchable, profiles) val status = computeStatus(detected, severeSet, customDetections) val confidence = computeConfidence(product, source, hasAnyData = detected.isNotEmpty() || normalizedIngredients.isNotBlank()) - val unhealthyCustomNames = customDetections - .filter { it.item.tag == CustomItemTag.UNHEALTHY } - .map { it.item.name } + val unhealthyCustomNames = + customDetections + .filter { it.item.tag == CustomItemTag.UNHEALTHY } + .map { it.item.name } val health = HealthClassifier.classify(product, unhealthyCustomNames, healthStrictness) return ScanResult( product = product, safetyStatus = status, - detectedAllergens = detected.sortedWith( - compareByDescending { it.detectionLevel.ordinal == DetectionLevel.CONFIRMED.ordinal } - .thenByDescending { it.severe } - ), + detectedAllergens = + detected.sortedWith( + compareByDescending { it.detectionLevel.ordinal == DetectionLevel.CONFIRMED.ordinal } + .thenByDescending { it.severe }, + ), detectedCustomItems = customDetections, health = health, analyzedProfiles = profiles, confidence = confidence, - source = source + source = source, ) } @@ -186,7 +198,10 @@ object AllergenAnalysisEngine { * Match each profile's custom items against the pre-normalized [searchable] text * (ingredients + tags + labels + categories + product name). */ - private fun detectCustomItems(searchable: String, profiles: List): List { + private fun detectCustomItems( + searchable: String, + profiles: List, + ): List { if (searchable.isBlank()) return emptyList() // Group by (name, tag) so the same item across two profiles becomes a single detection // with the union of profile IDs. @@ -207,8 +222,8 @@ object AllergenAnalysisEngine { DetectedCustomItem( item = first, matchedKeywords = hits, - profileIds = pairs.map { it.first.id }.distinct() - ) + profileIds = pairs.map { it.first.id }.distinct(), + ), ) } } @@ -220,9 +235,10 @@ object AllergenAnalysisEngine { /** Lower-case, strip accents, expand common ligatures, collapse punctuation. */ fun normalize(raw: String): String { if (raw.isBlank()) return "" - val lowered = raw.lowercase() - .replace("œ", "oe") - .replace("æ", "ae") + val lowered = + raw.lowercase() + .replace("œ", "oe") + .replace("æ", "ae") val decomposed = Normalizer.normalize(lowered, Normalizer.Form.NFD) val withoutAccents = decomposed.replace(Regex("\\p{Mn}+"), "") // Replace various apostrophe/quote styles with a space, normalize whitespace. @@ -233,14 +249,20 @@ object AllergenAnalysisEngine { .trim() } - private fun keywordsFor(allergen: AllergenType, language: DetectionLanguage): List = + private fun keywordsFor( + allergen: AllergenType, + language: DetectionLanguage, + ): List = when (language) { DetectionLanguage.FR -> allergen.keywordsFr DetectionLanguage.EN -> allergen.keywordsEn DetectionLanguage.BOTH -> allergen.keywordsFr + allergen.keywordsEn }.map { normalize(it) }.filter { it.isNotBlank() }.distinct() - private fun matchTags(productTags: List, allergenTags: List): List { + private fun matchTags( + productTags: List, + allergenTags: List, + ): List { if (productTags.isEmpty()) return emptyList() val lowered = productTags.map { it.lowercase() } return allergenTags.filter { tag -> lowered.any { it == tag.lowercase() } } @@ -250,7 +272,10 @@ object AllergenAnalysisEngine { * Word-boundary aware keyword matcher. Handles plurals by also matching the keyword * followed by an "s". Supports multi-word keywords. */ - private fun findKeywordMatches(normalized: String, keywords: List): List { + private fun findKeywordMatches( + normalized: String, + keywords: List, + ): List { if (normalized.isBlank()) return emptyList() val hits = mutableListOf() for (kw in keywords) { @@ -273,7 +298,10 @@ object AllergenAnalysisEngine { } } - private fun stripRegions(text: String, regions: List): String { + private fun stripRegions( + text: String, + regions: List, + ): String { var result = text for (region in regions) { result = result.replace(region, " ") @@ -283,7 +311,10 @@ object AllergenAnalysisEngine { // endregion - private fun merge(existing: DetectedAllergen?, incoming: DetectedAllergen): DetectedAllergen { + private fun merge( + existing: DetectedAllergen?, + incoming: DetectedAllergen, + ): DetectedAllergen { if (existing == null) return incoming // Keep the most severe detection level: CONFIRMED > TRACE > SUSPECTED. val priority: (DetectionLevel) -> Int = { @@ -297,28 +328,34 @@ object AllergenAnalysisEngine { return best.copy( matchedKeywords = (existing.matchedKeywords + incoming.matchedKeywords).distinct(), source = if (best == existing) existing.source else incoming.source, - profileIds = (existing.profileIds + incoming.profileIds).distinct() + profileIds = (existing.profileIds + incoming.profileIds).distinct(), ) } private fun computeStatus( detected: List, severeSet: Set, - customDetections: List + customDetections: List, ): SafetyStatus { - val hasSevereConfirmed = detected.any { - it.detectionLevel != DetectionLevel.TRACE && it.allergenType in severeSet - } + val hasSevereConfirmed = + detected.any { + it.detectionLevel != DetectionLevel.TRACE && it.allergenType in severeSet + } val customHasAllergy = customDetections.any { it.item.tag == CustomItemTag.ALLERGY } if (hasSevereConfirmed || customHasAllergy) return SafetyStatus.DANGER - val customTriggersWarning = customDetections.any { - it.item.tag == CustomItemTag.INTOLERANCE || it.item.tag == CustomItemTag.DIET - } + val customTriggersWarning = + customDetections.any { + it.item.tag == CustomItemTag.INTOLERANCE || it.item.tag == CustomItemTag.DIET + } if (detected.isEmpty() && !customTriggersWarning) return SafetyStatus.SAFE return SafetyStatus.WARNING } - private fun computeConfidence(product: Product, source: DataSource, hasAnyData: Boolean): AnalysisConfidence { + private fun computeConfidence( + product: Product, + source: DataSource, + hasAnyData: Boolean, + ): AnalysisConfidence { return when (source) { DataSource.OCR -> AnalysisConfidence.LOW DataSource.API, DataSource.CACHE -> { diff --git a/app/src/main/java/com/safebite/app/domain/engine/CatalogProvider.kt b/app/src/main/java/com/safebite/app/domain/engine/CatalogProvider.kt index a3325b0..0c8bb9b 100644 --- a/app/src/main/java/com/safebite/app/domain/engine/CatalogProvider.kt +++ b/app/src/main/java/com/safebite/app/domain/engine/CatalogProvider.kt @@ -13,1636 +13,1749 @@ import javax.inject.Singleton * cohérence de la classification automatique. */ @Singleton -class CatalogProvider @Inject constructor() { +class CatalogProvider + @Inject + constructor() { + data class CatalogItem( + val name: String, + val category: String, + val emoji: String, + val aliases: List = emptyList(), + val variants: List = emptyList(), + ) { + fun matches(query: String): Boolean { + val q = query.trim().lowercase() + if (q.isEmpty()) return true + if (name.lowercase().contains(q)) return true + return aliases.any { it.lowercase().contains(q) } + } + } - data class CatalogItem( - val name: String, - val category: String, - val emoji: String, - val aliases: List = emptyList(), - val variants: List = emptyList() - ) { - fun matches(query: String): Boolean { + /** Toutes les sections catalogue, dans l'ordre d'affichage. */ + val categories: List = + listOf( + "Fruits & Légumes", + "Boulangerie", + "Produits laitiers", + "Boucherie", + "Épicerie", + "Condiments & Épices", + "Surgelés", + "Snacks & Bonbons", + "Boissons", + "Hygiène", + "Entretien", + "Bébé", + "Animaux", + "Maison & Jardin", + ) + + /** Liste plate du catalogue. */ + val items: List = + buildList { + // Fruits & Légumes + add( + CatalogItem( + "Pomme", + "Fruits & Légumes", + "🍎", + listOf("apple", "apples", "pommes"), + listOf("Gala", "Cortland", "Honeycrisp", "Granny Smith", "Fuji", "McIntosh"), + ), + ) + add(CatalogItem("Banane", "Fruits & Légumes", "🍌", listOf("banana", "bananas"))) + add(CatalogItem("Orange", "Fruits & Légumes", "🍊", listOf("oranges"))) + add(CatalogItem("Citron", "Fruits & Légumes", "🍋", listOf("lemon"))) + add(CatalogItem("Fraise", "Fruits & Légumes", "🍓", listOf("strawberry", "strawberries"))) + add(CatalogItem("Raisin", "Fruits & Légumes", "🍇", listOf("grapes"), listOf("Vert", "Rouge", "Sans pépins"))) + add(CatalogItem("Poire", "Fruits & Légumes", "🍐", listOf("pear", "pears", "poires"))) + add( + CatalogItem( + "Tomate", + "Fruits & Légumes", + "🍅", + listOf("tomatoes"), + listOf("Baby", "Cherry", "Diced", "Roma", "Sundried"), + ), + ) + add(CatalogItem("Salade", "Fruits & Légumes", "🥬", listOf("lettuce", "salad"))) + add(CatalogItem("Carotte", "Fruits & Légumes", "🥕", listOf("carrots", "carrotte"), listOf("Baby", "Râpée", "Bio"))) + add(CatalogItem("Brocoli", "Fruits & Légumes", "🥦", listOf("broccoli"))) + add(CatalogItem("Concombre", "Fruits & Légumes", "🥒", listOf("cucumber"))) + add( + CatalogItem( + "Poivron", + "Fruits & Légumes", + "🫑", + listOf("bell pepper", "bell peppers"), + listOf("Rouge", "Vert", "Jaune", "Orange"), + ), + ) + add(CatalogItem("Avocat", "Fruits & Légumes", "🥑", listOf("avocado"))) + add( + CatalogItem( + "Oignon", + "Fruits & Légumes", + "🧅", + listOf("onions"), + listOf("Blanc", "Rouge", "Vert", "Espagnol", "Vidalia"), + ), + ) + add(CatalogItem("Ail", "Fruits & Légumes", "🧄", listOf("garlic"))) + add( + CatalogItem( + "Pomme de terre", + "Fruits & Légumes", + "🥔", + listOf("patate", "patates", "potatoes"), + listOf("Régulière", "Grelot", "Douce", "Russet", "Yukon Gold"), + ), + ) + add( + CatalogItem( + "Champignon", + "Fruits & Légumes", + "🍄", + listOf("mushrooms", "champignons"), + listOf("Blanc", "Portobello", "Shiitake", "Crimini"), + ), + ) + add(CatalogItem("Épinard", "Fruits & Légumes", "🥬", listOf("spinach"))) + add(CatalogItem("Ananas", "Fruits & Légumes", "🍍", listOf("pineapple"))) + add(CatalogItem("Pêche", "Fruits & Légumes", "🍑", listOf("peach"))) + add(CatalogItem("Cerise", "Fruits & Légumes", "🍒", listOf("cherries"))) + add(CatalogItem("Kiwi", "Fruits & Légumes", "🥝", listOf("kiwi fruit", "kiwis"))) + add(CatalogItem("Mangue", "Fruits & Légumes", "🥭", listOf("mangoes"))) + add(CatalogItem("Melon", "Fruits & Légumes", "🍈", listOf("melon"))) + add(CatalogItem("Pastèque", "Fruits & Légumes", "🍉", listOf("watermelon"))) + add(CatalogItem("Noix de coco", "Fruits & Légumes", "🥥", listOf("coconut"))) + add(CatalogItem("Aubergine", "Fruits & Légumes", "🍆", listOf("eggplant"))) + add(CatalogItem("Maïs", "Fruits & Légumes", "🌽", listOf("sweet corn", "corncobs", "corn cobs"))) + add( + CatalogItem( + "Piment", + "Fruits & Légumes", + "🌶️", + listOf("chillies", "chili", "piment jaune"), + listOf("Jalapeño", "Serrano", "Habanero", "Chipotle"), + ), + ) + add(CatalogItem("Courgette", "Fruits & Légumes", "🥒", listOf("zucchini"))) + add(CatalogItem("Chou-fleur", "Fruits & Légumes", "🥦", listOf("cauliflower"))) + add(CatalogItem("Chou", "Fruits & Légumes", "🥬", listOf("cabbage"))) + add(CatalogItem("Navet", "Fruits & Légumes", "🥕", listOf("turnip"))) + add(CatalogItem("Radis", "Fruits & Légumes", "🥕", listOf("radish"))) + add(CatalogItem("Poireau", "Fruits & Légumes", "🥬", listOf("leek", "leeks"))) + add(CatalogItem("Céleri", "Fruits & Légumes", "🥬", listOf("celery"))) + add(CatalogItem("Haricots verts", "Fruits & Légumes", "🫛", listOf("green beans"))) + add(CatalogItem("Petits pois", "Fruits & Légumes", "🫛", listOf("peas"))) + add(CatalogItem("Basilic", "Fruits & Légumes", "🌿", listOf("basil"))) + add(CatalogItem("Persil", "Fruits & Légumes", "🌿", listOf("parsley"))) + add(CatalogItem("Menthe", "Fruits & Légumes", "🌿", listOf("mint"))) + add(CatalogItem("Framboises", "Fruits & Légumes", "🍓", listOf("raspberries"))) + add(CatalogItem("Mûres", "Fruits & Légumes", "🫐", listOf("blackberries"))) + add(CatalogItem("Abricot", "Fruits & Légumes", "🍑", listOf("apricot"))) + add(CatalogItem("Prune", "Fruits & Légumes", "🍑", listOf("prunes"))) + add(CatalogItem("Figue", "Fruits & Légumes", "🍇", listOf("figs"))) + add(CatalogItem("Datte", "Fruits & Légumes", "🍇", listOf("dates"))) + add(CatalogItem("Grenade", "Fruits & Légumes", "🍎", listOf("pomegranate", "pomme grenade"))) + add(CatalogItem("Açaï", "Fruits & Légumes", "🫐", listOf("acai", "açaí", "açaí berries", "acai berries"))) + add(CatalogItem("Artichaut", "Fruits & Légumes", "🥬", listOf("artichokes"))) + add(CatalogItem("Roquette", "Fruits & Légumes", "🥬", listOf("arugula"))) + add(CatalogItem("Asperge", "Fruits & Légumes", "🥬", listOf("asparagus"))) + add(CatalogItem("Betterave", "Fruits & Légumes", "🫐", listOf("beetroot", "betterave"))) + add(CatalogItem("Myrtilles", "Fruits & Légumes", "🫐", listOf("blueberries", "bleuets"))) + add(CatalogItem("Bok choy", "Fruits & Légumes", "🥬", listOf("chinese cabbage"))) + add(CatalogItem("Chou de Bruxelles", "Fruits & Légumes", "🥬", listOf("brussels sprouts", "chou bruxelles"))) + add(CatalogItem("Courge butternut", "Fruits & Légumes", "🎃", listOf("butternut pumpkin", "butternut squash"))) + add(CatalogItem("Chou frisé", "Fruits & Légumes", "🥬", listOf("kale", "chard", "chou frisé"))) + add(CatalogItem("Poirée", "Fruits & Légumes", "🥬", listOf("swiss chard"))) + add(CatalogItem("Tomate cerise", "Fruits & Légumes", "🍅", listOf("cherry tomatoes"))) + add(CatalogItem("Châtaigne", "Fruits & Légumes", "🌰", listOf("chestnuts"))) + add(CatalogItem("Chicorée", "Fruits & Légumes", "🥬", listOf("chicory"))) + add(CatalogItem("Ciboulette", "Fruits & Légumes", "🌿", listOf("chives"))) + add(CatalogItem("Coriandre", "Fruits & Légumes", "🌿", listOf("cilantro"))) + add(CatalogItem("Clémentine", "Fruits & Légumes", "🍊", listOf("clementine", "clementines"))) + add(CatalogItem("Canneberge", "Fruits & Légumes", "🫐", listOf("cranberries"))) + add(CatalogItem("Cresson", "Fruits & Légumes", "🥬", listOf("cress"))) + add(CatalogItem("Crudités", "Fruits & Légumes", "🥗", listOf("crudites"))) + add(CatalogItem("Groseille", "Fruits & Légumes", "🍇", listOf("currants"))) + add(CatalogItem("Aneth", "Fruits & Légumes", "🌿", listOf("dill"))) + add(CatalogItem("Pamplemousse", "Fruits & Légumes", "🍊", listOf("grapefruit"))) + add(CatalogItem("Citronnelle", "Fruits & Légumes", "🌿", listOf("lemongrass"))) + add(CatalogItem("Mirabelle", "Fruits & Légumes", "🍑", listOf("mirabelles"))) + add(CatalogItem("Pitaya", "Fruits & Légumes", "🐉", listOf("dragon fruit", "fruit dragon"))) + add(CatalogItem("Échalote", "Fruits & Légumes", "🧅", listOf("shallots", "échalotes"))) + add(CatalogItem("Edamame", "Fruits & Légumes", "🫛")) + add(CatalogItem("Fenouil", "Fruits & Légumes", "🥬", listOf("fennel"))) + add(CatalogItem("Fève", "Fruits & Légumes", "🫛", listOf("fèves", "fava bean", "broad bean"))) + add(CatalogItem("Fève verte", "Fruits & Légumes", "🫛", listOf("fèves vertes"))) + add(CatalogItem("Graines de lin", "Fruits & Légumes", "🌾", listOf("flaxseed", "linseed"))) + add(CatalogItem("Fruit", "Fruits & Légumes", "🍎", listOf("fruits"))) + add(CatalogItem("Plateau fruits", "Fruits & Légumes", "🍎", listOf("fruit platter"))) + add(CatalogItem("Plateau légumes", "Fruits & Légumes", "🥗", listOf("vegetable platter"))) + add(CatalogItem("Baies de goji", "Fruits & Légumes", "🍇", listOf("goji berries"))) + add(CatalogItem("Groseille à maquereau", "Fruits & Légumes", "🍇", listOf("gooseberries"))) + add(CatalogItem("Goyave", "Fruits & Légumes", "🥭", listOf("guava"))) + add(CatalogItem("Citrouille Halloween", "Fruits & Légumes", "🎃", listOf("halloween pumpkin"))) + add(CatalogItem("Herbes", "Fruits & Légumes", "🌿", listOf("herbs"))) + add(CatalogItem("Citrouille Hokkaido", "Fruits & Légumes", "🎃", listOf("hokkaido pumpkin"))) + add(CatalogItem("Melon vert", "Fruits & Légumes", "🍈", listOf("honeydew melon"))) + add(CatalogItem("Mâche", "Fruits & Légumes", "🥬", listOf("lamb's lettuce", "corn salad"))) + add(CatalogItem("Citron vert", "Fruits & Légumes", "🍋", listOf("lime", "limes"))) + add(CatalogItem("Litchi", "Fruits & Légumes", "🥭", listOf("lychee", "lychees"))) + add(CatalogItem("Mandarine", "Fruits & Légumes", "🍊", listOf("mandarins", "mandarin"))) + add(CatalogItem("Marjolaine", "Fruits & Légumes", "🌿", listOf("marjoram"))) + add(CatalogItem("Nectarine", "Fruits & Légumes", "🍑", listOf("nectarines"))) + add(CatalogItem("Oignon rouge", "Fruits & Légumes", "🧅", listOf("red onion"))) + add(CatalogItem("Oignon vert", "Fruits & Légumes", "🧅", listOf("green onion", "spring onion", "scallions"))) + add(CatalogItem("Olive", "Fruits & Légumes", "🫒", listOf("olives"))) + add(CatalogItem("Papaye", "Fruits & Légumes", "🥭", listOf("papaya"))) + add(CatalogItem("Panais", "Fruits & Légumes", "🥕", listOf("parsnips"))) + add(CatalogItem("Fruit de la passion", "Fruits & Légumes", "🍇", listOf("passion fruit"))) + add(CatalogItem("Patate douce", "Fruits & Légumes", "🍠", listOf("sweet potatoes", "patate douce"))) + add(CatalogItem("Patates parisiennes", "Fruits & Légumes", "🥔", listOf("patates parisienne"))) + add(CatalogItem("Pousse de pois", "Fruits & Légumes", "🌱", listOf("pea eggplant"))) + add(CatalogItem("Pourpier", "Fruits & Légumes", "🥬", listOf("purslane"))) + add(CatalogItem("Coing", "Fruits & Légumes", "🍏", listOf("quinces"))) + add(CatalogItem("Chou rouge", "Fruits & Légumes", "🥬", listOf("red cabbage"))) + add(CatalogItem("Rhubarbe", "Fruits & Légumes", "🥬", listOf("rhubarb"))) + add(CatalogItem("Sauge", "Fruits & Légumes", "🌿", listOf("sage"))) + add(CatalogItem("Salade au chou", "Fruits & Légumes", "🥗", listOf("coleslaw"))) + add(CatalogItem("Chou de Milan", "Fruits & Légumes", "🥬", listOf("savoy cabbage"))) + add(CatalogItem("Courge", "Fruits & Légumes", "🎃", listOf("squash"))) + add(CatalogItem("Carambole", "Fruits & Légumes", "⭐", listOf("star fruit"))) + add(CatalogItem("Tomates séchées", "Fruits & Légumes", "🍅", listOf("sun-dried tomatoes"))) + add(CatalogItem("Basilic thaï", "Fruits & Légumes", "🌿", listOf("thai basil"))) + add(CatalogItem("Thym", "Fruits & Légumes", "🌿", listOf("thyme"))) + add(CatalogItem("Tomates Savoura", "Fruits & Légumes", "🍅", listOf("savoura tomatoes"))) + add(CatalogItem("Herbe de blé", "Fruits & Légumes", "🌾", listOf("wheatgrass"))) + add(CatalogItem("Salsifis noir", "Fruits & Légumes", "🥕", listOf("black salsify"))) + add(CatalogItem("Girolles", "Fruits & Légumes", "🍄", listOf("chanterelles"))) + add(CatalogItem("Orange sanguine", "Fruits & Légumes", "🍊", listOf("blood orange"))) + add(CatalogItem("Baies", "Fruits & Légumes", "🫐", listOf("berries"))) + + // Boulangerie + add( + CatalogItem( + "Pain", + "Boulangerie", + "🍞", + listOf("baguette"), + listOf("Blanc", "Brun", "Complet", "Sans Gluten", "Multigrains"), + ), + ) + add(CatalogItem("Baguette", "Boulangerie", "🥖")) + add(CatalogItem("Croissant", "Boulangerie", "🥐")) + add(CatalogItem("Brioche", "Boulangerie", "🥯")) + add(CatalogItem("Pain de mie", "Boulangerie", "🍞")) + add(CatalogItem("Biscotte", "Boulangerie", "🍞")) + add(CatalogItem("Tortillas", "Boulangerie", "🌯")) + add(CatalogItem("Pain complet", "Boulangerie", "🍞")) + add(CatalogItem("Pain aux céréales", "Boulangerie", "🍞")) + add(CatalogItem("Pain de seigle", "Boulangerie", "🍞")) + add(CatalogItem("Bagel", "Boulangerie", "🥯")) + add(CatalogItem("Muffin", "Boulangerie", "🧁")) + add(CatalogItem("Donut", "Boulangerie", "🍩")) + add(CatalogItem("Pain au chocolat", "Boulangerie", "🥐")) + add(CatalogItem("Chausson aux pommes", "Boulangerie", "🥐")) + add(CatalogItem("Éclair", "Boulangerie", "🍰")) + add(CatalogItem("Tarte", "Boulangerie", "🥧")) + add(CatalogItem("Gâteau", "Boulangerie", "🍰")) + add(CatalogItem("Bagel briana", "Boulangerie", "🥯")) + add(CatalogItem("Biscuits breton sans gluten", "Boulangerie", "🍪")) + add(CatalogItem("Biscuits Feuille D'Érable", "Boulangerie", "🍁")) + add(CatalogItem("Biscuits sans gluten", "Boulangerie", "🍪")) + add(CatalogItem("Biscuits swiss", "Boulangerie", "🍪")) + add(CatalogItem("Bread", "Boulangerie", "🍞")) + add(CatalogItem("Buns", "Boulangerie", "🍞")) + add(CatalogItem("Crispbread", "Boulangerie", "🍘")) + add(CatalogItem("Croûte à tarte", "Boulangerie", "🥧")) + add(CatalogItem("Dinner Rolls", "Boulangerie", "🍞")) + add(CatalogItem("Fond de tarte", "Boulangerie", "🥧")) + add(CatalogItem("Galette tortilla", "Boulangerie", "🌯")) + add(CatalogItem("Gâteau mille feuilles", "Boulangerie", "🍰")) + add(CatalogItem("Gaufres", "Boulangerie", "🧇")) + add(CatalogItem("Muffins Anglais", "Boulangerie", "🥯")) + add(CatalogItem("Pain Baguette", "Boulangerie", "🥖")) + add(CatalogItem("Pain Blanc", "Boulangerie", "🍞")) + add(CatalogItem("Pain Blanc Sans Lactose", "Boulangerie", "🍞")) + add(CatalogItem("Pain Bon Matin", "Boulangerie", "🍞")) + add(CatalogItem("Pain Briana", "Boulangerie", "🍞")) + add(CatalogItem("Pain brun", "Boulangerie", "🍞")) + add(CatalogItem("Pain burgers", "Boulangerie", "🍔")) + add(CatalogItem("Pain burgers sans gluten", "Boulangerie", "🍔")) + add(CatalogItem("Pain Croûte", "Boulangerie", "🍞")) + add(CatalogItem("Pain croûté sans gluten", "Boulangerie", "🍞")) + add(CatalogItem("Pain hamburger sans gluten", "Boulangerie", "🍔")) + add(CatalogItem("Pain Hot Dog", "Boulangerie", "🌭")) + add(CatalogItem("Pain Italien", "Boulangerie", "🥖")) + add(CatalogItem("Pain Sans Gluten", "Boulangerie", "🍞")) + add(CatalogItem("Pain Sans Lactose", "Boulangerie", "🍞")) + add(CatalogItem("Pain Sous Marin", "Boulangerie", "🥖")) + add(CatalogItem("Pains Grand Mère", "Boulangerie", "🍞")) + add(CatalogItem("Pancakes mix", "Boulangerie", "🥞")) + add(CatalogItem("Petit Pain Sandwich", "Boulangerie", "🥪")) + add(CatalogItem("Petit Pain Sous Marin", "Boulangerie", "🥖")) + add(CatalogItem("Pie", "Boulangerie", "🥧")) + add(CatalogItem("Pizza dough", "Boulangerie", "🍕")) + add(CatalogItem("Pizza sans gluten", "Boulangerie", "🍕")) + add(CatalogItem("Puff pastry", "Boulangerie", "🥐")) + add(CatalogItem("Pumpkin Pie", "Boulangerie", "🥧")) + add(CatalogItem("Rolls", "Boulangerie", "🍞")) + add(CatalogItem("Scones", "Boulangerie", "🧁")) + add(CatalogItem("Sliced bread", "Boulangerie", "🍞")) + add(CatalogItem("Toast", "Boulangerie", "🍞")) + add(CatalogItem("Toast melba", "Boulangerie", "🍞")) + add(CatalogItem("Tortilla", "Boulangerie", "🌯")) + add(CatalogItem("Vol au vent", "Boulangerie", "🥐")) + add(CatalogItem("Waffles", "Boulangerie", "🧇")) + + // Produits laitiers + add( + CatalogItem( + "Lait", + "Produits laitiers", + "🥛", + listOf("milk"), + listOf("Entier", "2%", "1%", "Écrémé", "Sans Lactose", "Avoine", "Amande", "Soya"), + ), + ) + add( + CatalogItem( + "Yaourt", + "Produits laitiers", + "🥣", + listOf("yogurt"), + listOf("Grec", "Nature", "Vaniille", "Fraise", "Sans Lactose"), + ), + ) + add(CatalogItem("Beurre", "Produits laitiers", "🧈")) + add( + CatalogItem( + "Fromage", + "Produits laitiers", + "🧀", + listOf("cheese"), + listOf("Cheddar", "Mozzarella", "Parmésan", "Feta", "Brie", "Emmental", "Chèvre"), + ), + ) + add(CatalogItem("Crème fraîche", "Produits laitiers", "🥛")) + add( + CatalogItem( + "Œufs", + "Produits laitiers", + "🥚", + listOf("oeufs", "eggs"), + listOf("Gros", "Très Gros", "Moyen", "Bio", "Libre Parcours"), + ), + ) + add(CatalogItem("Mozzarella", "Produits laitiers", "🧀")) + add(CatalogItem("Parmesan", "Produits laitiers", "🧀")) + add(CatalogItem("Cheddar", "Produits laitiers", "🧀")) + add(CatalogItem("Emmental", "Produits laitiers", "🧀")) + add(CatalogItem("Camembert", "Produits laitiers", "🧀")) + add(CatalogItem("Brie", "Produits laitiers", "🧀")) + add(CatalogItem("Chèvre", "Produits laitiers", "🧀")) + add(CatalogItem("Roquefort", "Produits laitiers", "🧀")) + add(CatalogItem("Gorgonzola", "Produits laitiers", "🧀")) + add(CatalogItem("Feta", "Produits laitiers", "🧀")) + add(CatalogItem("Ricotta", "Produits laitiers", "🧀")) + add(CatalogItem("Mascarpone", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage blanc", "Produits laitiers", "🥛")) + add(CatalogItem("Cottage cheese", "Produits laitiers", "🧀")) + add(CatalogItem("Crème liquide", "Produits laitiers", "🥛")) + add(CatalogItem("Lait concentré", "Produits laitiers", "🥛")) + add(CatalogItem("Lait de soja", "Produits laitiers", "🥛")) + add(CatalogItem("Lait d'amande", "Produits laitiers", "🥛")) + add(CatalogItem("Margarine", "Produits laitiers", "🧈")) + add(CatalogItem("Beurre Sans Lactose", "Produits laitiers", "🧈")) + add(CatalogItem("Cream", "Produits laitiers", "🥛")) + add(CatalogItem("Cream cheese", "Produits laitiers", "🧀")) + add(CatalogItem("Crème 10%", "Produits laitiers", "🥛")) + add(CatalogItem("Crème à café", "Produits laitiers", "🥛")) + add(CatalogItem("Creme fraiche", "Produits laitiers", "🥛")) + add(CatalogItem("Crème Glacée", "Produits laitiers", "🍨")) + add(CatalogItem("Crème glacée Sans Lactose", "Produits laitiers", "🍨")) + add(CatalogItem("Crème Glacée Sans Lactose Au Chocolat", "Produits laitiers", "🍨")) + add(CatalogItem("Crème Sans Lactose", "Produits laitiers", "🥛")) + add(CatalogItem("Crème Sure", "Produits laitiers", "🥛")) + add(CatalogItem("Sour cream", "Produits laitiers", "🥛")) + add(CatalogItem("Blue cheese", "Produits laitiers", "🧀")) + add(CatalogItem("Crotte De Fromage", "Produits laitiers", "🧀")) + add(CatalogItem("Demi Lune", "Produits laitiers", "🧀")) + add(CatalogItem("Fondue Fromage", "Produits laitiers", "🧀")) + add(CatalogItem("Frite du petit Québec", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage à tartiner", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage Briana", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage Chèvre Confiture", "Produits laitiers", "🧀")) + add(CatalogItem("fromage cottage sans lactose", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage Déjà Rappé", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage en grains", "Produits laitiers", "🧀")) + add(CatalogItem("fromage en tranches", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage Philadelphia", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage Philadelphia sans lactose", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage Quick Quick", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage Rapé Briana", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage rapé sans lactose", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage Riviera", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage Sans Lactose", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage suisse", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage tex mex", "Produits laitiers", "🧀")) + add(CatalogItem("Fromage Vache Qui Rit", "Produits laitiers", "🧀")) + add(CatalogItem("Grated cheese", "Produits laitiers", "🧀")) + add(CatalogItem("Grilled cheese", "Produits laitiers", "🧀")) + add(CatalogItem("Parmesan sans lactose", "Produits laitiers", "🧀")) + add(CatalogItem("Quark", "Produits laitiers", "🧀")) + add(CatalogItem("Lait avoine", "Produits laitiers", "🥛")) + add(CatalogItem("Lait avoine lunch", "Produits laitiers", "🥛")) + add(CatalogItem("Lait avoine lunch chocolat", "Produits laitiers", "🥛")) + add(CatalogItem("Lait Chocolat", "Produits laitiers", "🥛")) + add(CatalogItem("Lait Chocolat Lunch", "Produits laitiers", "🥛")) + add(CatalogItem("Lait Chocolat Sans Lactose", "Produits laitiers", "🥛")) + add(CatalogItem("Lait condensé coco", "Produits laitiers", "🥛")) + add(CatalogItem("Lait Sans Lactose", "Produits laitiers", "🥛")) + add(CatalogItem("Lait Soya Lunch", "Produits laitiers", "🥛")) + add(CatalogItem("Lait Soya Sensationnel Nature", "Produits laitiers", "🥛")) + add(CatalogItem("Soy Milk", "Produits laitiers", "🥛")) + add(CatalogItem("Soy yogurt", "Produits laitiers", "🥣")) + add(CatalogItem("Yogourt iogo Vanille", "Produits laitiers", "🥣")) + add(CatalogItem("Yogourt logo vanille sans lactose", "Produits laitiers", "🥣")) + add(CatalogItem("Yogourt Sans Lactose", "Produits laitiers", "🥣")) + + // Boucherie + add(CatalogItem("Poulet", "Boucherie", "🍗", emptyList(), listOf("Poitrine", "Cuisse", "Aile", "Entier", "Haché"))) + add(CatalogItem("Bœuf haché", "Boucherie", "🥩", listOf("beef"))) + add(CatalogItem("Steak", "Boucherie", "🥩")) + add(CatalogItem("Porc", "Boucherie", "🥓")) + add(CatalogItem("Jambon", "Boucherie", "🥓")) + add(CatalogItem("Saucisse", "Boucherie", "🌭", emptyList(), listOf("Porc", "Veau", "Dinde", "Italienne", "Cocktail"))) + add(CatalogItem("Bacon", "Boucherie", "🥓")) + add( + CatalogItem( + "Saumon", + "Boucherie", + "🐟", + emptyList(), + listOf("Atlantique", "Pacifique", "Sockeye", "Fumé", "En Conserve"), + ), + ) + add(CatalogItem("Thon", "Boucherie", "🐟")) + add(CatalogItem("Dinde", "Boucherie", "🦃")) + add(CatalogItem("Canard", "Boucherie", "🦆")) + add(CatalogItem("Agneau", "Boucherie", "🥩")) + add(CatalogItem("Veau", "Boucherie", "🥩")) + add(CatalogItem("Côtelette", "Boucherie", "🥩")) + add(CatalogItem("Rôti", "Boucherie", "🥩")) + add(CatalogItem("Merguez", "Boucherie", "🌭")) + add(CatalogItem("Chorizo", "Boucherie", "🌭")) + add(CatalogItem("Salami", "Boucherie", "🥓")) + add(CatalogItem("Saucisson", "Boucherie", "🥓")) + add(CatalogItem("Pâté", "Boucherie", "🥓")) + add(CatalogItem("Truite", "Boucherie", "🐟")) + add(CatalogItem("Cabillaud", "Boucherie", "🐟")) + add(CatalogItem("Dorade", "Boucherie", "🐟")) + add(CatalogItem("Bar", "Boucherie", "🐟")) + add(CatalogItem("Crevettes", "Boucherie", "🦐")) + add(CatalogItem("Moules", "Boucherie", "🦪")) + add(CatalogItem("Huîtres", "Boucherie", "🦪")) + add(CatalogItem("Calamar", "Boucherie", "🦑")) + add(CatalogItem("Crabe", "Boucherie", "🦀")) + add(CatalogItem("Homard", "Boucherie", "🦞")) + + // Épicerie + add(CatalogItem("Riz", "Épicerie", "🍚")) + add(CatalogItem("Pâtes", "Épicerie", "🍝", listOf("spaghetti"))) + add(CatalogItem("Spaghetti", "Épicerie", "🍝")) + add(CatalogItem("Farine", "Épicerie", "🌾")) + add(CatalogItem("Sucre", "Épicerie", "🍬")) + add(CatalogItem("Sel", "Épicerie", "🧂")) + add(CatalogItem("Huile", "Épicerie", "🫒")) + add(CatalogItem("Huile d'olive", "Épicerie", "🫒")) + add(CatalogItem("Vinaigre", "Épicerie", "🧴")) + add(CatalogItem("Confiture", "Épicerie", "🍓")) + add(CatalogItem("Miel", "Épicerie", "🍯")) + add(CatalogItem("Chocolat", "Épicerie", "🍫")) + add(CatalogItem("Biscuits", "Épicerie", "🍪")) + add(CatalogItem("Céréales", "Épicerie", "🥣", listOf("muesli"))) + add(CatalogItem("Lentilles", "Épicerie", "🫘")) + add(CatalogItem("Pois chiches", "Épicerie", "🫘")) + add(CatalogItem("Haricots rouges", "Épicerie", "🫘")) + add(CatalogItem("Haricots blancs", "Épicerie", "🫘")) + add(CatalogItem("Conserves", "Épicerie", "🥫")) + add(CatalogItem("Soupe", "Épicerie", "🍲")) + add(CatalogItem("Riz basmati", "Épicerie", "🍚")) + add(CatalogItem("Quinoa", "Épicerie", "🍚")) + add(CatalogItem("Couscous", "Épicerie", "🍚")) + add(CatalogItem("Boulgour", "Épicerie", "🍚")) + add(CatalogItem("Polenta", "Épicerie", "🌽")) + add(CatalogItem("Flocons d'avoine", "Épicerie", "🥣")) + add(CatalogItem("Muesli", "Épicerie", "🥣")) + add(CatalogItem("Granola", "Épicerie", "🥣")) + add(CatalogItem("Pâte à tartiner", "Épicerie", "🍫")) + add(CatalogItem("Beurre de cacahuète", "Épicerie", "🥜")) + add(CatalogItem("Sirop d'érable", "Épicerie", "🍯")) + add(CatalogItem("Levure", "Épicerie", "🧂")) + add(CatalogItem("Bicarbonate", "Épicerie", "🧂")) + add(CatalogItem("Sucre vanillé", "Épicerie", "🍬")) + add(CatalogItem("Cacao", "Épicerie", "🍫")) + add(CatalogItem("Thon en boîte", "Épicerie", "🥫")) + add(CatalogItem("Sardines", "Épicerie", "🥫")) + add(CatalogItem("Maquereau", "Épicerie", "🥫")) + add(CatalogItem("Sauce tomate", "Épicerie", "🍅")) + add(CatalogItem("Concentré de tomate", "Épicerie", "🍅")) + add(CatalogItem("Tomates pelées", "Épicerie", "🥫")) + + // Condiments & Épices + add(CatalogItem("Moutarde", "Condiments & Épices", "🟡")) + add(CatalogItem("Ketchup", "Condiments & Épices", "🍅")) + add(CatalogItem("Mayonnaise", "Condiments & Épices", "🥚")) + add(CatalogItem("Poivre", "Condiments & Épices", "🧂")) + add(CatalogItem("Paprika", "Condiments & Épices", "🌶️")) + add(CatalogItem("Curry", "Condiments & Épices", "🌶️")) + add(CatalogItem("Cumin", "Condiments & Épices", "🌶️")) + add(CatalogItem("Curcuma", "Condiments & Épices", "🌶️")) + add(CatalogItem("Gingembre", "Condiments & Épices", "🌶️")) + add(CatalogItem("Cannelle", "Condiments & Épices", "🌶️")) + add(CatalogItem("Muscade", "Condiments & Épices", "🌶️")) + add(CatalogItem("Thym", "Condiments & Épices", "🌿")) + add(CatalogItem("Romarin", "Condiments & Épices", "🌿")) + add(CatalogItem("Origan", "Condiments & Épices", "🌿")) + add(CatalogItem("Laurier", "Condiments & Épices", "🌿")) + add(CatalogItem("Herbes de Provence", "Condiments & Épices", "🌿")) + add(CatalogItem("Sauce soja", "Condiments & Épices", "🧴")) + add(CatalogItem("Vinaigre balsamique", "Condiments & Épices", "🧴")) + add(CatalogItem("Tabasco", "Condiments & Épices", "🌶️")) + add(CatalogItem("Harissa", "Condiments & Épices", "🌶️")) + add(CatalogItem("Wasabi", "Condiments & Épices", "🌶️")) + add(CatalogItem("Pesto", "Condiments & Épices", "🌿")) + add(CatalogItem("Tapenade", "Condiments & Épices", "🫒")) + + // Surgelés + add(CatalogItem("Pizza surgelée", "Surgelés", "🍕")) + add(CatalogItem("Frites surgelées", "Surgelés", "🍟")) + add(CatalogItem("Glace", "Surgelés", "🍨")) + add(CatalogItem("Légumes surgelés", "Surgelés", "🥦")) + add(CatalogItem("Poisson surgelé", "Surgelés", "🐟")) + add(CatalogItem("Crevettes surgelées", "Surgelés", "🦐")) + add(CatalogItem("Fruits surgelés", "Surgelés", "🍓")) + add(CatalogItem("Plat préparé", "Surgelés", "🍱")) + add(CatalogItem("Lasagnes", "Surgelés", "🍝")) + add(CatalogItem("Nuggets", "Surgelés", "🍗")) + add(CatalogItem("Poisson pané", "Surgelés", "🐟")) + add(CatalogItem("Sorbet", "Surgelés", "🍧")) + add(CatalogItem("Gâteau glacé", "Surgelés", "🍰")) + + // Snacks & Bonbons + add(CatalogItem("Chips", "Snacks & Bonbons", "🥔")) + add(CatalogItem("Cacahuètes", "Snacks & Bonbons", "🥜")) + add(CatalogItem("Noix", "Snacks & Bonbons", "🌰")) + add(CatalogItem("Amandes", "Snacks & Bonbons", "🌰")) + add(CatalogItem("Noisettes", "Snacks & Bonbons", "🌰")) + add(CatalogItem("Pistaches", "Snacks & Bonbons", "🥜")) + add(CatalogItem("Noix de cajou", "Snacks & Bonbons", "🥜")) + add(CatalogItem("Pop-corn", "Snacks & Bonbons", "🍿")) + add(CatalogItem("Bonbons", "Snacks & Bonbons", "🍬")) + add(CatalogItem("Chewing-gum", "Snacks & Bonbons", "🍬")) + add(CatalogItem("Chocolat noir", "Snacks & Bonbons", "🍫")) + add(CatalogItem("Chocolat au lait", "Snacks & Bonbons", "🍫")) + add(CatalogItem("Barres chocolatées", "Snacks & Bonbons", "🍫")) + add(CatalogItem("Barres de céréales", "Snacks & Bonbons", "🥣")) + add(CatalogItem("Fruits secs", "Snacks & Bonbons", "🍇")) + add(CatalogItem("Raisins secs", "Snacks & Bonbons", "🍇")) + + // Boissons + add(CatalogItem("Eau", "Boissons", "💧")) + add(CatalogItem("Eau gazeuse", "Boissons", "💧")) + add(CatalogItem("Jus d'orange", "Boissons", "🧃")) + add(CatalogItem("Jus de pomme", "Boissons", "🧃")) + add(CatalogItem("Jus de raisin", "Boissons", "🧃")) + add(CatalogItem("Jus multivitaminé", "Boissons", "🧃")) + add(CatalogItem("Café", "Boissons", "☕")) + add(CatalogItem("Café moulu", "Boissons", "☕")) + add(CatalogItem("Café en grains", "Boissons", "☕")) + add(CatalogItem("Café soluble", "Boissons", "☕")) + add(CatalogItem("Thé", "Boissons", "🍵")) + add(CatalogItem("Thé vert", "Boissons", "🍵")) + add(CatalogItem("Thé noir", "Boissons", "🍵")) + add(CatalogItem("Infusion", "Boissons", "🍵")) + add(CatalogItem("Chocolat chaud", "Boissons", "☕")) + add(CatalogItem("Vin rouge", "Boissons", "🍷")) + add(CatalogItem("Vin blanc", "Boissons", "🍷")) + add(CatalogItem("Vin rosé", "Boissons", "🍷")) + add(CatalogItem("Champagne", "Boissons", "🍾")) + add(CatalogItem("Bière", "Boissons", "🍺")) + add(CatalogItem("Bière blonde", "Boissons", "🍺")) + add(CatalogItem("Bière brune", "Boissons", "🍺")) + add(CatalogItem("Cidre", "Boissons", "🍺")) + add(CatalogItem("Soda", "Boissons", "🥤")) + add(CatalogItem("Cola", "Boissons", "🥤")) + add(CatalogItem("Limonade", "Boissons", "🍋")) + add(CatalogItem("Orangina", "Boissons", "🍊")) + add(CatalogItem("Sirop", "Boissons", "🧃")) + add(CatalogItem("Smoothie", "Boissons", "🥤")) + add(CatalogItem("Boisson énergisante", "Boissons", "🥤")) + + // Hygiène + add(CatalogItem("Papier toilette", "Hygiène", "🧻", listOf("toilet paper"))) + add(CatalogItem("Mouchoirs", "Hygiène", "🤧")) + add(CatalogItem("Dentifrice", "Hygiène", "🦷")) + add(CatalogItem("Brosse à dents", "Hygiène", "🪥")) + add(CatalogItem("Fil dentaire", "Hygiène", "🦷")) + add(CatalogItem("Bain de bouche", "Hygiène", "🦷")) + add(CatalogItem("Shampoing", "Hygiène", "🧴")) + add(CatalogItem("Après-shampoing", "Hygiène", "🧴")) + add(CatalogItem("Savon", "Hygiène", "🧼")) + add(CatalogItem("Gel douche", "Hygiène", "🧴")) + add(CatalogItem("Déodorant", "Hygiène", "🧴")) + add(CatalogItem("Parfum", "Hygiène", "🧴")) + add(CatalogItem("Crème hydratante", "Hygiène", "🧴")) + add(CatalogItem("Crème solaire", "Hygiène", "🧴")) + add(CatalogItem("Rasoir", "Hygiène", "🪒")) + add(CatalogItem("Mousse à raser", "Hygiène", "🧴")) + add(CatalogItem("Coton-tige", "Hygiène", "🧻")) + add(CatalogItem("Coton", "Hygiène", "🧻")) + add(CatalogItem("Serviettes hygiéniques", "Hygiène", "🧻")) + add(CatalogItem("Tampons", "Hygiène", "🧻")) + + // Entretien + add(CatalogItem("Lessive", "Entretien", "🧺")) + add(CatalogItem("Adoucissant", "Entretien", "🧴")) + add(CatalogItem("Liquide vaisselle", "Entretien", "🧴")) + add(CatalogItem("Tablettes lave-vaisselle", "Entretien", "🧴")) + add(CatalogItem("Éponge", "Entretien", "🧽")) + add(CatalogItem("Javel", "Entretien", "🧴")) + add(CatalogItem("Nettoyant multi-usage", "Entretien", "🧴")) + add(CatalogItem("Nettoyant vitres", "Entretien", "🧴")) + add(CatalogItem("Nettoyant sol", "Entretien", "🧴")) + add(CatalogItem("Nettoyant WC", "Entretien", "🧴")) + add(CatalogItem("Sacs poubelle", "Entretien", "🗑️")) + add(CatalogItem("Essuie-tout", "Entretien", "🧻")) + add(CatalogItem("Serpillière", "Entretien", "🧹")) + add(CatalogItem("Balai", "Entretien", "🧹")) + add(CatalogItem("Pelle", "Entretien", "🧹")) + + // Bébé + add(CatalogItem("Couches", "Bébé", "👶")) + add(CatalogItem("Lait infantile", "Bébé", "🍼")) + add(CatalogItem("Compote bébé", "Bébé", "🍎")) + add(CatalogItem("Lingettes bébé", "Bébé", "🧻")) + add(CatalogItem("Petits pots", "Bébé", "🍼")) + add(CatalogItem("Céréales bébé", "Bébé", "🥣")) + add(CatalogItem("Biscuits bébé", "Bébé", "🍪")) + add(CatalogItem("Biberon", "Bébé", "🍼")) + add(CatalogItem("Tétine", "Bébé", "🍼")) + add(CatalogItem("Crème pour le change", "Bébé", "🧴")) + add(CatalogItem("Savon bébé", "Bébé", "🧼")) + add(CatalogItem("Shampoing bébé", "Bébé", "🧴")) + + // Animaux + add(CatalogItem("Croquettes chien", "Animaux", "🐶")) + add(CatalogItem("Croquettes chat", "Animaux", "🐱")) + add(CatalogItem("Pâtée chien", "Animaux", "🐶")) + add(CatalogItem("Pâtée chat", "Animaux", "🐈")) + add(CatalogItem("Friandises chien", "Animaux", "🦴")) + add(CatalogItem("Friandises chat", "Animaux", "🐟")) + add(CatalogItem("Litière", "Animaux", "🐈")) + add(CatalogItem("Jouets pour animaux", "Animaux", "🎾")) + add(CatalogItem("Shampoing animal", "Animaux", "🧴")) + + // Maison & Jardin + add(CatalogItem("Ampoules", "Maison & Jardin", "💡")) + add(CatalogItem("Piles", "Maison & Jardin", "🔋")) + add(CatalogItem("Allumettes", "Maison & Jardin", "🔥")) + add(CatalogItem("Bougies", "Maison & Jardin", "🕯️")) + add(CatalogItem("Papier aluminium", "Maison & Jardin", "📦")) + add(CatalogItem("Film alimentaire", "Maison & Jardin", "📦")) + add(CatalogItem("Papier cuisson", "Maison & Jardin", "📦")) + add(CatalogItem("Sacs congélation", "Maison & Jardin", "📦")) + add(CatalogItem("Terreau", "Maison & Jardin", "🌱")) + add(CatalogItem("Engrais", "Maison & Jardin", "🌱")) + add(CatalogItem("Graines", "Maison & Jardin", "🌱")) + add(CatalogItem("Pots de fleurs", "Maison & Jardin", "🪴")) + add(CatalogItem("Rum extract", "Condiments & Épices", "🧂")) + + add(CatalogItem("Salad dressing", "Condiments & Épices", "🧂")) + + add(CatalogItem("Salt", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce à pizza", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce à la viande", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce à poutine", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce aux piments", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce aux poissons", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce aux poivres", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce BBQ St-Hubert", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce béarnaise", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce brune", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce brune sans gluten", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce brune St-Hubert", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce hollandaise", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce Hunt", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce poutine", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce salade César", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sauce salsa", "Condiments & Épices", "🧂")) + + add(CatalogItem("Soy sauce", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sea salt", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sel de mer", "Condiments & Épices", "🧂")) + + add(CatalogItem("Stock", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sucre en poudre", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sugar", "Condiments & Épices", "🧂")) + + add(CatalogItem("Sunflower seeds", "Condiments & Épices", "🧂")) + + add(CatalogItem("Tahini", "Condiments & Épices", "🧂")) + + add(CatalogItem("Tamarind paste", "Condiments & Épices", "🧂")) + + add(CatalogItem("Tomato purée", "Condiments & Épices", "🧂")) + + add(CatalogItem("Tomato sauce", "Condiments & Épices", "🧂")) + + add(CatalogItem("Truffle", "Condiments & Épices", "🧂")) + + add(CatalogItem("Vanilla", "Condiments & Épices", "🧂")) + + add(CatalogItem("Vanilla bourbon", "Condiments & Épices", "🧂")) + + add(CatalogItem("Vanille", "Condiments & Épices", "🧂")) + + add(CatalogItem("Vegetable broth", "Condiments & Épices", "🧂")) + + add(CatalogItem("Vinegar", "Condiments & Épices", "🧂")) + + add(CatalogItem("Walnuts", "Condiments & Épices", "🧂")) + + add(CatalogItem("Yeast", "Condiments & Épices", "🧂")) + + add(CatalogItem("Baked beans", "Épicerie", "🌾")) + + add(CatalogItem("Bleuets congelés", "Épicerie", "🌾")) + + add(CatalogItem("Boulettes", "Épicerie", "🌾")) + + add(CatalogItem("Burritos", "Épicerie", "🌾")) + + add(CatalogItem("Chicken wings", "Épicerie", "🌾")) + + add(CatalogItem("Chinese food", "Épicerie", "🌾")) + + add(CatalogItem("Dumplings", "Épicerie", "🌾")) + + add(CatalogItem("Fish sticks", "Épicerie", "🌾")) + + add(CatalogItem("Fraises congelées", "Épicerie", "🌾")) + + add(CatalogItem("Framboises congelées", "Épicerie", "🌾")) + + add(CatalogItem("French fries", "Épicerie", "🌾")) + + add(CatalogItem("Frites congelées", "Épicerie", "🌾")) + + add(CatalogItem("Frozen vegetables", "Épicerie", "🌾")) + + add(CatalogItem("Fruits congelés", "Épicerie", "🌾")) + + add(CatalogItem("Ice cream", "Épicerie", "🌾")) + + add(CatalogItem("Indian food", "Épicerie", "🌾")) + + add(CatalogItem("Italian food", "Épicerie", "🌾")) + + add(CatalogItem("Lasagna", "Épicerie", "🌾")) + + add(CatalogItem("Légumes soupe", "Épicerie", "🌾")) + + add(CatalogItem("Mexican food", "Épicerie", "🌾")) + + add(CatalogItem("Pizza", "Épicerie", "🌾")) + + add(CatalogItem("Pizza froide", "Épicerie", "🌾")) + + add(CatalogItem("Soup", "Épicerie", "🌾")) + + add(CatalogItem("Soupe St Hubert poulet nouilles", "Épicerie", "🌾")) + + add(CatalogItem("Thai food", "Épicerie", "🌾")) + + add(CatalogItem("TV dinner", "Épicerie", "🌾")) + + add(CatalogItem("Avoine sans gluten", "Épicerie", "🌾")) + + add(CatalogItem("Basmati rice", "Épicerie", "🌾")) + + add(CatalogItem("Biscottes sans lactose", "Épicerie", "🌾")) + + add(CatalogItem("Biscuits avoine", "Épicerie", "🌾")) + + add(CatalogItem("Biscuits sans lactose", "Épicerie", "🌾")) + + add(CatalogItem("Biscuits soda", "Épicerie", "🌾")) + + add(CatalogItem("Breton sans gluten", "Épicerie", "🌾")) + + add(CatalogItem("Cereal", "Épicerie", "🌾")) + + add(CatalogItem("Céréales Life", "Épicerie", "🌾")) + + add(CatalogItem("Céréales sans gluten", "Épicerie", "🌾")) + + add(CatalogItem("Cinnamon toast crunch", "Épicerie", "🌾")) + + add(CatalogItem("Corn flakes", "Épicerie", "🌾")) + + add(CatalogItem("Couscous sans gluten", "Épicerie", "🌾")) + + add(CatalogItem("Farine d'épeautre", "Épicerie", "🌾")) + + add(CatalogItem("Farine de maïs", "Épicerie", "🌾")) + + add(CatalogItem("Farine de riz", "Épicerie", "🌾")) + + add(CatalogItem("Farine de riz brun", "Épicerie", "🌾")) + + add(CatalogItem("Farine sans gluten", "Épicerie", "🌾")) + + add(CatalogItem("Flour", "Épicerie", "🌾")) + + add(CatalogItem("Fusilli", "Épicerie", "🌾")) + + add(CatalogItem("Galette de riz", "Épicerie", "🌾")) + + add(CatalogItem("Jasmine rice", "Épicerie", "🌾")) + + add(CatalogItem("Macaroni", "Épicerie", "🌾")) + + add(CatalogItem("Noodles", "Épicerie", "🌾")) + + add(CatalogItem("Nouilles à soupe", "Épicerie", "🌾")) + + add(CatalogItem("Nouilles aux œufs", "Épicerie", "🌾")) + + add(CatalogItem("Oatmeal", "Épicerie", "🌾")) + + add(CatalogItem("Pasta", "Épicerie", "🌾")) + + add(CatalogItem("Pâte", "Épicerie", "🌾")) + + add(CatalogItem("Pâte à eggroll", "Épicerie", "🌾")) + + add(CatalogItem("Pâte fraîche", "Épicerie", "🌾")) + + add(CatalogItem("Pâte fraîche Nathan", "Épicerie", "🌾")) + + add(CatalogItem("Pâte Nathan", "Épicerie", "🌾")) + + add(CatalogItem("Pâte sans gluten", "Épicerie", "🌾")) + + add(CatalogItem("Pâtes à pizza", "Épicerie", "🌾")) + + add(CatalogItem("Pâtes en sachet", "Épicerie", "🌾")) + + add(CatalogItem("Penne", "Épicerie", "🌾")) + + add(CatalogItem("Ramen", "Épicerie", "🌾")) + + add(CatalogItem("Ramen en pot", "Épicerie", "🌾")) + + add(CatalogItem("Raviolis", "Épicerie", "🌾")) + + add(CatalogItem("Rice", "Épicerie", "🌾")) + + add(CatalogItem("Rice noodles", "Épicerie", "🌾")) + + add(CatalogItem("Rice paper", "Épicerie", "🌾")) + + add(CatalogItem("Risotto rice", "Épicerie", "🌾")) + + add(CatalogItem("Riz instantané", "Épicerie", "🌾")) + + add(CatalogItem("Semolina", "Épicerie", "🌾")) + + add(CatalogItem("Spelt flour", "Épicerie", "🌾")) + + add(CatalogItem("Tagliatelle", "Épicerie", "🌾")) + + add(CatalogItem("Tortellini", "Épicerie", "🌾")) + + add(CatalogItem("Wild rice", "Épicerie", "🌾")) + + add(CatalogItem("Barre Briana", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Barre pommes cannelle", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Barre Quacker", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Barre tendre Briana", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Barre tendre Cliff", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Barre tendre Life", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Barre tendre Nathan pomme cannelle", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Biscuit au riz", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Biscuit sans lactose", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Biscuits mince aux légumes", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Biscuits pépites chocolat", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Bonbon", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Biscuits Thé Social", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Cake", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Candy", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Cereal bar", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Chewing gum", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Chips aux fèves", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Chips BBQ", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Chocolat Lindor pâle individuel", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Chocolate bar", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Christmas cookies", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Clif noisette", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Cookies", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Crackers", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Dried fruit", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Fraises", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Gauffres Briana", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Gingerbread", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Grosse gaufres", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Honey", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Jam", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Jelly", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Kit Burritos", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Lollis", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Marshmallows", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Mini guimauve", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Mini-wheat", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Nougat cream", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Nutella", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Panettone", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Party mix original", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Peanuts", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Pépites de chocolat sans lactose", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Pop corn", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Pretzels", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Pudding", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Roquet bonbon", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Snacks", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Timbits chocolat", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("Tortilla chips", "Snacks & Bonbons", "🍿")) + + add(CatalogItem("7up", "Boissons", "🥤")) + + add(CatalogItem("Apple juice", "Boissons", "🥤")) + + add(CatalogItem("Bacardi breeze ananas", "Boissons", "🥤")) + + add(CatalogItem("Beer", "Boissons", "🥤")) + + add(CatalogItem("Beverages", "Boissons", "🥤")) + + add(CatalogItem("Bottled water", "Boissons", "🥤")) + + add(CatalogItem("Bouteille d'eau", "Boissons", "🥤")) + + add(CatalogItem("Cacao Hershey", "Boissons", "🥤")) + + add(CatalogItem("Café instantané", "Boissons", "🥤")) + + add(CatalogItem("Cider", "Boissons", "🥤")) + + add(CatalogItem("Cidre de pomme", "Boissons", "🥤")) + + add(CatalogItem("Coffee", "Boissons", "🥤")) + + add(CatalogItem("Coffee beans", "Boissons", "🥤")) + + add(CatalogItem("Coffee capsules", "Boissons", "🥤")) + + add(CatalogItem("Coffee pads", "Boissons", "🥤")) + + add(CatalogItem("Déjeuner liquide", "Boissons", "🥤")) + + add(CatalogItem("Diet cola", "Boissons", "🥤")) + + add(CatalogItem("Diet soda", "Boissons", "🥤")) + + add(CatalogItem("Eau Perrier", "Boissons", "🥤")) + + add(CatalogItem("Energy drink", "Boissons", "🥤")) + + add(CatalogItem("Fruit juice", "Boissons", "🥤")) + + add(CatalogItem("Gin", "Boissons", "🥤")) + + add(CatalogItem("Ginger ale", "Boissons", "🥤")) + + add(CatalogItem("Herbal tea", "Boissons", "🥤")) + + add(CatalogItem("Hertel bouchon rose", "Boissons", "🥤")) + + add(CatalogItem("Hot chocolate", "Boissons", "🥤")) + + add(CatalogItem("Iced tea", "Boissons", "🥤")) + + add(CatalogItem("Jus avoine chocolat lunch", "Boissons", "🥤")) + + add(CatalogItem("Jus de citron", "Boissons", "🥤")) + + add(CatalogItem("Jus de lime", "Boissons", "🥤")) + + add(CatalogItem("Jus de mangue", "Boissons", "🥤")) + + add(CatalogItem("Jus de pomme lunch", "Boissons", "🥤")) + + add(CatalogItem("Jus de tomate", "Boissons", "🥤")) + + add(CatalogItem("Jus de tomate lunch", "Boissons", "🥤")) + + add(CatalogItem("Jus de vrais fruits", "Boissons", "🥤")) + + add(CatalogItem("Jus fraises bananes", "Boissons", "🥤")) + + add(CatalogItem("Jus kiwi", "Boissons", "🥤")) + + add(CatalogItem("Jus lunch", "Boissons", "🥤")) + + add(CatalogItem("Jus lunch limonade", "Boissons", "🥤")) + + add(CatalogItem("Jus orange congelé", "Boissons", "🥤")) + + add(CatalogItem("Jus ou lait collation", "Boissons", "🥤")) + + add(CatalogItem("Jus tropical", "Boissons", "🥤")) + + add(CatalogItem("Jus V-8", "Boissons", "🥤")) + + add(CatalogItem("L'eau de coco", "Boissons", "🥤")) + + add(CatalogItem("Liqueur pommes grenade", "Boissons", "🥤")) + + add(CatalogItem("Orange juice", "Boissons", "🥤")) + + add(CatalogItem("Pepsi", "Boissons", "🥤")) + + add(CatalogItem("Prosecco", "Boissons", "🥤")) + + add(CatalogItem("Punch", "Boissons", "🥤")) + + add(CatalogItem("Red wine", "Boissons", "🥤")) + + add(CatalogItem("Root beer", "Boissons", "🥤")) + + add(CatalogItem("Rum", "Boissons", "🥤")) + + add(CatalogItem("Sirop de cannes", "Boissons", "🥤")) + + add(CatalogItem("Soda stream", "Boissons", "🥤")) + + add(CatalogItem("Spirits", "Boissons", "🥤")) + + add(CatalogItem("Sports drink", "Boissons", "🥤")) + + add(CatalogItem("Tea", "Boissons", "🥤")) + + add(CatalogItem("Thé glacé", "Boissons", "🥤")) + + add(CatalogItem("Thé Tetley", "Boissons", "🥤")) + + add(CatalogItem("Tonic water", "Boissons", "🥤")) + + add(CatalogItem("Vin", "Boissons", "🥤")) + + add(CatalogItem("Vodka", "Boissons", "🥤")) + + add(CatalogItem("Water", "Boissons", "🥤")) + + add(CatalogItem("Whiskey", "Boissons", "🥤")) + + add(CatalogItem("White wine", "Boissons", "🥤")) + + add(CatalogItem("Alcool ménager", "Maison", "🏠")) + + add(CatalogItem("Aluminum foil", "Maison", "🏠")) + + add(CatalogItem("Baby food", "Maison", "🏠")) + + add(CatalogItem("Balloon", "Maison", "🏠")) + + add(CatalogItem("Barbecue lighter", "Maison", "🏠")) + + add(CatalogItem("Barbecue tongs", "Maison", "🏠")) + + add(CatalogItem("Bathroom cleaner", "Maison", "🏠")) + + add(CatalogItem("Batteries", "Maison", "🏠")) + + add(CatalogItem("Bicarbonate de soude", "Maison", "🏠")) + + add(CatalogItem("Boîte à chaussures", "Maison", "🏠")) + + add(CatalogItem("Boule cannelle Briana", "Maison", "🏠")) + + add(CatalogItem("Bowl", "Maison", "🏠")) + + add(CatalogItem("Brosse à cheveux", "Maison", "🏠")) + + add(CatalogItem("Candles", "Maison", "🏠")) + + add(CatalogItem("Cap", "Maison", "🏠")) + + add(CatalogItem("Cherries multi grain", "Maison", "🏠")) + + add(CatalogItem("Christmas tree candles", "Maison", "🏠")) + + add(CatalogItem("Christmas tree ornaments", "Maison", "🏠")) + + add(CatalogItem("Cinnamon stars", "Maison", "🏠")) + + add(CatalogItem("Cleaning rags", "Maison", "🏠")) + + add(CatalogItem("Cleaning supplies", "Maison", "🏠")) + + add(CatalogItem("Cocktail umbrellas", "Maison", "🏠")) + + add(CatalogItem("Cookie cutters", "Maison", "🏠")) + + add(CatalogItem("Costume", "Maison", "🏠")) + + add(CatalogItem("Coupe plastique", "Maison", "🏠")) + + add(CatalogItem("Cruche eau", "Maison", "🏠")) + + add(CatalogItem("Cuillère en plastique", "Maison", "🏠")) + + add(CatalogItem("Cure-dents", "Maison", "🏠")) + + add(CatalogItem("Démêlant à cheveux", "Maison", "🏠")) + + add(CatalogItem("Deodorant", "Maison", "🏠")) + + add(CatalogItem("Descaling agent", "Maison", "🏠")) + + add(CatalogItem("Désodorisant Danielle", "Maison", "🏠")) + + add(CatalogItem("Dishwasher salt", "Maison", "🏠")) + + add(CatalogItem("Dishwasher tabs", "Maison", "🏠")) + + add(CatalogItem("Dishwashing liquid", "Maison", "🏠")) + + add(CatalogItem("Drain cleaner", "Maison", "🏠")) + + add(CatalogItem("Eau de Javel", "Maison", "🏠")) + + add(CatalogItem("Envelopes", "Maison", "🏠")) + + add(CatalogItem("Eraser", "Maison", "🏠")) + + add(CatalogItem("Fabric softener", "Maison", "🏠")) + + add(CatalogItem("Feather duster", "Maison", "🏠")) + + add(CatalogItem("Filtre Brita", "Maison", "🏠")) + + add(CatalogItem("Flashlight", "Maison", "🏠")) + + add(CatalogItem("Fromage en plastique", "Maison", "🏠")) + + add(CatalogItem("Flowers", "Maison", "🏠")) + + add(CatalogItem("Furniture polish", "Maison", "🏠")) + + add(CatalogItem("Gant de vaisselle", "Maison", "🏠")) + + add(CatalogItem("Gift", "Maison", "🏠")) + + add(CatalogItem("Gift ribbon", "Maison", "🏠")) + + add(CatalogItem("Glass cleaner", "Maison", "🏠")) + + add(CatalogItem("Gloves", "Maison", "🏠")) + + add(CatalogItem("Gruau en sachet", "Maison", "🏠")) + + add(CatalogItem("Juicer", "Maison", "🏠")) + + add(CatalogItem("Kleenex", "Maison", "🏠")) + + add(CatalogItem("Kleenex petit sac", "Maison", "🏠")) + + add(CatalogItem("Laundry detergent", "Maison", "🏠")) + + add(CatalogItem("Lighter", "Maison", "🏠")) + + add(CatalogItem("Lime à ongles", "Maison", "🏠")) + + add(CatalogItem("Light bulb", "Maison", "🏠")) + + add(CatalogItem("Lingette Lysol", "Maison", "🏠")) + + add(CatalogItem("Marker", "Maison", "🏠")) + + add(CatalogItem("Mozza stick", "Maison", "🏠")) + + add(CatalogItem("Mr freez", "Maison", "🏠")) + + add(CatalogItem("Mr Net", "Maison", "🏠")) + + add(CatalogItem("Multi-purpose cleaner", "Maison", "🏠")) + + add(CatalogItem("Napkins", "Maison", "🏠")) + + add(CatalogItem("Notepad", "Maison", "🏠")) + + add(CatalogItem("Paper", "Maison", "🏠")) + + add(CatalogItem("Paper towels", "Maison", "🏠")) + + add(CatalogItem("Paperclips", "Maison", "🏠")) + + add(CatalogItem("Papier de toilette", "Maison", "🏠")) + + add(CatalogItem("Papier pellicule", "Maison", "🏠")) + + add(CatalogItem("Parchment paper", "Maison", "🏠")) + + add(CatalogItem("Pastry brush", "Maison", "🏠")) + + add(CatalogItem("Pâte à dents", "Maison", "🏠")) + + add(CatalogItem("Pen", "Maison", "🏠")) + + add(CatalogItem("Pencil", "Maison", "🏠")) + + add(CatalogItem("Plastic wrap", "Maison", "🏠")) + + add(CatalogItem("Post-it's", "Maison", "🏠")) + + add(CatalogItem("Pot de bébé", "Maison", "🏠")) + + add(CatalogItem("Protège-dessous", "Maison", "🏠")) + + add(CatalogItem("Q-tips", "Maison", "🏠")) + + add(CatalogItem("Reflectors", "Maison", "🏠")) + + add(CatalogItem("Rince-bouche", "Maison", "🏠")) + + add(CatalogItem("Rince lave-vaisselle", "Maison", "🏠")) + + add(CatalogItem("Rubber gloves", "Maison", "🏠")) + + add(CatalogItem("Sac à vidange", "Maison", "🏠")) + + add(CatalogItem("Sac compost", "Maison", "🏠")) + + add(CatalogItem("Sac de bean St Arnaud", "Maison", "🏠")) + + add(CatalogItem("Sac de poubelles", "Maison", "🏠")) + + add(CatalogItem("Sac poubelles bio", "Maison", "🏠")) + + add(CatalogItem("Sac Ziploc grand", "Maison", "🏠")) + + add(CatalogItem("Sac Ziploc zip", "Maison", "🏠")) + + add(CatalogItem("Sacs à compostage", "Maison", "🏠")) + + add(CatalogItem("Sandwich crème glacée", "Maison", "🏠")) + + add(CatalogItem("Savon à linge", "Maison", "🏠")) + + add(CatalogItem("Savon à mains", "Maison", "🏠")) + + add(CatalogItem("Savon à vaisselle", "Maison", "🏠")) + + add(CatalogItem("Savon Dove", "Maison", "🏠")) + + add(CatalogItem("Savon face Nathan", "Maison", "🏠")) + + add(CatalogItem("Savon lavé-vaisselle", "Maison", "🏠")) + + add(CatalogItem("Savon vaisselle liquide", "Maison", "🏠")) + + add(CatalogItem("Savon vert", "Maison", "🏠")) + + add(CatalogItem("Scarf", "Maison", "🏠")) + + add(CatalogItem("Sharpener", "Maison", "🏠")) + + add(CatalogItem("Skewers", "Maison", "🏠")) + + add(CatalogItem("Sol produit", "Maison", "🏠")) + + add(CatalogItem("Spatula", "Maison", "🏠")) + + add(CatalogItem("Sponge", "Maison", "🏠")) + + add(CatalogItem("Swiffer wet", "Maison", "🏠")) + + add(CatalogItem("Table bomb", "Maison", "🏠")) + + add(CatalogItem("Tape à bandage", "Maison", "🏠")) + + add(CatalogItem("Tinsel", "Maison", "🏠")) + + add(CatalogItem("Toilet brush", "Maison", "🏠")) + + add(CatalogItem("Toilet cleaner", "Maison", "🏠")) + + add(CatalogItem("Toner", "Maison", "🏠")) + + add(CatalogItem("Tylenol extra fort", "Maison", "🏠")) + + add(CatalogItem("Vacuum cleaner bags", "Maison", "🏠")) + + add(CatalogItem("Verres en plastique", "Maison", "🏠")) + + add(CatalogItem("Whisk", "Maison", "🏠")) + + add(CatalogItem("Windex", "Maison", "🏠")) + + add(CatalogItem("Wool socks", "Maison", "🏠")) + + add(CatalogItem("Wrapping paper", "Maison", "🏠")) + + add(CatalogItem("After sun", "Santé", "💊")) + + add(CatalogItem("Aftershave", "Santé", "💊")) + + add(CatalogItem("Antiseptic cream", "Santé", "💊")) + + add(CatalogItem("Bandages", "Santé", "💊")) + + add(CatalogItem("Bath essence", "Santé", "💊")) + + add(CatalogItem("Bath salt", "Santé", "💊")) + + add(CatalogItem("Beard oil", "Santé", "💊")) + + add(CatalogItem("Blister plaster", "Santé", "💊")) + + add(CatalogItem("Body lotion", "Santé", "💊")) + + add(CatalogItem("Charcoal tablets", "Santé", "💊")) + + add(CatalogItem("Children's face painting", "Santé", "💊")) + + add(CatalogItem("Compresses", "Santé", "💊")) + + add(CatalogItem("Conditioner", "Santé", "💊")) + + add(CatalogItem("Condoms", "Santé", "💊")) + + add(CatalogItem("Contact lens solution", "Santé", "💊")) + + add(CatalogItem("Cooling gel", "Santé", "💊")) + + add(CatalogItem("Cotton pads", "Santé", "💊")) + + add(CatalogItem("Cotton swabs", "Santé", "💊")) + + add(CatalogItem("Cough sweet", "Santé", "💊")) + + add(CatalogItem("Crème Aveeno", "Santé", "💊")) + + add(CatalogItem("Dental floss", "Santé", "💊")) + + add(CatalogItem("Diapers", "Santé", "💊")) + + add(CatalogItem("Disinfectant spray", "Santé", "💊")) + + add(CatalogItem("Eye drops", "Santé", "💊")) + + add(CatalogItem("Face cream", "Santé", "💊")) + + add(CatalogItem("Face mask", "Santé", "💊")) + + add(CatalogItem("Facial tissues", "Santé", "💊")) + + add(CatalogItem("Hair gel", "Santé", "💊")) + + add(CatalogItem("Hair oil", "Santé", "💊")) + + add(CatalogItem("Hair spray", "Santé", "💊")) + + add(CatalogItem("Hand cream", "Santé", "💊")) + + add(CatalogItem("Insect repellent", "Santé", "💊")) + + add(CatalogItem("Lip balm", "Santé", "💊")) + + add(CatalogItem("Lipstick", "Santé", "💊")) + + add(CatalogItem("Makeup remover", "Santé", "💊")) + + add(CatalogItem("Mouthwash", "Santé", "💊")) + + add(CatalogItem("Muscle cream", "Santé", "💊")) + + add(CatalogItem("Nail file", "Santé", "💊")) + + add(CatalogItem("Nail polish", "Santé", "💊")) + + add(CatalogItem("Nail polish remover", "Santé", "💊")) + + add(CatalogItem("Nasal ointment", "Santé", "💊")) + + add(CatalogItem("Ointment", "Santé", "💊")) + + add(CatalogItem("Pads", "Santé", "💊")) + + add(CatalogItem("Pain reliever", "Santé", "💊")) + + add(CatalogItem("Peeling", "Santé", "💊")) + + add(CatalogItem("Perfume", "Santé", "💊")) + + add(CatalogItem("Plasters", "Santé", "💊")) + + add(CatalogItem("Powder", "Santé", "💊")) + + add(CatalogItem("Razor", "Santé", "💊")) + + add(CatalogItem("Razor blades", "Santé", "💊")) + + add(CatalogItem("Shampoo", "Santé", "💊")) + + add(CatalogItem("Shaving cream", "Santé", "💊")) + + add(CatalogItem("Shower gel", "Santé", "💊")) + + add(CatalogItem("Soap", "Santé", "💊")) + + add(CatalogItem("Sunblock", "Santé", "💊")) + + add(CatalogItem("Thermometer", "Santé", "💊")) + + add(CatalogItem("Tissues", "Santé", "💊")) + + add(CatalogItem("Toilet paper", "Santé", "💊")) + + add(CatalogItem("Toothbrush", "Santé", "💊")) + + add(CatalogItem("Toothpaste", "Santé", "💊")) + + add(CatalogItem("Tweezers", "Santé", "💊")) + + add(CatalogItem("Vitamins", "Santé", "💊")) + + add(CatalogItem("Wet wipes", "Santé", "💊")) + + add(CatalogItem("Bird food", "Animaux", "🐾")) + + add(CatalogItem("Cat food", "Animaux", "🐾")) + + add(CatalogItem("Cat litter", "Animaux", "🐾")) + + add(CatalogItem("Cat treats", "Animaux", "🐾")) + + add(CatalogItem("Croquettes de poulet", "Animaux", "🐾")) + + add(CatalogItem("Croquettes Minouche", "Animaux", "🐾")) + + add(CatalogItem("Dog food", "Animaux", "🐾")) + + add(CatalogItem("Dog treats", "Animaux", "🐾")) + + add(CatalogItem("Fish food", "Animaux", "🐾")) + + add(CatalogItem("Gâterie pour chat", "Animaux", "🐾")) + + add(CatalogItem("Pâté pour chat", "Animaux", "🐾")) + + add(CatalogItem("Bâton de colle blanche", "Jardin", "🌱")) + + add(CatalogItem("Bolts", "Jardin", "🌱")) + + add(CatalogItem("Briquettes", "Jardin", "🌱")) + + add(CatalogItem("Brush", "Jardin", "🌱")) + + add(CatalogItem("Charcoal", "Jardin", "🌱")) + + add(CatalogItem("Dibble", "Jardin", "🌱")) + + add(CatalogItem("Fertilizer", "Jardin", "🌱")) + + add(CatalogItem("Flower trowel", "Jardin", "🌱")) + + add(CatalogItem("Garden tool", "Jardin", "🌱")) + + add(CatalogItem("Gardening gloves", "Jardin", "🌱")) + + add(CatalogItem("Grill", "Jardin", "🌱")) + + add(CatalogItem("Hedge shears", "Jardin", "🌱")) + + add(CatalogItem("Hoe", "Jardin", "🌱")) + + add(CatalogItem("Lawnmower", "Jardin", "🌱")) + + add(CatalogItem("Nails", "Jardin", "🌱")) + + add(CatalogItem("Parasol", "Jardin", "🌱")) + + add(CatalogItem("Pesticides", "Jardin", "🌱")) + + add(CatalogItem("Planter box", "Jardin", "🌱")) + + add(CatalogItem("Plants", "Jardin", "🌱")) + + add(CatalogItem("Plat plastique jetable", "Jardin", "🌱")) + + add(CatalogItem("Pots", "Jardin", "🌱")) + + add(CatalogItem("Potting soil", "Jardin", "🌱")) + + add(CatalogItem("Propane", "Jardin", "🌱")) + + add(CatalogItem("Road salt", "Jardin", "🌱")) + + add(CatalogItem("Seeds", "Jardin", "🌱")) + + add(CatalogItem("Seedlings", "Jardin", "🌱")) + + add(CatalogItem("Snow chains", "Jardin", "🌱")) + + add(CatalogItem("Snow shovel", "Jardin", "🌱")) + + add(CatalogItem("Watering can", "Jardin", "🌱")) + + add(CatalogItem("Arachides", "Épicerie", "🌾")) + + add(CatalogItem("Bande élastique genou", "Épicerie", "🌾")) + + add(CatalogItem("Barre Nathan", "Épicerie", "🌾")) + + add(CatalogItem("Barre tendre", "Épicerie", "🌾")) + + add(CatalogItem("Boisson atypique", "Épicerie", "🌾")) + + add(CatalogItem("Boost", "Épicerie", "🌾")) + + add(CatalogItem("Canneberges", "Épicerie", "🌾")) + + add(CatalogItem("Carte bus Nathan", "Épicerie", "🌾")) + + add(CatalogItem("Carte Jérémy", "Épicerie", "🌾")) + + add(CatalogItem("Cereal Cheerios", "Épicerie", "🌾")) + + add(CatalogItem("Cereal Chex cannelle", "Épicerie", "🌾")) + + add(CatalogItem("Cereal Life aux son", "Épicerie", "🌾")) + + add(CatalogItem("Cereal Pop", "Épicerie", "🌾")) + + add(CatalogItem("Chasse-tâches", "Épicerie", "🌾")) + + add(CatalogItem("Cheese whiz", "Épicerie", "🌾")) + + add(CatalogItem("Cheetos", "Épicerie", "🌾")) + + add(CatalogItem("Colorant alimentaire", "Épicerie", "🌾")) + + add(CatalogItem("Côtes levées", "Épicerie", "🌾")) + + add(CatalogItem("Épice italienne", "Épicerie", "🌾")) + + add(CatalogItem("Éponge Me Net", "Épicerie", "🌾")) + + add(CatalogItem("Friche épicée", "Épicerie", "🌾")) + + add(CatalogItem("Graines d'oiseaux", "Épicerie", "🌾")) + + add(CatalogItem("Gruau", "Épicerie", "🌾")) + + add(CatalogItem("Harvest Crunch", "Épicerie", "🌾")) + + add(CatalogItem("Herbamare", "Épicerie", "🌾")) + + add(CatalogItem("Jos Louis", "Épicerie", "🌾")) + + add(CatalogItem("Kit taco", "Épicerie", "🌾")) + + add(CatalogItem("Kraft dinner", "Épicerie", "🌾")) + + add(CatalogItem("Le souper", "Épicerie", "🌾")) + + add(CatalogItem("Limonade congelée", "Épicerie", "🌾")) + + add(CatalogItem("Liqueur", "Épicerie", "🌾")) + + add(CatalogItem("Mine de crayon 0.7", "Épicerie", "🌾")) + + add(CatalogItem("Muffin anglais", "Épicerie", "🌾")) + + add(CatalogItem("Omega 3", "Épicerie", "🌾")) + + add(CatalogItem("Peanuts salés", "Épicerie", "🌾")) + + add(CatalogItem("Pepperoni", "Épicerie", "🌾")) + + add(CatalogItem("Photo", "Épicerie", "🌾")) + + add(CatalogItem("Pilule allergie", "Épicerie", "🌾")) + + add(CatalogItem("Pilule Briana", "Épicerie", "🌾")) + + add(CatalogItem("Pilule Bruno", "Épicerie", "🌾")) + + add(CatalogItem("Pilule Brunone", "Épicerie", "🌾")) + + add(CatalogItem("Piments verts", "Épicerie", "🌾")) + + add(CatalogItem("Pomme Paulared Melba McIntosh", "Épicerie", "🌾")) + + add(CatalogItem("Pomme verte", "Épicerie", "🌾")) + + add(CatalogItem("Rice Krispies", "Épicerie", "🌾")) + + add(CatalogItem("Sachet sloppy joes", "Épicerie", "🌾")) + + add(CatalogItem("Saumon fumé", "Épicerie", "🌾")) + + add(CatalogItem("Sent bon", "Épicerie", "🌾")) + + add(CatalogItem("Trappe fourmis", "Épicerie", "🌾")) + + add(CatalogItem("Trappe souris", "Épicerie", "🌾")) + + add(CatalogItem("Viande de bœuf", "Épicerie", "🌾")) + + add(CatalogItem("Vim", "Épicerie", "🌾")) + } + + /** Items pour une catégorie donnée (ordre catalogue). */ + fun itemsForCategory(category: String): List = items.filter { it.category == category } + + /** + * Recherche dans le catalogue. Retourne au maximum [limit] résultats triés par + * pertinence : préfixe d'abord, puis sous-chaîne. + */ + fun search( + query: String, + limit: Int = 8, + ): List { val q = query.trim().lowercase() - if (q.isEmpty()) return true - if (name.lowercase().contains(q)) return true - return aliases.any { it.lowercase().contains(q) } + if (q.isEmpty()) return emptyList() + val prefix = items.filter { it.name.lowercase().startsWith(q) } + val contains = + items.filter { + !it.name.lowercase().startsWith(q) && it.matches(q) + } + return (prefix + contains).take(limit) + } + + /** + * Suggestions populaires (utilisées dans la barre de saisie quand vide, + * équivalent du panneau "Vous avez sûrement besoin"). + */ + val popularSuggestions: List = + listOf( + items.first { it.name == "Lait" }, + items.first { it.name == "Pain" }, + items.first { it.name == "Œufs" }, + items.first { it.name == "Beurre" }, + items.first { it.name == "Pomme" }, + items.first { it.name == "Pâtes" }, + items.first { it.name == "Tomate" }, + items.first { it.name == "Yaourt" }, + items.first { it.name == "Papier toilette" }, + ) + + /** + * Retourne un emoji représentatif pour un nom d'article libre. Utilise d'abord + * une correspondance exacte puis un repli par catégorie. + */ + fun emojiFor( + name: String, + category: String?, + ): String { + val direct = items.firstOrNull { it.name.equals(name, ignoreCase = true) } + if (direct != null) return direct.emoji + return when (category) { + "Fruits & Légumes" -> "🥗" + "Boulangerie" -> "🥖" + "Produits laitiers" -> "🥛" + "Boucherie" -> "🥩" + "Épicerie" -> "🛒" + "Condiments & Épices" -> "🌶️" + "Surgelés" -> "🧊" + "Snacks & Bonbons" -> "🍿" + "Boissons" -> "🥤" + "Hygiène" -> "🧴" + "Entretien" -> "🧹" + "Bébé" -> "👶" + "Animaux" -> "🐾" + "Maison & Jardin" -> "🏡" + else -> "📦" + } } } - - /** Toutes les sections catalogue, dans l'ordre d'affichage. */ - val categories: List = listOf( - "Fruits & Légumes", - "Boulangerie", - "Produits laitiers", - "Boucherie", - "Épicerie", - "Condiments & Épices", - "Surgelés", - "Snacks & Bonbons", - "Boissons", - "Hygiène", - "Entretien", - "Bébé", - "Animaux", - "Maison & Jardin" - ) - - /** Liste plate du catalogue. */ - val items: List = buildList { - // Fruits & Légumes - add(CatalogItem("Pomme", "Fruits & Légumes", "🍎", listOf("apple", "apples", "pommes"), listOf("Gala", "Cortland", "Honeycrisp", "Granny Smith", "Fuji", "McIntosh"))) - add(CatalogItem("Banane", "Fruits & Légumes", "🍌", listOf("banana", "bananas"))) - add(CatalogItem("Orange", "Fruits & Légumes", "🍊", listOf("oranges"))) - add(CatalogItem("Citron", "Fruits & Légumes", "🍋", listOf("lemon"))) - add(CatalogItem("Fraise", "Fruits & Légumes", "🍓", listOf("strawberry", "strawberries"))) - add(CatalogItem("Raisin", "Fruits & Légumes", "🍇", listOf("grapes"), listOf("Vert", "Rouge", "Sans pépins"))) - add(CatalogItem("Poire", "Fruits & Légumes", "🍐", listOf("pear", "pears", "poires"))) - add(CatalogItem("Tomate", "Fruits & Légumes", "🍅", listOf("tomatoes"), listOf("Baby", "Cherry", "Diced", "Roma", "Sundried"))) - add(CatalogItem("Salade", "Fruits & Légumes", "🥬", listOf("lettuce", "salad"))) - add(CatalogItem("Carotte", "Fruits & Légumes", "🥕", listOf("carrots", "carrotte"), listOf("Baby", "Râpée", "Bio"))) - add(CatalogItem("Brocoli", "Fruits & Légumes", "🥦", listOf("broccoli"))) - add(CatalogItem("Concombre", "Fruits & Légumes", "🥒", listOf("cucumber"))) - add(CatalogItem("Poivron", "Fruits & Légumes", "🫑", listOf("bell pepper", "bell peppers"), listOf("Rouge", "Vert", "Jaune", "Orange"))) - add(CatalogItem("Avocat", "Fruits & Légumes", "🥑", listOf("avocado"))) - add(CatalogItem("Oignon", "Fruits & Légumes", "🧅", listOf("onions"), listOf("Blanc", "Rouge", "Vert", "Espagnol", "Vidalia"))) - add(CatalogItem("Ail", "Fruits & Légumes", "🧄", listOf("garlic"))) - add(CatalogItem("Pomme de terre", "Fruits & Légumes", "🥔", listOf("patate", "patates", "potatoes"), listOf("Régulière", "Grelot", "Douce", "Russet", "Yukon Gold"))) - add(CatalogItem("Champignon", "Fruits & Légumes", "🍄", listOf("mushrooms", "champignons"), listOf("Blanc", "Portobello", "Shiitake", "Crimini"))) - add(CatalogItem("Épinard", "Fruits & Légumes", "🥬", listOf("spinach"))) - add(CatalogItem("Ananas", "Fruits & Légumes", "🍍", listOf("pineapple"))) - add(CatalogItem("Pêche", "Fruits & Légumes", "🍑", listOf("peach"))) - add(CatalogItem("Cerise", "Fruits & Légumes", "🍒", listOf("cherries"))) - add(CatalogItem("Kiwi", "Fruits & Légumes", "🥝", listOf("kiwi fruit", "kiwis"))) - add(CatalogItem("Mangue", "Fruits & Légumes", "🥭", listOf("mangoes"))) - add(CatalogItem("Melon", "Fruits & Légumes", "🍈", listOf("melon"))) - add(CatalogItem("Pastèque", "Fruits & Légumes", "🍉", listOf("watermelon"))) - add(CatalogItem("Noix de coco", "Fruits & Légumes", "🥥", listOf("coconut"))) - add(CatalogItem("Aubergine", "Fruits & Légumes", "🍆", listOf("eggplant"))) - add(CatalogItem("Maïs", "Fruits & Légumes", "🌽", listOf("sweet corn", "corncobs", "corn cobs"))) - add(CatalogItem("Piment", "Fruits & Légumes", "🌶️", listOf("chillies", "chili", "piment jaune"), listOf("Jalapeño", "Serrano", "Habanero", "Chipotle"))) - add(CatalogItem("Courgette", "Fruits & Légumes", "🥒", listOf("zucchini"))) - add(CatalogItem("Chou-fleur", "Fruits & Légumes", "🥦", listOf("cauliflower"))) - add(CatalogItem("Chou", "Fruits & Légumes", "🥬", listOf("cabbage"))) - add(CatalogItem("Navet", "Fruits & Légumes", "🥕", listOf("turnip"))) - add(CatalogItem("Radis", "Fruits & Légumes", "🥕", listOf("radish"))) - add(CatalogItem("Poireau", "Fruits & Légumes", "🥬", listOf("leek", "leeks"))) - add(CatalogItem("Céleri", "Fruits & Légumes", "🥬", listOf("celery"))) - add(CatalogItem("Haricots verts", "Fruits & Légumes", "🫛", listOf("green beans"))) - add(CatalogItem("Petits pois", "Fruits & Légumes", "🫛", listOf("peas"))) - add(CatalogItem("Basilic", "Fruits & Légumes", "🌿", listOf("basil"))) - add(CatalogItem("Persil", "Fruits & Légumes", "🌿", listOf("parsley"))) - add(CatalogItem("Menthe", "Fruits & Légumes", "🌿", listOf("mint"))) - add(CatalogItem("Framboises", "Fruits & Légumes", "🍓", listOf("raspberries"))) - add(CatalogItem("Mûres", "Fruits & Légumes", "🫐", listOf("blackberries"))) - add(CatalogItem("Abricot", "Fruits & Légumes", "🍑", listOf("apricot"))) - add(CatalogItem("Prune", "Fruits & Légumes", "🍑", listOf("prunes"))) - add(CatalogItem("Figue", "Fruits & Légumes", "🍇", listOf("figs"))) - add(CatalogItem("Datte", "Fruits & Légumes", "🍇", listOf("dates"))) - add(CatalogItem("Grenade", "Fruits & Légumes", "🍎", listOf("pomegranate", "pomme grenade"))) - add(CatalogItem("Açaï", "Fruits & Légumes", "🫐", listOf("acai", "açaí", "açaí berries", "acai berries"))) - add(CatalogItem("Artichaut", "Fruits & Légumes", "🥬", listOf("artichokes"))) - add(CatalogItem("Roquette", "Fruits & Légumes", "🥬", listOf("arugula"))) - add(CatalogItem("Asperge", "Fruits & Légumes", "🥬", listOf("asparagus"))) - add(CatalogItem("Betterave", "Fruits & Légumes", "🫐", listOf("beetroot", "betterave"))) - add(CatalogItem("Myrtilles", "Fruits & Légumes", "🫐", listOf("blueberries", "bleuets"))) - add(CatalogItem("Bok choy", "Fruits & Légumes", "🥬", listOf("chinese cabbage"))) - add(CatalogItem("Chou de Bruxelles", "Fruits & Légumes", "🥬", listOf("brussels sprouts", "chou bruxelles"))) - add(CatalogItem("Courge butternut", "Fruits & Légumes", "🎃", listOf("butternut pumpkin", "butternut squash"))) - add(CatalogItem("Chou frisé", "Fruits & Légumes", "🥬", listOf("kale", "chard", "chou frisé"))) - add(CatalogItem("Poirée", "Fruits & Légumes", "🥬", listOf("swiss chard"))) - add(CatalogItem("Tomate cerise", "Fruits & Légumes", "🍅", listOf("cherry tomatoes"))) - add(CatalogItem("Châtaigne", "Fruits & Légumes", "🌰", listOf("chestnuts"))) - add(CatalogItem("Chicorée", "Fruits & Légumes", "🥬", listOf("chicory"))) - add(CatalogItem("Ciboulette", "Fruits & Légumes", "🌿", listOf("chives"))) - add(CatalogItem("Coriandre", "Fruits & Légumes", "🌿", listOf("cilantro"))) - add(CatalogItem("Clémentine", "Fruits & Légumes", "🍊", listOf("clementine", "clementines"))) - add(CatalogItem("Canneberge", "Fruits & Légumes", "🫐", listOf("cranberries"))) - add(CatalogItem("Cresson", "Fruits & Légumes", "🥬", listOf("cress"))) - add(CatalogItem("Crudités", "Fruits & Légumes", "🥗", listOf("crudites"))) - add(CatalogItem("Groseille", "Fruits & Légumes", "🍇", listOf("currants"))) - add(CatalogItem("Aneth", "Fruits & Légumes", "🌿", listOf("dill"))) - add(CatalogItem("Pamplemousse", "Fruits & Légumes", "🍊", listOf("grapefruit"))) - add(CatalogItem("Citronnelle", "Fruits & Légumes", "🌿", listOf("lemongrass"))) - add(CatalogItem("Mirabelle", "Fruits & Légumes", "🍑", listOf("mirabelles"))) - add(CatalogItem("Pitaya", "Fruits & Légumes", "🐉", listOf("dragon fruit", "fruit dragon"))) - add(CatalogItem("Échalote", "Fruits & Légumes", "🧅", listOf("shallots", "échalotes"))) - add(CatalogItem("Edamame", "Fruits & Légumes", "🫛")) - add(CatalogItem("Fenouil", "Fruits & Légumes", "🥬", listOf("fennel"))) - add(CatalogItem("Fève", "Fruits & Légumes", "🫛", listOf("fèves", "fava bean", "broad bean"))) - add(CatalogItem("Fève verte", "Fruits & Légumes", "🫛", listOf("fèves vertes"))) - add(CatalogItem("Graines de lin", "Fruits & Légumes", "🌾", listOf("flaxseed", "linseed"))) - add(CatalogItem("Fruit", "Fruits & Légumes", "🍎", listOf("fruits"))) - add(CatalogItem("Plateau fruits", "Fruits & Légumes", "🍎", listOf("fruit platter"))) - add(CatalogItem("Plateau légumes", "Fruits & Légumes", "🥗", listOf("vegetable platter"))) - add(CatalogItem("Baies de goji", "Fruits & Légumes", "🍇", listOf("goji berries"))) - add(CatalogItem("Groseille à maquereau", "Fruits & Légumes", "🍇", listOf("gooseberries"))) - add(CatalogItem("Goyave", "Fruits & Légumes", "🥭", listOf("guava"))) - add(CatalogItem("Citrouille Halloween", "Fruits & Légumes", "🎃", listOf("halloween pumpkin"))) - add(CatalogItem("Herbes", "Fruits & Légumes", "🌿", listOf("herbs"))) - add(CatalogItem("Citrouille Hokkaido", "Fruits & Légumes", "🎃", listOf("hokkaido pumpkin"))) - add(CatalogItem("Melon vert", "Fruits & Légumes", "🍈", listOf("honeydew melon"))) - add(CatalogItem("Mâche", "Fruits & Légumes", "🥬", listOf("lamb's lettuce", "corn salad"))) - add(CatalogItem("Citron vert", "Fruits & Légumes", "🍋", listOf("lime", "limes"))) - add(CatalogItem("Litchi", "Fruits & Légumes", "🥭", listOf("lychee", "lychees"))) - add(CatalogItem("Mandarine", "Fruits & Légumes", "🍊", listOf("mandarins", "mandarin"))) - add(CatalogItem("Marjolaine", "Fruits & Légumes", "🌿", listOf("marjoram"))) - add(CatalogItem("Nectarine", "Fruits & Légumes", "🍑", listOf("nectarines"))) - add(CatalogItem("Oignon rouge", "Fruits & Légumes", "🧅", listOf("red onion"))) - add(CatalogItem("Oignon vert", "Fruits & Légumes", "🧅", listOf("green onion", "spring onion", "scallions"))) - add(CatalogItem("Olive", "Fruits & Légumes", "🫒", listOf("olives"))) - add(CatalogItem("Papaye", "Fruits & Légumes", "🥭", listOf("papaya"))) - add(CatalogItem("Panais", "Fruits & Légumes", "🥕", listOf("parsnips"))) - add(CatalogItem("Fruit de la passion", "Fruits & Légumes", "🍇", listOf("passion fruit"))) - add(CatalogItem("Patate douce", "Fruits & Légumes", "🍠", listOf("sweet potatoes", "patate douce"))) - add(CatalogItem("Patates parisiennes", "Fruits & Légumes", "🥔", listOf("patates parisienne"))) - add(CatalogItem("Pousse de pois", "Fruits & Légumes", "🌱", listOf("pea eggplant"))) - add(CatalogItem("Pourpier", "Fruits & Légumes", "🥬", listOf("purslane"))) - add(CatalogItem("Coing", "Fruits & Légumes", "🍏", listOf("quinces"))) - add(CatalogItem("Chou rouge", "Fruits & Légumes", "🥬", listOf("red cabbage"))) - add(CatalogItem("Rhubarbe", "Fruits & Légumes", "🥬", listOf("rhubarb"))) - add(CatalogItem("Sauge", "Fruits & Légumes", "🌿", listOf("sage"))) - add(CatalogItem("Salade au chou", "Fruits & Légumes", "🥗", listOf("coleslaw"))) - add(CatalogItem("Chou de Milan", "Fruits & Légumes", "🥬", listOf("savoy cabbage"))) - add(CatalogItem("Courge", "Fruits & Légumes", "🎃", listOf("squash"))) - add(CatalogItem("Carambole", "Fruits & Légumes", "⭐", listOf("star fruit"))) - add(CatalogItem("Tomates séchées", "Fruits & Légumes", "🍅", listOf("sun-dried tomatoes"))) - add(CatalogItem("Basilic thaï", "Fruits & Légumes", "🌿", listOf("thai basil"))) - add(CatalogItem("Thym", "Fruits & Légumes", "🌿", listOf("thyme"))) - add(CatalogItem("Tomates Savoura", "Fruits & Légumes", "🍅", listOf("savoura tomatoes"))) - add(CatalogItem("Herbe de blé", "Fruits & Légumes", "🌾", listOf("wheatgrass"))) - add(CatalogItem("Salsifis noir", "Fruits & Légumes", "🥕", listOf("black salsify"))) - add(CatalogItem("Girolles", "Fruits & Légumes", "🍄", listOf("chanterelles"))) - add(CatalogItem("Orange sanguine", "Fruits & Légumes", "🍊", listOf("blood orange"))) - add(CatalogItem("Baies", "Fruits & Légumes", "🫐", listOf("berries"))) - - // Boulangerie - add(CatalogItem("Pain", "Boulangerie", "🍞", listOf("baguette"), listOf("Blanc", "Brun", "Complet", "Sans Gluten", "Multigrains"))) - add(CatalogItem("Baguette", "Boulangerie", "🥖")) - add(CatalogItem("Croissant", "Boulangerie", "🥐")) - add(CatalogItem("Brioche", "Boulangerie", "🥯")) - add(CatalogItem("Pain de mie", "Boulangerie", "🍞")) - add(CatalogItem("Biscotte", "Boulangerie", "🍞")) - add(CatalogItem("Tortillas", "Boulangerie", "🌯")) - add(CatalogItem("Pain complet", "Boulangerie", "🍞")) - add(CatalogItem("Pain aux céréales", "Boulangerie", "🍞")) - add(CatalogItem("Pain de seigle", "Boulangerie", "🍞")) - add(CatalogItem("Bagel", "Boulangerie", "🥯")) - add(CatalogItem("Muffin", "Boulangerie", "🧁")) - add(CatalogItem("Donut", "Boulangerie", "🍩")) - add(CatalogItem("Pain au chocolat", "Boulangerie", "🥐")) - add(CatalogItem("Chausson aux pommes", "Boulangerie", "🥐")) - add(CatalogItem("Éclair", "Boulangerie", "🍰")) - add(CatalogItem("Tarte", "Boulangerie", "🥧")) - add(CatalogItem("Gâteau", "Boulangerie", "🍰")) - add(CatalogItem("Bagel briana", "Boulangerie", "🥯")) - add(CatalogItem("Biscuits breton sans gluten", "Boulangerie", "🍪")) - add(CatalogItem("Biscuits Feuille D'Érable", "Boulangerie", "🍁")) - add(CatalogItem("Biscuits sans gluten", "Boulangerie", "🍪")) - add(CatalogItem("Biscuits swiss", "Boulangerie", "🍪")) - add(CatalogItem("Bread", "Boulangerie", "🍞")) - add(CatalogItem("Buns", "Boulangerie", "🍞")) - add(CatalogItem("Crispbread", "Boulangerie", "🍘")) - add(CatalogItem("Croûte à tarte", "Boulangerie", "🥧")) - add(CatalogItem("Dinner Rolls", "Boulangerie", "🍞")) - add(CatalogItem("Fond de tarte", "Boulangerie", "🥧")) - add(CatalogItem("Galette tortilla", "Boulangerie", "🌯")) - add(CatalogItem("Gâteau mille feuilles", "Boulangerie", "🍰")) - add(CatalogItem("Gaufres", "Boulangerie", "🧇")) - add(CatalogItem("Muffins Anglais", "Boulangerie", "🥯")) - add(CatalogItem("Pain Baguette", "Boulangerie", "🥖")) - add(CatalogItem("Pain Blanc", "Boulangerie", "🍞")) - add(CatalogItem("Pain Blanc Sans Lactose", "Boulangerie", "🍞")) - add(CatalogItem("Pain Bon Matin", "Boulangerie", "🍞")) - add(CatalogItem("Pain Briana", "Boulangerie", "🍞")) - add(CatalogItem("Pain brun", "Boulangerie", "🍞")) - add(CatalogItem("Pain burgers", "Boulangerie", "🍔")) - add(CatalogItem("Pain burgers sans gluten", "Boulangerie", "🍔")) - add(CatalogItem("Pain Croûte", "Boulangerie", "🍞")) - add(CatalogItem("Pain croûté sans gluten", "Boulangerie", "🍞")) - add(CatalogItem("Pain hamburger sans gluten", "Boulangerie", "🍔")) - add(CatalogItem("Pain Hot Dog", "Boulangerie", "🌭")) - add(CatalogItem("Pain Italien", "Boulangerie", "🥖")) - add(CatalogItem("Pain Sans Gluten", "Boulangerie", "🍞")) - add(CatalogItem("Pain Sans Lactose", "Boulangerie", "🍞")) - add(CatalogItem("Pain Sous Marin", "Boulangerie", "🥖")) - add(CatalogItem("Pains Grand Mère", "Boulangerie", "🍞")) - add(CatalogItem("Pancakes mix", "Boulangerie", "🥞")) - add(CatalogItem("Petit Pain Sandwich", "Boulangerie", "🥪")) - add(CatalogItem("Petit Pain Sous Marin", "Boulangerie", "🥖")) - add(CatalogItem("Pie", "Boulangerie", "🥧")) - add(CatalogItem("Pizza dough", "Boulangerie", "🍕")) - add(CatalogItem("Pizza sans gluten", "Boulangerie", "🍕")) - add(CatalogItem("Puff pastry", "Boulangerie", "🥐")) - add(CatalogItem("Pumpkin Pie", "Boulangerie", "🥧")) - add(CatalogItem("Rolls", "Boulangerie", "🍞")) - add(CatalogItem("Scones", "Boulangerie", "🧁")) - add(CatalogItem("Sliced bread", "Boulangerie", "🍞")) - add(CatalogItem("Toast", "Boulangerie", "🍞")) - add(CatalogItem("Toast melba", "Boulangerie", "🍞")) - add(CatalogItem("Tortilla", "Boulangerie", "🌯")) - add(CatalogItem("Vol au vent", "Boulangerie", "🥐")) - add(CatalogItem("Waffles", "Boulangerie", "🧇")) - - // Produits laitiers - add(CatalogItem("Lait", "Produits laitiers", "🥛", listOf("milk"), listOf("Entier", "2%", "1%", "Écrémé", "Sans Lactose", "Avoine", "Amande", "Soya"))) - add(CatalogItem("Yaourt", "Produits laitiers", "🥣", listOf("yogurt"), listOf("Grec", "Nature", "Vaniille", "Fraise", "Sans Lactose"))) - add(CatalogItem("Beurre", "Produits laitiers", "🧈")) - add(CatalogItem("Fromage", "Produits laitiers", "🧀", listOf("cheese"), listOf("Cheddar", "Mozzarella", "Parmésan", "Feta", "Brie", "Emmental", "Chèvre"))) - add(CatalogItem("Crème fraîche", "Produits laitiers", "🥛")) - add(CatalogItem("Œufs", "Produits laitiers", "🥚", listOf("oeufs", "eggs"), listOf("Gros", "Très Gros", "Moyen", "Bio", "Libre Parcours"))) - add(CatalogItem("Mozzarella", "Produits laitiers", "🧀")) - add(CatalogItem("Parmesan", "Produits laitiers", "🧀")) - add(CatalogItem("Cheddar", "Produits laitiers", "🧀")) - add(CatalogItem("Emmental", "Produits laitiers", "🧀")) - add(CatalogItem("Camembert", "Produits laitiers", "🧀")) - add(CatalogItem("Brie", "Produits laitiers", "🧀")) - add(CatalogItem("Chèvre", "Produits laitiers", "🧀")) - add(CatalogItem("Roquefort", "Produits laitiers", "🧀")) - add(CatalogItem("Gorgonzola", "Produits laitiers", "🧀")) - add(CatalogItem("Feta", "Produits laitiers", "🧀")) - add(CatalogItem("Ricotta", "Produits laitiers", "🧀")) - add(CatalogItem("Mascarpone", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage blanc", "Produits laitiers", "🥛")) - add(CatalogItem("Cottage cheese", "Produits laitiers", "🧀")) - add(CatalogItem("Crème liquide", "Produits laitiers", "🥛")) - add(CatalogItem("Lait concentré", "Produits laitiers", "🥛")) - add(CatalogItem("Lait de soja", "Produits laitiers", "🥛")) - add(CatalogItem("Lait d'amande", "Produits laitiers", "🥛")) - add(CatalogItem("Margarine", "Produits laitiers", "🧈")) - add(CatalogItem("Beurre Sans Lactose", "Produits laitiers", "🧈")) - add(CatalogItem("Cream", "Produits laitiers", "🥛")) - add(CatalogItem("Cream cheese", "Produits laitiers", "🧀")) - add(CatalogItem("Crème 10%", "Produits laitiers", "🥛")) - add(CatalogItem("Crème à café", "Produits laitiers", "🥛")) - add(CatalogItem("Creme fraiche", "Produits laitiers", "🥛")) - add(CatalogItem("Crème Glacée", "Produits laitiers", "🍨")) - add(CatalogItem("Crème glacée Sans Lactose", "Produits laitiers", "🍨")) - add(CatalogItem("Crème Glacée Sans Lactose Au Chocolat", "Produits laitiers", "🍨")) - add(CatalogItem("Crème Sans Lactose", "Produits laitiers", "🥛")) - add(CatalogItem("Crème Sure", "Produits laitiers", "🥛")) - add(CatalogItem("Sour cream", "Produits laitiers", "🥛")) - add(CatalogItem("Blue cheese", "Produits laitiers", "🧀")) - add(CatalogItem("Crotte De Fromage", "Produits laitiers", "🧀")) - add(CatalogItem("Demi Lune", "Produits laitiers", "🧀")) - add(CatalogItem("Fondue Fromage", "Produits laitiers", "🧀")) - add(CatalogItem("Frite du petit Québec", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage à tartiner", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage Briana", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage Chèvre Confiture", "Produits laitiers", "🧀")) - add(CatalogItem("fromage cottage sans lactose", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage Déjà Rappé", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage en grains", "Produits laitiers", "🧀")) - add(CatalogItem("fromage en tranches", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage Philadelphia", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage Philadelphia sans lactose", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage Quick Quick", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage Rapé Briana", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage rapé sans lactose", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage Riviera", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage Sans Lactose", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage suisse", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage tex mex", "Produits laitiers", "🧀")) - add(CatalogItem("Fromage Vache Qui Rit", "Produits laitiers", "🧀")) - add(CatalogItem("Grated cheese", "Produits laitiers", "🧀")) - add(CatalogItem("Grilled cheese", "Produits laitiers", "🧀")) - add(CatalogItem("Parmesan sans lactose", "Produits laitiers", "🧀")) - add(CatalogItem("Quark", "Produits laitiers", "🧀")) - add(CatalogItem("Lait avoine", "Produits laitiers", "🥛")) - add(CatalogItem("Lait avoine lunch", "Produits laitiers", "🥛")) - add(CatalogItem("Lait avoine lunch chocolat", "Produits laitiers", "🥛")) - add(CatalogItem("Lait Chocolat", "Produits laitiers", "🥛")) - add(CatalogItem("Lait Chocolat Lunch", "Produits laitiers", "🥛")) - add(CatalogItem("Lait Chocolat Sans Lactose", "Produits laitiers", "🥛")) - add(CatalogItem("Lait condensé coco", "Produits laitiers", "🥛")) - add(CatalogItem("Lait Sans Lactose", "Produits laitiers", "🥛")) - add(CatalogItem("Lait Soya Lunch", "Produits laitiers", "🥛")) - add(CatalogItem("Lait Soya Sensationnel Nature", "Produits laitiers", "🥛")) - add(CatalogItem("Soy Milk", "Produits laitiers", "🥛")) - add(CatalogItem("Soy yogurt", "Produits laitiers", "🥣")) - add(CatalogItem("Yogourt iogo Vanille", "Produits laitiers", "🥣")) - add(CatalogItem("Yogourt logo vanille sans lactose", "Produits laitiers", "🥣")) - add(CatalogItem("Yogourt Sans Lactose", "Produits laitiers", "🥣")) - - // Boucherie - add(CatalogItem("Poulet", "Boucherie", "🍗", emptyList(), listOf("Poitrine", "Cuisse", "Aile", "Entier", "Haché"))) - add(CatalogItem("Bœuf haché", "Boucherie", "🥩", listOf("beef"))) - add(CatalogItem("Steak", "Boucherie", "🥩")) - add(CatalogItem("Porc", "Boucherie", "🥓")) - add(CatalogItem("Jambon", "Boucherie", "🥓")) - add(CatalogItem("Saucisse", "Boucherie", "🌭", emptyList(), listOf("Porc", "Veau", "Dinde", "Italienne", "Cocktail"))) - add(CatalogItem("Bacon", "Boucherie", "🥓")) - add(CatalogItem("Saumon", "Boucherie", "🐟", emptyList(), listOf("Atlantique", "Pacifique", "Sockeye", "Fumé", "En Conserve"))) - add(CatalogItem("Thon", "Boucherie", "🐟")) - add(CatalogItem("Dinde", "Boucherie", "🦃")) - add(CatalogItem("Canard", "Boucherie", "🦆")) - add(CatalogItem("Agneau", "Boucherie", "🥩")) - add(CatalogItem("Veau", "Boucherie", "🥩")) - add(CatalogItem("Côtelette", "Boucherie", "🥩")) - add(CatalogItem("Rôti", "Boucherie", "🥩")) - add(CatalogItem("Merguez", "Boucherie", "🌭")) - add(CatalogItem("Chorizo", "Boucherie", "🌭")) - add(CatalogItem("Salami", "Boucherie", "🥓")) - add(CatalogItem("Saucisson", "Boucherie", "🥓")) - add(CatalogItem("Pâté", "Boucherie", "🥓")) - add(CatalogItem("Truite", "Boucherie", "🐟")) - add(CatalogItem("Cabillaud", "Boucherie", "🐟")) - add(CatalogItem("Dorade", "Boucherie", "🐟")) - add(CatalogItem("Bar", "Boucherie", "🐟")) - add(CatalogItem("Crevettes", "Boucherie", "🦐")) - add(CatalogItem("Moules", "Boucherie", "🦪")) - add(CatalogItem("Huîtres", "Boucherie", "🦪")) - add(CatalogItem("Calamar", "Boucherie", "🦑")) - add(CatalogItem("Crabe", "Boucherie", "🦀")) - add(CatalogItem("Homard", "Boucherie", "🦞")) - - // Épicerie - add(CatalogItem("Riz", "Épicerie", "🍚")) - add(CatalogItem("Pâtes", "Épicerie", "🍝", listOf("spaghetti"))) - add(CatalogItem("Spaghetti", "Épicerie", "🍝")) - add(CatalogItem("Farine", "Épicerie", "🌾")) - add(CatalogItem("Sucre", "Épicerie", "🍬")) - add(CatalogItem("Sel", "Épicerie", "🧂")) - add(CatalogItem("Huile", "Épicerie", "🫒")) - add(CatalogItem("Huile d'olive", "Épicerie", "🫒")) - add(CatalogItem("Vinaigre", "Épicerie", "🧴")) - add(CatalogItem("Confiture", "Épicerie", "🍓")) - add(CatalogItem("Miel", "Épicerie", "🍯")) - add(CatalogItem("Chocolat", "Épicerie", "🍫")) - add(CatalogItem("Biscuits", "Épicerie", "🍪")) - add(CatalogItem("Céréales", "Épicerie", "🥣", listOf("muesli"))) - add(CatalogItem("Lentilles", "Épicerie", "🫘")) - add(CatalogItem("Pois chiches", "Épicerie", "🫘")) - add(CatalogItem("Haricots rouges", "Épicerie", "🫘")) - add(CatalogItem("Haricots blancs", "Épicerie", "🫘")) - add(CatalogItem("Conserves", "Épicerie", "🥫")) - add(CatalogItem("Soupe", "Épicerie", "🍲")) - add(CatalogItem("Riz basmati", "Épicerie", "🍚")) - add(CatalogItem("Quinoa", "Épicerie", "🍚")) - add(CatalogItem("Couscous", "Épicerie", "🍚")) - add(CatalogItem("Boulgour", "Épicerie", "🍚")) - add(CatalogItem("Polenta", "Épicerie", "🌽")) - add(CatalogItem("Flocons d'avoine", "Épicerie", "🥣")) - add(CatalogItem("Muesli", "Épicerie", "🥣")) - add(CatalogItem("Granola", "Épicerie", "🥣")) - add(CatalogItem("Pâte à tartiner", "Épicerie", "🍫")) - add(CatalogItem("Beurre de cacahuète", "Épicerie", "🥜")) - add(CatalogItem("Sirop d'érable", "Épicerie", "🍯")) - add(CatalogItem("Levure", "Épicerie", "🧂")) - add(CatalogItem("Bicarbonate", "Épicerie", "🧂")) - add(CatalogItem("Sucre vanillé", "Épicerie", "🍬")) - add(CatalogItem("Cacao", "Épicerie", "🍫")) - add(CatalogItem("Thon en boîte", "Épicerie", "🥫")) - add(CatalogItem("Sardines", "Épicerie", "🥫")) - add(CatalogItem("Maquereau", "Épicerie", "🥫")) - add(CatalogItem("Sauce tomate", "Épicerie", "🍅")) - add(CatalogItem("Concentré de tomate", "Épicerie", "🍅")) - add(CatalogItem("Tomates pelées", "Épicerie", "🥫")) - - // Condiments & Épices - add(CatalogItem("Moutarde", "Condiments & Épices", "🟡")) - add(CatalogItem("Ketchup", "Condiments & Épices", "🍅")) - add(CatalogItem("Mayonnaise", "Condiments & Épices", "🥚")) - add(CatalogItem("Poivre", "Condiments & Épices", "🧂")) - add(CatalogItem("Paprika", "Condiments & Épices", "🌶️")) - add(CatalogItem("Curry", "Condiments & Épices", "🌶️")) - add(CatalogItem("Cumin", "Condiments & Épices", "🌶️")) - add(CatalogItem("Curcuma", "Condiments & Épices", "🌶️")) - add(CatalogItem("Gingembre", "Condiments & Épices", "🌶️")) - add(CatalogItem("Cannelle", "Condiments & Épices", "🌶️")) - add(CatalogItem("Muscade", "Condiments & Épices", "🌶️")) - add(CatalogItem("Thym", "Condiments & Épices", "🌿")) - add(CatalogItem("Romarin", "Condiments & Épices", "🌿")) - add(CatalogItem("Origan", "Condiments & Épices", "🌿")) - add(CatalogItem("Laurier", "Condiments & Épices", "🌿")) - add(CatalogItem("Herbes de Provence", "Condiments & Épices", "🌿")) - add(CatalogItem("Sauce soja", "Condiments & Épices", "🧴")) - add(CatalogItem("Vinaigre balsamique", "Condiments & Épices", "🧴")) - add(CatalogItem("Tabasco", "Condiments & Épices", "🌶️")) - add(CatalogItem("Harissa", "Condiments & Épices", "🌶️")) - add(CatalogItem("Wasabi", "Condiments & Épices", "🌶️")) - add(CatalogItem("Pesto", "Condiments & Épices", "🌿")) - add(CatalogItem("Tapenade", "Condiments & Épices", "🫒")) - - // Surgelés - add(CatalogItem("Pizza surgelée", "Surgelés", "🍕")) - add(CatalogItem("Frites surgelées", "Surgelés", "🍟")) - add(CatalogItem("Glace", "Surgelés", "🍨")) - add(CatalogItem("Légumes surgelés", "Surgelés", "🥦")) - add(CatalogItem("Poisson surgelé", "Surgelés", "🐟")) - add(CatalogItem("Crevettes surgelées", "Surgelés", "🦐")) - add(CatalogItem("Fruits surgelés", "Surgelés", "🍓")) - add(CatalogItem("Plat préparé", "Surgelés", "🍱")) - add(CatalogItem("Lasagnes", "Surgelés", "🍝")) - add(CatalogItem("Nuggets", "Surgelés", "🍗")) - add(CatalogItem("Poisson pané", "Surgelés", "🐟")) - add(CatalogItem("Sorbet", "Surgelés", "🍧")) - add(CatalogItem("Gâteau glacé", "Surgelés", "🍰")) - - // Snacks & Bonbons - add(CatalogItem("Chips", "Snacks & Bonbons", "🥔")) - add(CatalogItem("Cacahuètes", "Snacks & Bonbons", "🥜")) - add(CatalogItem("Noix", "Snacks & Bonbons", "🌰")) - add(CatalogItem("Amandes", "Snacks & Bonbons", "🌰")) - add(CatalogItem("Noisettes", "Snacks & Bonbons", "🌰")) - add(CatalogItem("Pistaches", "Snacks & Bonbons", "🥜")) - add(CatalogItem("Noix de cajou", "Snacks & Bonbons", "🥜")) - add(CatalogItem("Pop-corn", "Snacks & Bonbons", "🍿")) - add(CatalogItem("Bonbons", "Snacks & Bonbons", "🍬")) - add(CatalogItem("Chewing-gum", "Snacks & Bonbons", "🍬")) - add(CatalogItem("Chocolat noir", "Snacks & Bonbons", "🍫")) - add(CatalogItem("Chocolat au lait", "Snacks & Bonbons", "🍫")) - add(CatalogItem("Barres chocolatées", "Snacks & Bonbons", "🍫")) - add(CatalogItem("Barres de céréales", "Snacks & Bonbons", "🥣")) - add(CatalogItem("Fruits secs", "Snacks & Bonbons", "🍇")) - add(CatalogItem("Raisins secs", "Snacks & Bonbons", "🍇")) - - // Boissons - add(CatalogItem("Eau", "Boissons", "💧")) - add(CatalogItem("Eau gazeuse", "Boissons", "💧")) - add(CatalogItem("Jus d'orange", "Boissons", "🧃")) - add(CatalogItem("Jus de pomme", "Boissons", "🧃")) - add(CatalogItem("Jus de raisin", "Boissons", "🧃")) - add(CatalogItem("Jus multivitaminé", "Boissons", "🧃")) - add(CatalogItem("Café", "Boissons", "☕")) - add(CatalogItem("Café moulu", "Boissons", "☕")) - add(CatalogItem("Café en grains", "Boissons", "☕")) - add(CatalogItem("Café soluble", "Boissons", "☕")) - add(CatalogItem("Thé", "Boissons", "🍵")) - add(CatalogItem("Thé vert", "Boissons", "🍵")) - add(CatalogItem("Thé noir", "Boissons", "🍵")) - add(CatalogItem("Infusion", "Boissons", "🍵")) - add(CatalogItem("Chocolat chaud", "Boissons", "☕")) - add(CatalogItem("Vin rouge", "Boissons", "🍷")) - add(CatalogItem("Vin blanc", "Boissons", "🍷")) - add(CatalogItem("Vin rosé", "Boissons", "🍷")) - add(CatalogItem("Champagne", "Boissons", "🍾")) - add(CatalogItem("Bière", "Boissons", "🍺")) - add(CatalogItem("Bière blonde", "Boissons", "🍺")) - add(CatalogItem("Bière brune", "Boissons", "🍺")) - add(CatalogItem("Cidre", "Boissons", "🍺")) - add(CatalogItem("Soda", "Boissons", "🥤")) - add(CatalogItem("Cola", "Boissons", "🥤")) - add(CatalogItem("Limonade", "Boissons", "🍋")) - add(CatalogItem("Orangina", "Boissons", "🍊")) - add(CatalogItem("Sirop", "Boissons", "🧃")) - add(CatalogItem("Smoothie", "Boissons", "🥤")) - add(CatalogItem("Boisson énergisante", "Boissons", "🥤")) - - // Hygiène - add(CatalogItem("Papier toilette", "Hygiène", "🧻", listOf("toilet paper"))) - add(CatalogItem("Mouchoirs", "Hygiène", "🤧")) - add(CatalogItem("Dentifrice", "Hygiène", "🦷")) - add(CatalogItem("Brosse à dents", "Hygiène", "🪥")) - add(CatalogItem("Fil dentaire", "Hygiène", "🦷")) - add(CatalogItem("Bain de bouche", "Hygiène", "🦷")) - add(CatalogItem("Shampoing", "Hygiène", "🧴")) - add(CatalogItem("Après-shampoing", "Hygiène", "🧴")) - add(CatalogItem("Savon", "Hygiène", "🧼")) - add(CatalogItem("Gel douche", "Hygiène", "🧴")) - add(CatalogItem("Déodorant", "Hygiène", "🧴")) - add(CatalogItem("Parfum", "Hygiène", "🧴")) - add(CatalogItem("Crème hydratante", "Hygiène", "🧴")) - add(CatalogItem("Crème solaire", "Hygiène", "🧴")) - add(CatalogItem("Rasoir", "Hygiène", "🪒")) - add(CatalogItem("Mousse à raser", "Hygiène", "🧴")) - add(CatalogItem("Coton-tige", "Hygiène", "🧻")) - add(CatalogItem("Coton", "Hygiène", "🧻")) - add(CatalogItem("Serviettes hygiéniques", "Hygiène", "🧻")) - add(CatalogItem("Tampons", "Hygiène", "🧻")) - - // Entretien - add(CatalogItem("Lessive", "Entretien", "🧺")) - add(CatalogItem("Adoucissant", "Entretien", "🧴")) - add(CatalogItem("Liquide vaisselle", "Entretien", "🧴")) - add(CatalogItem("Tablettes lave-vaisselle", "Entretien", "🧴")) - add(CatalogItem("Éponge", "Entretien", "🧽")) - add(CatalogItem("Javel", "Entretien", "🧴")) - add(CatalogItem("Nettoyant multi-usage", "Entretien", "🧴")) - add(CatalogItem("Nettoyant vitres", "Entretien", "🧴")) - add(CatalogItem("Nettoyant sol", "Entretien", "🧴")) - add(CatalogItem("Nettoyant WC", "Entretien", "🧴")) - add(CatalogItem("Sacs poubelle", "Entretien", "🗑️")) - add(CatalogItem("Essuie-tout", "Entretien", "🧻")) - add(CatalogItem("Serpillière", "Entretien", "🧹")) - add(CatalogItem("Balai", "Entretien", "🧹")) - add(CatalogItem("Pelle", "Entretien", "🧹")) - - // Bébé - add(CatalogItem("Couches", "Bébé", "👶")) - add(CatalogItem("Lait infantile", "Bébé", "🍼")) - add(CatalogItem("Compote bébé", "Bébé", "🍎")) - add(CatalogItem("Lingettes bébé", "Bébé", "🧻")) - add(CatalogItem("Petits pots", "Bébé", "🍼")) - add(CatalogItem("Céréales bébé", "Bébé", "🥣")) - add(CatalogItem("Biscuits bébé", "Bébé", "🍪")) - add(CatalogItem("Biberon", "Bébé", "🍼")) - add(CatalogItem("Tétine", "Bébé", "🍼")) - add(CatalogItem("Crème pour le change", "Bébé", "🧴")) - add(CatalogItem("Savon bébé", "Bébé", "🧼")) - add(CatalogItem("Shampoing bébé", "Bébé", "🧴")) - - // Animaux - add(CatalogItem("Croquettes chien", "Animaux", "🐶")) - add(CatalogItem("Croquettes chat", "Animaux", "🐱")) - add(CatalogItem("Pâtée chien", "Animaux", "🐶")) - add(CatalogItem("Pâtée chat", "Animaux", "🐈")) - add(CatalogItem("Friandises chien", "Animaux", "🦴")) - add(CatalogItem("Friandises chat", "Animaux", "🐟")) - add(CatalogItem("Litière", "Animaux", "🐈")) - add(CatalogItem("Jouets pour animaux", "Animaux", "🎾")) - add(CatalogItem("Shampoing animal", "Animaux", "🧴")) - - // Maison & Jardin - add(CatalogItem("Ampoules", "Maison & Jardin", "💡")) - add(CatalogItem("Piles", "Maison & Jardin", "🔋")) - add(CatalogItem("Allumettes", "Maison & Jardin", "🔥")) - add(CatalogItem("Bougies", "Maison & Jardin", "🕯️")) - add(CatalogItem("Papier aluminium", "Maison & Jardin", "📦")) - add(CatalogItem("Film alimentaire", "Maison & Jardin", "📦")) - add(CatalogItem("Papier cuisson", "Maison & Jardin", "📦")) - add(CatalogItem("Sacs congélation", "Maison & Jardin", "📦")) - add(CatalogItem("Terreau", "Maison & Jardin", "🌱")) - add(CatalogItem("Engrais", "Maison & Jardin", "🌱")) - add(CatalogItem("Graines", "Maison & Jardin", "🌱")) - add(CatalogItem("Pots de fleurs", "Maison & Jardin", "🪴")) - add(CatalogItem("Rum extract", "Condiments & Épices", "🧂")) - - add(CatalogItem("Salad dressing", "Condiments & Épices", "🧂")) - - add(CatalogItem("Salt", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce à pizza", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce à la viande", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce à poutine", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce aux piments", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce aux poissons", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce aux poivres", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce BBQ St-Hubert", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce béarnaise", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce brune", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce brune sans gluten", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce brune St-Hubert", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce hollandaise", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce Hunt", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce poutine", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce salade César", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sauce salsa", "Condiments & Épices", "🧂")) - - add(CatalogItem("Soy sauce", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sea salt", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sel de mer", "Condiments & Épices", "🧂")) - - add(CatalogItem("Stock", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sucre en poudre", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sugar", "Condiments & Épices", "🧂")) - - add(CatalogItem("Sunflower seeds", "Condiments & Épices", "🧂")) - - add(CatalogItem("Tahini", "Condiments & Épices", "🧂")) - - add(CatalogItem("Tamarind paste", "Condiments & Épices", "🧂")) - - add(CatalogItem("Tomato purée", "Condiments & Épices", "🧂")) - - add(CatalogItem("Tomato sauce", "Condiments & Épices", "🧂")) - - add(CatalogItem("Truffle", "Condiments & Épices", "🧂")) - - add(CatalogItem("Vanilla", "Condiments & Épices", "🧂")) - - add(CatalogItem("Vanilla bourbon", "Condiments & Épices", "🧂")) - - add(CatalogItem("Vanille", "Condiments & Épices", "🧂")) - - add(CatalogItem("Vegetable broth", "Condiments & Épices", "🧂")) - - add(CatalogItem("Vinegar", "Condiments & Épices", "🧂")) - - add(CatalogItem("Walnuts", "Condiments & Épices", "🧂")) - - add(CatalogItem("Yeast", "Condiments & Épices", "🧂")) - - add(CatalogItem("Baked beans", "Épicerie", "🌾")) - - add(CatalogItem("Bleuets congelés", "Épicerie", "🌾")) - - add(CatalogItem("Boulettes", "Épicerie", "🌾")) - - add(CatalogItem("Burritos", "Épicerie", "🌾")) - - add(CatalogItem("Chicken wings", "Épicerie", "🌾")) - - add(CatalogItem("Chinese food", "Épicerie", "🌾")) - - add(CatalogItem("Dumplings", "Épicerie", "🌾")) - - add(CatalogItem("Fish sticks", "Épicerie", "🌾")) - - add(CatalogItem("Fraises congelées", "Épicerie", "🌾")) - - add(CatalogItem("Framboises congelées", "Épicerie", "🌾")) - - add(CatalogItem("French fries", "Épicerie", "🌾")) - - add(CatalogItem("Frites congelées", "Épicerie", "🌾")) - - add(CatalogItem("Frozen vegetables", "Épicerie", "🌾")) - - add(CatalogItem("Fruits congelés", "Épicerie", "🌾")) - - add(CatalogItem("Ice cream", "Épicerie", "🌾")) - - add(CatalogItem("Indian food", "Épicerie", "🌾")) - - add(CatalogItem("Italian food", "Épicerie", "🌾")) - - add(CatalogItem("Lasagna", "Épicerie", "🌾")) - - add(CatalogItem("Légumes soupe", "Épicerie", "🌾")) - - add(CatalogItem("Mexican food", "Épicerie", "🌾")) - - add(CatalogItem("Pizza", "Épicerie", "🌾")) - - add(CatalogItem("Pizza froide", "Épicerie", "🌾")) - - add(CatalogItem("Soup", "Épicerie", "🌾")) - - add(CatalogItem("Soupe St Hubert poulet nouilles", "Épicerie", "🌾")) - - add(CatalogItem("Thai food", "Épicerie", "🌾")) - - add(CatalogItem("TV dinner", "Épicerie", "🌾")) - - add(CatalogItem("Avoine sans gluten", "Épicerie", "🌾")) - - add(CatalogItem("Basmati rice", "Épicerie", "🌾")) - - add(CatalogItem("Biscottes sans lactose", "Épicerie", "🌾")) - - add(CatalogItem("Biscuits avoine", "Épicerie", "🌾")) - - add(CatalogItem("Biscuits sans lactose", "Épicerie", "🌾")) - - add(CatalogItem("Biscuits soda", "Épicerie", "🌾")) - - add(CatalogItem("Breton sans gluten", "Épicerie", "🌾")) - - add(CatalogItem("Cereal", "Épicerie", "🌾")) - - add(CatalogItem("Céréales Life", "Épicerie", "🌾")) - - add(CatalogItem("Céréales sans gluten", "Épicerie", "🌾")) - - add(CatalogItem("Cinnamon toast crunch", "Épicerie", "🌾")) - - add(CatalogItem("Corn flakes", "Épicerie", "🌾")) - - add(CatalogItem("Couscous sans gluten", "Épicerie", "🌾")) - - add(CatalogItem("Farine d'épeautre", "Épicerie", "🌾")) - - add(CatalogItem("Farine de maïs", "Épicerie", "🌾")) - - add(CatalogItem("Farine de riz", "Épicerie", "🌾")) - - add(CatalogItem("Farine de riz brun", "Épicerie", "🌾")) - - add(CatalogItem("Farine sans gluten", "Épicerie", "🌾")) - - add(CatalogItem("Flour", "Épicerie", "🌾")) - - add(CatalogItem("Fusilli", "Épicerie", "🌾")) - - add(CatalogItem("Galette de riz", "Épicerie", "🌾")) - - add(CatalogItem("Jasmine rice", "Épicerie", "🌾")) - - add(CatalogItem("Macaroni", "Épicerie", "🌾")) - - add(CatalogItem("Noodles", "Épicerie", "🌾")) - - add(CatalogItem("Nouilles à soupe", "Épicerie", "🌾")) - - add(CatalogItem("Nouilles aux œufs", "Épicerie", "🌾")) - - add(CatalogItem("Oatmeal", "Épicerie", "🌾")) - - add(CatalogItem("Pasta", "Épicerie", "🌾")) - - add(CatalogItem("Pâte", "Épicerie", "🌾")) - - add(CatalogItem("Pâte à eggroll", "Épicerie", "🌾")) - - add(CatalogItem("Pâte fraîche", "Épicerie", "🌾")) - - add(CatalogItem("Pâte fraîche Nathan", "Épicerie", "🌾")) - - add(CatalogItem("Pâte Nathan", "Épicerie", "🌾")) - - add(CatalogItem("Pâte sans gluten", "Épicerie", "🌾")) - - add(CatalogItem("Pâtes à pizza", "Épicerie", "🌾")) - - add(CatalogItem("Pâtes en sachet", "Épicerie", "🌾")) - - add(CatalogItem("Penne", "Épicerie", "🌾")) - - add(CatalogItem("Ramen", "Épicerie", "🌾")) - - add(CatalogItem("Ramen en pot", "Épicerie", "🌾")) - - add(CatalogItem("Raviolis", "Épicerie", "🌾")) - - add(CatalogItem("Rice", "Épicerie", "🌾")) - - add(CatalogItem("Rice noodles", "Épicerie", "🌾")) - - add(CatalogItem("Rice paper", "Épicerie", "🌾")) - - add(CatalogItem("Risotto rice", "Épicerie", "🌾")) - - add(CatalogItem("Riz instantané", "Épicerie", "🌾")) - - add(CatalogItem("Semolina", "Épicerie", "🌾")) - - add(CatalogItem("Spelt flour", "Épicerie", "🌾")) - - add(CatalogItem("Tagliatelle", "Épicerie", "🌾")) - - add(CatalogItem("Tortellini", "Épicerie", "🌾")) - - add(CatalogItem("Wild rice", "Épicerie", "🌾")) - - add(CatalogItem("Barre Briana", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Barre pommes cannelle", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Barre Quacker", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Barre tendre Briana", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Barre tendre Cliff", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Barre tendre Life", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Barre tendre Nathan pomme cannelle", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Biscuit au riz", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Biscuit sans lactose", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Biscuits mince aux légumes", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Biscuits pépites chocolat", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Bonbon", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Biscuits Thé Social", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Cake", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Candy", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Cereal bar", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Chewing gum", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Chips aux fèves", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Chips BBQ", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Chocolat Lindor pâle individuel", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Chocolate bar", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Christmas cookies", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Clif noisette", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Cookies", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Crackers", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Dried fruit", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Fraises", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Gauffres Briana", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Gingerbread", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Grosse gaufres", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Honey", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Jam", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Jelly", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Kit Burritos", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Lollis", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Marshmallows", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Mini guimauve", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Mini-wheat", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Nougat cream", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Nutella", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Panettone", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Party mix original", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Peanuts", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Pépites de chocolat sans lactose", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Pop corn", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Pretzels", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Pudding", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Roquet bonbon", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Snacks", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Timbits chocolat", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("Tortilla chips", "Snacks & Bonbons", "🍿")) - - add(CatalogItem("7up", "Boissons", "🥤")) - - add(CatalogItem("Apple juice", "Boissons", "🥤")) - - add(CatalogItem("Bacardi breeze ananas", "Boissons", "🥤")) - - add(CatalogItem("Beer", "Boissons", "🥤")) - - add(CatalogItem("Beverages", "Boissons", "🥤")) - - add(CatalogItem("Bottled water", "Boissons", "🥤")) - - add(CatalogItem("Bouteille d'eau", "Boissons", "🥤")) - - add(CatalogItem("Cacao Hershey", "Boissons", "🥤")) - - add(CatalogItem("Café instantané", "Boissons", "🥤")) - - add(CatalogItem("Cider", "Boissons", "🥤")) - - add(CatalogItem("Cidre de pomme", "Boissons", "🥤")) - - add(CatalogItem("Coffee", "Boissons", "🥤")) - - add(CatalogItem("Coffee beans", "Boissons", "🥤")) - - add(CatalogItem("Coffee capsules", "Boissons", "🥤")) - - add(CatalogItem("Coffee pads", "Boissons", "🥤")) - - add(CatalogItem("Déjeuner liquide", "Boissons", "🥤")) - - add(CatalogItem("Diet cola", "Boissons", "🥤")) - - add(CatalogItem("Diet soda", "Boissons", "🥤")) - - add(CatalogItem("Eau Perrier", "Boissons", "🥤")) - - add(CatalogItem("Energy drink", "Boissons", "🥤")) - - add(CatalogItem("Fruit juice", "Boissons", "🥤")) - - add(CatalogItem("Gin", "Boissons", "🥤")) - - add(CatalogItem("Ginger ale", "Boissons", "🥤")) - - add(CatalogItem("Herbal tea", "Boissons", "🥤")) - - add(CatalogItem("Hertel bouchon rose", "Boissons", "🥤")) - - add(CatalogItem("Hot chocolate", "Boissons", "🥤")) - - add(CatalogItem("Iced tea", "Boissons", "🥤")) - - add(CatalogItem("Jus avoine chocolat lunch", "Boissons", "🥤")) - - add(CatalogItem("Jus de citron", "Boissons", "🥤")) - - add(CatalogItem("Jus de lime", "Boissons", "🥤")) - - add(CatalogItem("Jus de mangue", "Boissons", "🥤")) - - add(CatalogItem("Jus de pomme lunch", "Boissons", "🥤")) - - add(CatalogItem("Jus de tomate", "Boissons", "🥤")) - - add(CatalogItem("Jus de tomate lunch", "Boissons", "🥤")) - - add(CatalogItem("Jus de vrais fruits", "Boissons", "🥤")) - - add(CatalogItem("Jus fraises bananes", "Boissons", "🥤")) - - add(CatalogItem("Jus kiwi", "Boissons", "🥤")) - - add(CatalogItem("Jus lunch", "Boissons", "🥤")) - - add(CatalogItem("Jus lunch limonade", "Boissons", "🥤")) - - add(CatalogItem("Jus orange congelé", "Boissons", "🥤")) - - add(CatalogItem("Jus ou lait collation", "Boissons", "🥤")) - - add(CatalogItem("Jus tropical", "Boissons", "🥤")) - - add(CatalogItem("Jus V-8", "Boissons", "🥤")) - - add(CatalogItem("L'eau de coco", "Boissons", "🥤")) - - add(CatalogItem("Liqueur pommes grenade", "Boissons", "🥤")) - - add(CatalogItem("Orange juice", "Boissons", "🥤")) - - add(CatalogItem("Pepsi", "Boissons", "🥤")) - - add(CatalogItem("Prosecco", "Boissons", "🥤")) - - add(CatalogItem("Punch", "Boissons", "🥤")) - - add(CatalogItem("Red wine", "Boissons", "🥤")) - - add(CatalogItem("Root beer", "Boissons", "🥤")) - - add(CatalogItem("Rum", "Boissons", "🥤")) - - add(CatalogItem("Sirop de cannes", "Boissons", "🥤")) - - add(CatalogItem("Soda stream", "Boissons", "🥤")) - - add(CatalogItem("Spirits", "Boissons", "🥤")) - - add(CatalogItem("Sports drink", "Boissons", "🥤")) - - add(CatalogItem("Tea", "Boissons", "🥤")) - - add(CatalogItem("Thé glacé", "Boissons", "🥤")) - - add(CatalogItem("Thé Tetley", "Boissons", "🥤")) - - add(CatalogItem("Tonic water", "Boissons", "🥤")) - - add(CatalogItem("Vin", "Boissons", "🥤")) - - add(CatalogItem("Vodka", "Boissons", "🥤")) - - add(CatalogItem("Water", "Boissons", "🥤")) - - add(CatalogItem("Whiskey", "Boissons", "🥤")) - - add(CatalogItem("White wine", "Boissons", "🥤")) - - add(CatalogItem("Alcool ménager", "Maison", "🏠")) - - add(CatalogItem("Aluminum foil", "Maison", "🏠")) - - add(CatalogItem("Baby food", "Maison", "🏠")) - - add(CatalogItem("Balloon", "Maison", "🏠")) - - add(CatalogItem("Barbecue lighter", "Maison", "🏠")) - - add(CatalogItem("Barbecue tongs", "Maison", "🏠")) - - add(CatalogItem("Bathroom cleaner", "Maison", "🏠")) - - add(CatalogItem("Batteries", "Maison", "🏠")) - - add(CatalogItem("Bicarbonate de soude", "Maison", "🏠")) - - add(CatalogItem("Boîte à chaussures", "Maison", "🏠")) - - add(CatalogItem("Boule cannelle Briana", "Maison", "🏠")) - - add(CatalogItem("Bowl", "Maison", "🏠")) - - add(CatalogItem("Brosse à cheveux", "Maison", "🏠")) - - add(CatalogItem("Candles", "Maison", "🏠")) - - add(CatalogItem("Cap", "Maison", "🏠")) - - add(CatalogItem("Cherries multi grain", "Maison", "🏠")) - - add(CatalogItem("Christmas tree candles", "Maison", "🏠")) - - add(CatalogItem("Christmas tree ornaments", "Maison", "🏠")) - - add(CatalogItem("Cinnamon stars", "Maison", "🏠")) - - add(CatalogItem("Cleaning rags", "Maison", "🏠")) - - add(CatalogItem("Cleaning supplies", "Maison", "🏠")) - - add(CatalogItem("Cocktail umbrellas", "Maison", "🏠")) - - add(CatalogItem("Cookie cutters", "Maison", "🏠")) - - add(CatalogItem("Costume", "Maison", "🏠")) - - add(CatalogItem("Coupe plastique", "Maison", "🏠")) - - add(CatalogItem("Cruche eau", "Maison", "🏠")) - - add(CatalogItem("Cuillère en plastique", "Maison", "🏠")) - - add(CatalogItem("Cure-dents", "Maison", "🏠")) - - add(CatalogItem("Démêlant à cheveux", "Maison", "🏠")) - - add(CatalogItem("Deodorant", "Maison", "🏠")) - - add(CatalogItem("Descaling agent", "Maison", "🏠")) - - add(CatalogItem("Désodorisant Danielle", "Maison", "🏠")) - - add(CatalogItem("Dishwasher salt", "Maison", "🏠")) - - add(CatalogItem("Dishwasher tabs", "Maison", "🏠")) - - add(CatalogItem("Dishwashing liquid", "Maison", "🏠")) - - add(CatalogItem("Drain cleaner", "Maison", "🏠")) - - add(CatalogItem("Eau de Javel", "Maison", "🏠")) - - add(CatalogItem("Envelopes", "Maison", "🏠")) - - add(CatalogItem("Eraser", "Maison", "🏠")) - - add(CatalogItem("Fabric softener", "Maison", "🏠")) - - add(CatalogItem("Feather duster", "Maison", "🏠")) - - add(CatalogItem("Filtre Brita", "Maison", "🏠")) - - add(CatalogItem("Flashlight", "Maison", "🏠")) - - add(CatalogItem("Fromage en plastique", "Maison", "🏠")) - - add(CatalogItem("Flowers", "Maison", "🏠")) - - add(CatalogItem("Furniture polish", "Maison", "🏠")) - - add(CatalogItem("Gant de vaisselle", "Maison", "🏠")) - - add(CatalogItem("Gift", "Maison", "🏠")) - - add(CatalogItem("Gift ribbon", "Maison", "🏠")) - - add(CatalogItem("Glass cleaner", "Maison", "🏠")) - - add(CatalogItem("Gloves", "Maison", "🏠")) - - add(CatalogItem("Gruau en sachet", "Maison", "🏠")) - - add(CatalogItem("Juicer", "Maison", "🏠")) - - add(CatalogItem("Kleenex", "Maison", "🏠")) - - add(CatalogItem("Kleenex petit sac", "Maison", "🏠")) - - add(CatalogItem("Laundry detergent", "Maison", "🏠")) - - add(CatalogItem("Lighter", "Maison", "🏠")) - - add(CatalogItem("Lime à ongles", "Maison", "🏠")) - - add(CatalogItem("Light bulb", "Maison", "🏠")) - - add(CatalogItem("Lingette Lysol", "Maison", "🏠")) - - add(CatalogItem("Marker", "Maison", "🏠")) - - add(CatalogItem("Mozza stick", "Maison", "🏠")) - - add(CatalogItem("Mr freez", "Maison", "🏠")) - - add(CatalogItem("Mr Net", "Maison", "🏠")) - - add(CatalogItem("Multi-purpose cleaner", "Maison", "🏠")) - - add(CatalogItem("Napkins", "Maison", "🏠")) - - add(CatalogItem("Notepad", "Maison", "🏠")) - - add(CatalogItem("Paper", "Maison", "🏠")) - - add(CatalogItem("Paper towels", "Maison", "🏠")) - - add(CatalogItem("Paperclips", "Maison", "🏠")) - - add(CatalogItem("Papier de toilette", "Maison", "🏠")) - - add(CatalogItem("Papier pellicule", "Maison", "🏠")) - - add(CatalogItem("Parchment paper", "Maison", "🏠")) - - add(CatalogItem("Pastry brush", "Maison", "🏠")) - - add(CatalogItem("Pâte à dents", "Maison", "🏠")) - - add(CatalogItem("Pen", "Maison", "🏠")) - - add(CatalogItem("Pencil", "Maison", "🏠")) - - add(CatalogItem("Plastic wrap", "Maison", "🏠")) - - add(CatalogItem("Post-it's", "Maison", "🏠")) - - add(CatalogItem("Pot de bébé", "Maison", "🏠")) - - add(CatalogItem("Protège-dessous", "Maison", "🏠")) - - add(CatalogItem("Q-tips", "Maison", "🏠")) - - add(CatalogItem("Reflectors", "Maison", "🏠")) - - add(CatalogItem("Rince-bouche", "Maison", "🏠")) - - add(CatalogItem("Rince lave-vaisselle", "Maison", "🏠")) - - add(CatalogItem("Rubber gloves", "Maison", "🏠")) - - add(CatalogItem("Sac à vidange", "Maison", "🏠")) - - add(CatalogItem("Sac compost", "Maison", "🏠")) - - add(CatalogItem("Sac de bean St Arnaud", "Maison", "🏠")) - - add(CatalogItem("Sac de poubelles", "Maison", "🏠")) - - add(CatalogItem("Sac poubelles bio", "Maison", "🏠")) - - add(CatalogItem("Sac Ziploc grand", "Maison", "🏠")) - - add(CatalogItem("Sac Ziploc zip", "Maison", "🏠")) - - add(CatalogItem("Sacs à compostage", "Maison", "🏠")) - - add(CatalogItem("Sandwich crème glacée", "Maison", "🏠")) - - add(CatalogItem("Savon à linge", "Maison", "🏠")) - - add(CatalogItem("Savon à mains", "Maison", "🏠")) - - add(CatalogItem("Savon à vaisselle", "Maison", "🏠")) - - add(CatalogItem("Savon Dove", "Maison", "🏠")) - - add(CatalogItem("Savon face Nathan", "Maison", "🏠")) - - add(CatalogItem("Savon lavé-vaisselle", "Maison", "🏠")) - - add(CatalogItem("Savon vaisselle liquide", "Maison", "🏠")) - - add(CatalogItem("Savon vert", "Maison", "🏠")) - - add(CatalogItem("Scarf", "Maison", "🏠")) - - add(CatalogItem("Sharpener", "Maison", "🏠")) - - add(CatalogItem("Skewers", "Maison", "🏠")) - - add(CatalogItem("Sol produit", "Maison", "🏠")) - - add(CatalogItem("Spatula", "Maison", "🏠")) - - add(CatalogItem("Sponge", "Maison", "🏠")) - - add(CatalogItem("Swiffer wet", "Maison", "🏠")) - - add(CatalogItem("Table bomb", "Maison", "🏠")) - - add(CatalogItem("Tape à bandage", "Maison", "🏠")) - - add(CatalogItem("Tinsel", "Maison", "🏠")) - - add(CatalogItem("Toilet brush", "Maison", "🏠")) - - add(CatalogItem("Toilet cleaner", "Maison", "🏠")) - - add(CatalogItem("Toner", "Maison", "🏠")) - - add(CatalogItem("Tylenol extra fort", "Maison", "🏠")) - - add(CatalogItem("Vacuum cleaner bags", "Maison", "🏠")) - - add(CatalogItem("Verres en plastique", "Maison", "🏠")) - - add(CatalogItem("Whisk", "Maison", "🏠")) - - add(CatalogItem("Windex", "Maison", "🏠")) - - add(CatalogItem("Wool socks", "Maison", "🏠")) - - add(CatalogItem("Wrapping paper", "Maison", "🏠")) - - add(CatalogItem("After sun", "Santé", "💊")) - - add(CatalogItem("Aftershave", "Santé", "💊")) - - add(CatalogItem("Antiseptic cream", "Santé", "💊")) - - add(CatalogItem("Bandages", "Santé", "💊")) - - add(CatalogItem("Bath essence", "Santé", "💊")) - - add(CatalogItem("Bath salt", "Santé", "💊")) - - add(CatalogItem("Beard oil", "Santé", "💊")) - - add(CatalogItem("Blister plaster", "Santé", "💊")) - - add(CatalogItem("Body lotion", "Santé", "💊")) - - add(CatalogItem("Charcoal tablets", "Santé", "💊")) - - add(CatalogItem("Children's face painting", "Santé", "💊")) - - add(CatalogItem("Compresses", "Santé", "💊")) - - add(CatalogItem("Conditioner", "Santé", "💊")) - - add(CatalogItem("Condoms", "Santé", "💊")) - - add(CatalogItem("Contact lens solution", "Santé", "💊")) - - add(CatalogItem("Cooling gel", "Santé", "💊")) - - add(CatalogItem("Cotton pads", "Santé", "💊")) - - add(CatalogItem("Cotton swabs", "Santé", "💊")) - - add(CatalogItem("Cough sweet", "Santé", "💊")) - - add(CatalogItem("Crème Aveeno", "Santé", "💊")) - - add(CatalogItem("Dental floss", "Santé", "💊")) - - add(CatalogItem("Diapers", "Santé", "💊")) - - add(CatalogItem("Disinfectant spray", "Santé", "💊")) - - add(CatalogItem("Eye drops", "Santé", "💊")) - - add(CatalogItem("Face cream", "Santé", "💊")) - - add(CatalogItem("Face mask", "Santé", "💊")) - - add(CatalogItem("Facial tissues", "Santé", "💊")) - - add(CatalogItem("Hair gel", "Santé", "💊")) - - add(CatalogItem("Hair oil", "Santé", "💊")) - - add(CatalogItem("Hair spray", "Santé", "💊")) - - add(CatalogItem("Hand cream", "Santé", "💊")) - - add(CatalogItem("Insect repellent", "Santé", "💊")) - - add(CatalogItem("Lip balm", "Santé", "💊")) - - add(CatalogItem("Lipstick", "Santé", "💊")) - - add(CatalogItem("Makeup remover", "Santé", "💊")) - - add(CatalogItem("Mouthwash", "Santé", "💊")) - - add(CatalogItem("Muscle cream", "Santé", "💊")) - - add(CatalogItem("Nail file", "Santé", "💊")) - - add(CatalogItem("Nail polish", "Santé", "💊")) - - add(CatalogItem("Nail polish remover", "Santé", "💊")) - - add(CatalogItem("Nasal ointment", "Santé", "💊")) - - add(CatalogItem("Ointment", "Santé", "💊")) - - add(CatalogItem("Pads", "Santé", "💊")) - - add(CatalogItem("Pain reliever", "Santé", "💊")) - - add(CatalogItem("Peeling", "Santé", "💊")) - - add(CatalogItem("Perfume", "Santé", "💊")) - - add(CatalogItem("Plasters", "Santé", "💊")) - - add(CatalogItem("Powder", "Santé", "💊")) - - add(CatalogItem("Razor", "Santé", "💊")) - - add(CatalogItem("Razor blades", "Santé", "💊")) - - add(CatalogItem("Shampoo", "Santé", "💊")) - - add(CatalogItem("Shaving cream", "Santé", "💊")) - - add(CatalogItem("Shower gel", "Santé", "💊")) - - add(CatalogItem("Soap", "Santé", "💊")) - - add(CatalogItem("Sunblock", "Santé", "💊")) - - add(CatalogItem("Thermometer", "Santé", "💊")) - - add(CatalogItem("Tissues", "Santé", "💊")) - - add(CatalogItem("Toilet paper", "Santé", "💊")) - - add(CatalogItem("Toothbrush", "Santé", "💊")) - - add(CatalogItem("Toothpaste", "Santé", "💊")) - - add(CatalogItem("Tweezers", "Santé", "💊")) - - add(CatalogItem("Vitamins", "Santé", "💊")) - - add(CatalogItem("Wet wipes", "Santé", "💊")) - - add(CatalogItem("Bird food", "Animaux", "🐾")) - - add(CatalogItem("Cat food", "Animaux", "🐾")) - - add(CatalogItem("Cat litter", "Animaux", "🐾")) - - add(CatalogItem("Cat treats", "Animaux", "🐾")) - - add(CatalogItem("Croquettes de poulet", "Animaux", "🐾")) - - add(CatalogItem("Croquettes Minouche", "Animaux", "🐾")) - - add(CatalogItem("Dog food", "Animaux", "🐾")) - - add(CatalogItem("Dog treats", "Animaux", "🐾")) - - add(CatalogItem("Fish food", "Animaux", "🐾")) - - add(CatalogItem("Gâterie pour chat", "Animaux", "🐾")) - - add(CatalogItem("Pâté pour chat", "Animaux", "🐾")) - - add(CatalogItem("Bâton de colle blanche", "Jardin", "🌱")) - - add(CatalogItem("Bolts", "Jardin", "🌱")) - - add(CatalogItem("Briquettes", "Jardin", "🌱")) - - add(CatalogItem("Brush", "Jardin", "🌱")) - - add(CatalogItem("Charcoal", "Jardin", "🌱")) - - add(CatalogItem("Dibble", "Jardin", "🌱")) - - add(CatalogItem("Fertilizer", "Jardin", "🌱")) - - add(CatalogItem("Flower trowel", "Jardin", "🌱")) - - add(CatalogItem("Garden tool", "Jardin", "🌱")) - - add(CatalogItem("Gardening gloves", "Jardin", "🌱")) - - add(CatalogItem("Grill", "Jardin", "🌱")) - - add(CatalogItem("Hedge shears", "Jardin", "🌱")) - - add(CatalogItem("Hoe", "Jardin", "🌱")) - - add(CatalogItem("Lawnmower", "Jardin", "🌱")) - - add(CatalogItem("Nails", "Jardin", "🌱")) - - add(CatalogItem("Parasol", "Jardin", "🌱")) - - add(CatalogItem("Pesticides", "Jardin", "🌱")) - - add(CatalogItem("Planter box", "Jardin", "🌱")) - - add(CatalogItem("Plants", "Jardin", "🌱")) - - add(CatalogItem("Plat plastique jetable", "Jardin", "🌱")) - - add(CatalogItem("Pots", "Jardin", "🌱")) - - add(CatalogItem("Potting soil", "Jardin", "🌱")) - - add(CatalogItem("Propane", "Jardin", "🌱")) - - add(CatalogItem("Road salt", "Jardin", "🌱")) - - add(CatalogItem("Seeds", "Jardin", "🌱")) - - add(CatalogItem("Seedlings", "Jardin", "🌱")) - - add(CatalogItem("Snow chains", "Jardin", "🌱")) - - add(CatalogItem("Snow shovel", "Jardin", "🌱")) - - add(CatalogItem("Watering can", "Jardin", "🌱")) - - add(CatalogItem("Arachides", "Épicerie", "🌾")) - - add(CatalogItem("Bande élastique genou", "Épicerie", "🌾")) - - add(CatalogItem("Barre Nathan", "Épicerie", "🌾")) - - add(CatalogItem("Barre tendre", "Épicerie", "🌾")) - - add(CatalogItem("Boisson atypique", "Épicerie", "🌾")) - - add(CatalogItem("Boost", "Épicerie", "🌾")) - - add(CatalogItem("Canneberges", "Épicerie", "🌾")) - - add(CatalogItem("Carte bus Nathan", "Épicerie", "🌾")) - - add(CatalogItem("Carte Jérémy", "Épicerie", "🌾")) - - add(CatalogItem("Cereal Cheerios", "Épicerie", "🌾")) - - add(CatalogItem("Cereal Chex cannelle", "Épicerie", "🌾")) - - add(CatalogItem("Cereal Life aux son", "Épicerie", "🌾")) - - add(CatalogItem("Cereal Pop", "Épicerie", "🌾")) - - add(CatalogItem("Chasse-tâches", "Épicerie", "🌾")) - - add(CatalogItem("Cheese whiz", "Épicerie", "🌾")) - - add(CatalogItem("Cheetos", "Épicerie", "🌾")) - - add(CatalogItem("Colorant alimentaire", "Épicerie", "🌾")) - - add(CatalogItem("Côtes levées", "Épicerie", "🌾")) - - add(CatalogItem("Épice italienne", "Épicerie", "🌾")) - - add(CatalogItem("Éponge Me Net", "Épicerie", "🌾")) - - add(CatalogItem("Friche épicée", "Épicerie", "🌾")) - - add(CatalogItem("Graines d'oiseaux", "Épicerie", "🌾")) - - add(CatalogItem("Gruau", "Épicerie", "🌾")) - - add(CatalogItem("Harvest Crunch", "Épicerie", "🌾")) - - add(CatalogItem("Herbamare", "Épicerie", "🌾")) - - add(CatalogItem("Jos Louis", "Épicerie", "🌾")) - - add(CatalogItem("Kit taco", "Épicerie", "🌾")) - - add(CatalogItem("Kraft dinner", "Épicerie", "🌾")) - - add(CatalogItem("Le souper", "Épicerie", "🌾")) - - add(CatalogItem("Limonade congelée", "Épicerie", "🌾")) - - add(CatalogItem("Liqueur", "Épicerie", "🌾")) - - add(CatalogItem("Mine de crayon 0.7", "Épicerie", "🌾")) - - add(CatalogItem("Muffin anglais", "Épicerie", "🌾")) - - add(CatalogItem("Omega 3", "Épicerie", "🌾")) - - add(CatalogItem("Peanuts salés", "Épicerie", "🌾")) - - add(CatalogItem("Pepperoni", "Épicerie", "🌾")) - - add(CatalogItem("Photo", "Épicerie", "🌾")) - - add(CatalogItem("Pilule allergie", "Épicerie", "🌾")) - - add(CatalogItem("Pilule Briana", "Épicerie", "🌾")) - - add(CatalogItem("Pilule Bruno", "Épicerie", "🌾")) - - add(CatalogItem("Pilule Brunone", "Épicerie", "🌾")) - - add(CatalogItem("Piments verts", "Épicerie", "🌾")) - - add(CatalogItem("Pomme Paulared Melba McIntosh", "Épicerie", "🌾")) - - add(CatalogItem("Pomme verte", "Épicerie", "🌾")) - - add(CatalogItem("Rice Krispies", "Épicerie", "🌾")) - - add(CatalogItem("Sachet sloppy joes", "Épicerie", "🌾")) - - add(CatalogItem("Saumon fumé", "Épicerie", "🌾")) - - add(CatalogItem("Sent bon", "Épicerie", "🌾")) - - add(CatalogItem("Trappe fourmis", "Épicerie", "🌾")) - - add(CatalogItem("Trappe souris", "Épicerie", "🌾")) - - add(CatalogItem("Viande de bœuf", "Épicerie", "🌾")) - - add(CatalogItem("Vim", "Épicerie", "🌾")) - - } - - /** Items pour une catégorie donnée (ordre catalogue). */ - fun itemsForCategory(category: String): List = - items.filter { it.category == category } - - /** - * Recherche dans le catalogue. Retourne au maximum [limit] résultats triés par - * pertinence : préfixe d'abord, puis sous-chaîne. - */ - fun search(query: String, limit: Int = 8): List { - val q = query.trim().lowercase() - if (q.isEmpty()) return emptyList() - val prefix = items.filter { it.name.lowercase().startsWith(q) } - val contains = items.filter { - !it.name.lowercase().startsWith(q) && it.matches(q) - } - return (prefix + contains).take(limit) - } - - /** - * Suggestions populaires (utilisées dans la barre de saisie quand vide, - * équivalent du panneau "Vous avez sûrement besoin"). - */ - val popularSuggestions: List = listOf( - items.first { it.name == "Lait" }, - items.first { it.name == "Pain" }, - items.first { it.name == "Œufs" }, - items.first { it.name == "Beurre" }, - items.first { it.name == "Pomme" }, - items.first { it.name == "Pâtes" }, - items.first { it.name == "Tomate" }, - items.first { it.name == "Yaourt" }, - items.first { it.name == "Papier toilette" } - ) - - /** - * Retourne un emoji représentatif pour un nom d'article libre. Utilise d'abord - * une correspondance exacte puis un repli par catégorie. - */ - fun emojiFor(name: String, category: String?): String { - val direct = items.firstOrNull { it.name.equals(name, ignoreCase = true) } - if (direct != null) return direct.emoji - return when (category) { - "Fruits & Légumes" -> "🥗" - "Boulangerie" -> "🥖" - "Produits laitiers" -> "🥛" - "Boucherie" -> "🥩" - "Épicerie" -> "🛒" - "Condiments & Épices" -> "🌶️" - "Surgelés" -> "🧊" - "Snacks & Bonbons" -> "🍿" - "Boissons" -> "🥤" - "Hygiène" -> "🧴" - "Entretien" -> "🧹" - "Bébé" -> "👶" - "Animaux" -> "🐾" - "Maison & Jardin" -> "🏡" - else -> "📦" - } - } -} diff --git a/app/src/main/java/com/safebite/app/domain/engine/CategoryEngine.kt b/app/src/main/java/com/safebite/app/domain/engine/CategoryEngine.kt index d40d39d..576c2c2 100644 --- a/app/src/main/java/com/safebite/app/domain/engine/CategoryEngine.kt +++ b/app/src/main/java/com/safebite/app/domain/engine/CategoryEngine.kt @@ -5,75 +5,86 @@ import javax.inject.Singleton /** * 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. */ @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 = emptyList(), + ): String { + val text = (listOf(productName) + categories).joinToString(" ").lowercase() - /** - * Détecte le rayon d'un produit basé sur son nom et ses catégories. - */ - fun detectCategory(productName: String, categories: List = emptyList()): String { - val text = (listOf(productName) + categories).joinToString(" ").lowercase() - - return when { - text.containsAny(freshKeywords) -> "Frais" - text.containsAny(fruitKeywords) -> "Fruits & Légumes" - text.containsAny(bakeryKeywords) -> "Boulangerie" - text.containsAny(meatKeywords) -> "Boucherie" - text.containsAny(dairyKeywords) -> "Produits laitiers" - text.containsAny(groceryKeywords) -> "Épicerie" - text.containsAny(beverageKeywords) -> "Boissons" - text.containsAny(frozenKeywords) -> "Surgelés" - text.containsAny(hygieneKeywords) -> "Hygiène" - text.containsAny(babyKeywords) -> "Bébé" - text.containsAny(petKeywords) -> "Animaux" - text.containsAny(cleaningKeywords) -> "Entretien" - else -> "Autre" + return when { + text.containsAny(freshKeywords) -> "Frais" + text.containsAny(fruitKeywords) -> "Fruits & Légumes" + text.containsAny(bakeryKeywords) -> "Boulangerie" + text.containsAny(meatKeywords) -> "Boucherie" + text.containsAny(dairyKeywords) -> "Produits laitiers" + text.containsAny(groceryKeywords) -> "Épicerie" + text.containsAny(beverageKeywords) -> "Boissons" + text.containsAny(frozenKeywords) -> "Surgelés" + text.containsAny(hygieneKeywords) -> "Hygiène" + text.containsAny(babyKeywords) -> "Bébé" + text.containsAny(petKeywords) -> "Animaux" + text.containsAny(cleaningKeywords) -> "Entretien" + else -> "Autre" + } } + + /** + * Catégorise une liste de produits et retourne un map catégorie -> produits. + */ + fun categorizeProducts(products: List): Map> { + return products + .groupBy { detectCategory(it.name, it.categories) } + .toSortedMap(compareBy { it }) + } + + data class ProductInfo( + val name: String, + val categories: List = emptyList(), + ) + + private fun String.containsAny(keywords: List): 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): Map> { - return products - .groupBy { detectCategory(it.name, it.categories) } - .toSortedMap(compareBy { it }) - } - - data class ProductInfo( - val name: String, - val categories: List = emptyList() - ) - - private fun String.containsAny(keywords: List): 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") -} diff --git a/app/src/main/java/com/safebite/app/domain/engine/HealthClassifier.kt b/app/src/main/java/com/safebite/app/domain/engine/HealthClassifier.kt index e2a375a..f7004e1 100644 --- a/app/src/main/java/com/safebite/app/domain/engine/HealthClassifier.kt +++ b/app/src/main/java/com/safebite/app/domain/engine/HealthClassifier.kt @@ -15,11 +15,10 @@ import com.safebite.app.domain.model.Product * - STRICT: only A + Nova ≤ 2 → HEALTHY; B → MODERATE; C/D/E or Nova ≥ 3 → UNHEALTHY. */ object HealthClassifier { - fun classify( product: Product, unhealthyCustomHits: List, - strictness: HealthStrictness + strictness: HealthStrictness, ): HealthAssessment { val nutri = product.nutriScore?.lowercase()?.takeIf { it in listOf("a", "b", "c", "d", "e") } val nova = product.novaGroup?.takeIf { it in 1..4 } @@ -30,13 +29,15 @@ object HealthClassifier { if (unhealthyCustomHits.isNotEmpty()) { reasons += "Contient: ${unhealthyCustomHits.joinToString()}" - rating = when (strictness) { - HealthStrictness.LENIENT -> when (rating) { - HealthRating.HEALTHY, HealthRating.UNKNOWN -> HealthRating.MODERATE - else -> rating + rating = + when (strictness) { + HealthStrictness.LENIENT -> + when (rating) { + HealthRating.HEALTHY, HealthRating.UNKNOWN -> HealthRating.MODERATE + else -> rating + } + HealthStrictness.NORMAL, HealthStrictness.STRICT -> HealthRating.UNHEALTHY } - HealthStrictness.NORMAL, HealthStrictness.STRICT -> HealthRating.UNHEALTHY - } } // If we truly have nothing to judge on, keep UNKNOWN. @@ -49,7 +50,7 @@ object HealthClassifier { reasons = reasons, nutriScore = nutri, novaGroup = nova, - ecoScore = eco + ecoScore = eco, ) } @@ -57,7 +58,7 @@ object HealthClassifier { nutri: String?, nova: Int?, strictness: HealthStrictness, - reasons: MutableList + reasons: MutableList, ): HealthRating { // Score nutri (a=0 best, e=4 worst) val nutriScoreValue = nutri?.let { it[0].code - 'a'.code } @@ -67,35 +68,38 @@ object HealthClassifier { if (nova != null) reasons += "NOVA $nova" return when (strictness) { - HealthStrictness.LENIENT -> when { - nutriScoreValue != null && nutriScoreValue >= 3 && novaValue == 4 -> HealthRating.UNHEALTHY - nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.MODERATE - novaValue == 4 -> HealthRating.MODERATE - nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.HEALTHY - nutriScoreValue != null -> HealthRating.MODERATE - novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY - else -> HealthRating.UNKNOWN - } - HealthStrictness.NORMAL -> when { - nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.UNHEALTHY - novaValue != null && novaValue >= 4 -> HealthRating.UNHEALTHY - nutriScoreValue == 2 && (novaValue ?: 1) >= 3 -> HealthRating.UNHEALTHY - nutriScoreValue == 2 -> HealthRating.MODERATE - novaValue == 3 && nutriScoreValue == null -> HealthRating.MODERATE - nutriScoreValue != null && nutriScoreValue <= 1 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY - nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.MODERATE - novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY - else -> HealthRating.UNKNOWN - } - HealthStrictness.STRICT -> when { - nutriScoreValue != null && nutriScoreValue >= 2 -> HealthRating.UNHEALTHY - novaValue != null && novaValue >= 3 -> HealthRating.UNHEALTHY - nutriScoreValue == 1 -> HealthRating.MODERATE - nutriScoreValue == 0 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY - nutriScoreValue == 0 -> HealthRating.MODERATE - novaValue != null && novaValue <= 2 -> HealthRating.MODERATE - else -> HealthRating.UNKNOWN - } + HealthStrictness.LENIENT -> + when { + nutriScoreValue != null && nutriScoreValue >= 3 && novaValue == 4 -> HealthRating.UNHEALTHY + nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.MODERATE + novaValue == 4 -> HealthRating.MODERATE + nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.HEALTHY + nutriScoreValue != null -> HealthRating.MODERATE + novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY + else -> HealthRating.UNKNOWN + } + HealthStrictness.NORMAL -> + when { + nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.UNHEALTHY + novaValue != null && novaValue >= 4 -> HealthRating.UNHEALTHY + nutriScoreValue == 2 && (novaValue ?: 1) >= 3 -> HealthRating.UNHEALTHY + nutriScoreValue == 2 -> HealthRating.MODERATE + novaValue == 3 && nutriScoreValue == null -> HealthRating.MODERATE + nutriScoreValue != null && nutriScoreValue <= 1 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY + nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.MODERATE + novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY + else -> HealthRating.UNKNOWN + } + HealthStrictness.STRICT -> + when { + nutriScoreValue != null && nutriScoreValue >= 2 -> HealthRating.UNHEALTHY + novaValue != null && novaValue >= 3 -> HealthRating.UNHEALTHY + nutriScoreValue == 1 -> HealthRating.MODERATE + nutriScoreValue == 0 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY + nutriScoreValue == 0 -> HealthRating.MODERATE + novaValue != null && novaValue <= 2 -> HealthRating.MODERATE + else -> HealthRating.UNKNOWN + } } } } diff --git a/app/src/main/java/com/safebite/app/domain/model/AllergenType.kt b/app/src/main/java/com/safebite/app/domain/model/AllergenType.kt index 7598620..6ea6a4f 100644 --- a/app/src/main/java/com/safebite/app/domain/model/AllergenType.kt +++ b/app/src/main/java/com/safebite/app/domain/model/AllergenType.kt @@ -11,192 +11,247 @@ enum class AllergenType( val icon: String, val openFoodFactsTags: List, val keywordsFr: List, - val keywordsEn: List + val keywordsEn: List, ) { GLUTEN( - "Gluten", "Gluten", "🌾", + "Gluten", + "Gluten", + "🌾", listOf("en:gluten"), listOf( "gluten", "blé", "froment", "seigle", "orge", "avoine", "épeautre", "kamut", "triticale", "malt", "amidon de blé", "farine de blé", "farine d'orge", "farine de seigle", - "protéine de blé", "seitan" + "protéine de blé", "seitan", ), listOf( "gluten", "wheat", "rye", "barley", "oats", "spelt", - "kamut", "triticale", "malt", "wheat starch", "wheat flour" - ) + "kamut", "triticale", "malt", "wheat starch", "wheat flour", + ), ), PEANUTS( - "Arachides", "Peanuts", "🥜", + "Arachides", + "Peanuts", + "🥜", listOf("en:peanuts"), listOf( - "arachide", "arachides", "cacahuète", "cacahuètes", - "beurre d'arachide", "huile d'arachide" + "arachide", + "arachides", + "cacahuète", + "cacahuètes", + "beurre d'arachide", + "huile d'arachide", ), listOf( - "peanut", "peanuts", "peanut butter", "peanut oil", - "groundnut", "groundnuts" - ) + "peanut", + "peanuts", + "peanut butter", + "peanut oil", + "groundnut", + "groundnuts", + ), ), TREE_NUTS( - "Noix", "Tree Nuts", "🌰", + "Noix", + "Tree Nuts", + "🌰", listOf("en:nuts", "en:tree-nuts"), listOf( "noix", "amande", "amandes", "noisette", "noisettes", "cajou", "noix de cajou", "pistache", "pistaches", "noix de pécan", "pécan", "noix du brésil", "macadamia", "noix de macadamia", "pralin", "praliné", "massepain", - "pâte d'amande", "poudre d'amande" + "pâte d'amande", "poudre d'amande", ), listOf( "nut", "nuts", "almond", "almonds", "hazelnut", "hazelnuts", "cashew", "cashews", "pistachio", "pecan", "pecans", "brazil nut", "macadamia", "walnut", "walnuts", "praline", - "marzipan", "almond paste" - ) + "marzipan", "almond paste", + ), ), MILK( - "Lait", "Milk", "🥛", + "Lait", + "Milk", + "🥛", listOf("en:milk"), listOf( "lait", "lactose", "caséine", "caséinate", "lactosérum", "petit-lait", "beurre", "crème", "fromage", "yogourt", "babeurre", "ghee", "lactalbumine", "lactoglobuline", "protéine de lait", "poudre de lait", "lait écrémé", - "lait entier", "concentré de protéines de lait" + "lait entier", "concentré de protéines de lait", ), listOf( "milk", "lactose", "casein", "caseinate", "whey", "butter", "cream", "cheese", "yogurt", "buttermilk", "ghee", "lactalbumin", "lactoglobulin", "milk protein", - "milk powder", "skim milk", "whole milk" - ) + "milk powder", "skim milk", "whole milk", + ), ), EGGS( - "Œufs", "Eggs", "🥚", + "Œufs", + "Eggs", + "🥚", listOf("en:eggs"), listOf( "œuf", "oeuf", "œufs", "oeufs", "albumine", "ovomucine", "ovomucoïde", "ovalbumine", "lécithine d'œuf", "lysozyme", "jaune d'œuf", "blanc d'œuf", "poudre d'œuf", - "œuf entier" + "œuf entier", ), listOf( "egg", "eggs", "albumin", "ovomucin", "ovomucoid", "ovalbumin", "egg lecithin", "lysozyme", "egg yolk", - "egg white", "egg powder", "whole egg" - ) + "egg white", "egg powder", "whole egg", + ), ), SOY( - "Soja", "Soy", "🫘", + "Soja", + "Soy", + "🫘", listOf("en:soybeans"), listOf( "soja", "soya", "lécithine de soja", "protéine de soja", "tofu", "tempeh", "edamame", "fève de soja", - "huile de soja", "sauce soja", "miso" + "huile de soja", "sauce soja", "miso", ), listOf( "soy", "soya", "soybean", "soybeans", "soy lecithin", "soy protein", "tofu", "tempeh", "edamame", - "soybean oil", "soy sauce", "miso" - ) + "soybean oil", "soy sauce", "miso", + ), ), FISH( - "Poisson", "Fish", "🐟", + "Poisson", + "Fish", + "🐟", listOf("en:fish"), listOf( "poisson", "anchois", "bar", "cabillaud", "colin", "dorade", "flétan", "hareng", "maquereau", "merlu", "morue", "perche", "sardine", "saumon", "sole", "thon", "truite", "huile de poisson", "sauce de poisson", - "surimi", "gélatine de poisson" + "surimi", "gélatine de poisson", ), listOf( "fish", "anchovy", "anchovies", "bass", "cod", "haddock", "halibut", "herring", "mackerel", "perch", "salmon", "sardine", "sole", "trout", "tuna", "fish oil", - "fish sauce", "surimi", "fish gelatin" - ) + "fish sauce", "surimi", "fish gelatin", + ), ), CRUSTACEANS( - "Crustacés", "Crustaceans", "🦐", + "Crustacés", + "Crustaceans", + "🦐", listOf("en:crustaceans"), listOf( "crustacé", "crustacés", "crevette", "crevettes", "homard", "crabe", "langouste", "langoustine", - "écrevisse", "fruits de mer" + "écrevisse", "fruits de mer", ), listOf( "crustacean", "crustaceans", "shrimp", "lobster", - "crab", "crayfish", "prawn", "langoustine", "seafood" - ) + "crab", "crayfish", "prawn", "langoustine", "seafood", + ), ), SESAME( - "Sésame", "Sesame", "⚪", + "Sésame", + "Sesame", + "⚪", listOf("en:sesame-seeds"), listOf( - "sésame", "graines de sésame", "huile de sésame", - "tahini", "tahina", "halva" + "sésame", + "graines de sésame", + "huile de sésame", + "tahini", + "tahina", + "halva", ), listOf( - "sesame", "sesame seeds", "sesame oil", "tahini", - "tahina", "halva" - ) + "sesame", + "sesame seeds", + "sesame oil", + "tahini", + "tahina", + "halva", + ), ), MUSTARD( - "Moutarde", "Mustard", "🟡", + "Moutarde", + "Mustard", + "🟡", listOf("en:mustard"), listOf( - "moutarde", "graines de moutarde", "huile de moutarde", - "farine de moutarde" + "moutarde", + "graines de moutarde", + "huile de moutarde", + "farine de moutarde", ), - listOf("mustard", "mustard seeds", "mustard oil", "mustard flour") + listOf("mustard", "mustard seeds", "mustard oil", "mustard flour"), ), SULPHITES( - "Sulfites", "Sulphites", "🟣", + "Sulfites", + "Sulphites", + "🟣", listOf("en:sulphur-dioxide-and-sulphites"), listOf( "sulfite", "sulfites", "dioxyde de soufre", "bisulfite", "métabisulfite", "anhydride sulfureux", - "e220", "e221", "e222", "e223", "e224", "e225", "e226", "e228" + "e220", "e221", "e222", "e223", "e224", "e225", "e226", "e228", ), listOf( - "sulphite", "sulphites", "sulfite", "sulfites", - "sulphur dioxide", "bisulphite", "metabisulphite" - ) + "sulphite", + "sulphites", + "sulfite", + "sulfites", + "sulphur dioxide", + "bisulphite", + "metabisulphite", + ), ), LUPIN( - "Lupin", "Lupin", "💐", + "Lupin", + "Lupin", + "💐", listOf("en:lupin"), listOf("lupin", "lupins", "farine de lupin"), - listOf("lupin", "lupine", "lupin flour") + listOf("lupin", "lupine", "lupin flour"), ), MOLLUSCS( - "Mollusques", "Molluscs", "🐚", + "Mollusques", + "Molluscs", + "🐚", listOf("en:molluscs"), listOf( "mollusque", "mollusques", "huître", "moule", "moules", "palourde", "pétoncle", "calmar", "calamar", "pieuvre", - "poulpe", "escargot", "coquille saint-jacques" + "poulpe", "escargot", "coquille saint-jacques", ), listOf( "mollusc", "molluscs", "mollusk", "oyster", "mussel", - "clam", "scallop", "squid", "octopus", "snail" - ) + "clam", "scallop", "squid", "octopus", "snail", + ), ), CELERY( - "Céleri", "Celery", "🥬", + "Céleri", + "Celery", + "🥬", listOf("en:celery"), listOf( - "céleri", "celeri", "sel de céleri", "graines de céleri", - "celeriac", "céleri-rave" + "céleri", + "celeri", + "sel de céleri", + "graines de céleri", + "celeriac", + "céleri-rave", ), - listOf("celery", "celeriac", "celery salt", "celery seed") - ); + listOf("celery", "celeriac", "celery salt", "celery seed"), + ), + ; companion object { - fun fromName(name: String): AllergenType? = - values().firstOrNull { it.name.equals(name, ignoreCase = true) } + fun fromName(name: String): AllergenType? = values().firstOrNull { it.name.equals(name, ignoreCase = true) } } } diff --git a/app/src/main/java/com/safebite/app/domain/model/DomainModels.kt b/app/src/main/java/com/safebite/app/domain/model/DomainModels.kt index 9fe7b79..b66a1ac 100644 --- a/app/src/main/java/com/safebite/app/domain/model/DomainModels.kt +++ b/app/src/main/java/com/safebite/app/domain/model/DomainModels.kt @@ -13,7 +13,7 @@ enum class DietaryRestriction(val displayFr: String, val displayEn: String) { VEGETARIAN("Végétarien", "Vegetarian"), HALAL("Halal", "Halal"), KOSHER("Casher", "Kosher"), - NO_PORK("Sans porc", "No pork") + NO_PORK("Sans porc", "No pork"), } enum class DetectionLanguage { FR, EN, BOTH } @@ -31,7 +31,7 @@ enum class CustomItemTag(val displayFr: String, val displayEn: String) { ALLERGY("Allergie", "Allergy"), INTOLERANCE("Intolérance", "Intolerance"), DIET("Diète", "Diet"), - UNHEALTHY("Non-santé", "Unhealthy") + UNHEALTHY("Non-santé", "Unhealthy"), } /** A user-defined ingredient/substance to watch for (e.g. "huile de palme"). */ @@ -39,10 +39,9 @@ data class CustomDietItem( val name: String, val tag: CustomItemTag, /** Optional additional keywords; if empty, [name] is used. */ - val keywords: List = emptyList() + val keywords: List = emptyList(), ) { - fun allKeywords(): List = - (listOf(name) + keywords).filter { it.isNotBlank() }.distinct() + fun allKeywords(): List = (listOf(name) + keywords).filter { it.isNotBlank() }.distinct() } /** A user's allergy profile. */ @@ -54,7 +53,7 @@ data class UserProfile( val moderateIntolerances: Set = emptySet(), val dietaryRestrictions: Set = emptySet(), val customItems: List = emptyList(), - val isDefault: Boolean = false + val isDefault: Boolean = false, ) { /** Returns every allergen (severe + moderate) referenced by this profile. */ fun allAllergens(): Set = severeAllergens + moderateIntolerances @@ -71,12 +70,13 @@ data class Nutriments( val sodium100g: Double? = null, val fiber100g: Double? = null, val proteins100g: Double? = null, - val carbohydrates100g: Double? = null + val carbohydrates100g: Double? = null, ) { - fun isEmpty(): Boolean = listOf( - energyKcal100g, energyKcalServing, fat100g, saturatedFat100g, - sugars100g, salt100g, sodium100g, fiber100g, proteins100g, carbohydrates100g - ).all { it == null } + fun isEmpty(): Boolean = + listOf( + energyKcal100g, energyKcalServing, fat100g, saturatedFat100g, + sugars100g, salt100g, sodium100g, fiber100g, proteins100g, carbohydrates100g, + ).all { it == null } } /** A product fetched from Open Food Facts (or reconstructed from OCR). */ @@ -94,7 +94,7 @@ data class Product( val servingSize: String? = null, val nutriments: Nutriments = Nutriments(), val labels: List = emptyList(), - val categories: List = emptyList() + val categories: List = emptyList(), ) { /** Public Open Food Facts product page URL. */ fun openFoodFactsUrl(): String = "https://world.openfoodfacts.org/product/$barcode" @@ -104,7 +104,7 @@ data class Product( data class DetectedCustomItem( val item: CustomDietItem, val matchedKeywords: List, - val profileIds: List = emptyList() + val profileIds: List = emptyList(), ) /** High-level health verdict computed from Nutri-Score, Nova, Eco-Score + custom rules. */ @@ -113,7 +113,7 @@ data class HealthAssessment( val reasons: List = emptyList(), val nutriScore: String? = null, val novaGroup: Int? = null, - val ecoScore: String? = null + val ecoScore: String? = null, ) /** Describes a single allergen that was detected during analysis. */ @@ -125,7 +125,7 @@ data class DetectedAllergen( /** Which profiles this detection concerns (useful for multi-profile scans). */ val profileIds: List = emptyList(), /** True when at least one profile lists this as a *severe* allergy. */ - val severe: Boolean = true + val severe: Boolean = true, ) data class ScanResult( @@ -136,7 +136,7 @@ data class ScanResult( val health: HealthAssessment = HealthAssessment(), val analyzedProfiles: List, val confidence: AnalysisConfidence, - val source: DataSource + val source: DataSource, ) data class ScanHistoryItem( @@ -148,5 +148,5 @@ data class ScanHistoryItem( val safetyStatus: SafetyStatus, val profileNames: List, val scannedAt: Long, - val source: DataSource + val source: DataSource, ) diff --git a/app/src/main/java/com/safebite/app/domain/repository/Repositories.kt b/app/src/main/java/com/safebite/app/domain/repository/Repositories.kt index 927c8d3..1ad691b 100644 --- a/app/src/main/java/com/safebite/app/domain/repository/Repositories.kt +++ b/app/src/main/java/com/safebite/app/domain/repository/Repositories.kt @@ -15,38 +15,54 @@ import kotlinx.coroutines.flow.Flow sealed class ProductFetchResult { data class Found(val product: Product, val fromCache: Boolean) : ProductFetchResult() + data object NotFound : ProductFetchResult() + data class Error(val message: String, val offline: Boolean = false) : ProductFetchResult() } interface ProductRepository { suspend fun fetchProduct(barcode: String): ProductFetchResult + suspend fun cacheProduct(product: Product) + suspend fun getCachedProduct(barcode: String): Product? + suspend fun clearCache() + /** Search for products in the same category without specific allergens. */ suspend fun searchAlternatives( category: String, excludeAllergens: Set, - limit: Int = 5 + limit: Int = 5, ): List } interface UserProfileRepository { fun observeProfiles(): Flow> + suspend fun getProfile(id: Long): UserProfile? + suspend fun upsert(profile: UserProfile): Long + suspend fun delete(profile: UserProfile) + suspend fun setDefault(id: Long) + fun observeActiveProfileIds(): Flow> + suspend fun setActiveProfileIds(ids: Set) } interface ScanHistoryRepository { fun observeHistory(): Flow> + suspend fun save(result: ScanResult): Long + suspend fun delete(id: Long) + suspend fun clear() + suspend fun getById(id: Long): ScanHistoryItem? } @@ -61,12 +77,19 @@ interface SettingsRepository { val splashScreenEnabled: Flow suspend fun setAppLanguage(value: AppLanguage) + suspend fun setDetectionLanguage(value: DetectionLanguage) + suspend fun setHaptics(enabled: Boolean) + suspend fun setSound(enabled: Boolean) + suspend fun setTheme(value: ThemePref) + suspend fun setOnboardingCompleted(value: Boolean) + suspend fun setHealthStrictness(value: HealthStrictness) + suspend fun setSplashScreenEnabled(enabled: Boolean) } @@ -77,34 +100,61 @@ interface SettingsRepository { interface ShoppingListRepository { // Lists fun observeActiveLists(): Flow> + fun observeAllLists(): Flow> + suspend fun getListById(id: Long): ShoppingListEntity? - suspend fun createList(name: String, backgroundResName: String? = null): Long + + suspend fun createList( + name: String, + backgroundResName: String? = null, + ): Long + suspend fun updateList(list: ShoppingListEntity) + suspend fun deleteList(list: ShoppingListEntity) + suspend fun archiveList(id: Long) // Items fun observeItems(listId: Long): Flow> + suspend fun getItems(listId: Long): List + suspend fun addItem(item: ShoppingListItemEntity): Long + suspend fun updateItem(item: ShoppingListItemEntity) + suspend fun deleteItem(item: ShoppingListItemEntity) - suspend fun setItemChecked(id: Long, checked: Boolean) + + suspend fun setItemChecked( + id: Long, + checked: Boolean, + ) + suspend fun uncheckAllItems(listId: Long) + suspend fun deleteAllItems(listId: Long) // Stats fun observeItemCount(listId: Long): Flow + fun observeCheckedCount(listId: Long): Flow // Members fun observeMembers(listId: Long): Flow> + suspend fun addMember(member: ShoppingListMemberEntity): Long + suspend fun updateMember(member: ShoppingListMemberEntity) + suspend fun removeMember(member: ShoppingListMemberEntity) + suspend fun deleteAllMembers(listId: Long) // Helpers - suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) + suspend fun addItemToList( + listId: Long, + item: ShoppingListItemEntity, + ) } diff --git a/app/src/main/java/com/safebite/app/domain/usecase/GetAlternativesUseCase.kt b/app/src/main/java/com/safebite/app/domain/usecase/GetAlternativesUseCase.kt index ad2fa05..7de2e7a 100644 --- a/app/src/main/java/com/safebite/app/domain/usecase/GetAlternativesUseCase.kt +++ b/app/src/main/java/com/safebite/app/domain/usecase/GetAlternativesUseCase.kt @@ -8,15 +8,17 @@ import javax.inject.Inject * UseCase pour trouver des produits alternatifs dans la même catégorie * sans les allergènes problématiques. */ -class GetAlternativesUseCase @Inject constructor( - private val productRepository: ProductRepository -) { - suspend operator fun invoke( - category: String, - excludeAllergenTags: Set, - limit: Int = 5 - ): List { - if (category.isBlank()) return emptyList() - return productRepository.searchAlternatives(category, excludeAllergenTags, limit) +class GetAlternativesUseCase + @Inject + constructor( + private val productRepository: ProductRepository, + ) { + suspend operator fun invoke( + category: String, + excludeAllergenTags: Set, + limit: Int = 5, + ): List { + if (category.isBlank()) return emptyList() + return productRepository.searchAlternatives(category, excludeAllergenTags, limit) + } } -} diff --git a/app/src/main/java/com/safebite/app/domain/usecase/UseCases.kt b/app/src/main/java/com/safebite/app/domain/usecase/UseCases.kt index 8b87201..fc8482a 100644 --- a/app/src/main/java/com/safebite/app/domain/usecase/UseCases.kt +++ b/app/src/main/java/com/safebite/app/domain/usecase/UseCases.kt @@ -2,7 +2,6 @@ package com.safebite.app.domain.usecase import com.safebite.app.domain.engine.AllergenAnalysisEngine import com.safebite.app.domain.model.DataSource -import com.safebite.app.domain.model.DetectionLanguage import com.safebite.app.domain.model.Product import com.safebite.app.domain.model.ScanResult import com.safebite.app.domain.model.UserProfile @@ -16,108 +15,163 @@ import kotlinx.coroutines.flow.first import javax.inject.Inject /** Fetch a product by barcode (remote or cache). */ -class FetchProductUseCase @Inject constructor( - private val productRepository: ProductRepository -) { - suspend operator fun invoke(barcode: String): ProductFetchResult = - productRepository.fetchProduct(barcode) -} +class FetchProductUseCase + @Inject + constructor( + private val productRepository: ProductRepository, + ) { + suspend operator fun invoke(barcode: String): ProductFetchResult = productRepository.fetchProduct(barcode) + } /** Analyze a product against a list of profiles using the engine. */ -class AnalyzeProductUseCase @Inject constructor( - private val settingsRepository: SettingsRepository -) { - suspend operator fun invoke( - product: Product, - profiles: List, - source: DataSource - ): ScanResult { - val lang = settingsRepository.detectionLanguage.first() - val strictness = settingsRepository.healthStrictness.first() - return AllergenAnalysisEngine.analyze(product, profiles, source, lang, strictness) +class AnalyzeProductUseCase + @Inject + constructor( + private val settingsRepository: SettingsRepository, + ) { + suspend operator fun invoke( + product: Product, + profiles: List, + source: DataSource, + ): ScanResult { + val lang = settingsRepository.detectionLanguage.first() + val strictness = settingsRepository.healthStrictness.first() + return AllergenAnalysisEngine.analyze(product, profiles, source, lang, strictness) + } } -} /** Analyze free-form ingredients text (OCR path). */ -class AnalyzeIngredientsTextUseCase @Inject constructor( - private val analyzeProductUseCase: AnalyzeProductUseCase -) { - suspend operator fun invoke( - text: String, - profiles: List, - barcode: String? = null, - productName: String? = null - ): ScanResult { - val product = Product( - barcode = barcode ?: "ocr-${System.currentTimeMillis()}", - name = productName, - brand = null, - imageUrl = null, - ingredientsText = text, - allergensTags = emptyList(), - tracesTags = emptyList() - ) - return analyzeProductUseCase(product, profiles, DataSource.OCR) +class AnalyzeIngredientsTextUseCase + @Inject + constructor( + private val analyzeProductUseCase: AnalyzeProductUseCase, + ) { + suspend operator fun invoke( + text: String, + profiles: List, + barcode: String? = null, + productName: String? = null, + ): ScanResult { + val product = + Product( + barcode = barcode ?: "ocr-${System.currentTimeMillis()}", + name = productName, + brand = null, + imageUrl = null, + ingredientsText = text, + allergensTags = emptyList(), + tracesTags = emptyList(), + ) + return analyzeProductUseCase(product, profiles, DataSource.OCR) + } } -} -class ManageProfileUseCase @Inject constructor( - private val repo: UserProfileRepository -) { - fun observe(): Flow> = repo.observeProfiles() - suspend fun get(id: Long) = repo.getProfile(id) - suspend fun save(profile: UserProfile): Long = repo.upsert(profile) - suspend fun delete(profile: UserProfile) = repo.delete(profile) - suspend fun setDefault(id: Long) = repo.setDefault(id) - fun observeActiveIds() = repo.observeActiveProfileIds() - suspend fun setActive(ids: Set) = repo.setActiveProfileIds(ids) -} +class ManageProfileUseCase + @Inject + constructor( + private val repo: UserProfileRepository, + ) { + fun observe(): Flow> = repo.observeProfiles() -class GetScanHistoryUseCase @Inject constructor( - private val repo: ScanHistoryRepository -) { - fun observe(): Flow> = repo.observeHistory() - suspend fun delete(id: Long) = repo.delete(id) - suspend fun clear() = repo.clear() - suspend fun get(id: Long) = repo.getById(id) -} + suspend fun get(id: Long) = repo.getProfile(id) -class SaveScanUseCase @Inject constructor( - private val repo: ScanHistoryRepository -) { - suspend operator fun invoke(result: ScanResult): Long = repo.save(result) -} + suspend fun save(profile: UserProfile): Long = repo.upsert(profile) + + suspend fun delete(profile: UserProfile) = repo.delete(profile) + + suspend fun setDefault(id: Long) = repo.setDefault(id) + + fun observeActiveIds() = repo.observeActiveProfileIds() + + suspend fun setActive(ids: Set) = repo.setActiveProfileIds(ids) + } + +class GetScanHistoryUseCase + @Inject + constructor( + private val repo: ScanHistoryRepository, + ) { + fun observe(): Flow> = repo.observeHistory() + + suspend fun delete(id: Long) = repo.delete(id) + + suspend fun clear() = repo.clear() + + suspend fun get(id: Long) = repo.getById(id) + } + +class SaveScanUseCase + @Inject + constructor( + private val repo: ScanHistoryRepository, + ) { + suspend operator fun invoke(result: ScanResult): Long = repo.save(result) + } // ============================================================================= // Shopping List UseCases (Phase 2) // ============================================================================= -class GetShoppingListsUseCase @Inject constructor( - private val repo: com.safebite.app.domain.repository.ShoppingListRepository -) { - fun observeActive() = repo.observeActiveLists() - fun observeAll() = repo.observeAllLists() - suspend fun getList(id: Long) = repo.getListById(id) - suspend fun createList(name: String, backgroundResName: String? = null) = repo.createList(name, backgroundResName) - suspend fun updateList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.updateList(list) - suspend fun deleteList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.deleteList(list) - fun observeItemCount(listId: Long) = repo.observeItemCount(listId) - fun observeCheckedCount(listId: Long) = repo.observeCheckedCount(listId) - fun observeMembers(listId: Long) = repo.observeMembers(listId) - suspend fun addMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.addMember(member) - suspend fun removeMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.removeMember(member) -} +class GetShoppingListsUseCase + @Inject + constructor( + private val repo: com.safebite.app.domain.repository.ShoppingListRepository, + ) { + fun observeActive() = repo.observeActiveLists() -class ManageShoppingListUseCase @Inject constructor( - private val repo: com.safebite.app.domain.repository.ShoppingListRepository -) { - fun observeItems(listId: Long) = repo.observeItems(listId) - suspend fun getItems(listId: Long) = repo.getItems(listId) - suspend fun addItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.addItem(item) - suspend fun updateItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.updateItem(item) - suspend fun deleteItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.deleteItem(item) - suspend fun setItemChecked(id: Long, checked: Boolean) = repo.setItemChecked(id, checked) - suspend fun uncheckAllItems(listId: Long) = repo.uncheckAllItems(listId) - suspend fun deleteAllItems(listId: Long) = repo.deleteAllItems(listId) - suspend fun addItemToList(listId: Long, item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.addItemToList(listId, item) -} + fun observeAll() = repo.observeAllLists() + + suspend fun getList(id: Long) = repo.getListById(id) + + suspend fun createList( + name: String, + backgroundResName: String? = null, + ) = repo.createList(name, backgroundResName) + + suspend fun updateList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.updateList(list) + + suspend fun deleteList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.deleteList(list) + + fun observeItemCount(listId: Long) = repo.observeItemCount(listId) + + fun observeCheckedCount(listId: Long) = repo.observeCheckedCount(listId) + + fun observeMembers(listId: Long) = repo.observeMembers(listId) + + suspend fun addMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.addMember(member) + + suspend fun removeMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.removeMember(member) + } + +class ManageShoppingListUseCase + @Inject + constructor( + private val repo: com.safebite.app.domain.repository.ShoppingListRepository, + ) { + fun observeItems(listId: Long) = repo.observeItems(listId) + + suspend fun getItems(listId: Long) = repo.getItems(listId) + + suspend fun addItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.addItem(item) + + suspend fun updateItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.updateItem(item) + + suspend fun deleteItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.deleteItem(item) + + suspend fun setItemChecked( + id: Long, + checked: Boolean, + ) = repo.setItemChecked(id, checked) + + suspend fun uncheckAllItems(listId: Long) = repo.uncheckAllItems(listId) + + suspend fun deleteAllItems(listId: Long) = repo.deleteAllItems(listId) + + suspend fun addItemToList( + listId: Long, + item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity, + ) = repo.addItemToList( + listId, + item, + ) + } diff --git a/app/src/main/java/com/safebite/app/presentation/MainActivity.kt b/app/src/main/java/com/safebite/app/presentation/MainActivity.kt index e06bf5e..03c4143 100644 --- a/app/src/main/java/com/safebite/app/presentation/MainActivity.kt +++ b/app/src/main/java/com/safebite/app/presentation/MainActivity.kt @@ -4,11 +4,11 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.compose.runtime.getValue import androidx.core.view.WindowCompat import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.runtime.getValue +import androidx.lifecycle.viewModelScope import com.safebite.app.domain.model.ThemePref import com.safebite.app.domain.repository.SettingsRepository import com.safebite.app.presentation.navigation.SafeBiteNavGraph @@ -25,30 +25,32 @@ data class RootUi( val onboardingDone: Boolean = false, val theme: ThemePref = ThemePref.SYSTEM, val showSplash: Boolean = false, - val ready: Boolean = false + val ready: Boolean = false, ) @HiltViewModel -class RootViewModel @Inject constructor( - settings: SettingsRepository -) : ViewModel() { - val state: StateFlow = combine( - settings.onboardingCompleted, - settings.theme, - settings.splashScreenEnabled - ) { done, theme, splashEnabled -> - RootUi( - onboardingDone = done, - theme = theme, - showSplash = splashEnabled && done, - ready = true - ) - }.stateIn(viewModelScope, SharingStarted.Eagerly, RootUi()) -} +class RootViewModel + @Inject + constructor( + settings: SettingsRepository, + ) : ViewModel() { + val state: StateFlow = + combine( + settings.onboardingCompleted, + settings.theme, + settings.splashScreenEnabled, + ) { done, theme, splashEnabled -> + RootUi( + onboardingDone = done, + theme = theme, + showSplash = splashEnabled && done, + ready = true, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, RootUi()) + } @AndroidEntryPoint class MainActivity : ComponentActivity() { - private val rootViewModel: RootViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -56,16 +58,17 @@ class MainActivity : ComponentActivity() { WindowCompat.setDecorFitsSystemWindows(window, true) setContent { val ui by rootViewModel.state.collectAsStateWithLifecycle() - val dark = when (ui.theme) { - ThemePref.LIGHT -> false - ThemePref.DARK -> true - ThemePref.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme() - } + val dark = + when (ui.theme) { + ThemePref.LIGHT -> false + ThemePref.DARK -> true + ThemePref.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme() + } SafeBiteTheme(darkTheme = dark) { if (ui.ready) { SafeBiteNavGraph( onboardingCompleted = ui.onboardingDone, - showSplash = ui.showSplash + showSplash = ui.showSplash, ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/AllergenGrid.kt b/app/src/main/java/com/safebite/app/presentation/common/components/AllergenGrid.kt index dae115b..c8c85e6 100644 --- a/app/src/main/java/com/safebite/app/presentation/common/components/AllergenGrid.kt +++ b/app/src/main/java/com/safebite/app/presentation/common/components/AllergenGrid.kt @@ -31,27 +31,29 @@ import com.safebite.app.presentation.theme.LocalDimens enum class AllergenLevel(val label: String, val emoji: String) { NONE("Aucun", ""), TRACE("Traces", "⚠️"), - SEVERE("Sévère", "❌") + SEVERE("Sévère", "❌"), } /** * Couleurs de fond par état d'allergie (spec UX §4.2). */ -fun AllergenLevel.backgroundColor(): Color = when (this) { - AllergenLevel.NONE -> Color.Transparent - AllergenLevel.TRACE -> Color(0xFFFEF5E7) // Orange clair - AllergenLevel.SEVERE -> Color(0xFFFDEDEC) // Rouge clair -} +fun AllergenLevel.backgroundColor(): Color = + when (this) { + AllergenLevel.NONE -> Color.Transparent + AllergenLevel.TRACE -> Color(0xFFFEF5E7) // Orange clair + AllergenLevel.SEVERE -> Color(0xFFFDEDEC) // Rouge clair + } /** * Couleur de bordure par état. */ @Composable -fun AllergenLevel.borderColor(): Color = when (this) { - AllergenLevel.NONE -> MaterialTheme.colorScheme.outlineVariant - AllergenLevel.TRACE -> Color(0xFFF39C12) - AllergenLevel.SEVERE -> Color(0xFFE74C3C) -} +fun AllergenLevel.borderColor(): Color = + when (this) { + AllergenLevel.NONE -> MaterialTheme.colorScheme.outlineVariant + AllergenLevel.TRACE -> Color(0xFFF39C12) + AllergenLevel.SEVERE -> Color(0xFFE74C3C) + } /** * Grille de sélection d'allergènes avec 3 états par tap. @@ -66,7 +68,7 @@ fun AllergenLevel.borderColor(): Color = when (this) { fun AllergenSelectionGrid( selectedAllergens: Map, onLevelChanged: (AllergenType, AllergenLevel) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val dimens = LocalDimens.current val columns = 3 @@ -74,12 +76,12 @@ fun AllergenSelectionGrid( Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(dimens.spacingSm) + verticalArrangement = Arrangement.spacedBy(dimens.spacingSm), ) { rows.forEach { row -> Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm) + horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm), ) { row.forEach { allergen -> val currentLevel = selectedAllergens[allergen] ?: AllergenLevel.NONE @@ -88,13 +90,14 @@ fun AllergenSelectionGrid( allergen = allergen, level = currentLevel, onClick = { - val nextLevel = when (currentLevel) { - AllergenLevel.NONE -> AllergenLevel.TRACE - AllergenLevel.TRACE -> AllergenLevel.SEVERE - AllergenLevel.SEVERE -> AllergenLevel.NONE - } + val nextLevel = + when (currentLevel) { + AllergenLevel.NONE -> AllergenLevel.TRACE + AllergenLevel.TRACE -> AllergenLevel.SEVERE + AllergenLevel.SEVERE -> AllergenLevel.NONE + } onLevelChanged(allergen, nextLevel) - } + }, ) } repeat(columns - row.size) { @@ -113,33 +116,37 @@ fun AllergenSelectionChip( allergen: AllergenType, level: AllergenLevel, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val dimens = LocalDimens.current Card( - modifier = modifier - .clickable(onClick = onClick), + modifier = + modifier + .clickable(onClick = onClick), shape = RoundedCornerShape(dimens.radiusMd), - colors = CardDefaults.cardColors( - containerColor = level.backgroundColor() - ), - border = androidx.compose.foundation.BorderStroke( - width = 2.dp, - color = level.borderColor() - ) + colors = + CardDefaults.cardColors( + containerColor = level.backgroundColor(), + ), + border = + androidx.compose.foundation.BorderStroke( + width = 2.dp, + color = level.borderColor(), + ), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = dimens.spacingSm, horizontal = dimens.spacingXs), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = dimens.spacingSm, horizontal = dimens.spacingXs), + horizontalAlignment = Alignment.CenterHorizontally, ) { // Emoji allergène Text( text = allergen.icon, style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) // Nom court @@ -148,7 +155,7 @@ fun AllergenSelectionChip( style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, - maxLines = 1 + maxLines = 1, ) // Indicateur d'état @@ -156,7 +163,7 @@ fun AllergenSelectionChip( Text( text = level.emoji, style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } @@ -171,7 +178,7 @@ fun AllergenSelectionChip( fun AllergenDisplayGrid( severeAllergens: Set, moderateIntolerances: Set, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val dimens = LocalDimens.current @@ -180,29 +187,29 @@ fun AllergenDisplayGrid( text = "Aucune allergie détectée ✅", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = modifier.padding(dimens.spacingSm) + modifier = modifier.padding(dimens.spacingSm), ) return } Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(dimens.spacingSm) + verticalArrangement = Arrangement.spacedBy(dimens.spacingSm), ) { // Allergènes sévères if (severeAllergens.isNotEmpty()) { Text( text = "❌ Allergies sévères :", style = MaterialTheme.typography.labelMedium, - color = Color(0xFFE74C3C) + color = Color(0xFFE74C3C), ) Row( - horizontalArrangement = Arrangement.spacedBy(4.dp) + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { severeAllergens.forEach { allergen -> AllergenBadge( allergen = allergen, - level = AllergenLevel.SEVERE + level = AllergenLevel.SEVERE, ) } } @@ -213,15 +220,15 @@ fun AllergenDisplayGrid( Text( text = "⚠️ Intolérances :", style = MaterialTheme.typography.labelMedium, - color = Color(0xFFF39C12) + color = Color(0xFFF39C12), ) Row( - horizontalArrangement = Arrangement.spacedBy(4.dp) + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { moderateIntolerances.forEach { allergen -> AllergenBadge( allergen = allergen, - level = AllergenLevel.TRACE + level = AllergenLevel.TRACE, ) } } @@ -236,30 +243,31 @@ fun AllergenDisplayGrid( fun AllergenBadge( allergen: AllergenType, level: AllergenLevel, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Box( - modifier = modifier - .background( - color = level.backgroundColor(), - shape = RoundedCornerShape(12.dp) - ) - .border( - width = 1.dp, - color = level.borderColor(), - shape = RoundedCornerShape(12.dp) - ) - .padding(horizontal = 8.dp, vertical = 4.dp) + modifier = + modifier + .background( + color = level.backgroundColor(), + shape = RoundedCornerShape(12.dp), + ) + .border( + width = 1.dp, + color = level.borderColor(), + shape = RoundedCornerShape(12.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Text(text = allergen.icon, style = MaterialTheme.typography.bodySmall) Text( text = allergen.displayNameFr, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/AppBars.kt b/app/src/main/java/com/safebite/app/presentation/common/components/AppBars.kt index 5ea5df6..24b259e 100644 --- a/app/src/main/java/com/safebite/app/presentation/common/components/AppBars.kt +++ b/app/src/main/java/com/safebite/app/presentation/common/components/AppBars.kt @@ -32,21 +32,23 @@ fun SafeBiteTopAppBar( actions: @Composable RowScope.() -> Unit = {}, scrollBehavior: TopAppBarScrollBehavior? = null, ) { - val colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant, - titleContentColor = MaterialTheme.colorScheme.onSurface, - navigationIconContentColor = MaterialTheme.colorScheme.onSurface, - actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ) + val colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) val titleComposable: @Composable () -> Unit = { Text( title, - style = when (variant) { - AppBarVariant.Large -> MaterialTheme.typography.headlineMedium - AppBarVariant.CenterAligned -> MaterialTheme.typography.titleLarge - AppBarVariant.Small -> MaterialTheme.typography.titleLarge - } + style = + when (variant) { + AppBarVariant.Large -> MaterialTheme.typography.headlineMedium + AppBarVariant.CenterAligned -> MaterialTheme.typography.titleLarge + AppBarVariant.Small -> MaterialTheme.typography.titleLarge + }, ) } val navIcon: @Composable () -> Unit = { @@ -57,35 +59,39 @@ fun SafeBiteTopAppBar( } } when (variant) { - AppBarVariant.Small -> TopAppBar( - title = titleComposable, - modifier = modifier, - navigationIcon = navIcon, - actions = actions, - colors = colors, - scrollBehavior = scrollBehavior, - ) - AppBarVariant.CenterAligned -> CenterAlignedTopAppBar( - title = titleComposable, - modifier = modifier, - navigationIcon = navIcon, - actions = actions, - colors = colors, - scrollBehavior = scrollBehavior, - ) - AppBarVariant.Large -> LargeTopAppBar( - title = titleComposable, - modifier = modifier, - navigationIcon = navIcon, - actions = actions, - colors = TopAppBarDefaults.largeTopAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant, - titleContentColor = MaterialTheme.colorScheme.onSurface, - navigationIconContentColor = MaterialTheme.colorScheme.onSurface, - actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - scrollBehavior = scrollBehavior, - ) + AppBarVariant.Small -> + TopAppBar( + title = titleComposable, + modifier = modifier, + navigationIcon = navIcon, + actions = actions, + colors = colors, + scrollBehavior = scrollBehavior, + ) + AppBarVariant.CenterAligned -> + CenterAlignedTopAppBar( + title = titleComposable, + modifier = modifier, + navigationIcon = navIcon, + actions = actions, + colors = colors, + scrollBehavior = scrollBehavior, + ) + AppBarVariant.Large -> + LargeTopAppBar( + title = titleComposable, + modifier = modifier, + navigationIcon = navIcon, + actions = actions, + colors = + TopAppBarDefaults.largeTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + scrollBehavior = scrollBehavior, + ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/Buttons.kt b/app/src/main/java/com/safebite/app/presentation/common/components/Buttons.kt index 7c54cd4..25c8f94 100644 --- a/app/src/main/java/com/safebite/app/presentation/common/components/Buttons.kt +++ b/app/src/main/java/com/safebite/app/presentation/common/components/Buttons.kt @@ -51,7 +51,7 @@ private fun pressedScale(interactionSource: MutableInteractionSource): Float { val scale by animateFloatAsState( targetValue = if (pressed) 0.96f else 1f, animationSpec = tween(durationMillis = 120), - label = "buttonPressScale" + label = "buttonPressScale", ) return scale } @@ -76,10 +76,11 @@ fun PrimaryButton( Button( onClick = { if (!loading) onClick() }, enabled = enabled && !loading, - modifier = modifier - .scale(scale) - .heightIn(min = if (large) ButtonTokens.MinHeightLarge else ButtonTokens.MinHeight) - .defaultMinSize(minHeight = if (large) ButtonTokens.MinHeightLarge else ButtonTokens.MinHeight), + modifier = + modifier + .scale(scale) + .heightIn(min = if (large) ButtonTokens.MinHeightLarge else ButtonTokens.MinHeight) + .defaultMinSize(minHeight = if (large) ButtonTokens.MinHeightLarge else ButtonTokens.MinHeight), shape = MaterialTheme.shapes.medium, contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm), interactionSource = interaction, @@ -104,10 +105,11 @@ fun SecondaryButton( FilledTonalButton( onClick = { if (!loading) onClick() }, enabled = enabled && !loading, - modifier = modifier - .scale(scale) - .heightIn(min = ButtonTokens.MinHeight) - .defaultMinSize(minHeight = ButtonTokens.MinHeight), + modifier = + modifier + .scale(scale) + .heightIn(min = ButtonTokens.MinHeight) + .defaultMinSize(minHeight = ButtonTokens.MinHeight), shape = MaterialTheme.shapes.medium, contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm), interactionSource = interaction, @@ -132,10 +134,11 @@ fun OutlinedActionButton( OutlinedButton( onClick = { if (!loading) onClick() }, enabled = enabled && !loading, - modifier = modifier - .scale(scale) - .heightIn(min = ButtonTokens.MinHeight) - .defaultMinSize(minHeight = ButtonTokens.MinHeight), + modifier = + modifier + .scale(scale) + .heightIn(min = ButtonTokens.MinHeight) + .defaultMinSize(minHeight = ButtonTokens.MinHeight), shape = MaterialTheme.shapes.medium, contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm), interactionSource = interaction, @@ -158,10 +161,11 @@ fun TertiaryButton( TextButton( onClick = onClick, enabled = enabled, - modifier = modifier - .scale(scale) - .heightIn(min = ButtonTokens.MinHeight) - .defaultMinSize(minHeight = ButtonTokens.MinHeight), + modifier = + modifier + .scale(scale) + .heightIn(min = ButtonTokens.MinHeight) + .defaultMinSize(minHeight = ButtonTokens.MinHeight), shape = MaterialTheme.shapes.medium, interactionSource = interaction, ) { @@ -182,17 +186,19 @@ fun DestructiveButton( val dimens = LocalDimens.current val interaction = remember { MutableInteractionSource() } val scale = pressedScale(interaction) - val colors: ButtonColors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ) + val colors: ButtonColors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) FilledTonalButton( onClick = { if (!loading) onClick() }, enabled = enabled && !loading, - modifier = modifier - .scale(scale) - .heightIn(min = ButtonTokens.MinHeight) - .defaultMinSize(minHeight = ButtonTokens.MinHeight), + modifier = + modifier + .scale(scale) + .heightIn(min = ButtonTokens.MinHeight) + .defaultMinSize(minHeight = ButtonTokens.MinHeight), shape = MaterialTheme.shapes.medium, colors = colors, contentPadding = PaddingValues(horizontal = dimens.spacingLg, vertical = dimens.spacingSm), @@ -203,20 +209,24 @@ fun DestructiveButton( } @Composable -private fun ButtonContent(text: String, icon: ImageVector?, loading: Boolean) { +private fun ButtonContent( + text: String, + icon: ImageVector?, + loading: Boolean, +) { Row(verticalAlignment = Alignment.CenterVertically) { if (loading) { CircularProgressIndicator( modifier = Modifier.size(ButtonTokens.ProgressSize), strokeWidth = 2.dp, - color = LocalContentColor.current + color = LocalContentColor.current, ) Spacer(Modifier.width(ButtonTokens.IconSpacer)) } else if (icon != null) { Icon( imageVector = icon, contentDescription = null, - modifier = Modifier.size(ButtonTokens.IconSize) + modifier = Modifier.size(ButtonTokens.IconSize), ) Spacer(Modifier.width(ButtonTokens.IconSpacer)) } diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/Cards.kt b/app/src/main/java/com/safebite/app/presentation/common/components/Cards.kt index 50e174e..66af2be 100644 --- a/app/src/main/java/com/safebite/app/presentation/common/components/Cards.kt +++ b/app/src/main/java/com/safebite/app/presentation/common/components/Cards.kt @@ -46,19 +46,21 @@ fun StandardCard( onClick = onClick, modifier = modifier, shape = shape, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), ) { InnerPadding(contentPadding, content) } } else { Card( modifier = modifier, shape = shape, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), ) { InnerPadding(contentPadding, content) } } } @@ -79,6 +81,9 @@ fun StandardCard( } @Composable -private fun InnerPadding(pad: PaddingValues, content: @Composable () -> Unit) { +private fun InnerPadding( + pad: PaddingValues, + content: @Composable () -> Unit, +) { androidx.compose.foundation.layout.Box(Modifier.padding(pad)) { content() } } diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/Charts.kt b/app/src/main/java/com/safebite/app/presentation/common/components/Charts.kt index 2c33443..1d785fd 100644 --- a/app/src/main/java/com/safebite/app/presentation/common/components/Charts.kt +++ b/app/src/main/java/com/safebite/app/presentation/common/components/Charts.kt @@ -51,19 +51,19 @@ fun DonutChart( progressColor: Color = LocalStatusColors.current.safe, centerText: String = "${(progress * 100).toInt()}%", centerSubText: String? = null, - animated: Boolean = true + animated: Boolean = true, ) { val dimens = LocalDimens.current val animatedProgress by animateFloatAsState( targetValue = progress.coerceIn(0f, 1f), animationSpec = tween(durationMillis = 800), - label = "donutProgress" + label = "donutProgress", ) val displayProgress = if (animated) animatedProgress else progress Box( modifier = modifier.size(size), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Canvas(modifier = Modifier.fillMaxSize()) { val sweepAngle = 360f * displayProgress @@ -75,7 +75,7 @@ fun DonutChart( startAngle = -90f, sweepAngle = 360f, useCenter = false, - style = Stroke(width = stroke, cap = StrokeCap.Round) + style = Stroke(width = stroke, cap = StrokeCap.Round), ) // Arc de progression @@ -85,27 +85,27 @@ fun DonutChart( startAngle = -90f, sweepAngle = sweepAngle, useCenter = false, - style = Stroke(width = stroke, cap = StrokeCap.Round) + style = Stroke(width = stroke, cap = StrokeCap.Round), ) } } // Texte central Column( - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = centerText, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) if (centerSubText != null) { Text( text = centerSubText, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } @@ -117,9 +117,10 @@ fun DonutChart( */ data class SparklineData( val values: List, - val labels: List = emptyList() + val labels: List = emptyList(), ) { fun max(): Float = values.maxOrNull() ?: 0f + fun min(): Float = values.minOrNull() ?: 0f } @@ -138,20 +139,21 @@ fun Sparkline( lineColor: Color = LocalStatusColors.current.safe, fillColor: Color = LocalStatusColors.current.safe.copy(alpha = 0.15f), height: Dp = 80.dp, - showDots: Boolean = true + showDots: Boolean = true, ) { if (data.values.isEmpty()) return val animatedProgress by animateFloatAsState( targetValue = 1f, animationSpec = tween(durationMillis = 600), - label = "sparklineProgress" + label = "sparklineProgress", ) Canvas( - modifier = modifier - .fillMaxWidth() - .height(height) + modifier = + modifier + .fillMaxWidth() + .height(height), ) { val width = size.width val height = size.height @@ -163,39 +165,42 @@ fun Sparkline( val stepX = (width - 2 * padding) / (data.values.size - 1).coerceAtLeast(1) // Calcul des points - val points = data.values.mapIndexed { index, value -> - val x = padding + index * stepX - val normalizedValue = if (range > 0) (value - minVal) / range else 0.5f - val y = height - padding - normalizedValue * (height - 2 * padding) - Offset(x, y) - } + val points = + data.values.mapIndexed { index, value -> + val x = padding + index * stepX + val normalizedValue = if (range > 0) (value - minVal) / range else 0.5f + val y = height - padding - normalizedValue * (height - 2 * padding) + Offset(x, y) + } // Zone de remplissage if (points.size > 1) { - val fillPath = androidx.compose.ui.graphics.Path().apply { - moveTo(points.first().x, height) - lineTo(points.first().x, points.first().y) - points.forEach { point -> - lineTo(point.x, point.y) + val fillPath = + androidx.compose.ui.graphics.Path().apply { + moveTo(points.first().x, height) + lineTo(points.first().x, points.first().y) + points.forEach { point -> + lineTo(point.x, point.y) + } + lineTo(points.last().x, height) + close() } - lineTo(points.last().x, height) - close() - } drawPath(fillPath, color = fillColor) } // Ligne if (points.size > 1) { - val linePath = androidx.compose.ui.graphics.Path().apply { - moveTo(points.first().x, points.first().y) - points.forEach { point -> - lineTo(point.x, point.y) + val linePath = + androidx.compose.ui.graphics.Path().apply { + moveTo(points.first().x, points.first().y) + points.forEach { point -> + lineTo(point.x, point.y) + } } - } drawPath( path = linePath, color = lineColor, - style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round) + style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round), ) } @@ -205,12 +210,12 @@ fun Sparkline( drawCircle( color = lineColor, radius = 4.dp.toPx(), - center = point + center = point, ) drawCircle( color = Color.White, radius = 2.dp.toPx(), - center = point + center = point, ) } } @@ -221,13 +226,13 @@ fun Sparkline( * Données pour un graphique à barres. */ data class BarChartData( - val items: List + val items: List, ) data class BarChartItem( val label: String, val value: Int, - val color: Color = Color.Unspecified + val color: Color = Color.Unspecified, ) /** @@ -244,7 +249,7 @@ fun HorizontalBarChart( modifier: Modifier = Modifier, maxValue: Int? = null, height: Dp = 32.dp, - spacing: Dp = LocalDimens.current.spacingSm + spacing: Dp = LocalDimens.current.spacingSm, ) { if (data.items.isEmpty()) return @@ -252,13 +257,13 @@ fun HorizontalBarChart( Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(spacing) + verticalArrangement = Arrangement.spacedBy(spacing), ) { data.items.forEach { item -> Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd) + horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd), ) { // Label Text( @@ -266,7 +271,7 @@ fun HorizontalBarChart( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(0.4f), - maxLines = 1 + maxLines = 1, ) // Barre @@ -274,13 +279,14 @@ fun HorizontalBarChart( val animatedProgress by animateFloatAsState( targetValue = progress, animationSpec = tween(durationMillis = 500), - label = "barProgress" + label = "barProgress", ) Box( - modifier = Modifier - .weight(0.4f) - .height(height) + modifier = + Modifier + .weight(0.4f) + .height(height), ) { // Fond Canvas(modifier = Modifier.fillMaxSize()) { @@ -288,7 +294,7 @@ fun HorizontalBarChart( drawRoundRect( color = androidx.compose.ui.graphics.Color(0xFFE3E2EC), size = Size(size.width, size.height), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius) + cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius), ) // Progression @@ -296,7 +302,7 @@ fun HorizontalBarChart( drawRoundRect( color = item.color, size = Size(size.width * animatedProgress, size.height), - cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius) + cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius), ) } } @@ -306,8 +312,9 @@ fun HorizontalBarChart( text = "${item.value}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.align(Alignment.CenterEnd) - .padding(end = 8.dp) + modifier = + Modifier.align(Alignment.CenterEnd) + .padding(end = 8.dp), ) } } @@ -324,30 +331,31 @@ fun StatCard( value: String, label: String, modifier: Modifier = Modifier, - valueColor: Color = MaterialTheme.colorScheme.onSurface + valueColor: Color = MaterialTheme.colorScheme.onSurface, ) { val dimens = LocalDimens.current Column( - modifier = modifier - .padding(dimens.spacingSm), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + modifier + .padding(dimens.spacingSm), + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = icon, - style = MaterialTheme.typography.headlineSmall + style = MaterialTheme.typography.headlineSmall, ) Text( text = value, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, - color = valueColor + color = valueColor, ) Text( text = label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } @@ -359,25 +367,25 @@ enum class TimeFilter(val label: String) { WEEK("Semaine"), MONTH("Mois"), YEAR("Année"), - ALL("Tout") + ALL("Tout"), } @Composable fun TimeFilterRow( selected: TimeFilter, onFilterChanged: (TimeFilter) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Row( modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm) + horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm), ) { TimeFilter.values().forEach { filter -> - androidx.compose.material3.FilterChip( - selected = selected == filter, - onClick = { onFilterChanged(filter) }, - label = { Text(filter.label) } - ) - } + androidx.compose.material3.FilterChip( + selected = selected == filter, + onClick = { onFilterChanged(filter) }, + label = { Text(filter.label) }, + ) + } } } diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/Components.kt b/app/src/main/java/com/safebite/app/presentation/common/components/Components.kt index c037e5e..7cd53a2 100644 --- a/app/src/main/java/com/safebite/app/presentation/common/components/Components.kt +++ b/app/src/main/java/com/safebite/app/presentation/common/components/Components.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,10 +23,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics @@ -69,38 +66,39 @@ fun AllergenChip( allergen: AllergenType, selected: Boolean, onToggle: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val dimens = LocalDimens.current val bg = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface val fg = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface val stateDesc = if (selected) "Sélectionné" else "Non sélectionné" Surface( - modifier = modifier - .semantics { - contentDescription = "${allergen.displayNameFr} - $stateDesc" - role = Role.Checkbox - stateDescription = stateDesc - }, + modifier = + modifier + .semantics { + contentDescription = "${allergen.displayNameFr} - $stateDesc" + role = Role.Checkbox + stateDescription = stateDesc + }, shape = RoundedCornerShape(dimens.radiusPill), color = bg, border = if (selected) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outline), - onClick = onToggle + onClick = onToggle, ) { Row( modifier = Modifier.padding(horizontal = dimens.spacingMd, vertical = dimens.spacingSm), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( text = allergen.icon, color = fg, - modifier = Modifier.semantics { contentDescription = "" } + modifier = Modifier.semantics { contentDescription = "" }, ) Spacer(Modifier.width(dimens.spacingXs + 2.dp)) Text( text = allergen.displayNameFr, color = fg, - style = MaterialTheme.typography.labelLarge + style = MaterialTheme.typography.labelLarge, ) } } @@ -120,112 +118,118 @@ fun SafetyStatusBanner( modifier: Modifier = Modifier, profileName: String? = null, allergenName: String? = null, - severity: String? = null + severity: String? = null, ) { val dimens = LocalDimens.current val colors = LocalStatusColors.current - - val a11yDescription = when (status) { - SafetyStatus.SAFE -> stringResource(R.string.a11y_verdict_safe) - SafetyStatus.WARNING -> stringResource(R.string.a11y_verdict_warning) - SafetyStatus.DANGER -> if (profileName != null) - stringResource(R.string.a11y_verdict_danger, profileName) - else - stringResource(R.string.a11y_danger_status, "") - } - - val (titleRes, icon, shapeIcon, containerColor, onContainerColor) = when (status) { - SafetyStatus.SAFE -> { - VerdictBannerData( - titleRes = R.string.result_safe_headline, - icon = "✅", - shapeIcon = "⭕", - containerColor = colors.safe, - onContainerColor = colors.onSafe - ) + + val a11yDescription = + when (status) { + SafetyStatus.SAFE -> stringResource(R.string.a11y_verdict_safe) + SafetyStatus.WARNING -> stringResource(R.string.a11y_verdict_warning) + SafetyStatus.DANGER -> + if (profileName != null) { + stringResource(R.string.a11y_verdict_danger, profileName) + } else { + stringResource(R.string.a11y_danger_status, "") + } } - SafetyStatus.WARNING -> { - VerdictBannerData( - titleRes = R.string.result_warning_headline, - icon = "⚠️", - shapeIcon = "🔺", - containerColor = colors.warning, - onContainerColor = colors.onWarning - ) + + val (titleRes, icon, shapeIcon, containerColor, onContainerColor) = + when (status) { + SafetyStatus.SAFE -> { + VerdictBannerData( + titleRes = R.string.result_safe_headline, + icon = "✅", + shapeIcon = "⭕", + containerColor = colors.safe, + onContainerColor = colors.onSafe, + ) + } + SafetyStatus.WARNING -> { + VerdictBannerData( + titleRes = R.string.result_warning_headline, + icon = "⚠️", + shapeIcon = "🔺", + containerColor = colors.warning, + onContainerColor = colors.onWarning, + ) + } + SafetyStatus.DANGER -> { + VerdictBannerData( + titleRes = R.string.result_danger_headline, + icon = "❌", + shapeIcon = "🔷", + containerColor = colors.danger, + onContainerColor = colors.onDanger, + ) + } } - SafetyStatus.DANGER -> { - VerdictBannerData( - titleRes = R.string.result_danger_headline, - icon = "❌", - shapeIcon = "🔷", - containerColor = colors.danger, - onContainerColor = colors.onDanger - ) - } - } - + Surface( - modifier = modifier - .fillMaxWidth() - .semantics { - contentDescription = a11yDescription - }, + modifier = + modifier + .fillMaxWidth() + .semantics { + contentDescription = a11yDescription + }, color = containerColor, - contentColor = onContainerColor + contentColor = onContainerColor, ) { Column( - modifier = Modifier.padding(dimens.spacingLg) + modifier = Modifier.padding(dimens.spacingLg), ) { // Ligne supérieure : forme daltonienne + icône + titre // Système daltonien : forme géométrique + couleur + icône Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + horizontalArrangement = Arrangement.Center, ) { // Forme daltonienne (jamais couleur seule) DaltonianShape( status = status, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp), ) Spacer(Modifier.width(dimens.spacingXs)) Text( text = icon, style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.semantics { contentDescription = "" } + modifier = Modifier.semantics { contentDescription = "" }, ) Spacer(Modifier.width(dimens.spacingSm)) Text( text = stringResource(titleRes), style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) } - + // Sous-titre contextuel (si allergène et profil) if (allergenName != null && profileName != null) { Spacer(Modifier.height(dimens.spacingXs)) - val subtitle = when (status) { - SafetyStatus.WARNING -> "⚠️ Attention pour $profileName : $allergenName" - SafetyStatus.DANGER -> "❌ Interdit pour $profileName : $allergenName${if (severity == "anaphylaxis") " (anaphylaxie)" else ""}" - else -> "" - } + val subtitle = + when (status) { + SafetyStatus.WARNING -> "⚠️ Attention pour $profileName : $allergenName" + SafetyStatus.DANGER -> "❌ Interdit pour $profileName : $allergenName${if (severity == "anaphylaxis") " (anaphylaxie)" else ""}" + else -> "" + } if (subtitle.isNotEmpty()) { Text( text = subtitle, style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ) } } - + // Message supplémentaire pour danger if (status == SafetyStatus.DANGER) { Spacer(Modifier.height(dimens.spacingXs)) Text( text = "Ne pas consommer", style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) } } @@ -242,46 +246,50 @@ fun SafetyStatusBanner( @Composable fun DaltonianShape( status: SafetyStatus, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val colors = LocalStatusColors.current - val color = when (status) { - SafetyStatus.SAFE -> colors.safe - SafetyStatus.WARNING -> colors.warning - SafetyStatus.DANGER -> colors.danger - } - + val color = + when (status) { + SafetyStatus.SAFE -> colors.safe + SafetyStatus.WARNING -> colors.warning + SafetyStatus.DANGER -> colors.danger + } + when (status) { SafetyStatus.SAFE -> { // Cercle pour SAFE Box( - modifier = modifier - .background(color, CircleShape) - .semantics { contentDescription = "" } + modifier = + modifier + .background(color, CircleShape) + .semantics { contentDescription = "" }, ) } SafetyStatus.WARNING -> { // Triangle pour WARNING (dessiné avec Canvas) Canvas(modifier = modifier) { - val path = androidx.compose.ui.graphics.Path().apply { - moveTo(size.width / 2, 0f) - lineTo(size.width, size.height) - lineTo(0f, size.height) - close() - } + val path = + androidx.compose.ui.graphics.Path().apply { + moveTo(size.width / 2, 0f) + lineTo(size.width, size.height) + lineTo(0f, size.height) + close() + } drawPath(path, color = color) } } SafetyStatus.DANGER -> { // Losange pour DANGER Canvas(modifier = modifier) { - val path = androidx.compose.ui.graphics.Path().apply { - moveTo(size.width / 2, 0f) - lineTo(size.width, size.height / 2) - lineTo(size.width / 2, size.height) - lineTo(0f, size.height / 2) - close() - } + val path = + androidx.compose.ui.graphics.Path().apply { + moveTo(size.width / 2, 0f) + lineTo(size.width, size.height / 2) + lineTo(size.width / 2, size.height) + lineTo(0f, size.height / 2) + close() + } drawPath(path, color = color) } } @@ -294,7 +302,7 @@ private data class VerdictBannerData( val icon: String, val shapeIcon: String, val containerColor: androidx.compose.ui.graphics.Color, - val onContainerColor: androidx.compose.ui.graphics.Color + val onContainerColor: androidx.compose.ui.graphics.Color, ) @Composable @@ -303,7 +311,7 @@ fun ProductCard( subtitle: String?, imageUrl: String?, modifier: Modifier = Modifier, - imageContentDescription: String? = null + imageContentDescription: String? = null, ) { val dimens = LocalDimens.current val imgDesc = imageContentDescription ?: "Image du produit" @@ -317,26 +325,28 @@ fun ProductCard( AsyncImage( model = imageUrl, contentDescription = imageContentDescription, - modifier = Modifier - .size(64.dp) - .background( - MaterialTheme.colorScheme.surfaceVariant, - RoundedCornerShape(dimens.radiusMd) - ) + modifier = + Modifier + .size(64.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(dimens.radiusMd), + ), ) } else { Box( - modifier = Modifier - .size(64.dp) - .background( - MaterialTheme.colorScheme.surfaceVariant, - RoundedCornerShape(dimens.radiusMd) - ), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(64.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(dimens.radiusMd), + ), + contentAlignment = Alignment.Center, ) { Text( text = "🛒", - modifier = Modifier.semantics { contentDescription = imgDesc } + modifier = Modifier.semantics { contentDescription = imgDesc }, ) } } @@ -346,13 +356,13 @@ fun ProductCard( title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, - maxLines = 2 + maxLines = 2, ) if (!subtitle.isNullOrBlank()) { Text( subtitle, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -361,18 +371,23 @@ fun ProductCard( } @Composable -fun AvatarBubble(avatar: String, modifier: Modifier = Modifier, size: Dp = 40.dp) { +fun AvatarBubble( + avatar: String, + modifier: Modifier = Modifier, + size: Dp = 40.dp, +) { Box( - modifier = modifier - .size(size) - .background(MaterialTheme.colorScheme.primaryContainer, CircleShape) - .border(1.dp, MaterialTheme.colorScheme.primary, CircleShape), - contentAlignment = Alignment.Center + modifier = + modifier + .size(size) + .background(MaterialTheme.colorScheme.primaryContainer, CircleShape) + .border(1.dp, MaterialTheme.colorScheme.primary, CircleShape), + contentAlignment = Alignment.Center, ) { Text( avatar, style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onPrimaryContainer + color = MaterialTheme.colorScheme.onPrimaryContainer, ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/Feedback.kt b/app/src/main/java/com/safebite/app/presentation/common/components/Feedback.kt index 72ddb39..8aa04c7 100644 --- a/app/src/main/java/com/safebite/app/presentation/common/components/Feedback.kt +++ b/app/src/main/java/com/safebite/app/presentation/common/components/Feedback.kt @@ -52,25 +52,28 @@ fun ShimmerBox( val progress by transition.animateFloat( initialValue = 0f, targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1200, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Restart, - ), - label = "shimmerProgress" + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Restart, + ), + label = "shimmerProgress", ) val base = MaterialTheme.colorScheme.surfaceVariant val highlight = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f) val colors = listOf(base, highlight, base) val offset = 1000f * progress - val brush = Brush.linearGradient( - colors = colors, - start = Offset(offset - 500f, 0f), - end = Offset(offset, 0f), - ) + val brush = + Brush.linearGradient( + colors = colors, + start = Offset(offset - 500f, 0f), + end = Offset(offset, 0f), + ) Box( - modifier = modifier - .clip(RoundedCornerShape(cornerRadius)) - .background(brush) + modifier = + modifier + .clip(RoundedCornerShape(cornerRadius)) + .background(brush), ) } @@ -80,7 +83,7 @@ fun ShimmerListItem(modifier: Modifier = Modifier) { val dimens = LocalDimens.current Row( modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { ShimmerBox(modifier = Modifier.size(64.dp), cornerRadius = dimens.radiusMd) Spacer(Modifier.width(dimens.spacingMd)) @@ -109,54 +112,61 @@ fun ShimmerListItem(modifier: Modifier = Modifier) { fun ProductSkeleton(modifier: Modifier = Modifier) { val dimens = LocalDimens.current Column( - modifier = modifier - .fillMaxWidth() - .padding(dimens.spacingMd), - verticalArrangement = Arrangement.spacedBy(dimens.spacingMd) + modifier = + modifier + .fillMaxWidth() + .padding(dimens.spacingMd), + verticalArrangement = Arrangement.spacedBy(dimens.spacingMd), ) { // Image produit ShimmerBox( - modifier = Modifier - .size(120.dp) - .align(Alignment.CenterHorizontally), - cornerRadius = dimens.radiusMd + modifier = + Modifier + .size(120.dp) + .align(Alignment.CenterHorizontally), + cornerRadius = dimens.radiusMd, ) - + // Nom produit ShimmerBox( - modifier = Modifier - .fillMaxWidth(0.8f) - .height(20.dp) + modifier = + Modifier + .fillMaxWidth(0.8f) + .height(20.dp), ) - + // Marque ShimmerBox( - modifier = Modifier - .fillMaxWidth(0.5f) - .height(14.dp) + modifier = + Modifier + .fillMaxWidth(0.5f) + .height(14.dp), ) - + // Verdict banner (zone colorée) ShimmerBox( - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - cornerRadius = dimens.radiusMd + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + cornerRadius = dimens.radiusMd, ) - + // Actions ShimmerBox( - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - cornerRadius = dimens.radiusPill + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), + cornerRadius = dimens.radiusPill, ) - + ShimmerBox( - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - cornerRadius = dimens.radiusPill + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), + cornerRadius = dimens.radiusPill, ) } } @@ -172,9 +182,10 @@ fun EmptyState( ) { val dimens = LocalDimens.current Column( - modifier = modifier - .fillMaxWidth() - .padding(dimens.spacingXl), + modifier = + modifier + .fillMaxWidth() + .padding(dimens.spacingXl), horizontalAlignment = Alignment.CenterHorizontally, ) { Text(emoji, style = MaterialTheme.typography.displaySmall) @@ -182,7 +193,7 @@ fun EmptyState( Text( title, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) if (message != null) { Spacer(Modifier.height(dimens.spacingSm)) @@ -212,17 +223,18 @@ fun LoadingIndicator(modifier: Modifier = Modifier) { fun OfflineIndicator(modifier: Modifier = Modifier) { val dimens = LocalDimens.current Row( - modifier = modifier - .clip(RoundedCornerShape(dimens.radiusLg)) - .background(MaterialTheme.colorScheme.errorContainer) - .padding(horizontal = dimens.spacingMd, vertical = dimens.spacingXs), - verticalAlignment = Alignment.CenterVertically + modifier = + modifier + .clip(RoundedCornerShape(dimens.radiusLg)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = dimens.spacingMd, vertical = dimens.spacingXs), + verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.Filled.CloudOff, contentDescription = null, tint = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(16.dp), ) Spacer(Modifier.width(dimens.spacingXs)) Text( @@ -238,27 +250,28 @@ fun OfflineIndicator(modifier: Modifier = Modifier) { fun ErrorView( message: String, onRetry: (() -> Unit)? = null, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val dimens = LocalDimens.current Column( - modifier = modifier - .fillMaxSize() - .padding(dimens.spacingXl), + modifier = + modifier + .fillMaxSize() + .padding(dimens.spacingXl), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Icon( imageVector = Icons.Filled.Warning, contentDescription = null, tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(48.dp) + modifier = Modifier.size(48.dp), ) Spacer(Modifier.height(dimens.spacingMd)) Text( message, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) if (onRetry != null) { Spacer(Modifier.height(dimens.spacingLg)) diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/ImageCropBottomSheet.kt b/app/src/main/java/com/safebite/app/presentation/common/components/ImageCropBottomSheet.kt index 1fc565b..8941750 100644 --- a/app/src/main/java/com/safebite/app/presentation/common/components/ImageCropBottomSheet.kt +++ b/app/src/main/java/com/safebite/app/presentation/common/components/ImageCropBottomSheet.kt @@ -62,7 +62,7 @@ fun ImageCropBottomSheet( bitmap: Bitmap, onCropComplete: (String?) -> Unit, onDismiss: () -> Unit, - sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { val context = LocalContext.current val density = LocalDensity.current @@ -105,33 +105,35 @@ fun ImageCropBottomSheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - dragHandle = null + dragHandle = null, ) { Column( - modifier = Modifier - .fillMaxWidth() - .height(560.dp) - .padding(horizontal = 16.dp, vertical = 12.dp) + modifier = + Modifier + .fillMaxWidth() + .height(560.dp) + .padding(horizontal = 16.dp, vertical = 12.dp), ) { Text( text = "Ajuster le cadrage", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 12.dp) + modifier = Modifier.padding(bottom = 12.dp), ) Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .onSizeChanged { containerSize = it } + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .onSizeChanged { containerSize = it }, ) { // Photo fixe en arrière-plan (ContentScale.Fit) Image( bitmap = bitmap.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) // Overlay sombre + cadre blanc @@ -145,22 +147,22 @@ fun ImageCropBottomSheet( drawRect( color = Color.Black.copy(alpha = 0.5f), topLeft = Offset(0f, 0f), - size = Size(size.width, fT) + size = Size(size.width, fT), ) drawRect( color = Color.Black.copy(alpha = 0.5f), topLeft = Offset(0f, fB), - size = Size(size.width, size.height - fB) + size = Size(size.width, size.height - fB), ) drawRect( color = Color.Black.copy(alpha = 0.5f), topLeft = Offset(0f, fT), - size = Size(fL, fB - fT) + size = Size(fL, fB - fT), ) drawRect( color = Color.Black.copy(alpha = 0.5f), topLeft = Offset(fR, fT), - size = Size(size.width - fR, fB - fT) + size = Size(size.width - fR, fB - fT), ) // Bordure blanche @@ -168,35 +170,36 @@ fun ImageCropBottomSheet( color = Color.White, topLeft = Offset(fL, fT), size = Size(fR - fL, fB - fT), - style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f) + style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f), ) } // Zone centrale draggable pour déplacer le cadre entier Box( - modifier = Modifier - .offset( - x = with(density) { frameLeft.toDp() }, - y = with(density) { frameTop.toDp() } - ) - .size( - width = with(density) { (frameRight - frameLeft).toDp() }, - height = with(density) { (frameBottom - frameTop).toDp() } - ) - .pointerInput(Unit) { - detectDragGestures { change, dragAmount -> - change.consume() - val dx = dragAmount.x - val dy = dragAmount.y - val w = frameRight - frameLeft - val h = frameBottom - frameTop - frameLeft = (frameLeft + dx).coerceIn(0f, containerW - w) - frameTop = (frameTop + dy).coerceIn(0f, containerH - h) - frameRight = frameLeft + w - frameBottom = frameTop + h + modifier = + Modifier + .offset( + x = with(density) { frameLeft.toDp() }, + y = with(density) { frameTop.toDp() }, + ) + .size( + width = with(density) { (frameRight - frameLeft).toDp() }, + height = with(density) { (frameBottom - frameTop).toDp() }, + ) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + val dx = dragAmount.x + val dy = dragAmount.y + val w = frameRight - frameLeft + val h = frameBottom - frameTop + frameLeft = (frameLeft + dx).coerceIn(0f, containerW - w) + frameTop = (frameTop + dy).coerceIn(0f, containerH - h) + frameRight = frameLeft + w + frameBottom = frameTop + h + } } - } - .zIndex(1f) + .zIndex(1f), ) // Poignée coin haut-gauche @@ -207,7 +210,7 @@ fun ImageCropBottomSheet( onDrag = { dx, dy -> frameLeft = min(frameLeft + dx, frameRight - minPx).coerceAtLeast(0f) frameTop = min(frameTop + dy, frameBottom - minPx).coerceAtLeast(0f) - } + }, ) // Poignée coin haut-droit @@ -218,7 +221,7 @@ fun ImageCropBottomSheet( onDrag = { dx, dy -> frameRight = max(frameRight + dx, frameLeft + minPx).coerceAtMost(containerW) frameTop = min(frameTop + dy, frameBottom - minPx).coerceAtLeast(0f) - } + }, ) // Poignée coin bas-gauche @@ -229,7 +232,7 @@ fun ImageCropBottomSheet( onDrag = { dx, dy -> frameLeft = min(frameLeft + dx, frameRight - minPx).coerceAtLeast(0f) frameBottom = max(frameBottom + dy, frameTop + minPx).coerceAtMost(containerH) - } + }, ) // Poignée coin bas-droit @@ -240,21 +243,22 @@ fun ImageCropBottomSheet( onDrag = { dx, dy -> frameRight = max(frameRight + dx, frameLeft + minPx).coerceAtMost(containerW) frameBottom = max(frameBottom + dy, frameTop + minPx).coerceAtMost(containerH) - } + }, ) } Spacer(modifier = Modifier.height(12.dp)) Row( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding(), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .navigationBarsPadding(), + verticalAlignment = Alignment.CenterVertically, ) { TextButton( onClick = onDismiss, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text("Annuler") } @@ -271,14 +275,17 @@ fun ImageCropBottomSheet( val cropped = Bitmap.createBitmap(bitmap, cropLeft, cropTop, cropW, cropH) val maxSize = 512 - val out = if (cropW > maxSize || cropH > maxSize) { - val ratio = maxSize.toFloat() / max(cropW, cropH) - val newW = (cropW * ratio).toInt() - val newH = (cropH * ratio).toInt() - Bitmap.createScaledBitmap(cropped, newW, newH, true).also { - if (it != cropped) cropped.recycle() + val out = + if (cropW > maxSize || cropH > maxSize) { + val ratio = maxSize.toFloat() / max(cropW, cropH) + val newW = (cropW * ratio).toInt() + val newH = (cropH * ratio).toInt() + Bitmap.createScaledBitmap(cropped, newW, newH, true).also { + if (it != cropped) cropped.recycle() + } + } else { + cropped } - } else cropped val file = File(context.cacheDir, "item_${System.currentTimeMillis()}.jpg") file.outputStream().use { fos -> @@ -288,7 +295,7 @@ fun ImageCropBottomSheet( onCropComplete(Uri.fromFile(file).toString()) }, modifier = Modifier.weight(1f), - large = true + large = true, ) } Spacer(modifier = Modifier.height(8.dp)) @@ -301,40 +308,42 @@ private fun FrameHandle( x: Float, y: Float, halfHandlePx: Float, - onDrag: (dx: Float, dy: Float) -> Unit + onDrag: (dx: Float, dy: Float) -> Unit, ) { val density = LocalDensity.current Box( - modifier = Modifier - .offset( - x = with(density) { (x - halfHandlePx).toDp() }, - y = with(density) { (y - halfHandlePx).toDp() } - ) - .size(HandleSize) - .pointerInput(Unit) { - detectDragGestures { change, dragAmount -> - change.consume() - onDrag(dragAmount.x, dragAmount.y) + modifier = + Modifier + .offset( + x = with(density) { (x - halfHandlePx).toDp() }, + y = with(density) { (y - halfHandlePx).toDp() }, + ) + .size(HandleSize) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + onDrag(dragAmount.x, dragAmount.y) + } } - } - .zIndex(2f), - contentAlignment = Alignment.Center + .zIndex(2f), + contentAlignment = Alignment.Center, ) { androidx.compose.foundation.layout.Box( - modifier = Modifier - .size(12.dp) - .zIndex(2f) + modifier = + Modifier + .size(12.dp) + .zIndex(2f), ) { Canvas(modifier = Modifier.fillMaxSize()) { drawCircle( color = Color.White, radius = size.minDimension / 2f, - center = Offset(size.width / 2f, size.height / 2f) + center = Offset(size.width / 2f, size.height / 2f), ) drawCircle( color = Color(0xFF1976D2), radius = size.minDimension / 2f - 2f, - center = Offset(size.width / 2f, size.height / 2f) + center = Offset(size.width / 2f, size.height / 2f), ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/common/components/TextFields.kt b/app/src/main/java/com/safebite/app/presentation/common/components/TextFields.kt index 5ef33fc..39e683f 100644 --- a/app/src/main/java/com/safebite/app/presentation/common/components/TextFields.kt +++ b/app/src/main/java/com/safebite/app/presentation/common/components/TextFields.kt @@ -4,14 +4,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.ui.unit.dp import com.safebite.app.presentation.theme.LocalDimens /** @@ -65,8 +64,12 @@ fun StandardTextField( Text( msg, style = MaterialTheme.typography.bodySmall, - color = if (isError) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.onSurfaceVariant + color = + if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, ) } if (showCounter && maxLength != null) { @@ -74,7 +77,7 @@ fun StandardTextField( Text( "${value.length}/$maxLength", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/common/util/UiState.kt b/app/src/main/java/com/safebite/app/presentation/common/util/UiState.kt index 18a4237..0c2ad56 100644 --- a/app/src/main/java/com/safebite/app/presentation/common/util/UiState.kt +++ b/app/src/main/java/com/safebite/app/presentation/common/util/UiState.kt @@ -2,7 +2,10 @@ package com.safebite.app.presentation.common.util sealed interface UiState { data object Idle : UiState + data object Loading : UiState + data class Success(val data: T) : UiState + data class Error(val message: String, val offline: Boolean = false) : UiState } diff --git a/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt b/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt index 6325a0d..4d2e9d5 100644 --- a/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt @@ -1,11 +1,11 @@ package com.safebite.app.presentation.navigation -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -13,29 +13,28 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.safebite.app.presentation.screen.catalog.CatalogScreen -import com.safebite.app.presentation.screen.catalog.CategoryItemsScreen import com.safebite.app.presentation.screen.catalog.CatalogSearchScreen +import com.safebite.app.presentation.screen.catalog.CategoryItemsScreen import com.safebite.app.presentation.screen.catalog.DomainCategoriesScreen -import com.safebite.app.presentation.screen.product.ProductDetailScreen -import com.safebite.app.presentation.screen.tracking.TrackingScreen import com.safebite.app.presentation.screen.lists.ListDetailScreen -import com.safebite.app.presentation.screen.lists.ListsScreen import com.safebite.app.presentation.screen.lists.create.CreateListScreen +import com.safebite.app.presentation.screen.lists.settings.ListMembersScreen +import com.safebite.app.presentation.screen.lists.settings.ListNameImageScreen +import com.safebite.app.presentation.screen.lists.settings.ListRegionScreen import com.safebite.app.presentation.screen.lists.settings.ListSettingsScreen import com.safebite.app.presentation.screen.lists.settings.ListSortScreen -import com.safebite.app.presentation.screen.lists.settings.ListRegionScreen -import com.safebite.app.presentation.screen.lists.settings.ListNameImageScreen -import com.safebite.app.presentation.screen.lists.settings.ListMembersScreen import com.safebite.app.presentation.screen.main.MainScreen import com.safebite.app.presentation.screen.ocr.OcrCaptureScreen import com.safebite.app.presentation.screen.ocr.OcrReviewScreen import com.safebite.app.presentation.screen.onboarding.OnboardingScreen +import com.safebite.app.presentation.screen.product.ProductDetailScreen import com.safebite.app.presentation.screen.profile.ProfileEditScreen import com.safebite.app.presentation.screen.profile.ProfileListScreen import com.safebite.app.presentation.screen.result.ResultScreen import com.safebite.app.presentation.screen.scanner.ScannerScreen import com.safebite.app.presentation.screen.settings.SettingsScreen import com.safebite.app.presentation.screen.splash.SplashScreen +import com.safebite.app.presentation.screen.tracking.TrackingScreen /** * Graph de navigation principal de l'application SafeBite. @@ -46,20 +45,26 @@ import com.safebite.app.presentation.screen.splash.SplashScreen * - Écrans de navigation : Scanner, Result, OCR, Settings, etc. */ @Composable -fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) { +fun SafeBiteNavGraph( + onboardingCompleted: Boolean, + showSplash: Boolean = false, +) { val navController = rememberNavController() - val startDestination = when { - showSplash -> Screen.Splash.route - onboardingCompleted -> Screen.Dashboard.route - else -> Screen.Onboarding.route - } + val startDestination = + when { + showSplash -> Screen.Splash.route + onboardingCompleted -> Screen.Dashboard.route + else -> Screen.Onboarding.route + } - val enterAnim = fadeIn(animationSpec = tween(250)) + - slideInHorizontally(animationSpec = tween(250)) { it / 24 } + val enterAnim = + fadeIn(animationSpec = tween(250)) + + slideInHorizontally(animationSpec = tween(250)) { it / 24 } val exitAnim = fadeOut(animationSpec = tween(200)) val popEnterAnim = fadeIn(animationSpec = tween(250)) - val popExitAnim = fadeOut(animationSpec = tween(200)) + - slideOutHorizontally(animationSpec = tween(250)) { it / 24 } + val popExitAnim = + fadeOut(animationSpec = tween(200)) + + slideOutHorizontally(animationSpec = tween(250)) { it / 24 } NavHost( navController = navController, @@ -76,7 +81,7 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) navController.navigate(Screen.Dashboard.route) { popUpTo(Screen.Splash.route) { inclusive = true } } - } + }, ) } @@ -98,7 +103,7 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) onOpenListDetail = { id, name -> navController.navigate(Screen.ListDetail.build(id, name)) }, onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) }, onOpenListCreate = { navController.navigate(Screen.ListCreate.route) }, - onOpenListSettings = { id -> navController.navigate(Screen.ListSettings.build(id)) } + onOpenListSettings = { id -> navController.navigate(Screen.ListSettings.build(id)) }, ) } @@ -110,7 +115,7 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) navController.navigate(Screen.Result.fromBarcode(code)) { popUpTo(Screen.Dashboard.route) } - } + }, ) } @@ -118,14 +123,14 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) composable(Screen.OcrCapture.route) { OcrCaptureScreen( onBack = { navController.popBackStack() }, - onCaptured = { text -> navController.navigate(Screen.OcrReview.build(text)) } + onCaptured = { text -> navController.navigate(Screen.OcrReview.build(text)) }, ) } // ── OCR Review ── composable( route = Screen.OcrReview.route, - arguments = listOf(navArgument("text") { type = NavType.StringType }) + arguments = listOf(navArgument("text") { type = NavType.StringType }), ) { entry -> val text = entry.arguments?.getString("text").orEmpty() OcrReviewScreen( @@ -135,18 +140,26 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) navController.navigate(Screen.Result.fromOcr(edited)) { popUpTo(Screen.Dashboard.route) } - } + }, ) } // ── Result ── composable( route = Screen.Result.route, - arguments = listOf( - navArgument("barcode") { type = NavType.StringType }, - navArgument("fromOcr") { type = NavType.BoolType; defaultValue = false }, - navArgument("ocrText") { type = NavType.StringType; nullable = true; defaultValue = null } - ) + arguments = + listOf( + navArgument("barcode") { type = NavType.StringType }, + navArgument("fromOcr") { + type = NavType.BoolType + defaultValue = false + }, + navArgument("ocrText") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), ) { entry -> val barcode = entry.arguments?.getString("barcode") val fromOcr = entry.arguments?.getBoolean("fromOcr") == true @@ -165,7 +178,12 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) navController.navigate(Screen.OcrCapture.route) { popUpTo(Screen.Dashboard.route) } - } + }, + onOpenAlternatives = { + barcode?.let { + navController.navigate(Screen.ProductDetail.build(it)) + } + }, ) } @@ -174,24 +192,24 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) ProfileListScreen( onBack = { navController.popBackStack() }, onNew = { navController.navigate(Screen.ProfileEdit.new()) }, - onEdit = { id -> navController.navigate(Screen.ProfileEdit.edit(id)) } + onEdit = { id -> navController.navigate(Screen.ProfileEdit.edit(id)) }, ) } composable( route = Screen.ProfileEdit.route, - arguments = listOf(navArgument("id") { type = NavType.LongType }) + arguments = listOf(navArgument("id") { type = NavType.LongType }), ) { entry -> val id = entry.arguments?.getLong("id") ?: 0L ProfileEditScreen( id = id, onBack = { navController.popBackStack() }, - onSaved = { navController.popBackStack() } + onSaved = { navController.popBackStack() }, ) } composable(Screen.Tracking.route) { TrackingScreen( onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) }, - onOpenScanner = { navController.navigate(Screen.Scanner.route) } + onOpenScanner = { navController.navigate(Screen.Scanner.route) }, ) } composable(Screen.Settings.route) { @@ -202,10 +220,14 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) @OptIn(ExperimentalFoundationApi::class) composable( route = Screen.ListDetail.route, - arguments = listOf( - navArgument("id") { type = NavType.LongType }, - navArgument("name") { type = NavType.StringType; defaultValue = "Ma liste" } - ) + arguments = + listOf( + navArgument("id") { type = NavType.LongType }, + navArgument("name") { + type = NavType.StringType + defaultValue = "Ma liste" + }, + ), ) { entry -> val listId = entry.arguments?.getLong("id") ?: 0L val listName = entry.arguments?.getString("name") ?: "Ma liste" @@ -215,14 +237,14 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) onBack = { navController.popBackStack() }, onOpenScanner = { navController.navigate(Screen.Scanner.route) }, onOpenProduct = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) }, - onOpenCatalog = { id -> navController.navigate(Screen.Catalog.build(id)) } + onOpenCatalog = { id -> navController.navigate(Screen.Catalog.build(id)) }, ) } // ── Catalogue (refonte) ── composable( route = Screen.Catalog.route, - arguments = listOf(navArgument("listId") { type = NavType.LongType }) + arguments = listOf(navArgument("listId") { type = NavType.LongType }), ) { entry -> val listId = entry.arguments?.getLong("listId") ?: 0L CatalogScreen( @@ -233,15 +255,16 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) }, onOpenSearch = { navController.navigate(Screen.CatalogSearch.build(listId)) - } + }, ) } composable( route = Screen.CatalogDomain.route, - arguments = listOf( - navArgument("listId") { type = NavType.LongType }, - navArgument("domainId") { type = NavType.StringType } - ) + arguments = + listOf( + navArgument("listId") { type = NavType.LongType }, + navArgument("domainId") { type = NavType.StringType }, + ), ) { entry -> val listId = entry.arguments?.getLong("listId") ?: 0L val domainId = entry.arguments?.getString("domainId").orEmpty() @@ -250,45 +273,46 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) onBack = { navController.popBackStack() }, onOpenCategory = { categoryId -> navController.navigate(Screen.CatalogCategory.build(listId, categoryId)) - } + }, ) } composable( route = Screen.CatalogCategory.route, - arguments = listOf( - navArgument("listId") { type = NavType.LongType }, - navArgument("categoryId") { type = NavType.StringType } - ) + arguments = + listOf( + navArgument("listId") { type = NavType.LongType }, + navArgument("categoryId") { type = NavType.StringType }, + ), ) { entry -> val listId = entry.arguments?.getLong("listId") ?: 0L val categoryId = entry.arguments?.getString("categoryId").orEmpty() CategoryItemsScreen( categoryId = categoryId, listId = listId, - onBack = { navController.popBackStack() } + onBack = { navController.popBackStack() }, ) } composable( route = Screen.CatalogSearch.route, - arguments = listOf(navArgument("listId") { type = NavType.LongType }) + arguments = listOf(navArgument("listId") { type = NavType.LongType }), ) { entry -> val listId = entry.arguments?.getLong("listId") ?: 0L CatalogSearchScreen( listId = listId, - onBack = { navController.popBackStack() } + onBack = { navController.popBackStack() }, ) } // ── Product Detail (Phase 5) ── composable( route = Screen.ProductDetail.route, - arguments = listOf(navArgument("barcode") { type = NavType.StringType }) + arguments = listOf(navArgument("barcode") { type = NavType.StringType }), ) { entry -> val barcode = entry.arguments?.getString("barcode").orEmpty() ProductDetailScreen( barcode = barcode, onBack = { navController.popBackStack() }, - onOpenProduct = { b -> navController.navigate(Screen.ProductDetail.build(b)) } + onOpenProduct = { b -> navController.navigate(Screen.ProductDetail.build(b)) }, ) } @@ -296,12 +320,12 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) composable(Screen.ListCreate.route) { CreateListScreen( onBack = { navController.popBackStack() }, - onListCreated = { navController.popBackStack() } + onListCreated = { navController.popBackStack() }, ) } composable( route = Screen.ListSettings.route, - arguments = listOf(navArgument("id") { type = NavType.LongType }) + arguments = listOf(navArgument("id") { type = NavType.LongType }), ) { entry -> val listId = entry.arguments?.getLong("id") ?: 0L ListSettingsScreen( @@ -310,47 +334,47 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) onOpenSort = { navController.navigate(Screen.ListSort.build(listId)) }, onOpenRegion = { navController.navigate(Screen.ListRegion.build(listId)) }, onOpenNameImage = { navController.navigate(Screen.ListNameImage.build(listId)) }, - onOpenMembers = { navController.navigate(Screen.ListMembers.build(listId)) } + onOpenMembers = { navController.navigate(Screen.ListMembers.build(listId)) }, ) } composable( route = Screen.ListSort.route, - arguments = listOf(navArgument("id") { type = NavType.LongType }) + arguments = listOf(navArgument("id") { type = NavType.LongType }), ) { entry -> val listId = entry.arguments?.getLong("id") ?: 0L ListSortScreen( listId = listId, - onBack = { navController.popBackStack() } + onBack = { navController.popBackStack() }, ) } composable( route = Screen.ListRegion.route, - arguments = listOf(navArgument("id") { type = NavType.LongType }) + arguments = listOf(navArgument("id") { type = NavType.LongType }), ) { entry -> val listId = entry.arguments?.getLong("id") ?: 0L ListRegionScreen( listId = listId, - onBack = { navController.popBackStack() } + onBack = { navController.popBackStack() }, ) } composable( route = Screen.ListNameImage.route, - arguments = listOf(navArgument("id") { type = NavType.LongType }) + arguments = listOf(navArgument("id") { type = NavType.LongType }), ) { entry -> val listId = entry.arguments?.getLong("id") ?: 0L ListNameImageScreen( listId = listId, - onBack = { navController.popBackStack() } + onBack = { navController.popBackStack() }, ) } composable( route = Screen.ListMembers.route, - arguments = listOf(navArgument("id") { type = NavType.LongType }) + arguments = listOf(navArgument("id") { type = NavType.LongType }), ) { entry -> val listId = entry.arguments?.getLong("id") ?: 0L ListMembersScreen( listId = listId, - onBack = { navController.popBackStack() } + onBack = { navController.popBackStack() }, ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt b/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt index 06c97ae..c1ceb41 100644 --- a/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt +++ b/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt @@ -22,56 +22,81 @@ import androidx.compose.ui.graphics.vector.ImageVector sealed class Screen(val route: String) { // ── Onglets principaux (Bottom Navigation) ── data object Dashboard : Screen("dashboard") + data object Lists : Screen("lists") + data object Tracking : Screen("tracking") + data object Family : Screen("family") // ── Écrans de navigation (non dans bottom nav) ── data object Scanner : Screen("scanner") + data object OcrCapture : Screen("ocr/capture") + data object OcrReview : Screen("ocr/review/{text}") { fun build(text: String) = "ocr/review/${android.net.Uri.encode(text)}" } + data object Result : Screen("result/{barcode}?fromOcr={fromOcr}&ocrText={ocrText}") { fun fromBarcode(barcode: String) = "result/$barcode?fromOcr=false&ocrText=" + fun fromOcr(text: String) = "result/ocr?fromOcr=true&ocrText=${android.net.Uri.encode(text)}" + fun fromHistory(barcode: String) = "result/$barcode?fromOcr=false&ocrText=" } + data object Onboarding : Screen("onboarding") + data object Settings : Screen("settings") + data object Splash : Screen("splash") // ── Sous-écrans ── data object ProfileList : Screen("profiles") + data object ProfileEdit : Screen("profile/edit/{id}") { fun new() = "profile/edit/0" + fun edit(id: Long) = "profile/edit/$id" } + data object ProductDetail : Screen("product/{barcode}") { fun build(barcode: String) = "product/$barcode" } + data object ListDetail : Screen("list/{id}?name={name}") { - fun build(id: Long, name: String = "Ma liste") = "list/$id?name=${android.net.Uri.encode(name)}" + fun build( + id: Long, + name: String = "Ma liste", + ) = "list/$id?name=${android.net.Uri.encode(name)}" } + data object ListEdit : Screen("list/edit?id={id}") { fun new() = "list/edit?id=0" + fun edit(id: Long) = "list/edit?id=$id" } // ── List management (refonte) ── data object ListCreate : Screen("list/create") + data object ListSettings : Screen("list/settings/{id}") { fun build(id: Long) = "list/settings/$id" } + data object ListSort : Screen("list/sort/{id}") { fun build(id: Long) = "list/sort/$id" } + data object ListRegion : Screen("list/region/{id}") { fun build(id: Long) = "list/region/$id" } + data object ListNameImage : Screen("list/nameimage/{id}") { fun build(id: Long) = "list/nameimage/$id" } + data object ListMembers : Screen("list/members/{id}") { fun build(id: Long) = "list/members/$id" } @@ -80,12 +105,21 @@ sealed class Screen(val route: String) { data object Catalog : Screen("catalog/{listId}") { fun build(listId: Long) = "catalog/$listId" } + data object CatalogDomain : Screen("catalog/{listId}/domain/{domainId}") { - fun build(listId: Long, domainId: String) = "catalog/$listId/domain/$domainId" + fun build( + listId: Long, + domainId: String, + ) = "catalog/$listId/domain/$domainId" } + data object CatalogCategory : Screen("catalog/{listId}/category/{categoryId}") { - fun build(listId: Long, categoryId: String) = "catalog/$listId/category/$categoryId" + fun build( + listId: Long, + categoryId: String, + ) = "catalog/$listId/category/$categoryId" } + data object CatalogSearch : Screen("catalog/{listId}/search") { fun build(listId: Long) = "catalog/$listId/search" } @@ -100,36 +134,37 @@ data class BottomNavItem( val iconUnselected: ImageVector, val label: String, val contentDescription: String, - val badgeCount: Int = 0 + val badgeCount: Int = 0, ) -val bottomNavItems = listOf( - BottomNavItem( - screen = Screen.Dashboard, - iconSelected = Icons.Filled.Home, - iconUnselected = Icons.Outlined.Home, - label = "Accueil", - contentDescription = "Tableau de bord" - ), - BottomNavItem( - screen = Screen.Lists, - iconSelected = Icons.Filled.List, - iconUnselected = Icons.Outlined.List, - label = "Listes", - contentDescription = "Mes listes de courses" - ), - BottomNavItem( - screen = Screen.Tracking, - iconSelected = Icons.Filled.ShowChart, - iconUnselected = Icons.Outlined.ShowChart, - label = "Suivi", - contentDescription = "Statistiques et historique" - ), - BottomNavItem( - screen = Screen.Family, - iconSelected = Icons.Filled.People, - iconUnselected = Icons.Outlined.People, - label = "Famille", - contentDescription = "Profils et réglages" +val bottomNavItems = + listOf( + BottomNavItem( + screen = Screen.Dashboard, + iconSelected = Icons.Filled.Home, + iconUnselected = Icons.Outlined.Home, + label = "Accueil", + contentDescription = "Tableau de bord", + ), + BottomNavItem( + screen = Screen.Lists, + iconSelected = Icons.Filled.List, + iconUnselected = Icons.Outlined.List, + label = "Listes", + contentDescription = "Mes listes de courses", + ), + BottomNavItem( + screen = Screen.Tracking, + iconSelected = Icons.Filled.ShowChart, + iconUnselected = Icons.Outlined.ShowChart, + label = "Suivi", + contentDescription = "Statistiques et historique", + ), + BottomNavItem( + screen = Screen.Family, + iconSelected = Icons.Filled.People, + iconUnselected = Icons.Outlined.People, + label = "Famille", + contentDescription = "Profils et réglages", + ), ) -) diff --git a/app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogScreens.kt b/app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogScreens.kt index be87c32..dee5418 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogScreens.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogScreens.kt @@ -62,9 +62,10 @@ import com.safebite.app.data.local.database.entity.CategoryEntity import com.safebite.app.data.local.database.relation.DomainWithCategoriesAndItems import kotlinx.coroutines.launch -private fun parseColor(hex: String?): Color? = runCatching { - hex?.takeIf { it.startsWith("#") }?.let { Color(android.graphics.Color.parseColor(it)) } -}.getOrNull() +private fun parseColor(hex: String?): Color? = + runCatching { + hex?.takeIf { it.startsWith("#") }?.let { Color(android.graphics.Color.parseColor(it)) } + }.getOrNull() @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -73,7 +74,7 @@ fun CatalogScreen( onBack: () -> Unit, onOpenDomain: (String) -> Unit, onOpenSearch: () -> Unit, - viewModel: CatalogViewModel = hiltViewModel() + viewModel: CatalogViewModel = hiltViewModel(), ) { LaunchedEffect(listId) { viewModel.setActiveList(listId) } val domains by viewModel.domains.collectAsStateWithLifecycle() @@ -91,9 +92,9 @@ fun CatalogScreen( IconButton(onClick = onOpenSearch) { Icon(Icons.Filled.Search, contentDescription = "Rechercher") } - } + }, ) - } + }, ) { padding -> if (domains.isEmpty()) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { @@ -106,12 +107,12 @@ fun CatalogScreen( modifier = Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(12.dp), ) { items(domains, key = { it.domain.domainId }) { domain -> DomainCard( domain = domain, - onClick = { onOpenDomain(domain.domain.domainId) } + onClick = { onOpenDomain(domain.domain.domainId) }, ) } } @@ -121,22 +122,22 @@ fun CatalogScreen( @Composable private fun DomainCard( domain: DomainWithCategoriesAndItems, - onClick: () -> Unit + onClick: () -> Unit, ) { val color = parseColor(domain.domain.color) ?: MaterialTheme.colorScheme.primaryContainer val itemCount = domain.categoriesWithItems.sumOf { it.items.size } Card( modifier = Modifier.fillMaxWidth().aspectRatio(1f).clickable(onClick = onClick), shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.18f)) + colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.18f)), ) { Column( modifier = Modifier.fillMaxSize().padding(16.dp), - verticalArrangement = Arrangement.SpaceBetween + verticalArrangement = Arrangement.SpaceBetween, ) { Text( text = domain.domain.emoji, - style = MaterialTheme.typography.displayMedium + style = MaterialTheme.typography.displayMedium, ) Column { Text( @@ -144,13 +145,13 @@ private fun DomainCard( style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 2, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) Spacer(Modifier.height(4.dp)) Text( text = "${domain.categoriesWithItems.size} catégories • $itemCount articles", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -163,7 +164,7 @@ fun DomainCategoriesScreen( domainId: String, onBack: () -> Unit, onOpenCategory: (String) -> Unit, - viewModel: CatalogViewModel = hiltViewModel() + viewModel: CatalogViewModel = hiltViewModel(), ) { LaunchedEffect(domainId) { viewModel.selectDomain(domainId) } val categories by viewModel.categoriesForSelectedDomain.collectAsStateWithLifecycle() @@ -178,14 +179,14 @@ fun DomainCategoriesScreen( IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour") } - } + }, ) - } + }, ) { padding -> LazyColumn( modifier = Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(categories, key = { it.categoryId }) { cat -> CategoryRow(category = cat, onClick = { onOpenCategory(cat.categoryId) }) @@ -195,23 +196,27 @@ fun DomainCategoriesScreen( } @Composable -private fun CategoryRow(category: CategoryEntity, onClick: () -> Unit) { +private fun CategoryRow( + category: CategoryEntity, + onClick: () -> Unit, +) { val color = parseColor(category.color) ?: MaterialTheme.colorScheme.surfaceVariant Card( modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.20f)) + colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.20f)), ) { Row( modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(color.copy(alpha = 0.4f)), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(48.dp) + .clip(CircleShape) + .background(color.copy(alpha = 0.4f)), + contentAlignment = Alignment.Center, ) { Text(text = category.emoji, style = MaterialTheme.typography.headlineSmall) } @@ -220,7 +225,7 @@ private fun CategoryRow(category: CategoryEntity, onClick: () -> Unit) { text = category.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) } } @@ -232,7 +237,7 @@ fun CategoryItemsScreen( categoryId: String, listId: Long, onBack: () -> Unit, - viewModel: CatalogViewModel = hiltViewModel() + viewModel: CatalogViewModel = hiltViewModel(), ) { LaunchedEffect(categoryId, listId) { viewModel.setActiveList(listId) @@ -250,17 +255,17 @@ fun CategoryItemsScreen( IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour") } - } + }, ) }, - snackbarHost = { SnackbarHost(snackbar) } + snackbarHost = { SnackbarHost(snackbar) }, ) { padding -> LazyVerticalGrid( columns = GridCells.Fixed(3), modifier = Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(12.dp), ) { items(items, key = { it.itemId }) { item -> ItemTile(item = item, onClick = { @@ -273,19 +278,22 @@ fun CategoryItemsScreen( } @Composable -private fun ItemTile(item: CatalogItemEntity, onClick: () -> Unit) { +private fun ItemTile( + item: CatalogItemEntity, + onClick: () -> Unit, +) { Card( modifier = Modifier.fillMaxWidth().aspectRatio(1f).clickable(onClick = onClick), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Box( modifier = Modifier.fillMaxSize().padding(8.dp), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Text(text = item.emoji, style = MaterialTheme.typography.displayMedium) Spacer(Modifier.height(6.dp)) @@ -295,14 +303,14 @@ private fun ItemTile(item: CatalogItemEntity, onClick: () -> Unit) { fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center, maxLines = 2, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) } Icon( imageVector = Icons.Filled.Add, contentDescription = "Ajouter", tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.align(Alignment.TopEnd).size(20.dp) + modifier = Modifier.align(Alignment.TopEnd).size(20.dp), ) } } @@ -313,7 +321,7 @@ private fun ItemTile(item: CatalogItemEntity, onClick: () -> Unit) { fun CatalogSearchScreen( listId: Long, onBack: () -> Unit, - viewModel: CatalogViewModel = hiltViewModel() + viewModel: CatalogViewModel = hiltViewModel(), ) { LaunchedEffect(listId) { viewModel.setActiveList(listId) } val query by viewModel.searchQuery.collectAsStateWithLifecycle() @@ -329,10 +337,10 @@ fun CatalogSearchScreen( IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour") } - } + }, ) }, - snackbarHost = { SnackbarHost(snackbar) } + snackbarHost = { SnackbarHost(snackbar) }, ) { padding -> Column(modifier = Modifier.fillMaxSize().padding(padding)) { OutlinedTextField( @@ -349,12 +357,12 @@ fun CatalogSearchScreen( } }, singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search) + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), ) LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(results, key = { it.itemId }) { item -> SearchResultRow(item = item, onAdd = { @@ -368,15 +376,18 @@ fun CatalogSearchScreen( } @Composable -private fun SearchResultRow(item: CatalogItemEntity, onAdd: () -> Unit) { +private fun SearchResultRow( + item: CatalogItemEntity, + onAdd: () -> Unit, +) { Card( modifier = Modifier.fillMaxWidth().clickable(onClick = onAdd), shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Row( modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text(text = item.emoji, style = MaterialTheme.typography.headlineMedium) Spacer(Modifier.width(12.dp)) @@ -384,7 +395,7 @@ private fun SearchResultRow(item: CatalogItemEntity, onAdd: () -> Unit) { Text( text = item.name, style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ) if (item.aliases.isNotBlank()) { Text( @@ -392,7 +403,7 @@ private fun SearchResultRow(item: CatalogItemEntity, onAdd: () -> Unit) { style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogViewModel.kt index 2afe95d..061d6de 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/catalog/CatalogViewModel.kt @@ -27,95 +27,112 @@ import javax.inject.Inject */ @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel -class CatalogViewModel @Inject constructor( - private val repository: CatalogRepository, - private val manageListUseCase: ManageShoppingListUseCase -) : ViewModel() { +class CatalogViewModel + @Inject + constructor( + private val repository: CatalogRepository, + private val manageListUseCase: ManageShoppingListUseCase, + ) : ViewModel() { + private val _activeListId = MutableStateFlow(null) + val activeListId: StateFlow = _activeListId.asStateFlow() - private val _activeListId = MutableStateFlow(null) - val activeListId: StateFlow = _activeListId.asStateFlow() - - val domains: StateFlow> = - repository.observeDomainsWithCategoriesAndItems().stateIn( - viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList() - ) - - private val _selectedDomainId = MutableStateFlow(null) - val selectedDomainId: StateFlow = _selectedDomainId.asStateFlow() - - val categoriesForSelectedDomain: StateFlow> = - _selectedDomainId - .flatMapLatest { id -> - if (id == null) flowOf(emptyList()) - else repository.observeCategoriesForDomain(id) - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - - private val _selectedCategoryId = MutableStateFlow(null) - val selectedCategoryId: StateFlow = _selectedCategoryId.asStateFlow() - - val itemsForSelectedCategory: StateFlow> = - _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 = _searchQuery.asStateFlow() - - val searchResults: StateFlow> = - _searchQuery - .flatMapLatest { q -> - if (q.isBlank()) flowOf(emptyList()) - else repository.search(q, limit = 30) - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - - fun setActiveList(listId: Long) { - _activeListId.value = listId.takeIf { it > 0 } - } - - fun selectDomain(domainId: String) { - _selectedDomainId.value = domainId - } - - fun selectCategory(categoryId: String) { - _selectedCategoryId.value = categoryId - } - - fun updateSearchQuery(query: String) { - _searchQuery.value = query - } - - fun clearSearch() { - _searchQuery.value = "" - } - - /** Ajoute l'article du catalogue à la liste active courante. */ - fun addItemToActiveList(item: CatalogItemEntity, categoryNameOverride: String? = null) { - val listId = _activeListId.value ?: return - viewModelScope.launch { - // Évite les doublons par nom (ignore-case). - val existing = manageListUseCase.getItems(listId) - .firstOrNull { it.productName.equals(item.name, ignoreCase = true) } - if (existing != null) { - if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false) - return@launch - } - val categoryName = categoryNameOverride - ?: item.primaryCategoryId?.let { repository.getCategory(it)?.name } - manageListUseCase.addItemToList( - listId, - ShoppingListItemEntity( - listId = listId, - productName = item.name, - category = categoryName, - customEmoji = item.emoji - ) + val domains: StateFlow> = + repository.observeDomainsWithCategoriesAndItems().stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + emptyList(), ) - repository.incrementPopularity(item.itemId) + + private val _selectedDomainId = MutableStateFlow(null) + val selectedDomainId: StateFlow = _selectedDomainId.asStateFlow() + + val categoriesForSelectedDomain: StateFlow> = + _selectedDomainId + .flatMapLatest { id -> + if (id == null) { + flowOf(emptyList()) + } else { + repository.observeCategoriesForDomain(id) + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private val _selectedCategoryId = MutableStateFlow(null) + val selectedCategoryId: StateFlow = _selectedCategoryId.asStateFlow() + + val itemsForSelectedCategory: StateFlow> = + _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 = _searchQuery.asStateFlow() + + val searchResults: StateFlow> = + _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) + } } } -} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardScreen.kt index a79918c..2f83350 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardScreen.kt @@ -2,17 +2,22 @@ package com.safebite.app.presentation.screen.dashboard import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -27,127 +32,374 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.safebite.app.R +import com.safebite.app.domain.model.SafetyStatus +import com.safebite.app.domain.model.ScanHistoryItem +import com.safebite.app.presentation.common.components.CardVariant import com.safebite.app.presentation.common.components.PrimaryButton import com.safebite.app.presentation.common.components.StandardCard -import com.safebite.app.presentation.common.components.CardVariant +import com.safebite.app.presentation.theme.LocalStatusColors +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale /** * Dashboard contextuel (spec UX §5.3). - * + * * Trois modes : - * - first_time : aucun scan dans l'historique - * - store_mode : détecté via géolocalisation/heure - * - home_mode : mode par défaut + * - FIRST_TIME : aucun scan → CTA "Commencer" + * - STORE : créneau magasin ou liste active → scan prominent + liste en cours + * - HOME : soirée/weekend → résumé hebdomadaire + derniers scans */ @Composable fun DashboardScreen( onScan: () -> Unit, onOpenList: (Long, String) -> Unit, onOpenHistoryItem: (String) -> Unit, - viewModel: DashboardViewModel = hiltViewModel() + viewModel: DashboardViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() Scaffold( - containerColor = MaterialTheme.colorScheme.background + containerColor = MaterialTheme.colorScheme.background, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - // Greeting + when (state.contextMode) { + DashboardContextMode.FIRST_TIME -> FirstTimeContent(onScan = onScan) + DashboardContextMode.STORE -> + StoreContent( + state = state, + onScan = onScan, + onOpenList = onOpenList, + ) + DashboardContextMode.HOME -> + HomeContent( + state = state, + onScan = onScan, + onOpenList = onOpenList, + onOpenHistoryItem = onOpenHistoryItem, + ) + } + } + } +} + +// ─── FIRST_TIME ────────────────────────────────────────────────────────────── + +@Composable +private fun FirstTimeContent(onScan: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Spacer(Modifier.height(32.dp)) + Text("🎉", style = MaterialTheme.typography.displayLarge) + Text( + text = stringResource(R.string.dashboard_first_time_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(R.string.dashboard_first_time_body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + PrimaryButton( + text = stringResource(R.string.dashboard_first_time_cta), + onClick = onScan, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +// ─── STORE ─────────────────────────────────────────────────────────────────── + +@Composable +private fun StoreContent( + state: DashboardUiState, + onScan: () -> Unit, + onOpenList: (Long, String) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Contexte : magasin + Text("🛒", style = MaterialTheme.typography.displayLarge) + Text( + text = stringResource(R.string.dashboard_store_mode_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + ) + + // Bouton scan prominent + PrimaryButton( + text = stringResource(R.string.dashboard_scan_button), + onClick = onScan, + modifier = Modifier.fillMaxWidth(), + ) + + // Liste en cours (si dispo) + if (state.lists.isNotEmpty()) { Text( - text = stringResource(R.string.dashboard_greeting, state.greetingName), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.SemiBold + text = stringResource(R.string.dashboard_current_list), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, ) - - // Quick actions - PrimaryButton( - text = stringResource(R.string.dashboard_scan_button), - onClick = onScan, - modifier = Modifier.fillMaxWidth() - ) - - // Shopping lists quick access - if (state.lists.isNotEmpty()) { - Row( + state.lists.forEach { list -> + StandardCard( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + variant = CardVariant.Filled, + onClick = { onOpenList(list.id, list.name) }, + contentPadding = PaddingValues(12.dp), ) { - state.lists.forEach { list -> - StandardCard( - modifier = Modifier - .weight(1f) - .height(72.dp), - variant = CardVariant.Filled, - onClick = { onOpenList(list.id, list.name) }, - contentPadding = PaddingValues(8.dp) - ) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = list.name, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource( - R.string.dashboard_remaining, - list.remaining - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = list.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringResource(R.string.dashboard_remaining, list.remaining), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } + Icon( + Icons.Filled.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) } } } + } + } +} - // Weekly stats placeholder - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f) - ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(R.string.dashboard_weekly_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Spacer(Modifier.height(8.dp)) - Text( - text = "78% produits OK", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) +// ─── HOME ──────────────────────────────────────────────────────────────────── + +@Composable +private fun HomeContent( + state: DashboardUiState, + onScan: () -> Unit, + onOpenList: (Long, String) -> Unit, + onOpenHistoryItem: (String) -> Unit, +) { + val statusColors = LocalStatusColors.current + + // Greeting + Text( + text = + if (state.greetingName.isNotEmpty()) { + stringResource(R.string.dashboard_greeting, state.greetingName) + } else { + stringResource(R.string.app_name) + }, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + ) + + // Quick actions + PrimaryButton( + text = stringResource(R.string.dashboard_scan_button), + onClick = onScan, + modifier = Modifier.fillMaxWidth(), + ) + + // Shopping lists quick access + if (state.lists.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.lists.forEach { list -> + StandardCard( + modifier = + Modifier + .weight(1f) + .height(72.dp), + variant = CardVariant.Filled, + onClick = { onOpenList(list.id, list.name) }, + contentPadding = PaddingValues(8.dp), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = list.name, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.dashboard_remaining, list.remaining), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } + } + } - // Recent scans - Text( - text = stringResource(R.string.dashboard_recent_scans), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold + // Weekly stats + if (state.weeklyStats != null) { + WeeklyStatsCard(state.weeklyStats!!) + } + + // Recent scans + Text( + text = stringResource(R.string.dashboard_recent_scans), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + if (state.recentScans.isNotEmpty()) { + state.recentScans.forEach { scan -> + RecentScanRow( + scan = scan, + onClick = { onOpenHistoryItem(scan.barcode) }, ) + } + } else { + Text( + text = stringResource(R.string.dashboard_no_scans), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +// ─── Shared components ────────────────────────────────────────────────────── + +@Composable +private fun WeeklyStatsCard(stats: WeeklyStats) { + val statusColors = LocalStatusColors.current + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { Text( - text = stringResource(R.string.dashboard_no_scans), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = stringResource(R.string.dashboard_weekly_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + StatBadge("✅", "${stats.safePercentage}%", stringResource(R.string.dashboard_stats_safe), statusColors.safe) + StatBadge("⚠️", "${stats.warningCount}", stringResource(R.string.dashboard_stats_warning), statusColors.warning) + StatBadge("❌", "${stats.dangerCount}", stringResource(R.string.dashboard_stats_danger), statusColors.danger) + } + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.dashboard_stats_total, stats.totalScans), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } + +@Composable +private fun StatBadge( + emoji: String, + count: String, + label: String, + color: androidx.compose.ui.graphics.Color, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(emoji, style = MaterialTheme.typography.headlineMedium) + Text(count, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = color) + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun RecentScanRow( + scan: ScanHistoryItem, + onClick: () -> Unit, +) { + val statusColors = LocalStatusColors.current + val emoji = + when (scan.safetyStatus) { + SafetyStatus.SAFE -> "✅" + SafetyStatus.WARNING -> "⚠️" + SafetyStatus.DANGER -> "❌" + } + StandardCard( + modifier = Modifier.fillMaxWidth(), + variant = CardVariant.Filled, + onClick = onClick, + contentPadding = PaddingValues(12.dp), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(emoji, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + Text( + scan.productName ?: scan.barcode, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!scan.brand.isNullOrBlank()) { + Text( + scan.brand, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + formatRelativeTime(scan.scannedAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + Icons.Filled.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } +} + +private fun formatRelativeTime(timestamp: Long): String { + val now = System.currentTimeMillis() + val diff = now - timestamp + return when { + diff < 60_000 -> "À l'instant" + diff < 3_600_000 -> "Il y a ${diff / 60_000} min" + diff < 86_400_000 -> "Il y a ${diff / 3_600_000}h" + diff < 604_800_000 -> "Il y a ${diff / 86_400_000}j" + else -> SimpleDateFormat("dd/MM", Locale.FRANCE).format(Date(timestamp)) + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardViewModel.kt index 0ba3839..c4906e6 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardViewModel.kt @@ -2,8 +2,10 @@ package com.safebite.app.presentation.screen.dashboard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.safebite.app.data.local.database.entity.ShoppingListEntity +import com.safebite.app.domain.model.SafetyStatus +import com.safebite.app.domain.model.ScanHistoryItem import com.safebite.app.domain.model.UserProfile +import com.safebite.app.domain.usecase.GetScanHistoryUseCase import com.safebite.app.domain.usecase.GetShoppingListsUseCase import com.safebite.app.domain.usecase.ManageProfileUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -15,66 +17,158 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn +import java.util.Calendar import javax.inject.Inject +/** Mode contextuel du dashboard. */ +enum class DashboardContextMode { + /** Aucun scan dans l'historique — onboarding implicite. */ + FIRST_TIME, + + /** Créneau 8h-20h en semaine OU liste active avec restants → mode magasin. */ + STORE, + + /** Soirée, weekend, ou aucune condition magasin remplie. */ + HOME, +} + data class DashboardUiState( val greetingName: String = "", - val lists: List = emptyList() + val contextMode: DashboardContextMode = DashboardContextMode.FIRST_TIME, + val lists: List = emptyList(), + val weeklyStats: WeeklyStats? = null, + val recentScans: List = emptyList(), +) + +data class WeeklyStats( + val safePercentage: Int, + val warningCount: Int, + val dangerCount: Int, + val totalScans: Int, ) data class ListSummary( val id: Long, val name: String, - val remaining: Int + val remaining: Int, ) @HiltViewModel -class DashboardViewModel @Inject constructor( - private val manageProfile: ManageProfileUseCase, - private val getShoppingLists: GetShoppingListsUseCase -) : ViewModel() { - - @OptIn(ExperimentalCoroutinesApi::class) - val state: StateFlow = combine( - manageProfile.observe(), - manageProfile.observeActiveIds() - ) { profiles, activeIds -> - profiles to activeIds - }.flatMapLatest { (profiles, activeIds) -> - val greetingName = resolveGreetingName(profiles, activeIds) - observeListsWithStats(greetingName) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = DashboardUiState() - ) - - @OptIn(ExperimentalCoroutinesApi::class) - private fun observeListsWithStats(greetingName: String): Flow { - return getShoppingLists.observeActive().flatMapLatest { lists -> - val sortedLists = lists.sortedBy { it.createdAt }.take(4) - if (sortedLists.isEmpty()) { - flowOf(DashboardUiState(greetingName = greetingName, lists = emptyList())) - } else { - val listFlows = sortedLists.map { list -> - combine( - getShoppingLists.observeItemCount(list.id), - getShoppingLists.observeCheckedCount(list.id) - ) { total, checked -> - ListSummary(list.id, list.name, total - checked) - } +class DashboardViewModel + @Inject + constructor( + private val manageProfile: ManageProfileUseCase, + private val getShoppingLists: GetShoppingListsUseCase, + private val getScanHistory: GetScanHistoryUseCase, + ) : ViewModel() { + @OptIn(ExperimentalCoroutinesApi::class) + val state: StateFlow = + combine( + manageProfile.observe(), + manageProfile.observeActiveIds(), + ) { profiles, activeIds -> + profiles to activeIds + }.flatMapLatest { (profiles, activeIds) -> + val greetingName = resolveGreetingName(profiles, activeIds) + combine( + observeListsWithStats(greetingName), + observeHistory(), + ) { dashboard, history -> + val weeklyStats = computeWeeklyStats(history) + val contextMode = detectContextMode(history, dashboard.lists) + dashboard.copy( + contextMode = contextMode, + recentScans = history.take(5), + weeklyStats = weeklyStats, + ) } - combine(listFlows) { array -> - DashboardUiState(greetingName, array.toList()) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = DashboardUiState(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + private fun observeListsWithStats(greetingName: String): Flow { + 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, activeIds: Set): String { - return when { - activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }.firstOrNull()?.name - else -> profiles.filter { it.isDefault }.firstOrNull()?.name ?: profiles.firstOrNull()?.name - } ?: "" + private fun observeHistory(): Flow> = getScanHistory.observe() + + /** + * Détection du mode contextuel : + * - FIRST_TIME : aucun scan dans l'historique + * - STORE : heure 8h-20h en semaine, OU liste active avec produits restants + * - HOME : par défaut (soirée, weekend) + */ + private fun detectContextMode( + history: List, + lists: List, + ): 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): 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, + activeIds: Set, + ): String { + return when { + activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }.firstOrNull()?.name + else -> profiles.filter { it.isDefault }.firstOrNull()?.name ?: profiles.firstOrNull()?.name + } ?: "" + } } -} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/family/FamilyScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/family/FamilyScreen.kt index 61f5e66..81d6549 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/family/FamilyScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/family/FamilyScreen.kt @@ -1,7 +1,6 @@ package com.safebite.app.presentation.screen.family import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -20,7 +19,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder import androidx.compose.material3.AlertDialog @@ -43,13 +41,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -65,7 +59,7 @@ import com.safebite.app.presentation.theme.LocalDimens fun FamilyScreen( onOpenProfile: (Long) -> Unit, onOpenSettings: () -> Unit, - viewModel: FamilyViewModel = hiltViewModel() + viewModel: FamilyViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val activeProfileIds by viewModel.activeProfileIds.collectAsStateWithLifecycle() @@ -78,7 +72,7 @@ fun FamilyScreen( SafeBiteTopAppBar( title = stringResource(R.string.family_title), onBack = null, - backContentDescription = null + backContentDescription = null, ) }, floatingActionButton = { @@ -86,39 +80,42 @@ fun FamilyScreen( FloatingActionButton( onClick = { onOpenProfile(0L) }, containerColor = MaterialTheme.colorScheme.primary, - modifier = Modifier.semantics { - contentDescription = addContentDesc - } + modifier = + Modifier.semantics { + contentDescription = addContentDesc + }, ) { Icon( Icons.Filled.Add, - contentDescription = null + contentDescription = null, ) } - } + }, ) { padding -> if (uiState.profiles.isEmpty()) { Box( - modifier = Modifier - .fillMaxSize() - .padding(padding), - contentAlignment = Alignment.Center + modifier = + Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, ) { EmptyState( title = stringResource(R.string.family_no_profiles), message = stringResource(R.string.family_no_profiles_body), - emoji = "👨‍👩‍👧‍👦" + emoji = "👨‍👩‍👧‍👦", ) } } else { LazyVerticalGrid( columns = GridCells.Fixed(2), - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = LocalDimens.current.spacingMd), + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = LocalDimens.current.spacingMd), horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd), - verticalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd) + verticalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd), ) { items(uiState.profiles, key = { it.id }) { profile -> val isActive = profile.id in activeProfileIds @@ -128,7 +125,7 @@ fun FamilyScreen( onToggleActive = { viewModel.toggleProfileActive(profile.id) }, onEdit = { onOpenProfile(profile.id) }, onDelete = { showDeleteDialog = profile.id }, - onSetDefault = { viewModel.setDefaultProfile(profile.id) } + onSetDefault = { viewModel.setDefaultProfile(profile.id) }, ) } } @@ -148,7 +145,7 @@ fun FamilyScreen( onClick = { viewModel.deleteProfile(profile) showDeleteDialog = null - } + }, ) { Text("Supprimer", color = MaterialTheme.colorScheme.error) } @@ -157,7 +154,7 @@ fun FamilyScreen( TextButton(onClick = { showDeleteDialog = null }) { Text("Annuler") } - } + }, ) } } @@ -174,82 +171,96 @@ fun ProfileCard( onEdit: () -> Unit, onDelete: () -> Unit, onSetDefault: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val dimens = LocalDimens.current Card( - modifier = modifier - .clickable(onClick = onEdit), + modifier = + modifier + .clickable(onClick = onEdit), shape = RoundedCornerShape(dimens.radiusMd), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(dimens.spacingMd) + modifier = + Modifier + .fillMaxWidth() + .padding(dimens.spacingMd), ) { // En-tête avec avatar et nom Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, ) { // Avatar Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background( - if (isActive) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceVariant - ), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(48.dp) + .clip(CircleShape) + .background( + if (isActive) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + contentAlignment = Alignment.Center, ) { Text( text = profile.avatar, - style = MaterialTheme.typography.headlineMedium + style = MaterialTheme.typography.headlineMedium, ) } // Nom et badge Column( - modifier = Modifier.weight(1f).padding(horizontal = dimens.spacingSm) + modifier = Modifier.weight(1f).padding(horizontal = dimens.spacingSm), ) { Text( text = profile.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - maxLines = 1 + maxLines = 1, ) if (profile.isDefault) { Text( text = stringResource(R.string.profile_default_badge), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } } // Bouton actif - val a11yDesc = if (isActive) - stringResource(R.string.a11y_profile_inactive, profile.name) - else - stringResource(R.string.a11y_profile_active, profile.name) + val a11yDesc = + if (isActive) { + stringResource(R.string.a11y_profile_inactive, profile.name) + } else { + stringResource(R.string.a11y_profile_active, profile.name) + } IconButton( onClick = onToggleActive, - modifier = Modifier.semantics { - contentDescription = a11yDesc - } + modifier = + Modifier.semantics { + contentDescription = a11yDesc + }, ) { Icon( imageVector = if (isActive) Icons.Filled.Star else Icons.Filled.StarBorder, contentDescription = null, - tint = if (isActive) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant + tint = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, ) } } @@ -260,7 +271,7 @@ fun ProfileCard( AllergenDisplayGrid( severeAllergens = profile.severeAllergens, moderateIntolerances = profile.moderateIntolerances, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(dimens.spacingSm)) @@ -271,7 +282,7 @@ fun ProfileCard( text = profile.dietaryRestrictions.joinToString(", ") { it.displayFr }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2 + maxLines = 2, ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/family/FamilyViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/family/FamilyViewModel.kt index 606b2d7..58856ef 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/family/FamilyViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/family/FamilyViewModel.kt @@ -19,45 +19,51 @@ import javax.inject.Inject data class FamilyUiState( val profiles: List = emptyList(), val activeProfileIds: Set = emptySet(), - val isLoading: Boolean = true + val isLoading: Boolean = true, ) @HiltViewModel -class FamilyViewModel @Inject constructor( - private val manageProfileUseCase: ManageProfileUseCase -) : ViewModel() { +class FamilyViewModel + @Inject + constructor( + private val manageProfileUseCase: ManageProfileUseCase, + ) : ViewModel() { + val uiState: StateFlow = + manageProfileUseCase.observe() + .map { profiles -> + FamilyUiState( + profiles = profiles, + isLoading = false, + ) + } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + FamilyUiState(), + ) - val uiState: StateFlow = manageProfileUseCase.observe() - .map { profiles -> - FamilyUiState( - profiles = profiles, - isLoading = false - ) - } - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5_000), - FamilyUiState() - ) + val activeProfileIds: StateFlow> = + manageProfileUseCase.observeActiveIds() + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + emptySet(), + ) - val activeProfileIds: StateFlow> = manageProfileUseCase.observeActiveIds() - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5_000), - emptySet() - ) + fun toggleProfileActive(id: Long) = + viewModelScope.launch { + val current = manageProfileUseCase.observeActiveIds().first() + val newIds = if (id in current) current - id else current + id + manageProfileUseCase.setActive(newIds) + } - fun toggleProfileActive(id: Long) = viewModelScope.launch { - val current = manageProfileUseCase.observeActiveIds().first() - val newIds = if (id in current) current - id else current + id - manageProfileUseCase.setActive(newIds) + fun deleteProfile(profile: UserProfile) = + viewModelScope.launch { + manageProfileUseCase.delete(profile) + } + + fun setDefaultProfile(id: Long) = + viewModelScope.launch { + manageProfileUseCase.setDefault(id) + } } - - fun deleteProfile(profile: UserProfile) = viewModelScope.launch { - manageProfileUseCase.delete(profile) - } - - fun setDefaultProfile(id: Long) = viewModelScope.launch { - manageProfileUseCase.setDefault(id) - } -} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/home/HomeScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/home/HomeScreen.kt index 79a02df..9af340c 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/home/HomeScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/home/HomeScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.safebite.app.R -import com.safebite.app.domain.model.SafetyStatus import com.safebite.app.domain.model.UserProfile import com.safebite.app.presentation.common.components.AvatarBubble import com.safebite.app.presentation.common.components.OutlinedActionButton @@ -60,7 +59,7 @@ fun HomeScreen( onHistory: () -> Unit, onSettings: () -> Unit, onOpenHistoryItem: (String) -> Unit, - viewModel: HomeViewModel = hiltViewModel() + viewModel: HomeViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -71,29 +70,36 @@ fun HomeScreen( SafeBiteTopAppBar( title = stringResource(R.string.app_name), actions = { - IconButton(onClick = onProfiles) { Icon(Icons.Filled.Person, contentDescription = stringResource(R.string.nav_profiles)) } - IconButton(onClick = onHistory) { Icon(Icons.Filled.History, contentDescription = stringResource(R.string.nav_history)) } - IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.nav_settings)) } + IconButton( + onClick = onProfiles, + ) { Icon(Icons.Filled.Person, contentDescription = stringResource(R.string.nav_profiles)) } + IconButton( + onClick = onHistory, + ) { Icon(Icons.Filled.History, contentDescription = stringResource(R.string.nav_history)) } + IconButton( + onClick = onSettings, + ) { Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.nav_settings)) } }, ) - } + }, ) { padding -> if (state.profiles.isEmpty()) { NoProfileBlock(modifier = Modifier.padding(padding), onCreate = onCreateProfile) return@Scaffold } Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg), - verticalArrangement = Arrangement.spacedBy(dimens.spacingLg) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg), + verticalArrangement = Arrangement.spacedBy(dimens.spacingLg), ) { ActiveProfilesRow( profiles = state.profiles, active = state.activeProfiles, onToggle = viewModel::toggleActive, - onManage = onProfiles + onManage = onProfiles, ) ScanButton(onClick = onScan) @@ -102,35 +108,38 @@ fun HomeScreen( text = stringResource(R.string.home_ocr_button), onClick = onOcr, icon = Icons.Filled.TextFields, - modifier = Modifier.fillMaxWidth().height(dimens.buttonHeightLg) + modifier = Modifier.fillMaxWidth().height(dimens.buttonHeightLg), ) Text( stringResource(R.string.home_recent_scans), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) if (state.recent.isEmpty()) { Text( stringResource(R.string.home_no_recent), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { state.recent.forEach { item -> - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .fillMaxWidth() - .clickable { onOpenHistoryItem(item.barcode) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { onOpenHistoryItem(item.barcode) }, ) { Box( - modifier = Modifier.size(12.dp).background(statusColor(item.safetyStatus), CircleShape) + modifier = Modifier.size(12.dp).background(statusColor(item.safetyStatus), CircleShape), ) Spacer(Modifier.size(8.dp)) ProductCard( title = item.productName ?: item.barcode, subtitle = item.brand, imageUrl = item.imageUrl, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) } } @@ -144,7 +153,7 @@ private fun ActiveProfilesRow( profiles: List, active: List, onToggle: (UserProfile) -> Unit, - onManage: () -> Unit + onManage: () -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -159,7 +168,7 @@ private fun ActiveProfilesRow( onClick = { onToggle(p) }, shape = MaterialTheme.shapes.medium, color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, - contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface + contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, ) { Row(modifier = Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { AvatarBubble(avatar = p.avatar, size = 32.dp) @@ -180,37 +189,40 @@ private fun ScanButton(onClick: () -> Unit) { onClick = onClick, icon = Icons.Filled.QrCodeScanner, large = true, - modifier = Modifier - .fillMaxWidth() - .height(dimens.buttonHeightHero) - .semantics { contentDescription = "Scan a product" }, + modifier = + Modifier + .fillMaxWidth() + .height(dimens.buttonHeightHero) + .semantics { contentDescription = "Scan a product" }, ) } @Composable -private fun NoProfileBlock(modifier: Modifier, onCreate: () -> Unit) { +private fun NoProfileBlock( + modifier: Modifier, + onCreate: () -> Unit, +) { val dimens = LocalDimens.current Column( modifier = modifier.fillMaxSize().padding(dimens.spacingXl), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Text( stringResource(R.string.home_no_profile_title), style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) Spacer(Modifier.size(dimens.spacingSm)) Text( stringResource(R.string.home_no_profile_body), style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.size(dimens.spacingLg)) PrimaryButton( text = stringResource(R.string.home_create_profile), - onClick = onCreate + onClick = onCreate, ) } } - diff --git a/app/src/main/java/com/safebite/app/presentation/screen/home/HomeViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/home/HomeViewModel.kt index c3e2afa..2edc049 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/home/HomeViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/home/HomeViewModel.kt @@ -17,34 +17,39 @@ import javax.inject.Inject data class HomeUi( val profiles: List = emptyList(), val activeProfiles: List = emptyList(), - val recent: List = emptyList() + val recent: List = emptyList(), ) @HiltViewModel -class HomeViewModel @Inject constructor( - private val manageProfile: ManageProfileUseCase, - private val history: GetScanHistoryUseCase -) : ViewModel() { +class HomeViewModel + @Inject + constructor( + private val manageProfile: ManageProfileUseCase, + private val history: GetScanHistoryUseCase, + ) : ViewModel() { + val state: StateFlow = + 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 = combine( - manageProfile.observe(), - manageProfile.observeActiveIds(), - history.observe() - ) { profiles, activeIds, scans -> - val resolvedActive = when { - activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds } - else -> profiles.filter { it.isDefault }.ifEmpty { profiles.take(1) } - } - HomeUi(profiles = profiles, activeProfiles = resolvedActive, recent = scans.take(3)) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeUi()) + fun toggleActive(profile: UserProfile) = + viewModelScope.launch { + val current = state.value.activeProfiles.map { it.id }.toMutableSet() + if (profile.id in current) current.remove(profile.id) else current.add(profile.id) + manageProfile.setActive(current) + } - fun toggleActive(profile: UserProfile) = viewModelScope.launch { - val current = state.value.activeProfiles.map { it.id }.toMutableSet() - if (profile.id in current) current.remove(profile.id) else current.add(profile.id) - manageProfile.setActive(current) + fun setActiveOnly(profile: UserProfile) = + viewModelScope.launch { + manageProfile.setActive(setOf(profile.id)) + } } - - fun setActiveOnly(profile: UserProfile) = viewModelScope.launch { - manageProfile.setActive(setOf(profile.id)) - } -} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/IconPickerSheet.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/IconPickerSheet.kt index 5591b3a..c650428 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/IconPickerSheet.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/IconPickerSheet.kt @@ -38,9 +38,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -52,13 +50,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.safebite.app.domain.engine.CatalogProvider -import javax.inject.Inject @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -67,7 +63,7 @@ fun IconPickerSheet( categories: List, onDismiss: () -> Unit, onSelectIcon: (String) -> Unit, - catalogProvider: CatalogProvider = hiltViewModel().catalog + catalogProvider: CatalogProvider = hiltViewModel().catalog, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var searchQuery by remember { mutableStateOf("") } @@ -75,18 +71,19 @@ fun IconPickerSheet( ModalBottomSheet( onDismissRequest = onDismiss, - sheetState = sheetState + sheetState = sheetState, ) { Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = 16.dp, vertical = 8.dp) + modifier = + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), ) { // Header Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = onDismiss) { Icon(Icons.Filled.Close, contentDescription = "Fermer") @@ -95,34 +92,36 @@ fun IconPickerSheet( text = "Choisir une icône", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) IconButton(onClick = { onSelectIcon("") }) { Icon( Icons.Filled.Delete, contentDescription = "Supprimer l'icône", - tint = MaterialTheme.colorScheme.error + tint = MaterialTheme.colorScheme.error, ) } } // Current icon display Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp), - contentAlignment = Alignment.Center + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, ) { Box( - modifier = Modifier - .size(120.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(120.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, ) { Text( text = currentEmoji, - style = MaterialTheme.typography.displayLarge + style = MaterialTheme.typography.displayLarge, ) } } @@ -131,9 +130,10 @@ fun IconPickerSheet( OutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), placeholder = { Text("Chercher une icône") }, leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) @@ -146,23 +146,26 @@ fun IconPickerSheet( } }, singleLine = true, - shape = RoundedCornerShape(28.dp) + shape = RoundedCornerShape(28.dp), ) - // Icon grid by category + // Icon grid by category (catalogue food items first, then extra emoji categories) + val extraCategories = remember { ExtraEmojiCategories.all } LazyColumn( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { + // 1. Catégories du catalogue alimentaire categories.forEach { category -> val categoryItems = catalogProvider.itemsForCategory(category) - val filteredItems = if (searchQuery.isNotBlank()) { - categoryItems.filter { - it.name.contains(searchQuery, ignoreCase = true) + val filteredItems = + if (searchQuery.isNotBlank()) { + categoryItems.filter { + it.name.contains(searchQuery, ignoreCase = true) + } + } else { + categoryItems } - } else { - categoryItems - } if (filteredItems.isNotEmpty()) { val expanded = expandedCategories[category] ?: (searchQuery.isNotBlank()) @@ -174,7 +177,7 @@ fun IconPickerSheet( expanded = expanded, onToggle = { expandedCategories[category] = !expanded - } + }, ) } @@ -183,7 +186,51 @@ fun IconPickerSheet( IconGrid( items = filteredItems, currentEmoji = currentEmoji, - onSelectIcon = onSelectIcon + onSelectIcon = onSelectIcon, + ) + } + } + } + } + + // 2. Catégories d'émojis supplémentaires (non-alimentaires) + extraCategories.forEach { (category, items) -> + val filteredItems = + if (searchQuery.isNotBlank()) { + items.filter { + it.name.contains(searchQuery, ignoreCase = true) || + it.emoji.contains(searchQuery, ignoreCase = true) + } + } else { + items + } + + if (filteredItems.isNotEmpty()) { + val expanded = expandedCategories[category] ?: (searchQuery.isNotBlank()) + + item(key = "header-extra-$category") { + CategoryHeader( + title = category, + count = filteredItems.size, + expanded = expanded, + onToggle = { + expandedCategories[category] = !expanded + }, + ) + } + + if (expanded) { + item(key = "grid-extra-$category") { + IconGrid( + items = filteredItems.map { + CatalogProvider.CatalogItem( + name = it.name, + category = category, + emoji = it.emoji, + ) + }, + currentEmoji = currentEmoji, + onSelectIcon = onSelectIcon, ) } } @@ -201,34 +248,35 @@ private fun CategoryHeader( title: String, count: Int, expanded: Boolean, - onToggle: () -> Unit + onToggle: () -> Unit, ) { Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onToggle) - .padding(vertical = 12.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onToggle) + .padding(vertical = 12.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.rotate(if (expanded) 90f else 0f), - tint = MaterialTheme.colorScheme.onSurface + tint = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.width(8.dp)) Text( text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) Icon( imageVector = Icons.Filled.KeyboardArrowDown, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(20.dp), ) } } @@ -237,23 +285,24 @@ private fun CategoryHeader( private fun IconGrid( items: List, currentEmoji: String, - onSelectIcon: (String) -> Unit + onSelectIcon: (String) -> Unit, ) { LazyVerticalGrid( columns = GridCells.Fixed(3), - modifier = Modifier - .fillMaxWidth() - .height(((items.size + 2) / 3 * 100).dp.coerceAtMost(400.dp)), + modifier = + Modifier + .fillMaxWidth() + .height(((items.size + 2) / 3 * 100).dp.coerceAtMost(400.dp)), contentPadding = PaddingValues(4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(items) { item -> IconCard( emoji = item.emoji, label = item.name, isSelected = item.emoji == currentEmoji, - onClick = { onSelectIcon(item.emoji) } + onClick = { onSelectIcon(item.emoji) }, ) } } @@ -264,39 +313,42 @@ private fun IconCard( emoji: String, label: String, isSelected: Boolean, - onClick: () -> Unit + onClick: () -> Unit, ) { - val backgroundColor = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - } - val contentColor = if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } + val backgroundColor = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val contentColor = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } Card( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clickable(onClick = onClick), + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clickable(onClick = onClick), shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = backgroundColor) + colors = CardDefaults.cardColors(containerColor = backgroundColor), ) { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(8.dp) + modifier = Modifier.padding(8.dp), ) { Text( text = emoji, - style = MaterialTheme.typography.headlineMedium + style = MaterialTheme.typography.headlineMedium, ) Spacer(modifier = Modifier.height(4.dp)) Text( @@ -305,7 +357,7 @@ private fun IconCard( color = contentColor, textAlign = TextAlign.Center, maxLines = 2, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, ) } if (isSelected) { @@ -313,12 +365,150 @@ private fun IconCard( imageVector = Icons.Filled.Check, contentDescription = "Sélectionné", tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(4.dp) - .size(20.dp) + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(20.dp), ) } } } } + +/** Émojis supplémentaires non-alimentaires pour l'IconPicker. */ +private data class ExtraEmoji(val emoji: String, val name: String) + +private object ExtraEmojiCategories { + val all: List>> = + 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"), + ), + ) +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt index c3f9908..0ce3f67 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt @@ -1,18 +1,23 @@ package com.safebite.app.presentation.screen.lists +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -30,7 +35,6 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -41,7 +45,6 @@ import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.History @@ -57,12 +60,10 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField @@ -76,28 +77,20 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import coil.compose.AsyncImage -import java.io.File import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource @@ -107,8 +100,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage import com.safebite.app.R -import com.safebite.app.domain.engine.CatalogProvider import com.safebite.app.presentation.common.components.ImageCropBottomSheet import com.safebite.app.presentation.common.components.PrimaryButton import com.safebite.app.presentation.theme.LocalDimens @@ -136,7 +129,7 @@ fun ListDetailScreen( onOpenScanner: () -> Unit, onOpenProduct: (String) -> Unit, onOpenCatalog: (Long) -> Unit = {}, - viewModel: ListDetailViewModel = hiltViewModel() + viewModel: ListDetailViewModel = hiltViewModel(), ) { LaunchedEffect(listId, listName) { viewModel.initList(listId, listName) @@ -169,32 +162,35 @@ fun ListDetailScreen( var cropForNewItem by remember { mutableStateOf(false) } var cropForItemId by remember { mutableStateOf(null) } - val takePictureLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.TakePicturePreview() - ) { bitmap: Bitmap? -> - bitmap?.let { - cropBitmap = it - cropForNewItem = true - cropForItemId = null - } - showPhotoPicker = false - } - - val pickImageLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.GetContent() - ) { uri: Uri? -> - uri?.let { - val bmp = context.contentResolver.openInputStream(it)?.use { stream -> - BitmapFactory.decodeStream(stream) - } - bmp?.let { bitmap -> - cropBitmap = bitmap + val takePictureLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.TakePicturePreview(), + ) { bitmap: Bitmap? -> + bitmap?.let { + cropBitmap = it cropForNewItem = true cropForItemId = null } + showPhotoPicker = false + } + + val pickImageLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.GetContent(), + ) { uri: Uri? -> + uri?.let { + val bmp = + context.contentResolver.openInputStream(it)?.use { stream -> + BitmapFactory.decodeStream(stream) + } + bmp?.let { bitmap -> + cropBitmap = bitmap + cropForNewItem = true + cropForItemId = null + } + } + showPhotoPicker = false } - showPhotoPicker = false - } Scaffold( containerColor = MaterialTheme.colorScheme.background, @@ -206,7 +202,7 @@ fun ListDetailScreen( style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) }, navigationIcon = { @@ -218,7 +214,7 @@ fun ListDetailScreen( IconButton(onClick = { showPhotoPicker = true }) { Icon( Icons.Filled.CameraAlt, - contentDescription = stringResource(R.string.list_add_photo) + contentDescription = stringResource(R.string.list_add_photo), ) } IconButton(onClick = onOpenScanner) { @@ -233,7 +229,7 @@ fun ListDetailScreen( } DropdownMenu( expanded = menuExpanded, - onDismissRequest = { menuExpanded = false } + onDismissRequest = { menuExpanded = false }, ) { DropdownMenuItem( text = { Text("Tout décocher") }, @@ -241,7 +237,7 @@ fun ListDetailScreen( onClick = { menuExpanded = false viewModel.uncheckAllItems() - } + }, ) DropdownMenuItem( text = { Text("Vider Recently Used") }, @@ -249,16 +245,16 @@ fun ListDetailScreen( onClick = { menuExpanded = false viewModel.clearRecentlyUsed() - } + }, ) DropdownMenuItem( text = { Text("Partager") }, leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) }, - onClick = { menuExpanded = false } + onClick = { menuExpanded = false }, ) } } - } + }, ) }, bottomBar = { @@ -275,14 +271,15 @@ fun ListDetailScreen( onCancel = viewModel::cancelSearch, onAddCustom = { if (searchQuery.isNotBlank()) viewModel.addCustomItem(searchQuery) - } + }, ) - } + }, ) { padding -> Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) + modifier = + Modifier + .fillMaxSize() + .padding(padding), ) { when (val s = state) { is ListDetailViewModel.UiState.Loading -> { @@ -292,7 +289,7 @@ fun ListDetailScreen( Text( text = s.message, modifier = Modifier.align(Alignment.Center), - color = MaterialTheme.colorScheme.error + color = MaterialTheme.colorScheme.error, ) } is ListDetailViewModel.UiState.Ready -> { @@ -313,7 +310,7 @@ fun ListDetailScreen( onLongPressActive = viewModel::openItemDetails, onTapRecent = viewModel::restoreItem, onLongPressRecent = viewModel::openItemDetails, - onTapCatalogItem = viewModel::addCatalogItem + onTapCatalogItem = viewModel::addCatalogItem, ) } } @@ -321,25 +318,25 @@ fun ListDetailScreen( // Overlay : panneau de détail inline (après sélection) ou suggestions. AnimatedVisibility( visible = pendingItem != null, - modifier = Modifier.align(Alignment.BottomCenter) + modifier = Modifier.align(Alignment.BottomCenter), ) { pendingItem?.let { ItemDetailPanel( pending = it, onQuantity = viewModel::updatePendingQuantity, onVariant = viewModel::updatePendingVariant, - onTag = viewModel::updatePendingTag, - onNote = viewModel::updatePendingNote + onToggleTag = viewModel::togglePendingTag, + onNote = viewModel::updatePendingNote, ) } } AnimatedVisibility( visible = pendingItem == null && suggestions.isNotEmpty(), - modifier = Modifier.align(Alignment.BottomCenter) + modifier = Modifier.align(Alignment.BottomCenter), ) { SuggestionPanel( suggestions = suggestions, - onPick = viewModel::applySuggestion + onPick = viewModel::applySuggestion, ) } } @@ -347,10 +344,11 @@ fun ListDetailScreen( // Feuille de détail (long-press) val ready = state as? ListDetailViewModel.UiState.Ready - val selected = ready?.let { r -> - r.activeItems.firstOrNull { it.id == selectedItemId } - ?: r.recentlyUsed.firstOrNull { it.id == selectedItemId } - } + val selected = + ready?.let { r -> + r.activeItems.firstOrNull { it.id == selectedItemId } + ?: r.recentlyUsed.firstOrNull { it.id == selectedItemId } + } if (selected != null) { ItemDetailSheet( item = selected, @@ -360,7 +358,7 @@ fun ListDetailScreen( onUpdateNote = { note -> viewModel.updateItemNote(selected.id, note) }, onUpdateCategory = { cat -> viewModel.updateItemCategory(selected.id, cat) }, onUpdateEmoji = { emoji -> viewModel.updateItemEmoji(selected.id, emoji) }, - onUpdateTag = { tag -> viewModel.updateItemTag(selected.id, tag) }, + onToggleTag = { tag -> viewModel.toggleItemTag(selected.id, tag) }, onUpdateImage = { imageUrl -> viewModel.updateItemImageUrl(selected.id, imageUrl) }, onMoveTo = { targetListId -> viewModel.moveItemToList(selected.id, targetListId) }, onDelete = { viewModel.deleteItem(selected.id) }, @@ -369,7 +367,7 @@ fun ListDetailScreen( cropBitmap = bitmap cropForNewItem = false cropForItemId = selected.id - } + }, ) } @@ -392,7 +390,7 @@ fun ListDetailScreen( cropBitmap = null cropForNewItem = false cropForItemId = null - } + }, ) } @@ -401,7 +399,7 @@ fun ListDetailScreen( PhotoSourceBottomSheet( onTakePhoto = { takePictureLauncher.launch(null) }, onPickGallery = { pickImageLauncher.launch("image/*") }, - onDismiss = { showPhotoPicker = false } + onDismiss = { showPhotoPicker = false }, ) } @@ -424,7 +422,7 @@ fun ListDetailScreen( itemDescription = "" selectedImageUri = null showDescriptionDialog = false - } + }, ) } } @@ -448,19 +446,20 @@ private fun ListDetailContent( onLongPressActive: (Long) -> Unit, onTapRecent: (Long) -> Unit, onLongPressRecent: (Long) -> Unit, - onTapCatalogItem: (com.safebite.app.data.local.database.entity.CatalogItemEntity) -> Unit + onTapCatalogItem: (com.safebite.app.data.local.database.entity.CatalogItemEntity) -> Unit, ) { val dimens = LocalDimens.current LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = dimens.spacingMd, - end = dimens.spacingMd, - top = dimens.spacingMd, - bottom = dimens.spacingXl + 80.dp // espace pour la barre de saisie - ), - verticalArrangement = Arrangement.spacedBy(dimens.spacingSm) + contentPadding = + PaddingValues( + start = dimens.spacingMd, + end = dimens.spacingMd, + top = dimens.spacingMd, + bottom = dimens.spacingXl + 80.dp, // espace pour la barre de saisie + ), + verticalArrangement = Arrangement.spacedBy(dimens.spacingSm), ) { // ── Articles actifs ───────────────────────────────────────────────── if (ready.activeItems.isEmpty()) { @@ -470,20 +469,21 @@ private fun ListDetailContent( } else { item { TileGrid( - items = ready.activeItems.map { - TileData( - id = it.id, - label = it.productName, - note = it.note, - emoji = it.emoji, - imageUrl = it.imageUrl, - tone = TileTone.Active, - badgeWarning = !it.allergenWarning.isNullOrBlank(), - tag = it.tag - ) - }, + items = + ready.activeItems.map { + TileData( + id = it.id, + label = it.productName, + note = it.note, + emoji = it.emoji, + imageUrl = it.imageUrl, + tone = TileTone.Active, + badgeWarning = !it.allergenWarning.isNullOrBlank(), + tags = it.tags, + ) + }, onTap = onTapActive, - onLongPress = onLongPressActive + onLongPress = onLongPressActive, ) } } @@ -495,26 +495,27 @@ private fun ListDetailContent( count = ready.recentlyUsed.size, expanded = recentlyExpanded, onToggle = onToggleRecently, - leadingIcon = Icons.Filled.History + leadingIcon = Icons.Filled.History, ) } if (recentlyExpanded && ready.recentlyUsed.isNotEmpty()) { item { TileGrid( - items = ready.recentlyUsed.map { - TileData( - id = it.id, - label = it.productName, - note = it.note, - emoji = it.emoji, - imageUrl = it.imageUrl, - tone = TileTone.Recent, - badgeWarning = false, - tag = it.tag - ) - }, + items = + ready.recentlyUsed.map { + TileData( + id = it.id, + label = it.productName, + note = it.note, + emoji = it.emoji, + imageUrl = it.imageUrl, + tone = TileTone.Recent, + badgeWarning = false, + tags = it.tags, + ) + }, onTap = onTapRecent, - onLongPress = onLongPressRecent + onLongPress = onLongPressRecent, ) } } @@ -523,54 +524,56 @@ private fun ListDetailContent( item { HorizontalDivider( modifier = Modifier.padding(vertical = dimens.spacingSm), - color = MaterialTheme.colorScheme.outlineVariant + color = MaterialTheme.colorScheme.outlineVariant, ) Text( text = "Catalogue", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ) } catalogDomains.forEach { domainWithCats -> val domainId = domainWithCats.domain.domainId val domainExpanded = expandedDomains[domainId] ?: false - val domainItemCount = domainWithCats.categoriesWithItems.sumOf { cat -> - cat.items.count { item -> - ready.activeItems.none { it.productName.equals(item.name, ignoreCase = true) } && - ready.recentlyUsed.none { it.productName.equals(item.name, ignoreCase = true) } + val domainItemCount = + domainWithCats.categoriesWithItems.sumOf { cat -> + cat.items.count { item -> + ready.activeItems.none { it.productName.equals(item.name, ignoreCase = true) } && + ready.recentlyUsed.none { it.productName.equals(item.name, ignoreCase = true) } + } } - } item(key = "domain-header-$domainId") { CollapsibleHeader( title = "${domainWithCats.domain.emoji} ${domainWithCats.domain.name}", count = domainItemCount, expanded = domainExpanded, - onToggle = { onToggleDomain(domainId) } + onToggle = { onToggleDomain(domainId) }, ) } if (domainExpanded) { domainWithCats.categoriesWithItems.forEach { catWithItems -> val catId = catWithItems.category.categoryId val catExpanded = expandedRoomCategories[catId] ?: false - val filteredItems = catWithItems.items.filter { item -> - ready.activeItems.none { it.productName.equals(item.name, ignoreCase = true) } && - ready.recentlyUsed.none { it.productName.equals(item.name, ignoreCase = true) } - } + val filteredItems = + catWithItems.items.filter { item -> + ready.activeItems.none { it.productName.equals(item.name, ignoreCase = true) } && + ready.recentlyUsed.none { it.productName.equals(item.name, ignoreCase = true) } + } item(key = "cat-header-$catId") { CollapsibleHeader( title = " ${catWithItems.category.emoji} ${catWithItems.category.name}", count = filteredItems.size, expanded = catExpanded, - onToggle = { onToggleRoomCategory(catId) } + onToggle = { onToggleRoomCategory(catId) }, ) } if (catExpanded && filteredItems.isNotEmpty()) { item(key = "cat-grid-$catId") { CatalogItemGrid( items = filteredItems, - onTap = onTapCatalogItem + onTap = onTapCatalogItem, ) } } @@ -594,7 +597,7 @@ private data class TileData( val imageUrl: String?, val tone: TileTone, val badgeWarning: Boolean, - val tag: String? = null + val tags: Set = emptySet(), ) @OptIn(ExperimentalFoundationApi::class) @@ -602,7 +605,7 @@ private data class TileData( private fun TileGrid( items: List, onTap: (Long) -> Unit, - onLongPress: (Long) -> Unit + onLongPress: (Long) -> Unit, ) { if (items.isEmpty()) return val dimens = LocalDimens.current @@ -612,14 +615,14 @@ private fun TileGrid( rows.forEach { row -> Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm) + horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm), ) { row.forEach { tile -> Box(modifier = Modifier.weight(1f)) { Tile( data = tile, onTap = { onTap(tile.id) }, - onLongPress = { onLongPress(tile.id) } + onLongPress = { onLongPress(tile.id) }, ) } } @@ -637,53 +640,57 @@ private fun TileGrid( private fun Tile( data: TileData, onTap: () -> Unit, - onLongPress: () -> Unit + onLongPress: () -> Unit, ) { val statusColors = LocalStatusColors.current - val (container, content) = when (data.tone) { - TileTone.Active -> statusColors.danger.copy(alpha = 0.85f) to Color.White - TileTone.Recent -> statusColors.safe.copy(alpha = 0.45f) to Color.White - TileTone.Catalog -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant - } + val (container, content) = + when (data.tone) { + TileTone.Active -> statusColors.danger.copy(alpha = 0.85f) to Color.White + TileTone.Recent -> statusColors.safe.copy(alpha = 0.45f) to Color.White + TileTone.Catalog -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant + } Card( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .combinedClickable( - onClick = onTap, - onLongClick = onLongPress - ), + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .combinedClickable( + onClick = onTap, + onLongClick = onLongPress, + ), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = container), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), ) { Box( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), - contentAlignment = Alignment.Center + modifier = + Modifier + .fillMaxSize() + .padding(8.dp), + contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { if (!data.imageUrl.isNullOrBlank()) { AsyncImage( model = data.imageUrl, contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(top = 4.dp) - .clip(RoundedCornerShape(8.dp)), - contentScale = ContentScale.Crop + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .padding(top = 4.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop, ) } else { Text( text = data.emoji, style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(top = 4.dp) + modifier = Modifier.padding(top = 4.dp), ) Spacer(modifier = Modifier.weight(1f)) } @@ -694,7 +701,7 @@ private fun Tile( color = content, textAlign = TextAlign.Center, maxLines = 2, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) if (!data.note.isNullOrBlank()) { Text( @@ -703,7 +710,7 @@ private fun Tile( color = content.copy(alpha = 0.85f), textAlign = TextAlign.Center, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) } } @@ -712,9 +719,10 @@ private fun Tile( imageVector = Icons.Filled.Warning, contentDescription = "Allergène", tint = statusColors.warning, - modifier = Modifier - .align(Alignment.TopEnd) - .size(16.dp) + modifier = + Modifier + .align(Alignment.TopEnd) + .size(16.dp), ) } if (!data.imageUrl.isNullOrBlank()) { @@ -722,29 +730,42 @@ private fun Tile( imageVector = Icons.Filled.CameraAlt, contentDescription = "Photo", tint = content, - modifier = Modifier - .align(Alignment.BottomEnd) - .size(18.dp) - .background(container.copy(alpha = 0.8f), CircleShape) - .padding(2.dp) + modifier = + Modifier + .align(Alignment.BottomEnd) + .size(18.dp) + .background(container.copy(alpha = 0.8f), CircleShape) + .padding(2.dp), ) } - if (!data.tag.isNullOrBlank()) { - val tagColor = when (data.tag.lowercase()) { - "urgent" -> statusColors.danger - "offre" -> statusColors.safe - else -> MaterialTheme.colorScheme.tertiary + if (data.tags.isNotEmpty()) { + Row( + modifier = Modifier.align(Alignment.TopStart), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + data.tags.forEach { tag -> + val (icon, tint) = + when (tag.lowercase()) { + "urgent" -> Icons.Filled.AutoAwesome to statusColors.danger + "offre" -> Icons.Filled.Done to statusColors.safe + else -> Icons.Filled.History to MaterialTheme.colorScheme.tertiary + } + Box( + modifier = + Modifier + .size(18.dp) + .background(tint, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = tag, + tint = Color.White, + modifier = Modifier.size(12.dp), + ) + } + } } - Text( - text = data.tag.uppercase(), - style = MaterialTheme.typography.labelSmall, - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = Modifier - .align(Alignment.TopStart) - .background(tagColor, RoundedCornerShape(4.dp)) - .padding(horizontal = 4.dp, vertical = 2.dp) - ) } } } @@ -760,22 +781,23 @@ private fun CollapsibleHeader( count: Int, expanded: Boolean, onToggle: () -> Unit, - leadingIcon: androidx.compose.ui.graphics.vector.ImageVector? = null + leadingIcon: androidx.compose.ui.graphics.vector.ImageVector? = null, ) { val rotation by animateFloatAsState(targetValue = if (expanded) 90f else 0f, label = "chevron") Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onToggle) - .padding(vertical = 12.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onToggle) + .padding(vertical = 12.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.Filled.KeyboardArrowRight, contentDescription = null, modifier = Modifier.rotate(rotation), - tint = MaterialTheme.colorScheme.onSurface + tint = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.width(4.dp)) if (leadingIcon != null) { @@ -783,7 +805,7 @@ private fun CollapsibleHeader( imageVector = leadingIcon, contentDescription = null, modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.width(8.dp)) } @@ -791,13 +813,13 @@ private fun CollapsibleHeader( text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) if (count > 0) { Text( text = "$count", style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -810,7 +832,7 @@ private fun CollapsibleHeader( @Composable private fun CatalogItemGrid( items: List, - onTap: (com.safebite.app.data.local.database.entity.CatalogItemEntity) -> Unit + onTap: (com.safebite.app.data.local.database.entity.CatalogItemEntity) -> Unit, ) { if (items.isEmpty()) return val dimens = LocalDimens.current @@ -820,7 +842,7 @@ private fun CatalogItemGrid( rows.forEach { row -> Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm) + horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm), ) { row.forEach { item -> Box(modifier = Modifier.weight(1f)) { @@ -838,30 +860,32 @@ private fun CatalogItemGrid( @Composable private fun CatalogItemTile( item: com.safebite.app.data.local.database.entity.CatalogItemEntity, - onTap: () -> Unit + onTap: () -> Unit, ) { Card( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clickable(onClick = onTap), + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clickable(onClick = onTap), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Box( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), - contentAlignment = Alignment.Center + modifier = + Modifier + .fillMaxSize() + .padding(8.dp), + contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { Text( text = item.emoji, - style = MaterialTheme.typography.displayMedium + style = MaterialTheme.typography.displayMedium, ) Spacer(modifier = Modifier.height(6.dp)) Text( @@ -870,14 +894,14 @@ private fun CatalogItemTile( fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center, maxLines = 2, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) } Icon( imageVector = Icons.Filled.Add, contentDescription = "Ajouter", tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.align(Alignment.TopEnd).size(20.dp) + modifier = Modifier.align(Alignment.TopEnd).size(20.dp), ) } } @@ -892,26 +916,27 @@ private fun EmptyActiveCard() { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(16.dp), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text(text = "🛒", style = MaterialTheme.typography.displaySmall) Text( text = "Votre liste est vide", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ) Text( text = "Tapez un article ci-dessous ou parcourez le catalogue", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } @@ -931,7 +956,7 @@ private fun BottomSearchBar( onActivate: () -> Unit, onClear: () -> Unit, onCancel: () -> Unit, - onAddCustom: () -> Unit + onAddCustom: () -> Unit, ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(isSearchActive) { @@ -939,32 +964,35 @@ private fun BottomSearchBar( runCatching { focusRequester.requestFocus() } } } - val placeholder = when { - hasPendingItem -> "Article suivant…" - isSearchActive -> "p. ex. pain" - else -> "J'ai besoin…" - } + val placeholder = + when { + hasPendingItem -> "Article suivant…" + isSearchActive -> "p. ex. pain" + else -> "J'ai besoin…" + } Surface( color = MaterialTheme.colorScheme.surface, - tonalElevation = 4.dp + tonalElevation = 4.dp, ) { Row( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .imePadding() - .padding(horizontal = 12.dp, vertical = 10.dp), + modifier = + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding() + .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TextField( value = query, onValueChange = onQueryChange, placeholder = { Text(placeholder) }, singleLine = true, - modifier = Modifier - .weight(1f) - .focusRequester(focusRequester), + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester), shape = RoundedCornerShape(28.dp), trailingIcon = { if (query.isNotEmpty()) { @@ -973,13 +1001,14 @@ private fun BottomSearchBar( } } }, - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent - ) + colors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), ) if (isSearchActive) { TextButton(onClick = onCancel) { @@ -993,7 +1022,7 @@ private fun BottomSearchBar( }, containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(48.dp) + modifier = Modifier.size(48.dp), ) { Icon(Icons.Filled.Add, contentDescription = "Ajouter") } @@ -1009,29 +1038,30 @@ private fun BottomSearchBar( @Composable private fun SuggestionPanel( suggestions: List, - onPick: (ListDetailViewModel.Suggestion) -> Unit + onPick: (ListDetailViewModel.Suggestion) -> Unit, ) { val tealColor = Color(0xFF26A69A) Surface( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 340.dp), + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 340.dp), color = MaterialTheme.colorScheme.surface, tonalElevation = 6.dp, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), ) { LazyVerticalGrid( columns = GridCells.Fixed(3), modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(bottom = 8.dp) + contentPadding = PaddingValues(bottom = 8.dp), ) { items(suggestions) { suggestion -> SuggestionTile( suggestion = suggestion, tealColor = tealColor, - onPick = { onPick(suggestion) } + onPick = { onPick(suggestion) }, ) } } @@ -1042,42 +1072,46 @@ private fun SuggestionPanel( private fun SuggestionTile( suggestion: ListDetailViewModel.Suggestion, tealColor: Color, - onPick: () -> Unit + onPick: () -> Unit, ) { val isCreate = suggestion is ListDetailViewModel.Suggestion.Create val isActive = suggestion is ListDetailViewModel.Suggestion.Active - val containerColor = when { - isCreate -> MaterialTheme.colorScheme.primaryContainer - isActive -> MaterialTheme.colorScheme.tertiaryContainer - else -> tealColor.copy(alpha = 0.15f) - } - val contentColor = when { - isCreate -> MaterialTheme.colorScheme.onPrimaryContainer - isActive -> MaterialTheme.colorScheme.onTertiaryContainer - else -> tealColor - } + val containerColor = + when { + isCreate -> MaterialTheme.colorScheme.primaryContainer + isActive -> MaterialTheme.colorScheme.tertiaryContainer + else -> tealColor.copy(alpha = 0.15f) + } + val contentColor = + when { + isCreate -> MaterialTheme.colorScheme.onPrimaryContainer + isActive -> MaterialTheme.colorScheme.onTertiaryContainer + else -> tealColor + } Card( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clickable(onClick = onPick), + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clickable(onClick = onPick), shape = RoundedCornerShape(14.dp), - colors = CardDefaults.cardColors(containerColor = containerColor) + colors = CardDefaults.cardColors(containerColor = containerColor), ) { Box( - modifier = Modifier - .fillMaxSize() - .padding(6.dp), - contentAlignment = Alignment.Center + modifier = + Modifier + .fillMaxSize() + .padding(6.dp), + contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) { Text( text = suggestion.emoji, - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.titleLarge, ) Spacer(modifier = Modifier.height(4.dp)) Text( @@ -1087,7 +1121,7 @@ private fun SuggestionTile( color = contentColor, textAlign = TextAlign.Center, maxLines = 2, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) } if (isCreate) { @@ -1095,9 +1129,10 @@ private fun SuggestionTile( imageVector = Icons.Filled.AutoAwesome, contentDescription = null, tint = contentColor, - modifier = Modifier - .align(Alignment.TopEnd) - .size(16.dp) + modifier = + Modifier + .align(Alignment.TopEnd) + .size(16.dp), ) } } @@ -1113,59 +1148,83 @@ private fun ItemDetailPanel( pending: ListDetailViewModel.PendingItem, onQuantity: (Int?) -> Unit, onVariant: (String?) -> Unit, - onTag: (String?) -> Unit, - onNote: (String) -> Unit + onToggleTag: (String) -> Unit, + onNote: (String) -> Unit, ) { var showNoteField by rememberSaveable(pending.itemId) { mutableStateOf(pending.note.isNotEmpty()) } var noteText by rememberSaveable(pending.itemId) { mutableStateOf(pending.note) } - val selectedColor = MaterialTheme.colorScheme.error - val selectedContent = MaterialTheme.colorScheme.onError - val idleContainer = MaterialTheme.colorScheme.surfaceVariant - val idleContent = MaterialTheme.colorScheme.onSurfaceVariant - Surface( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 360.dp), - color = MaterialTheme.colorScheme.surface, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, tonalElevation = 8.dp, - shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp) + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), ) { Column( - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 12.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp) + modifier = + Modifier + .padding(horizontal = 20.dp, vertical = 12.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - text = "Détails de l'article pour ${pending.emoji} ${pending.name}", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + // Poignée de glissement + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 4.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .width(40.dp) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)), + ) + } + + // En-tête harmonisé + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = pending.emoji, style = MaterialTheme.typography.displaySmall) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = pending.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + } // Quantité 1-5 Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { (1..5).forEach { qty -> val selected = pending.selectedQuantity == qty + val selectedColor = MaterialTheme.colorScheme.primaryContainer + val selectedContent = MaterialTheme.colorScheme.onPrimaryContainer + val idleContainer = MaterialTheme.colorScheme.surfaceVariant + val idleContent = MaterialTheme.colorScheme.onSurfaceVariant Surface( - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .clickable { onQuantity(if (selected) null else qty) }, + modifier = + Modifier + .size(44.dp) + .clip(CircleShape) + .clickable { onQuantity(if (selected) null else qty) }, color = if (selected) selectedColor else idleContainer, - shape = CircleShape + shape = CircleShape, ) { Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { Text( text = qty.toString(), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, - color = if (selected) selectedContent else idleContent + color = if (selected) selectedContent else idleContent, ) } } @@ -1176,65 +1235,82 @@ private fun ItemDetailPanel( if (pending.variants.isNotEmpty()) { Row( modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { pending.variants.forEach { variant -> val selected = pending.selectedVariant == variant + val selectedColor = MaterialTheme.colorScheme.primaryContainer + val selectedContent = MaterialTheme.colorScheme.onPrimaryContainer + val idleContainer = MaterialTheme.colorScheme.surfaceVariant + val idleContent = MaterialTheme.colorScheme.onSurfaceVariant Surface( - modifier = Modifier - .clip(RoundedCornerShape(20.dp)) - .clickable { onVariant(if (selected) null else variant) }, + modifier = + Modifier + .clip(RoundedCornerShape(20.dp)) + .clickable { onVariant(if (selected) null else variant) }, color = if (selected) selectedColor else idleContainer, - shape = RoundedCornerShape(20.dp) + shape = RoundedCornerShape(20.dp), ) { Text( text = variant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Medium, - color = if (selected) selectedContent else idleContent + color = if (selected) selectedContent else idleContent, ) } } } } - // Tags priorité - val tags = listOf("⚡ Urgent", "🏷 Offre", "📅 Quand ça convient") + // Tags + Text( + text = "Détails de l'article", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 4.dp), + ) Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - tags.forEach { tag -> - val selected = pending.selectedTag == tag - Surface( - modifier = Modifier - .clip(RoundedCornerShape(20.dp)) - .clickable { onTag(if (selected) null else tag) }, - color = if (selected) selectedColor else idleContainer, - shape = RoundedCornerShape(20.dp) - ) { - Text( - text = tag, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Medium, - color = if (selected) selectedContent else idleContent - ) - } - } + DetailTagButton( + emoji = "🚨", + label = "Urgent", + selected = pending.selectedTags.contains("urgent"), + onClick = { onToggleTag("urgent") }, + modifier = Modifier.weight(1f), + ) + DetailTagButton( + emoji = "🏷️", + label = "Offre", + selected = pending.selectedTags.contains("offre"), + onClick = { onToggleTag("offre") }, + modifier = Modifier.weight(1f), + ) + DetailTagButton( + emoji = "🕒", + label = "Quand cela convient", + selected = pending.selectedTags.contains("whenever"), + onClick = { onToggleTag("whenever") }, + modifier = Modifier.weight(1f), + ) } // Note - val noteButtonLabel = if (pending.selectedQuantity != null || pending.selectedVariant != null || pending.note.isNotBlank()) - "📝 Changer mes détails" else "📝 Notez vos propres détails" + val noteButtonLabel = + if (pending.selectedQuantity != null || pending.selectedVariant != null || pending.note.isNotBlank()) { + "📝 Changer mes détails" + } else { + "📝 Notez vos propres détails" + } TextButton( onClick = { showNoteField = !showNoteField }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { Text( text = noteButtonLabel, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) } if (showNoteField) { @@ -1248,13 +1324,31 @@ private fun ItemDetailPanel( modifier = Modifier.fillMaxWidth(), minLines = 2, maxLines = 4, - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(12.dp), ) } } } } +private fun parseQuantityFromNote(note: String?): Int? { + note ?: return null + val match = Regex("""^(\d+)""").find(note.trim()) + return match?.groupValues?.get(1)?.toIntOrNull()?.takeIf { it in 1..5 } +} + +private fun rebuildNote( + quantity: Int?, + note: String, +): String { + val cleanNote = note.trim().replace(Regex("""^\d+\s*([,\-—]\s*)?"""), "").trim() + return if (quantity != null) { + if (cleanNote.isEmpty()) quantity.toString() else "$quantity — $cleanNote" + } else { + cleanNote + } +} + // ───────────────────────────────────────────────────────────────────────────── // Feuille de détail (long-press) // ───────────────────────────────────────────────────────────────────────────── @@ -1269,32 +1363,35 @@ private fun ItemDetailSheet( onUpdateNote: (String) -> Unit, onUpdateCategory: (String) -> Unit, onUpdateEmoji: (String?) -> Unit, - onUpdateTag: (String?) -> Unit, + onToggleTag: (String) -> Unit, onUpdateImage: (String?) -> Unit, onMoveTo: (Long) -> Unit, onDelete: () -> Unit, onOpenProduct: (() -> Unit)?, - onRequestCrop: (Bitmap) -> Unit + onRequestCrop: (Bitmap) -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val context = LocalContext.current var note by remember(item.id) { mutableStateOf(item.note.orEmpty()) } - var currentTag by remember(item.id) { mutableStateOf(item.tag) } + var currentTags by remember(item.id) { mutableStateOf(item.tags) } + var selectedQuantity by remember(item.id) { mutableStateOf(parseQuantityFromNote(item.note)) } var showCategoryPicker by remember { mutableStateOf(false) } var showIconPicker by remember { mutableStateOf(false) } var showMovePicker by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current - val photoPickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() - ) { uri: Uri? -> - uri?.let { - val bmp = context.contentResolver.openInputStream(it)?.use { stream -> - BitmapFactory.decodeStream(stream) + val photoPickerLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + ) { uri: Uri? -> + uri?.let { + val bmp = + context.contentResolver.openInputStream(it)?.use { stream -> + BitmapFactory.decodeStream(stream) + } + bmp?.let { bitmap -> onRequestCrop(bitmap) } } - bmp?.let { bitmap -> onRequestCrop(bitmap) } } - } ModalBottomSheet( onDismissRequest = { @@ -1302,14 +1399,15 @@ private fun ItemDetailSheet( if (note != (item.note.orEmpty())) onUpdateNote(note) onDismiss() }, - sheetState = sheetState + sheetState = sheetState, ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp) - .navigationBarsPadding(), - verticalArrangement = Arrangement.spacedBy(12.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { // En-tête Row(verticalAlignment = Alignment.CenterVertically) { @@ -1319,13 +1417,13 @@ private fun ItemDetailSheet( Text( text = item.productName, style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) if (!item.brand.isNullOrBlank()) { Text( text = item.brand, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -1337,26 +1435,66 @@ private fun ItemDetailSheet( } } + // Quantité 1-5 + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + (1..5).forEach { qty -> + val selected = selectedQuantity == qty + val selectedColor = MaterialTheme.colorScheme.primaryContainer + val selectedContent = MaterialTheme.colorScheme.onPrimaryContainer + val idleContainer = MaterialTheme.colorScheme.surfaceVariant + val idleContent = MaterialTheme.colorScheme.onSurfaceVariant + Surface( + modifier = + Modifier + .size(44.dp) + .clip(CircleShape) + .clickable { + val newQty = if (selected) null else qty + selectedQuantity = newQty + note = rebuildNote(newQty, note) + onUpdateNote(note) + }, + color = if (selected) selectedColor else idleContainer, + shape = CircleShape, + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Text( + text = qty.toString(), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = if (selected) selectedContent else idleContent, + ) + } + } + } + } + // Photo de l'article if (!item.imageUrl.isNullOrBlank()) { AsyncImage( model = item.imageUrl, contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .height(180.dp) - .clip(RoundedCornerShape(12.dp)) - .clickable { - val bmp = try { - val uri = Uri.parse(item.imageUrl) - when { - item.imageUrl.startsWith("file://") -> BitmapFactory.decodeFile(uri.path) - else -> context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) } - } - } catch (_: Exception) { null } - bmp?.let { onRequestCrop(it) } - }, - contentScale = ContentScale.Crop + modifier = + Modifier + .fillMaxWidth() + .height(180.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable { + val bmp = + try { + val uri = Uri.parse(item.imageUrl) + when { + item.imageUrl.startsWith("file://") -> BitmapFactory.decodeFile(uri.path) + else -> context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it) } + } + } catch (_: Exception) { + null + } + bmp?.let { onRequestCrop(it) } + }, + contentScale = ContentScale.Crop, ) } @@ -1367,7 +1505,7 @@ private fun ItemDetailSheet( label = { Text("Quantité, description…") }, modifier = Modifier.fillMaxWidth(), singleLine = false, - maxLines = 3 + maxLines = 3, ) // Détails de l'article @@ -1375,45 +1513,45 @@ private fun ItemDetailSheet( text = "Détails de l'article pour ${item.productName}", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(top = 8.dp) + modifier = Modifier.padding(top = 8.dp), ) - + Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { DetailTagButton( - icon = Icons.Filled.AutoAwesome, + emoji = "🚨", label = "Urgent", - selected = currentTag == "urgent", + selected = currentTags.contains("urgent"), onClick = { - val newTag = if (currentTag == "urgent") null else "urgent" - currentTag = newTag - onUpdateTag(newTag) + val newTags = if (currentTags.contains("urgent")) currentTags - "urgent" else currentTags + "urgent" + currentTags = newTags + onToggleTag("urgent") }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) DetailTagButton( - icon = Icons.Filled.Done, + emoji = "🏷️", label = "Offre", - selected = currentTag == "offre", + selected = currentTags.contains("offre"), onClick = { - val newTag = if (currentTag == "offre") null else "offre" - currentTag = newTag - onUpdateTag(newTag) + val newTags = if (currentTags.contains("offre")) currentTags - "offre" else currentTags + "offre" + currentTags = newTags + onToggleTag("offre") }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) DetailTagButton( - icon = Icons.Filled.History, + emoji = "🕒", label = "Quand cela convient", - selected = currentTag == "whenever", + selected = currentTags.contains("whenever"), onClick = { - val newTag = if (currentTag == "whenever") null else "whenever" - currentTag = newTag - onUpdateTag(newTag) + val newTags = if (currentTags.contains("whenever")) currentTags - "whenever" else currentTags + "whenever" + currentTags = newTags + onToggleTag("whenever") }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) } @@ -1422,43 +1560,43 @@ private fun ItemDetailSheet( text = "Paramètres", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(top = 8.dp) + modifier = Modifier.padding(top = 8.dp), ) - + Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { ParameterButton( icon = Icons.Filled.Done, label = "Changer une icône", onClick = { showIconPicker = true }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) ParameterButton( icon = Icons.Filled.Camera, label = "Ajouter une photo", onClick = { photoPickerLauncher.launch("image/*") }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) } - + Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { ParameterButton( icon = Icons.Filled.KeyboardArrowDown, label = "Changer une section", onClick = { showCategoryPicker = true }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) if (otherLists.isNotEmpty()) { ParameterButton( icon = Icons.Filled.SwapHoriz, label = "Déplacer l'article", onClick = { showMovePicker = true }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) } } @@ -1472,33 +1610,35 @@ private fun ItemDetailSheet( focusManager.clearFocus() onOpenProduct() }, - leadingIcon = Icons.Filled.Camera + leadingIcon = Icons.Filled.Camera, ) } // Avertissement allergène éventuel if (!item.allergenWarning.isNullOrBlank()) { Card( - colors = CardDefaults.cardColors( - containerColor = LocalStatusColors.current.warningContainer - ) + colors = + CardDefaults.cardColors( + containerColor = LocalStatusColors.current.warningContainer, + ), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.Filled.Warning, contentDescription = null, - tint = LocalStatusColors.current.warning + tint = LocalStatusColors.current.warning, ) Spacer(modifier = Modifier.width(8.dp)) Text( text = item.allergenWarning, style = MaterialTheme.typography.bodyMedium, - color = LocalStatusColors.current.onWarningContainer + color = LocalStatusColors.current.onWarningContainer, ) } } @@ -1507,18 +1647,18 @@ private fun ItemDetailSheet( // Suppression définitive TextButton( onClick = onDelete, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { Icon( imageVector = Icons.Filled.Delete, contentDescription = null, - tint = MaterialTheme.colorScheme.error + tint = MaterialTheme.colorScheme.error, ) Spacer(modifier = Modifier.width(8.dp)) Text( text = "Supprimer l'article", color = MaterialTheme.colorScheme.error, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ) } } @@ -1527,38 +1667,40 @@ private fun ItemDetailSheet( // Sélecteur de catégorie if (showCategoryPicker) { ModalBottomSheet( - onDismissRequest = { showCategoryPicker = false } + onDismissRequest = { showCategoryPicker = false }, ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .navigationBarsPadding() + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp) + .navigationBarsPadding(), ) { Text( text = "Choisir une section", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier.padding(bottom = 8.dp), ) categories.forEach { cat -> Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable { - onUpdateCategory(cat) - showCategoryPicker = false - } - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + onUpdateCategory(cat) + showCategoryPicker = false + } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { Text(text = cat, modifier = Modifier.weight(1f)) if (cat == item.category) { Icon( imageVector = Icons.Filled.Done, contentDescription = null, - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) } } @@ -1570,36 +1712,38 @@ private fun ItemDetailSheet( // Sélecteur de liste cible if (showMovePicker) { ModalBottomSheet( - onDismissRequest = { showMovePicker = false } + onDismissRequest = { showMovePicker = false }, ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .navigationBarsPadding() + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp) + .navigationBarsPadding(), ) { Text( text = "Déplacer vers…", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier.padding(bottom = 8.dp), ) otherLists.forEach { list -> Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable { - onMoveTo(list.id) - showMovePicker = false - } - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + onMoveTo(list.id) + showMovePicker = false + } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.Filled.SwapHoriz, contentDescription = null, - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.width(12.dp)) Text(text = list.name) @@ -1618,7 +1762,7 @@ private fun ItemDetailSheet( onSelectIcon = { emoji -> onUpdateEmoji(emoji.ifEmpty { null }) showIconPicker = false - } + }, ) } } @@ -1628,22 +1772,23 @@ private fun ActionRow( title: String, value: String?, onClick: () -> Unit, - leadingIcon: androidx.compose.ui.graphics.vector.ImageVector? = null + leadingIcon: androidx.compose.ui.graphics.vector.ImageVector? = null, ) { Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onClick) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, ) { if (leadingIcon != null) { Icon( imageVector = leadingIcon, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.width(12.dp)) } @@ -1651,63 +1796,76 @@ private fun ActionRow( Text( text = title, style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium + fontWeight = FontWeight.Medium, ) if (!value.isNullOrBlank()) { Text( text = value, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } Icon( imageVector = Icons.Filled.KeyboardArrowRight, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @Composable private fun DetailTagButton( - icon: androidx.compose.ui.graphics.vector.ImageVector, + icon: androidx.compose.ui.graphics.vector.ImageVector? = null, + emoji: String? = null, label: String, selected: Boolean, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { - val backgroundColor = if (selected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) - } - val contentColor = if (selected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - + val backgroundColor = + if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) + } + val contentColor = + if (selected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Card( - modifier = modifier - .heightIn(min = 56.dp) - .clickable(onClick = onClick), + modifier = + modifier + .heightIn(min = 56.dp) + .clickable(onClick = onClick), shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = backgroundColor) + colors = CardDefaults.cardColors(containerColor = backgroundColor), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = contentColor, - modifier = Modifier.size(20.dp) - ) + if (emoji != null) { + Text( + text = emoji, + style = MaterialTheme.typography.titleMedium, + color = contentColor, + ) + } else if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(20.dp), + ) + } Spacer(modifier = Modifier.height(4.dp)) Text( text = label, @@ -1715,7 +1873,7 @@ private fun DetailTagButton( color = contentColor, textAlign = TextAlign.Center, maxLines = 2, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) } } @@ -1726,29 +1884,32 @@ private fun ParameterButton( icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Card( - modifier = modifier - .heightIn(min = 80.dp) - .clickable(onClick = onClick), + modifier = + modifier + .heightIn(min = 80.dp) + .clickable(onClick = onClick), shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f) - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + ), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Icon( imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(28.dp) + modifier = Modifier.size(28.dp), ) Spacer(modifier = Modifier.height(6.dp)) Text( @@ -1757,7 +1918,7 @@ private fun ParameterButton( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, maxLines = 2, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) } } @@ -1768,38 +1929,39 @@ private fun ParameterButton( private fun PhotoSourceBottomSheet( onTakePhoto: () -> Unit, onPickGallery: () -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, ) { ModalBottomSheet( onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), ) { Text( text = stringResource(R.string.list_add_photo), style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(20.dp)) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { ParameterButton( icon = Icons.Filled.Camera, label = stringResource(R.string.list_take_photo), onClick = onTakePhoto, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) ParameterButton( icon = Icons.Filled.Add, label = stringResource(R.string.list_pick_gallery), onClick = onPickGallery, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) } Spacer(Modifier.height(16.dp)) @@ -1815,22 +1977,23 @@ private fun AddPhotoItemDialog( description: String, onDescriptionChange: (String) -> Unit, onConfirm: () -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, ) { ModalBottomSheet( onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp) - .navigationBarsPadding() + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp) + .navigationBarsPadding(), ) { Text( text = stringResource(R.string.list_add_photo), style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(16.dp)) OutlinedTextField( @@ -1838,7 +2001,7 @@ private fun AddPhotoItemDialog( onValueChange = onNameChange, label = { Text(stringResource(R.string.list_item_name)) }, singleLine = true, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(12.dp)) OutlinedTextField( @@ -1847,16 +2010,16 @@ private fun AddPhotoItemDialog( label = { Text(stringResource(R.string.list_item_description)) }, singleLine = false, maxLines = 3, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(20.dp)) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { TextButton( onClick = onDismiss, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { Text(stringResource(R.string.action_cancel)) } @@ -1864,7 +2027,7 @@ private fun AddPhotoItemDialog( text = stringResource(R.string.list_add_item_confirm), onClick = onConfirm, modifier = Modifier.weight(1f), - enabled = name.isNotBlank() + enabled = name.isNotBlank(), ) } Spacer(Modifier.height(8.dp)) diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt index 0f41a17..054675b 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt @@ -2,9 +2,9 @@ package com.safebite.app.presentation.screen.lists import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.safebite.app.data.local.database.entity.CatalogItemEntity import com.safebite.app.data.local.database.entity.ShoppingListEntity import com.safebite.app.data.local.database.entity.ShoppingListItemEntity -import com.safebite.app.data.local.database.entity.CatalogItemEntity import com.safebite.app.data.local.database.relation.DomainWithCategoriesAndItems import com.safebite.app.data.repository.CatalogRepository import com.safebite.app.domain.engine.CatalogProvider @@ -40,602 +40,672 @@ import javax.inject.Inject * aucune correspondance, on peut créer un *own item*. */ @HiltViewModel -class ListDetailViewModel @Inject constructor( - private val manageListUseCase: ManageShoppingListUseCase, - private val getListsUseCase: GetShoppingListsUseCase, - private val categoryEngine: CategoryEngine, - private val catalogRepository: CatalogRepository, - val catalog: CatalogProvider -) : ViewModel() { +class ListDetailViewModel + @Inject + constructor( + private val manageListUseCase: ManageShoppingListUseCase, + private val getListsUseCase: GetShoppingListsUseCase, + private val categoryEngine: CategoryEngine, + private val catalogRepository: CatalogRepository, + val catalog: CatalogProvider, + ) : ViewModel() { + sealed class UiState { + data object Loading : UiState() - sealed class UiState { - data object Loading : UiState() - data class Ready( - val listId: Long, - val listName: String, - val list: ShoppingListEntity?, - val activeItems: List, - val recentlyUsed: List - ) : UiState() - data class Error(val message: String) : UiState() - } + data class Ready( + val listId: Long, + val listName: String, + val list: ShoppingListEntity?, + val activeItems: List, + val recentlyUsed: List, + ) : UiState() - data class ShoppingListItemUi( - val id: Long, - val barcode: String?, - val productName: String, - val brand: String?, - val imageUrl: String?, - val isChecked: Boolean, - val category: String?, - val safetyStatus: String?, - val allergenWarning: String?, - val note: String?, - val emoji: String, - val tag: String? - ) - - /** - * Article sélectionné en cours de paramétrage (quantité, variante, tag, note). - * L'item a déjà été ajouté à la liste — ce state permet d'en modifier les détails - * depuis le panneau inline. - */ - data class PendingItem( - val itemId: Long, - val name: String, - val emoji: String, - val variants: List, - val selectedQuantity: Int? = null, - val selectedVariant: String? = null, - val selectedTag: String? = null, - val note: String = "" - ) - - /** - * Suggestion affichée dans le panneau au-dessus de la barre de recherche. - * Peut être un article existant (catalogue / recently used / actif) ou la - * proposition de création d'un nouvel article. - */ - sealed class Suggestion { - abstract val label: String - abstract val emoji: String - - data class Catalog(val item: CatalogProvider.CatalogItem) : Suggestion() { - override val label: String = item.name - override val emoji: String = item.emoji + data class Error(val message: String) : UiState() } - data class RoomCatalog(val item: CatalogItemEntity, val categoryName: String?) : Suggestion() { - override val label: String = item.name - override val emoji: String = item.emoji + + data class ShoppingListItemUi( + val id: Long, + val barcode: String?, + val productName: String, + val brand: String?, + val imageUrl: String?, + val isChecked: Boolean, + val category: String?, + val safetyStatus: String?, + val allergenWarning: String?, + val note: String?, + val emoji: String, + val tags: Set = emptySet(), + ) + + /** + * Article sélectionné en cours de paramétrage (quantité, variante, tag, note). + * L'item a déjà été ajouté à la liste — ce state permet d'en modifier les détails + * depuis le panneau inline. + */ + data class PendingItem( + val itemId: Long, + val name: String, + val emoji: String, + val variants: List, + val selectedQuantity: Int? = null, + val selectedVariant: String? = null, + val selectedTags: Set = emptySet(), + val note: String = "", + ) + + /** + * Suggestion affichée dans le panneau au-dessus de la barre de recherche. + * Peut être un article existant (catalogue / recently used / actif) ou la + * proposition de création d'un nouvel article. + */ + sealed class Suggestion { + abstract val label: String + abstract val emoji: String + + data class Catalog(val item: CatalogProvider.CatalogItem) : Suggestion() { + override val label: String = item.name + override val emoji: String = item.emoji + } + + data class RoomCatalog(val item: CatalogItemEntity, val categoryName: String?) : Suggestion() { + override val label: String = item.name + override val emoji: String = item.emoji + } + + data class Recent(val item: ShoppingListItemUi) : Suggestion() { + override val label: String = item.productName + override val emoji: String = item.emoji + } + + data class Active(val item: ShoppingListItemUi) : Suggestion() { + override val label: String = item.productName + override val emoji: String = item.emoji + } + + data class Create(val rawText: String) : Suggestion() { + override val label: String = rawText + override val emoji: String = "✨" + } } - data class Recent(val item: ShoppingListItemUi) : Suggestion() { - override val label: String = item.productName - override val emoji: String = item.emoji + + private val _listIdFlow = MutableStateFlow(0L) + private val _listName = MutableStateFlow("") + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery + + /** Article actuellement ouvert dans la feuille de détail (long-press). */ + private val _selectedItemId = MutableStateFlow(null) + val selectedItemId: StateFlow = _selectedItemId + + /** Article en cours de paramétrage dans le panneau inline. */ + private val _pendingItem = MutableStateFlow(null) + val pendingItem: StateFlow = _pendingItem + + /** Vrai si la barre de recherche est active (clavier ouvert). */ + private val _isSearchActive = MutableStateFlow(false) + val isSearchActive: StateFlow = _isSearchActive + + /** Listes disponibles pour l'action "Déplacer l'article". */ + val otherLists: StateFlow> = + getListsUseCase + .observeActive() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + /** Catalogue Room hiérarchique (domaines → catégories → articles). */ + val catalogDomains: StateFlow> = + catalogRepository + .observeDomainsWithCategoriesAndItems() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun initList( + listId: Long, + listName: String, + ) { + _listIdFlow.value = listId + _listName.value = listName } - data class Active(val item: ShoppingListItemUi) : Suggestion() { - override val label: String = item.productName - override val emoji: String = item.emoji - } - data class Create(val rawText: String) : Suggestion() { - override val label: String = rawText - override val emoji: String = "✨" - } - } - private val _listIdFlow = MutableStateFlow(0L) - private val _listName = MutableStateFlow("") - - private val _searchQuery = MutableStateFlow("") - val searchQuery: StateFlow = _searchQuery - - /** Article actuellement ouvert dans la feuille de détail (long-press). */ - private val _selectedItemId = MutableStateFlow(null) - val selectedItemId: StateFlow = _selectedItemId - - /** Article en cours de paramétrage dans le panneau inline. */ - private val _pendingItem = MutableStateFlow(null) - val pendingItem: StateFlow = _pendingItem - - /** Vrai si la barre de recherche est active (clavier ouvert). */ - private val _isSearchActive = MutableStateFlow(false) - val isSearchActive: StateFlow = _isSearchActive - - /** Listes disponibles pour l'action "Déplacer l'article". */ - val otherLists: StateFlow> = getListsUseCase - .observeActive() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - /** Catalogue Room hiérarchique (domaines → catégories → articles). */ - val catalogDomains: StateFlow> = catalogRepository - .observeDomainsWithCategoriesAndItems() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - fun initList(listId: Long, listName: String) { - _listIdFlow.value = listId - _listName.value = listName - } - - @OptIn(ExperimentalCoroutinesApi::class) - val state: StateFlow = _listIdFlow.flatMapLatest { listId -> - manageListUseCase.observeItems(listId).map { items -> - val list = getListsUseCase.getList(listId) - val ui = items.map { it.toUi() } - UiState.Ready( - listId = listId, - listName = list?.name ?: _listName.value, - list = list, - activeItems = ui.filterNot { it.isChecked } - .sortedBy { it.productName.lowercase() }, - recentlyUsed = ui.filter { it.isChecked } - // Most-recently bought first (proxy: addedAt ordering preserved by DAO) - .take(MAX_RECENTLY_USED) + @OptIn(ExperimentalCoroutinesApi::class) + val state: StateFlow = + _listIdFlow.flatMapLatest { listId -> + manageListUseCase.observeItems(listId).map { items -> + val list = getListsUseCase.getList(listId) + val ui = items.map { it.toUi() } + UiState.Ready( + listId = listId, + listName = list?.name ?: _listName.value, + list = list, + activeItems = + ui.filterNot { it.isChecked } + .sortedBy { it.productName.lowercase() }, + recentlyUsed = + ui.filter { it.isChecked } + // Most-recently bought first (proxy: addedAt ordering preserved by DAO) + .take(MAX_RECENTLY_USED), + ) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = UiState.Loading, ) + + /** Liste filtrée des suggestions affichées au-dessus de la barre de saisie. */ + val suggestions: StateFlow> = + combine( + _searchQuery, + state, + ) { rawQuery, uiState -> rawQuery to uiState } + .flatMapLatest { (rawQuery, uiState) -> + val query = rawQuery.trim() + if (query.isEmpty() || uiState !is UiState.Ready) { + return@flatMapLatest flowOf(emptyList()) + } + + val ready = uiState + val staticResults = mutableListOf() + + // 1) Articles déjà sur la liste active (priorité haute pour rappel) + ready.activeItems + .filter { it.productName.contains(query, ignoreCase = true) } + .take(2) + .forEach { staticResults.add(Suggestion.Active(it)) } + + // 2) Recently used → restauration rapide + ready.recentlyUsed + .filter { it.productName.contains(query, ignoreCase = true) } + .take(3) + .forEach { staticResults.add(Suggestion.Recent(it)) } + + // 3) Catalogue statique (fallback si absent de la DB) + val staticCatalogItems = + catalog.search(query, limit = 20) + .filter { item -> staticResults.none { it.label.equals(item.name, ignoreCase = true) } } + + val categoryMap = + catalogDomains.value.flatMap { it.categoriesWithItems } + .associate { it.category.categoryId to it.category.name } + + // 3bis) Catalogue Room (tous domaines/catégories + items futurs) + catalogRepository.search(query, limit = 20).map { dbItems -> + val results = staticResults.toMutableList() + + dbItems.forEach { item -> + if (results.none { it.label.equals(item.name, ignoreCase = true) }) { + val catName = item.primaryCategoryId?.let { categoryMap[it] } ?: "Catalogue" + results.add(Suggestion.RoomCatalog(item, catName)) + } + } + + staticCatalogItems.forEach { item -> + if (results.none { it.label.equals(item.name, ignoreCase = true) }) { + results.add(Suggestion.Catalog(item)) + } + } + + // 4) Création d'un own item si aucune correspondance exacte + val exact = results.any { it.label.equals(query, ignoreCase = true) } + if (!exact) { + results.add(0, Suggestion.Create(query)) + } + results + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + // ── Actions ───────────────────────────────────────────────────────────── + + /** Met à jour le texte saisi dans la barre "J'ai besoin…". */ + fun updateSearchQuery(query: String) { + _searchQuery.value = query } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = UiState.Loading - ) - /** Liste filtrée des suggestions affichées au-dessus de la barre de saisie. */ - val suggestions: StateFlow> = combine( - _searchQuery, - state - ) { rawQuery, uiState -> rawQuery to uiState } - .flatMapLatest { (rawQuery, uiState) -> - val query = rawQuery.trim() - if (query.isEmpty() || uiState !is UiState.Ready) { - return@flatMapLatest flowOf(emptyList()) - } - - val ready = uiState - val staticResults = mutableListOf() - - // 1) Articles déjà sur la liste active (priorité haute pour rappel) - ready.activeItems - .filter { it.productName.contains(query, ignoreCase = true) } - .take(2) - .forEach { staticResults.add(Suggestion.Active(it)) } - - // 2) Recently used → restauration rapide - ready.recentlyUsed - .filter { it.productName.contains(query, ignoreCase = true) } - .take(3) - .forEach { staticResults.add(Suggestion.Recent(it)) } - - // 3) Catalogue statique (fallback si absent de la DB) - val staticCatalogItems = catalog.search(query, limit = 20) - .filter { item -> staticResults.none { it.label.equals(item.name, ignoreCase = true) } } - - val categoryMap = catalogDomains.value.flatMap { it.categoriesWithItems } - .associate { it.category.categoryId to it.category.name } - - // 3bis) Catalogue Room (tous domaines/catégories + items futurs) - catalogRepository.search(query, limit = 20).map { dbItems -> - val results = staticResults.toMutableList() - - dbItems.forEach { item -> - if (results.none { it.label.equals(item.name, ignoreCase = true) }) { - val catName = item.primaryCategoryId?.let { categoryMap[it] } ?: "Catalogue" - results.add(Suggestion.RoomCatalog(item, catName)) - } - } - - staticCatalogItems.forEach { item -> - if (results.none { it.label.equals(item.name, ignoreCase = true) }) { - results.add(Suggestion.Catalog(item)) - } - } - - // 4) Création d'un own item si aucune correspondance exacte - val exact = results.any { it.label.equals(query, ignoreCase = true) } - if (!exact) { - results.add(0, Suggestion.Create(query)) - } - results - } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - // ── Actions ───────────────────────────────────────────────────────────── - - /** Met à jour le texte saisi dans la barre "J'ai besoin…". */ - fun updateSearchQuery(query: String) { - _searchQuery.value = query - } - - fun clearSearch() { - _searchQuery.value = "" - } - - /** Active le mode recherche (clavier visible). */ - fun activateSearch() { - _isSearchActive.value = true - } - - /** Annule la recherche : ferme le clavier, vide la saisie et le panneau de détail. */ - fun cancelSearch() { - _isSearchActive.value = false - _searchQuery.value = "" - _pendingItem.value = null - } - - /** Ferme le panneau de détail inline sans fermer la recherche. */ - fun closePendingItem() { - _pendingItem.value = null - } - - /** - * Tap sur une suggestion : - * 1. Ajoute immédiatement l’article à la liste active. - * 2. Ouvre le panneau de détail inline (PendingItem). - * 3. Vide la saisie (mais garde le mode recherche actif). - */ - fun applySuggestion(suggestion: Suggestion) { - viewModelScope.launch { - val itemId = when (suggestion) { - is Suggestion.Catalog -> addCatalogItemAndGetId(suggestion.item) - is Suggestion.RoomCatalog -> addCatalogItemAndGetId(suggestion.item) - is Suggestion.Recent -> { - manageListUseCase.setItemChecked(suggestion.item.id, false) - suggestion.item.id - } - is Suggestion.Active -> suggestion.item.id - is Suggestion.Create -> addCustomItemAndGetId(suggestion.rawText) - } - val variants = when (suggestion) { - is Suggestion.RoomCatalog -> - suggestion.item.variants.split(",") - .map { it.trim() }.filter { it.isNotEmpty() } - is Suggestion.Catalog -> suggestion.item.variants - is Suggestion.Recent -> emptyList() - is Suggestion.Active -> emptyList() - is Suggestion.Create -> emptyList() - } - val existingItem = manageListUseCase.getItems(_listIdFlow.value) - .firstOrNull { it.id == itemId } - _pendingItem.value = PendingItem( - itemId = itemId, - name = suggestion.label, - emoji = suggestion.emoji, - variants = variants, - selectedTag = existingItem?.tag, - note = existingItem?.note.orEmpty() - ) + fun clearSearch() { _searchQuery.value = "" } - } - /** Met à jour la quantité sélectionnée et sauvegarde en DB. */ - fun updatePendingQuantity(qty: Int?) { - val pending = _pendingItem.value ?: return - val updated = pending.copy(selectedQuantity = qty) - _pendingItem.value = updated - savePendingNote(updated) - } + /** Active le mode recherche (clavier visible). */ + fun activateSearch() { + _isSearchActive.value = true + } - /** Met à jour la variante sélectionnée et sauvegarde en DB. */ - fun updatePendingVariant(variant: String?) { - val pending = _pendingItem.value ?: return - val updated = pending.copy(selectedVariant = variant) - _pendingItem.value = updated - savePendingNote(updated) - } + /** Annule la recherche : ferme le clavier, vide la saisie et le panneau de détail. */ + fun cancelSearch() { + _isSearchActive.value = false + _searchQuery.value = "" + _pendingItem.value = null + } - /** Met à jour le tag priorité et sauvegarde en DB. */ - fun updatePendingTag(tag: String?) { - val pending = _pendingItem.value ?: return - _pendingItem.value = pending.copy(selectedTag = tag) - viewModelScope.launch { + /** Ferme le panneau de détail inline sans fermer la recherche. */ + fun closePendingItem() { + _pendingItem.value = null + } + + /** + * Tap sur une suggestion : + * 1. Ajoute immédiatement l’article à la liste active. + * 2. Ouvre le panneau de détail inline (PendingItem). + * 3. Vide la saisie (mais garde le mode recherche actif). + */ + fun applySuggestion(suggestion: Suggestion) { + viewModelScope.launch { + val itemId = + when (suggestion) { + is Suggestion.Catalog -> addCatalogItemAndGetId(suggestion.item) + is Suggestion.RoomCatalog -> addCatalogItemAndGetId(suggestion.item) + is Suggestion.Recent -> { + manageListUseCase.setItemChecked(suggestion.item.id, false) + suggestion.item.id + } + is Suggestion.Active -> suggestion.item.id + is Suggestion.Create -> addCustomItemAndGetId(suggestion.rawText) + } + val variants = + when (suggestion) { + is Suggestion.RoomCatalog -> + suggestion.item.variants.split(",") + .map { it.trim() }.filter { it.isNotEmpty() } + is Suggestion.Catalog -> suggestion.item.variants + is Suggestion.Recent -> emptyList() + is Suggestion.Active -> emptyList() + is Suggestion.Create -> emptyList() + } + val existingItem = + manageListUseCase.getItems(_listIdFlow.value) + .firstOrNull { it.id == itemId } + _pendingItem.value = + PendingItem( + itemId = itemId, + name = suggestion.label, + emoji = suggestion.emoji, + variants = variants, + selectedTags = existingItem?.parseTags() ?: emptySet(), + note = existingItem?.note.orEmpty(), + ) + _searchQuery.value = "" + } + } + + /** Met à jour la quantité sélectionnée et sauvegarde en DB. */ + fun updatePendingQuantity(qty: Int?) { + val pending = _pendingItem.value ?: return + val updated = pending.copy(selectedQuantity = qty) + _pendingItem.value = updated + savePendingNote(updated) + } + + /** Met à jour la variante sélectionnée et sauvegarde en DB. */ + fun updatePendingVariant(variant: String?) { + val pending = _pendingItem.value ?: return + val updated = pending.copy(selectedVariant = variant) + _pendingItem.value = updated + savePendingNote(updated) + } + + /** Active/désactive un tag dans le panneau pending et sauvegarde en DB. */ + fun togglePendingTag(tag: String) { + val pending = _pendingItem.value ?: return + val newTags = + if (pending.selectedTags.contains(tag)) { + pending.selectedTags - tag + } else { + pending.selectedTags + tag + } + _pendingItem.value = pending.copy(selectedTags = newTags) + viewModelScope.launch { + val listId = _listIdFlow.value + val item = + manageListUseCase.getItems(listId) + .firstOrNull { it.id == pending.itemId } ?: return@launch + manageListUseCase.updateItem(item.copy(tag = newTags.serializeTags())) + } + } + + /** Met à jour la note libre et sauvegarde en DB. */ + fun updatePendingNote(note: String) { + val pending = _pendingItem.value ?: return + val updated = pending.copy(note = note) + _pendingItem.value = updated + savePendingNote(updated) + } + + private fun savePendingNote(pending: PendingItem) { + viewModelScope.launch { + val listId = _listIdFlow.value + val item = + manageListUseCase.getItems(listId) + .firstOrNull { it.id == pending.itemId } ?: return@launch + manageListUseCase.updateItem(item.copy(note = buildNote(pending))) + } + } + + private fun buildNote(pending: PendingItem): String? { + val parts = mutableListOf() + pending.selectedQuantity?.let { parts.add(it.toString()) } + pending.selectedVariant?.let { parts.add(it) } + val structured = parts.joinToString(", ").ifEmpty { null } + val userNote = pending.note.trim().ifEmpty { null } + return listOfNotNull(structured, userNote).joinToString(" — ").ifEmpty { null } + } + + private suspend fun addCatalogItemAndGetId(catalogItem: CatalogProvider.CatalogItem): Long { val listId = _listIdFlow.value - val item = manageListUseCase.getItems(listId) - .firstOrNull { it.id == pending.itemId } ?: return@launch - manageListUseCase.updateItem(item.copy(tag = tag)) - } - } - - /** Met à jour la note libre et sauvegarde en DB. */ - fun updatePendingNote(note: String) { - val pending = _pendingItem.value ?: return - val updated = pending.copy(note = note) - _pendingItem.value = updated - savePendingNote(updated) - } - - private fun savePendingNote(pending: PendingItem) { - viewModelScope.launch { - val listId = _listIdFlow.value - val item = manageListUseCase.getItems(listId) - .firstOrNull { it.id == pending.itemId } ?: return@launch - manageListUseCase.updateItem(item.copy(note = buildNote(pending))) - } - } - - private fun buildNote(pending: PendingItem): String? { - val parts = mutableListOf() - pending.selectedQuantity?.let { parts.add(it.toString()) } - pending.selectedVariant?.let { parts.add(it) } - val structured = parts.joinToString(", ").ifEmpty { null } - val userNote = pending.note.trim().ifEmpty { null } - return listOfNotNull(structured, userNote).joinToString(" — ").ifEmpty { null } - } - - private suspend fun addCatalogItemAndGetId(catalogItem: CatalogProvider.CatalogItem): Long { - val listId = _listIdFlow.value - val existing = manageListUseCase.getItems(listId) - .firstOrNull { it.productName.equals(catalogItem.name, ignoreCase = true) } - if (existing != null) { - if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false) - return existing.id - } - return manageListUseCase.addItem( - ShoppingListItemEntity( - listId = listId, - productName = catalogItem.name, - category = catalogItem.category - ) - ) - } - - private suspend fun addCatalogItemAndGetId(item: CatalogItemEntity): Long { - val listId = _listIdFlow.value - 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 existing.id - } - val categoryName = item.primaryCategoryId?.let { catId -> - catalogRepository.getCategory(catId)?.name - } ?: categoryEngine.detectCategory(item.name) - return manageListUseCase.addItem( - ShoppingListItemEntity( - listId = listId, - productName = item.name, - category = categoryName, - customEmoji = item.emoji - ) - ) - } - - private suspend fun addCustomItemAndGetId(rawText: String): Long { - val trimmed = rawText.trim().ifEmpty { return -1L } - val (quantity, name) = parseQuantityAndName(trimmed) - val listId = _listIdFlow.value - val existing = manageListUseCase.getItems(listId) - .firstOrNull { it.productName.equals(name, ignoreCase = true) } - if (existing != null) { - if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false) - return existing.id - } - val category = categoryEngine.detectCategory(name) - return manageListUseCase.addItem( - ShoppingListItemEntity( - listId = listId, - productName = name, - category = category, - note = quantity - ) - ) - } - - /** Crée un *own item* à partir de la saisie (appel direct depuis BottomSearchBar). */ - fun addCustomItem(rawText: String) { - viewModelScope.launch { addCustomItemAndGetId(rawText) } - } - - /** - * Tap sur une tuile du catalogue Room (section principale). - * Ajoute l'article et ouvre le panneau de détail inline. - */ - fun addCatalogItem(item: CatalogItemEntity) { - viewModelScope.launch { - val itemId = addCatalogItemAndGetId(item) - val variants = item.variants.split(",") - .map { it.trim() }.filter { it.isNotEmpty() } - val existingItem = manageListUseCase.getItems(_listIdFlow.value) - .firstOrNull { it.id == itemId } - _pendingItem.value = PendingItem( - itemId = itemId, - name = item.name, - emoji = item.emoji, - variants = variants, - selectedTag = existingItem?.tag, - note = existingItem?.note.orEmpty() - ) - } - } - - /** Crée un item avec photo et description. */ - fun addCustomItemWithImage(name: String, note: String?, imageUri: String?) { - val trimmedName = name.trim() - if (trimmedName.isEmpty()) return - viewModelScope.launch { - val listId = _listIdFlow.value - val category = categoryEngine.detectCategory(trimmedName) - manageListUseCase.addItemToList( - listId, + val existing = + manageListUseCase.getItems(listId) + .firstOrNull { it.productName.equals(catalogItem.name, ignoreCase = true) } + if (existing != null) { + if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false) + return existing.id + } + return manageListUseCase.addItem( ShoppingListItemEntity( listId = listId, - productName = trimmedName, - category = category, - note = note?.trim()?.ifEmpty { null }, - imageUrl = imageUri, - isChecked = false - ) + productName = catalogItem.name, + category = catalogItem.category, + ), ) } - } - /** - * Tap sur un article actif → marque comme acheté (déplace dans Recently Used). - */ - fun markAsBought(id: Long) { - viewModelScope.launch { - manageListUseCase.setItemChecked(id, true) - } - } - - /** - * Tap sur un article dans Recently Used → restaure dans la liste active. - */ - fun restoreItem(id: Long) { - viewModelScope.launch { - manageListUseCase.setItemChecked(id, false) - } - } - - /** - * Suppression permanente d'un article (depuis la feuille de détail ou l'appui - * long sur Recently Used). - */ - fun deleteItem(id: Long) { - viewModelScope.launch { + private suspend fun addCatalogItemAndGetId(item: CatalogItemEntity): Long { val listId = _listIdFlow.value - val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch - manageListUseCase.deleteItem(item) - if (_selectedItemId.value == id) _selectedItemId.value = null - } - } - - /** Vide entièrement la section Recently Used (catégorie supprimée définitivement). */ - fun clearRecentlyUsed() { - viewModelScope.launch { - val listId = _listIdFlow.value - manageListUseCase.getItems(listId) - .filter { it.isChecked } - .forEach { manageListUseCase.deleteItem(it) } - } - } - - fun openItemDetails(id: Long) { - _selectedItemId.value = id - } - - fun closeItemDetails() { - _selectedItemId.value = null - } - - /** Met à jour la note (quantité / description) d'un article. */ - fun updateItemNote(id: Long, note: String) { - viewModelScope.launch { - val listId = _listIdFlow.value - val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch - manageListUseCase.updateItem(item.copy(note = note.trim().ifEmpty { null })) - } - } - - /** Change la catégorie/section d'un article. */ - fun updateItemCategory(id: Long, category: String) { - viewModelScope.launch { - val listId = _listIdFlow.value - val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch - manageListUseCase.updateItem(item.copy(category = category)) - } - } - - /** Change le tag visuel d'un article. */ - fun updateItemTag(id: Long, tag: String?) { - viewModelScope.launch { - val listId = _listIdFlow.value - val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch - manageListUseCase.updateItem(item.copy(tag = tag)) - } - } - - /** Change l'image (URL/URI) d'un article. */ - fun updateItemImageUrl(id: Long, imageUrl: String?) { - viewModelScope.launch { - val listId = _listIdFlow.value - val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch - manageListUseCase.updateItem(item.copy(imageUrl = imageUrl)) - } - } - - /** Change l'emoji personnalisé d'un article. */ - fun updateItemEmoji(id: Long, emoji: String?) { - viewModelScope.launch { - val listId = _listIdFlow.value - val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch - manageListUseCase.updateItem(item.copy(customEmoji = emoji)) - } - } - - /** Déplace un article vers une autre liste. */ - fun moveItemToList(id: Long, targetListId: Long) { - viewModelScope.launch { - val sourceListId = _listIdFlow.value - if (targetListId == sourceListId) return@launch - val item = manageListUseCase.getItems(sourceListId).firstOrNull { it.id == id } ?: return@launch - manageListUseCase.deleteItem(item) - manageListUseCase.addItemToList( - targetListId, - item.copy(id = 0L, listId = targetListId, isChecked = false) - ) - if (_selectedItemId.value == id) _selectedItemId.value = null - } - } - - /** Décoche tous les articles (les réactive depuis Recently Used). */ - fun uncheckAllItems() { - viewModelScope.launch { - manageListUseCase.uncheckAllItems(_listIdFlow.value) - } - } - - fun shareList(listName: String, items: List): String { - val active = items.filterNot { it.isChecked } - val sb = StringBuilder() - sb.appendLine("📋 $listName") - sb.appendLine("━━━━━━━━━━━━━━━━━━━━") - sb.appendLine("${active.size} articles à acheter") - sb.appendLine() - val byCategory = active.groupBy { it.category ?: "Autre" } - byCategory.forEach { (category, categoryItems) -> - sb.appendLine("📂 $category") - categoryItems.forEach { item -> - val note = item.note?.let { " — $it" } ?: "" - val warning = if (!item.allergenWarning.isNullOrBlank()) " ⚠️${item.allergenWarning}" else "" - sb.appendLine(" ☐ ${item.productName}${note}${warning}") + 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 existing.id } + val categoryName = + item.primaryCategoryId?.let { catId -> + catalogRepository.getCategory(catId)?.name + } ?: categoryEngine.detectCategory(item.name) + return manageListUseCase.addItem( + ShoppingListItemEntity( + listId = listId, + productName = item.name, + category = categoryName, + customEmoji = item.emoji, + ), + ) + } + + private suspend fun addCustomItemAndGetId(rawText: String): Long { + val trimmed = rawText.trim().ifEmpty { return -1L } + val (quantity, name) = parseQuantityAndName(trimmed) + val listId = _listIdFlow.value + val existing = + manageListUseCase.getItems(listId) + .firstOrNull { it.productName.equals(name, ignoreCase = true) } + if (existing != null) { + if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false) + return existing.id + } + val category = categoryEngine.detectCategory(name) + return manageListUseCase.addItem( + ShoppingListItemEntity( + listId = listId, + productName = name, + category = category, + note = quantity, + ), + ) + } + + /** Crée un *own item* à partir de la saisie (appel direct depuis BottomSearchBar). */ + fun addCustomItem(rawText: String) { + viewModelScope.launch { addCustomItemAndGetId(rawText) } + } + + /** + * Tap sur une tuile du catalogue Room (section principale). + * Ajoute l'article et ouvre le panneau de détail inline. + */ + fun addCatalogItem(item: CatalogItemEntity) { + viewModelScope.launch { + val itemId = addCatalogItemAndGetId(item) + val variants = + item.variants.split(",") + .map { it.trim() }.filter { it.isNotEmpty() } + val existingItem = + manageListUseCase.getItems(_listIdFlow.value) + .firstOrNull { it.id == itemId } + _pendingItem.value = + PendingItem( + itemId = itemId, + name = item.name, + emoji = item.emoji, + variants = variants, + selectedTags = existingItem?.parseTags() ?: emptySet(), + note = existingItem?.note.orEmpty(), + ) + } + } + + /** Crée un item avec photo et description. */ + fun addCustomItemWithImage( + name: String, + note: String?, + imageUri: String?, + ) { + val trimmedName = name.trim() + if (trimmedName.isEmpty()) return + viewModelScope.launch { + val listId = _listIdFlow.value + val category = categoryEngine.detectCategory(trimmedName) + manageListUseCase.addItemToList( + listId, + ShoppingListItemEntity( + listId = listId, + productName = trimmedName, + category = category, + note = note?.trim()?.ifEmpty { null }, + imageUrl = imageUri, + isChecked = false, + ), + ) + } + } + + /** + * Tap sur un article actif → marque comme acheté (déplace dans Recently Used). + */ + fun markAsBought(id: Long) { + viewModelScope.launch { + manageListUseCase.setItemChecked(id, true) + } + } + + /** + * Tap sur un article dans Recently Used → restaure dans la liste active. + */ + fun restoreItem(id: Long) { + viewModelScope.launch { + manageListUseCase.setItemChecked(id, false) + } + } + + /** + * Suppression permanente d'un article (depuis la feuille de détail ou l'appui + * long sur Recently Used). + */ + fun deleteItem(id: Long) { + viewModelScope.launch { + val listId = _listIdFlow.value + val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch + manageListUseCase.deleteItem(item) + if (_selectedItemId.value == id) _selectedItemId.value = null + } + } + + /** Vide entièrement la section Recently Used (catégorie supprimée définitivement). */ + fun clearRecentlyUsed() { + viewModelScope.launch { + val listId = _listIdFlow.value + manageListUseCase.getItems(listId) + .filter { it.isChecked } + .forEach { manageListUseCase.deleteItem(it) } + } + } + + fun openItemDetails(id: Long) { + _selectedItemId.value = id + } + + fun closeItemDetails() { + _selectedItemId.value = null + } + + /** Met à jour la note (quantité / description) d'un article. */ + fun updateItemNote( + id: Long, + note: String, + ) { + viewModelScope.launch { + val listId = _listIdFlow.value + val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch + manageListUseCase.updateItem(item.copy(note = note.trim().ifEmpty { null })) + } + } + + /** Change la catégorie/section d'un article. */ + fun updateItemCategory( + id: Long, + category: String, + ) { + viewModelScope.launch { + val listId = _listIdFlow.value + val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch + manageListUseCase.updateItem(item.copy(category = category)) + } + } + + /** Active/désactive un tag visuel d'un article ( depuis le long-press sheet ). */ + fun toggleItemTag( + id: Long, + tag: String, + ) { + viewModelScope.launch { + val listId = _listIdFlow.value + val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch + val currentTags = item.parseTags() + val newTags = if (currentTags.contains(tag)) currentTags - tag else currentTags + tag + manageListUseCase.updateItem(item.copy(tag = newTags.serializeTags())) + } + } + + /** Change l'image (URL/URI) d'un article. */ + fun updateItemImageUrl( + id: Long, + imageUrl: String?, + ) { + viewModelScope.launch { + val listId = _listIdFlow.value + val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch + manageListUseCase.updateItem(item.copy(imageUrl = imageUrl)) + } + } + + /** Change l'emoji personnalisé d'un article. */ + fun updateItemEmoji( + id: Long, + emoji: String?, + ) { + viewModelScope.launch { + val listId = _listIdFlow.value + val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch + manageListUseCase.updateItem(item.copy(customEmoji = emoji)) + } + } + + /** Déplace un article vers une autre liste. */ + fun moveItemToList( + id: Long, + targetListId: Long, + ) { + viewModelScope.launch { + val sourceListId = _listIdFlow.value + if (targetListId == sourceListId) return@launch + val item = manageListUseCase.getItems(sourceListId).firstOrNull { it.id == id } ?: return@launch + manageListUseCase.deleteItem(item) + manageListUseCase.addItemToList( + targetListId, + item.copy(id = 0L, listId = targetListId, isChecked = false), + ) + if (_selectedItemId.value == id) _selectedItemId.value = null + } + } + + /** Décoche tous les articles (les réactive depuis Recently Used). */ + fun uncheckAllItems() { + viewModelScope.launch { + manageListUseCase.uncheckAllItems(_listIdFlow.value) + } + } + + fun shareList( + listName: String, + items: List, + ): String { + val active = items.filterNot { it.isChecked } + val sb = StringBuilder() + sb.appendLine("📋 $listName") + sb.appendLine("━━━━━━━━━━━━━━━━━━━━") + sb.appendLine("${active.size} articles à acheter") sb.appendLine() + val byCategory = active.groupBy { it.category ?: "Autre" } + byCategory.forEach { (category, categoryItems) -> + sb.appendLine("📂 $category") + categoryItems.forEach { item -> + val note = item.note?.let { " — $it" } ?: "" + val warning = if (!item.allergenWarning.isNullOrBlank()) " ⚠️${item.allergenWarning}" else "" + sb.appendLine(" ☐ ${item.productName}${note}$warning") + } + sb.appendLine() + } + return sb.toString() } - return sb.toString() - } - // ── Helpers internes ──────────────────────────────────────────────────── + // ── Helpers internes ──────────────────────────────────────────────────── - /** - * Sépare une saisie type "2 kg pommes" → (note="2 kg", name="pommes"). - * Retourne (null, raw) si aucun préfixe quantité détecté. - */ - internal fun parseQuantityAndName(raw: String): Pair { - val regex = Regex("""^(\d+([.,]\d+)?\s*(kg|g|l|ml|cl|pcs|pièces?|pieces?|x)?)\s+(.+)$""", RegexOption.IGNORE_CASE) - val match = regex.matchEntire(raw) - return if (match != null) { - val quantity = match.groupValues[1].trim() - val name = match.groupValues[4].trim().replaceFirstChar { it.uppercase() } - quantity to name - } else { - null to raw.replaceFirstChar { it.uppercase() } + /** + * Sépare une saisie type "2 kg pommes" → (note="2 kg", name="pommes"). + * Retourne (null, raw) si aucun préfixe quantité détecté. + */ + internal fun parseQuantityAndName(raw: String): Pair { + val regex = Regex("""^(\d+([.,]\d+)?\s*(kg|g|l|ml|cl|pcs|pièces?|pieces?|x)?)\s+(.+)$""", RegexOption.IGNORE_CASE) + val match = regex.matchEntire(raw) + return if (match != null) { + val quantity = match.groupValues[1].trim() + val name = match.groupValues[4].trim().replaceFirstChar { it.uppercase() } + quantity to name + } else { + null to raw.replaceFirstChar { it.uppercase() } + } + } + + private fun ShoppingListItemEntity.toUi() = + ShoppingListItemUi( + id = id, + barcode = barcode, + productName = productName, + brand = brand, + imageUrl = imageUrl, + isChecked = isChecked, + category = category, + safetyStatus = safetyStatus, + allergenWarning = allergenWarning, + note = note, + emoji = customEmoji ?: catalog.emojiFor(productName, category), + tags = parseTags(), + ) + + private fun ShoppingListItemEntity.parseTags(): Set = + tag?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet() ?: emptySet() + + private fun Set.serializeTags(): String? = if (isEmpty()) null else joinToString(",") + + companion object { + private const val MAX_RECENTLY_USED = 30 } } - - private fun ShoppingListItemEntity.toUi() = ShoppingListItemUi( - id = id, - barcode = barcode, - productName = productName, - brand = brand, - imageUrl = imageUrl, - isChecked = isChecked, - category = category, - safetyStatus = safetyStatus, - allergenWarning = allergenWarning, - note = note, - emoji = customEmoji ?: catalog.emojiFor(productName, category), - tag = tag - ) - - companion object { - private const val MAX_RECENTLY_USED = 30 - } -} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsScreen.kt index bf2e392..200114e 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsScreen.kt @@ -1,7 +1,5 @@ package com.safebite.app.presentation.screen.lists -import android.content.res.Resources -import androidx.compose.animation.core.Animatable import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -25,9 +23,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DragHandle -import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -62,7 +58,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -72,7 +67,6 @@ import com.safebite.app.presentation.common.components.EmptyState import com.safebite.app.presentation.common.components.PrimaryButton import com.safebite.app.presentation.screen.lists.util.backgroundByResName import com.safebite.app.presentation.theme.LocalDimens -import kotlinx.coroutines.launch import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @@ -82,7 +76,7 @@ fun ListsScreen( onOpenScanner: () -> Unit, onOpenListCreate: () -> Unit, onOpenListSettings: (Long) -> Unit, - viewModel: ListsViewModel = hiltViewModel() + viewModel: ListsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val isEditMode by viewModel.isEditMode.collectAsStateWithLifecycle() @@ -108,7 +102,7 @@ fun ListsScreen( } else -> {} } - } + }, ) }, floatingActionButton = { @@ -116,16 +110,17 @@ fun ListsScreen( onClick = onOpenListCreate, containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, - shape = MaterialTheme.shapes.medium + shape = MaterialTheme.shapes.medium, ) { Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.lists_new)) } - } + }, ) { padding -> Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) + modifier = + Modifier + .fillMaxSize() + .padding(padding), ) { when (val s = state) { is ListsViewModel.UiState.Loading -> { @@ -139,9 +134,9 @@ fun ListsScreen( action = { PrimaryButton( text = stringResource(R.string.lists_new), - onClick = onOpenListCreate + onClick = onOpenListCreate, ) - } + }, ) } is ListsViewModel.UiState.Success -> { @@ -150,14 +145,14 @@ fun ListsScreen( isEditMode = isEditMode, onItemClick = { item -> onOpenList(item.list.id, item.list.name) }, onSettingsClick = { item -> onOpenListSettings(item.list.id) }, - onReorder = { from, to -> viewModel.reorderLists(from, to) } + onReorder = { from, to -> viewModel.reorderLists(from, to) }, ) } is ListsViewModel.UiState.Error -> { EmptyState( title = "Erreur", message = s.message, - emoji = "❌" + emoji = "❌", ) } } @@ -171,7 +166,7 @@ private fun ReorderableList( isEditMode: Boolean, onItemClick: (ListsViewModel.ShoppingListWithStats) -> Unit, onSettingsClick: (ListsViewModel.ShoppingListWithStats) -> Unit, - onReorder: (Int, Int) -> Unit + onReorder: (Int, Int) -> Unit, ) { var draggedIndex by remember { mutableStateOf(null) } var dragOffsetY by remember { mutableFloatStateOf(0f) } @@ -182,11 +177,11 @@ private fun ReorderableList( LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(12.dp), ) { itemsIndexed( items = items, - key = { _, item -> item.list.id } + key = { _, item -> item.list.id }, ) { index, item -> val isDragged = draggedIndex == index val zIndex = if (isDragged) 1f else 0f @@ -194,45 +189,49 @@ private fun ReorderableList( val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0 Box( - modifier = Modifier - .zIndex(zIndex) - .offset { IntOffset(0, offsetY) } - .graphicsLayer { - scaleX = if (isDragged) 1.02f else 1f - scaleY = if (isDragged) 1.02f else 1f - } - .then( - if (isEditMode) { - Modifier.pointerInput(Unit) { - detectDragGesturesAfterLongPress( - onDragStart = { draggedIndex = index }, - onDragEnd = { - draggedIndex?.let { from -> - val to = (from + (dragOffsetY / itemPx).roundToInt()) - .coerceIn(0, items.size - 1) - if (from != to) onReorder(from, to) - } - draggedIndex = null - dragOffsetY = 0f - }, - onDragCancel = { - draggedIndex = null - dragOffsetY = 0f - }, - onDrag = { change, dragAmount -> - change.consume() - dragOffsetY += dragAmount.y - } - ) - } - } else Modifier - ) + modifier = + Modifier + .zIndex(zIndex) + .offset { IntOffset(0, offsetY) } + .graphicsLayer { + scaleX = if (isDragged) 1.02f else 1f + scaleY = if (isDragged) 1.02f else 1f + } + .then( + if (isEditMode) { + Modifier.pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { draggedIndex = index }, + onDragEnd = { + draggedIndex?.let { from -> + val to = + (from + (dragOffsetY / itemPx).roundToInt()) + .coerceIn(0, items.size - 1) + if (from != to) onReorder(from, to) + } + draggedIndex = null + dragOffsetY = 0f + }, + onDragCancel = { + draggedIndex = null + dragOffsetY = 0f + }, + onDrag = { change, dragAmount -> + change.consume() + dragOffsetY += dragAmount.y + }, + ) + } + } else { + Modifier + }, + ), ) { ShoppingListCard( item = item, isEditMode = isEditMode, onClick = { onItemClick(item) }, - onSettingsClick = { onSettingsClick(item) } + onSettingsClick = { onSettingsClick(item) }, ) } } @@ -244,18 +243,19 @@ private fun ShoppingListCard( item: ListsViewModel.ShoppingListWithStats, isEditMode: Boolean, onClick: () -> Unit, - onSettingsClick: () -> Unit + onSettingsClick: () -> Unit, ) { val dimens = LocalDimens.current val bg = backgroundByResName(item.list.backgroundResName) Card( - modifier = Modifier - .fillMaxWidth() - .height(160.dp) - .then(if (!isEditMode) Modifier.clickable(onClick = onClick) else Modifier), + modifier = + Modifier + .fillMaxWidth() + .height(160.dp) + .then(if (!isEditMode) Modifier.clickable(onClick = onClick) else Modifier), shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), ) { Box(modifier = Modifier.fillMaxSize()) { // Background image @@ -264,37 +264,40 @@ private fun ShoppingListCard( painter = painterResource(id = bg.drawableRes), contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, ) Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.35f)) + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)), ) } else { Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.primaryContainer) + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primaryContainer), ) } Column( - modifier = Modifier - .fillMaxSize() - .padding(dimens.spacingMd) + modifier = + Modifier + .fillMaxSize() + .padding(dimens.spacingMd), ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top + verticalAlignment = Alignment.Top, ) { if (isEditMode) { Icon( imageVector = Icons.Filled.DragHandle, contentDescription = "Reorder", tint = Color.White, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) } else { Spacer(modifier = Modifier.width(24.dp)) @@ -302,13 +305,13 @@ private fun ShoppingListCard( IconButton( onClick = onSettingsClick, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp), ) { Icon( imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.lists_settings), tint = Color.White, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(20.dp), ) } } @@ -316,40 +319,41 @@ private fun ShoppingListCard( Spacer(modifier = Modifier.weight(1f)) // List name - val regionFlagEmoji = item.list.region?.let { code -> - when (code) { - "de" -> "🇩🇪" - "au" -> "🇦🇺" - "at" -> "🇦🇹" - "ca" -> "🇨🇦" - "es" -> "🇪🇸" - "fr" -> "🇫🇷" - "hu" -> "🇭🇺" - "it" -> "🇮🇹" - "no" -> "🇳🇴" - "nl" -> "🇳🇱" - "pl" -> "🇵🇱" - "pt" -> "🇵🇹" - "gb" -> "🇬🇧" - "ru" -> "🇷🇺" - "ch_de", "ch_fr" -> "🇨🇭" - else -> "" - } - } ?: "" + val regionFlagEmoji = + item.list.region?.let { code -> + when (code) { + "de" -> "🇩🇪" + "au" -> "🇦🇺" + "at" -> "🇦🇹" + "ca" -> "🇨🇦" + "es" -> "🇪🇸" + "fr" -> "🇫🇷" + "hu" -> "🇭🇺" + "it" -> "🇮🇹" + "no" -> "🇳🇴" + "nl" -> "🇳🇱" + "pl" -> "🇵🇱" + "pt" -> "🇵🇹" + "gb" -> "🇬🇧" + "ru" -> "🇷🇺" + "ch_de", "ch_fr" -> "🇨🇭" + else -> "" + } + } ?: "" Text( text = "$regionFlagEmoji ${item.list.name}".trim(), style = MaterialTheme.typography.titleLarge, color = Color.White, fontWeight = FontWeight.Bold, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.height(4.dp)) Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { // Item count badge val remaining = item.itemCount - item.checkedCount @@ -358,16 +362,17 @@ private fun ShoppingListCard( text = "$remaining articles", style = MaterialTheme.typography.labelMedium, color = Color.White, - modifier = Modifier - .background(badgeColor.copy(alpha = 0.85f), RoundedCornerShape(12.dp)) - .padding(horizontal = 8.dp, vertical = 2.dp) + modifier = + Modifier + .background(badgeColor.copy(alpha = 0.85f), RoundedCornerShape(12.dp)) + .padding(horizontal = 8.dp, vertical = 2.dp), ) Spacer(modifier = Modifier.width(8.dp)) // Member avatars Row( - horizontalArrangement = Arrangement.spacedBy((-8).dp) + horizontalArrangement = Arrangement.spacedBy((-8).dp), ) { item.members.take(3).forEach { member -> MemberAvatar(member = member) @@ -382,17 +387,18 @@ private fun ShoppingListCard( @Composable private fun MemberAvatar(member: ShoppingListMemberEntity) { Box( - modifier = Modifier - .size(32.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, ) { Text( text = member.name.take(1).uppercase(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt index f1c8c48..9e53545 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt @@ -7,8 +7,8 @@ import com.safebite.app.data.local.database.entity.ShoppingListMemberEntity import com.safebite.app.domain.usecase.GetShoppingListsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest @@ -21,88 +21,100 @@ import javax.inject.Inject * ViewModel pour l'écran Listes (Phase 2). */ @HiltViewModel -class ListsViewModel @Inject constructor( - private val getShoppingListsUseCase: GetShoppingListsUseCase -) : ViewModel() { +class ListsViewModel + @Inject + constructor( + private val getShoppingListsUseCase: GetShoppingListsUseCase, + ) : ViewModel() { + sealed class UiState { + object Loading : UiState() - sealed class UiState { - object Loading : UiState() - data class Success( - val lists: List - ) : UiState() - data class Empty(val message: String = "") : UiState() - data class Error(val message: String) : UiState() - } + data class Success( + val lists: List, + ) : UiState() - data class ShoppingListWithStats( - val list: ShoppingListEntity, - val itemCount: Int, - val checkedCount: Int, - val members: List = emptyList() - ) + data class Empty(val message: String = "") : UiState() - private val _isEditMode = MutableStateFlow(false) - val isEditMode: StateFlow = _isEditMode - - @OptIn(ExperimentalCoroutinesApi::class) - val state: StateFlow = getShoppingListsUseCase.observeActive() - .flatMapLatest { lists -> - if (lists.isEmpty()) { - flowOf(UiState.Empty("Aucune liste de courses. Créez votre première liste !")) - } else { - val statsFlows = lists.sortedBy { it.displayOrder }.map { list -> - combine( - getShoppingListsUseCase.observeItemCount(list.id), - getShoppingListsUseCase.observeCheckedCount(list.id), - getShoppingListsUseCase.observeMembers(list.id) - ) { itemCount, checkedCount, members -> - ShoppingListWithStats(list, itemCount, checkedCount, members.take(3)) - } - } - combine(statsFlows) { array -> - UiState.Success(array.toList().sortedBy { it.list.displayOrder }) - } - } + data class Error(val message: String) : UiState() } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = UiState.Loading + + data class ShoppingListWithStats( + val list: ShoppingListEntity, + val itemCount: Int, + val checkedCount: Int, + val members: List = emptyList(), ) - fun createList(name: String, backgroundResName: String? = null) { - viewModelScope.launch { - getShoppingListsUseCase.createList(name, backgroundResName) - } - } + private val _isEditMode = MutableStateFlow(false) + val isEditMode: StateFlow = _isEditMode - fun deleteList(list: ShoppingListEntity) { - viewModelScope.launch { - getShoppingListsUseCase.deleteList(list) - } - } - - fun toggleEditMode() { - _isEditMode.value = !_isEditMode.value - } - - fun updateList(list: ShoppingListEntity) { - viewModelScope.launch { - getShoppingListsUseCase.updateList(list) - } - } - - fun reorderLists(fromIndex: Int, toIndex: Int) { - viewModelScope.launch { - val current = state.value as? UiState.Success ?: return@launch - val mutable = current.lists.toMutableList() - val moved = mutable.removeAt(fromIndex) - mutable.add(toIndex.coerceIn(0, mutable.size), moved) - mutable.forEachIndexed { index, item -> - getShoppingListsUseCase.updateList( - item.list.copy(displayOrder = index) + @OptIn(ExperimentalCoroutinesApi::class) + val state: StateFlow = + getShoppingListsUseCase.observeActive() + .flatMapLatest { lists -> + if (lists.isEmpty()) { + flowOf(UiState.Empty("Aucune liste de courses. Créez votre première liste !")) + } else { + val statsFlows = + lists.sortedBy { it.displayOrder }.map { list -> + combine( + getShoppingListsUseCase.observeItemCount(list.id), + getShoppingListsUseCase.observeCheckedCount(list.id), + getShoppingListsUseCase.observeMembers(list.id), + ) { itemCount, checkedCount, members -> + ShoppingListWithStats(list, itemCount, checkedCount, members.take(3)) + } + } + combine(statsFlows) { array -> + UiState.Success(array.toList().sortedBy { it.list.displayOrder }) + } + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = UiState.Loading, ) + + fun createList( + name: String, + backgroundResName: String? = null, + ) { + viewModelScope.launch { + getShoppingListsUseCase.createList(name, backgroundResName) + } + } + + fun deleteList(list: ShoppingListEntity) { + viewModelScope.launch { + getShoppingListsUseCase.deleteList(list) + } + } + + fun toggleEditMode() { + _isEditMode.value = !_isEditMode.value + } + + fun updateList(list: ShoppingListEntity) { + viewModelScope.launch { + getShoppingListsUseCase.updateList(list) + } + } + + fun reorderLists( + fromIndex: Int, + toIndex: Int, + ) { + viewModelScope.launch { + val current = state.value as? UiState.Success ?: return@launch + val mutable = current.lists.toMutableList() + val moved = mutable.removeAt(fromIndex) + mutable.add(toIndex.coerceIn(0, mutable.size), moved) + mutable.forEachIndexed { index, item -> + getShoppingListsUseCase.updateList( + item.list.copy(displayOrder = index), + ) + } } } } -} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/create/CreateListScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/create/CreateListScreen.kt index 6ba1cb9..20b054f 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/create/CreateListScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/create/CreateListScreen.kt @@ -1,13 +1,12 @@ package com.safebite.app.presentation.screen.lists.create -import androidx.compose.foundation.background import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -22,9 +21,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -57,7 +54,7 @@ import com.safebite.app.presentation.screen.lists.util.allListBackgrounds fun CreateListScreen( onBack: () -> Unit, onListCreated: () -> Unit, - viewModel: ListsViewModel = hiltViewModel() + viewModel: ListsViewModel = hiltViewModel(), ) { var listName by remember { mutableStateOf("") } var selectedBg by remember { mutableStateOf(allListBackgrounds.firstOrNull()?.resName) } @@ -79,26 +76,27 @@ fun CreateListScreen( onListCreated() } }, - enabled = listName.isNotBlank() + enabled = listName.isNotBlank(), ) { Text(stringResource(R.string.list_create_next)) } - } + }, ) - } + }, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), ) { OutlinedTextField( value = listName, onValueChange = { listName = it }, label = { Text(stringResource(R.string.list_name_hint)) }, modifier = Modifier.fillMaxWidth(), - singleLine = true + singleLine = true, ) Spacer(modifier = Modifier.height(16.dp)) @@ -106,7 +104,7 @@ fun CreateListScreen( Text( text = stringResource(R.string.list_choose_background), style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Spacer(modifier = Modifier.height(8.dp)) @@ -116,43 +114,46 @@ fun CreateListScreen( contentPadding = PaddingValues(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { items(allListBackgrounds) { bg -> val isSelected = selectedBg == bg.resName Card( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1.5f), + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1.5f), shape = RoundedCornerShape(12.dp), border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null, - onClick = { selectedBg = bg.resName } + onClick = { selectedBg = bg.resName }, ) { Box(modifier = Modifier.fillMaxSize()) { Image( painter = painterResource(id = bg.drawableRes), contentDescription = bg.label, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, ) if (isSelected) { Box( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), - contentAlignment = Alignment.TopEnd + modifier = + Modifier + .fillMaxSize() + .padding(8.dp), + contentAlignment = Alignment.TopEnd, ) { Box( - modifier = Modifier - .size(28.dp) - .background(Color.White, RoundedCornerShape(14.dp)), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(28.dp) + .background(Color.White, RoundedCornerShape(14.dp)), + contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Filled.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(18.dp), ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListMembersScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListMembersScreen.kt index 84bf121..ccc98d9 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListMembersScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListMembersScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -38,7 +37,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -53,7 +51,7 @@ import com.safebite.app.presentation.screen.lists.ListsViewModel fun ListMembersScreen( listId: Long, onBack: () -> Unit, - viewModel: ListsViewModel = hiltViewModel() + viewModel: ListsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId } @@ -67,15 +65,16 @@ fun ListMembersScreen( IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } - } + }, ) - } + }, ) { padding -> Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), ) { if (listData == null) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) @@ -85,19 +84,19 @@ fun ListMembersScreen( text = stringResource(R.string.list_members_count, members.size), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(vertical = 8.dp) + modifier = Modifier.padding(vertical = 8.dp), ) LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { items(members) { member -> MemberRow( member = member, onRemove = { // TODO: remove member via viewmodel/usecase - } + }, ) } } @@ -108,15 +107,16 @@ fun ListMembersScreen( onClick = { /* TODO: invite UI placeholder */ }, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), ) { Icon( imageVector = Icons.Filled.Add, contentDescription = null, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(20.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text(stringResource(R.string.list_invite_member)) @@ -132,33 +132,36 @@ fun ListMembersScreen( @Composable private fun MemberRow( member: ShoppingListMemberEntity, - onRemove: () -> Unit + onRemove: () -> Unit, ) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, ) { Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, ) { Text( text = member.name.take(1).uppercase(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer + color = MaterialTheme.colorScheme.onPrimaryContainer, ) } @@ -168,12 +171,12 @@ private fun MemberRow( Text( text = member.name, style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ) Text( text = member.email, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -181,7 +184,7 @@ private fun MemberRow( Icon( imageVector = Icons.Filled.RemoveCircleOutline, contentDescription = stringResource(R.string.list_remove_member), - tint = MaterialTheme.colorScheme.error + tint = MaterialTheme.colorScheme.error, ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListNameImageScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListNameImageScreen.kt index 202902a..7c150bc 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListNameImageScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListNameImageScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -57,7 +56,7 @@ import com.safebite.app.presentation.screen.lists.util.backgroundByResName fun ListNameImageScreen( listId: Long, onBack: () -> Unit, - viewModel: ListsViewModel = hiltViewModel() + viewModel: ListsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId } @@ -68,10 +67,11 @@ fun ListNameImageScreen( val onSave = { listData?.let { - val updated = it.list.copy( - name = listName.ifBlank { it.list.name }, - backgroundResName = selectedBg - ) + val updated = + it.list.copy( + name = listName.ifBlank { it.list.name }, + backgroundResName = selectedBg, + ) viewModel.updateList(updated) } onBack() @@ -90,18 +90,19 @@ fun ListNameImageScreen( IconButton(onClick = onSave) { Icon( imageVector = Icons.Filled.Check, - contentDescription = stringResource(R.string.action_save) + contentDescription = stringResource(R.string.action_save), ) } - } + }, ) - } + }, ) { padding -> Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), ) { if (listData == null) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) @@ -110,10 +111,11 @@ fun ListNameImageScreen( // Preview val bg = backgroundByResName(selectedBg) Card( - modifier = Modifier - .fillMaxWidth() - .height(120.dp), - shape = RoundedCornerShape(16.dp) + modifier = + Modifier + .fillMaxWidth() + .height(120.dp), + shape = RoundedCornerShape(16.dp), ) { Box(modifier = Modifier.fillMaxSize()) { if (bg != null) { @@ -121,18 +123,20 @@ fun ListNameImageScreen( painter = painterResource(id = bg.drawableRes), contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, ) Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.35f)) + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)), ) } else { Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.primaryContainer) + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primaryContainer), ) } Text( @@ -140,9 +144,10 @@ fun ListNameImageScreen( style = MaterialTheme.typography.titleLarge, color = Color.White, fontWeight = FontWeight.Bold, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(16.dp) + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(16.dp), ) } } @@ -154,7 +159,7 @@ fun ListNameImageScreen( onValueChange = { listName = it }, label = { Text(stringResource(R.string.list_name_hint)) }, modifier = Modifier.fillMaxWidth(), - singleLine = true + singleLine = true, ) Spacer(modifier = Modifier.height(16.dp)) @@ -162,7 +167,7 @@ fun ListNameImageScreen( Text( text = stringResource(R.string.list_choose_background), style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Spacer(modifier = Modifier.height(8.dp)) @@ -172,45 +177,49 @@ fun ListNameImageScreen( contentPadding = PaddingValues(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier - .fillMaxWidth() - .weight(1f) + modifier = + Modifier + .fillMaxWidth() + .weight(1f), ) { items(allListBackgrounds) { bg -> val isSelected = selectedBg == bg.resName Card( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1.5f), + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1.5f), shape = RoundedCornerShape(12.dp), border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null, - onClick = { selectedBg = bg.resName } + onClick = { selectedBg = bg.resName }, ) { Box(modifier = Modifier.fillMaxSize()) { Image( painter = painterResource(id = bg.drawableRes), contentDescription = bg.label, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, ) if (isSelected) { Box( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), - contentAlignment = Alignment.TopEnd + modifier = + Modifier + .fillMaxSize() + .padding(8.dp), + contentAlignment = Alignment.TopEnd, ) { Box( - modifier = Modifier - .size(28.dp) - .background(Color.White, RoundedCornerShape(14.dp)), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(28.dp) + .background(Color.White, RoundedCornerShape(14.dp)), + contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Filled.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(18.dp), ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListRegionScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListRegionScreen.kt index 07f75f5..a353d1a 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListRegionScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListRegionScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -35,7 +34,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -44,31 +42,32 @@ import com.safebite.app.presentation.screen.lists.ListsViewModel private data class Region(val name: String, val code: String, val flag: String) -private val availableRegions = listOf( - Region("Allemagne", "de", "🇩🇪"), - Region("Australie", "au", "🇦🇺"), - Region("Autriche", "at", "🇦🇹"), - Region("Canada", "ca", "🇨🇦"), - Region("Espagne", "es", "🇪🇸"), - Region("France", "fr", "🇫🇷"), - Region("Hongrie", "hu", "🇭🇺"), - Region("Italie", "it", "🇮🇹"), - Region("Norvège", "no", "🇳🇴"), - Region("Pays-Bas", "nl", "🇳🇱"), - Region("Pologne", "pl", "🇵🇱"), - Region("Portugal", "pt", "🇵🇹"), - Region("Royaume-Uni", "gb", "🇬🇧"), - Region("Russie", "ru", "🇷🇺"), - Region("Suisse (Allemand)", "ch_de", "🇨🇭"), - Region("Suisse (français)", "ch_fr", "🇨🇭") -) +private val availableRegions = + listOf( + Region("Allemagne", "de", "🇩🇪"), + Region("Australie", "au", "🇦🇺"), + Region("Autriche", "at", "🇦🇹"), + Region("Canada", "ca", "🇨🇦"), + Region("Espagne", "es", "🇪🇸"), + Region("France", "fr", "🇫🇷"), + Region("Hongrie", "hu", "🇭🇺"), + Region("Italie", "it", "🇮🇹"), + Region("Norvège", "no", "🇳🇴"), + Region("Pays-Bas", "nl", "🇳🇱"), + Region("Pologne", "pl", "🇵🇱"), + Region("Portugal", "pt", "🇵🇹"), + Region("Royaume-Uni", "gb", "🇬🇧"), + Region("Russie", "ru", "🇷🇺"), + Region("Suisse (Allemand)", "ch_de", "🇨🇭"), + Region("Suisse (français)", "ch_fr", "🇨🇭"), + ) @OptIn(ExperimentalMaterial3Api::class) @Composable fun ListRegionScreen( listId: Long, onBack: () -> Unit, - viewModel: ListsViewModel = hiltViewModel() + viewModel: ListsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId } @@ -96,59 +95,63 @@ fun ListRegionScreen( IconButton(onClick = onSave) { Icon( imageVector = Icons.Filled.Check, - contentDescription = stringResource(R.string.action_save) + contentDescription = stringResource(R.string.action_save), ) } - } + }, ) - } + }, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), ) { Text( text = stringResource(R.string.list_region_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp) + modifier = Modifier.padding(vertical = 8.dp), ) LazyColumn( contentPadding = PaddingValues(vertical = 8.dp), - modifier = Modifier - .fillMaxWidth() - .weight(1f) + modifier = + Modifier + .fillMaxWidth() + .weight(1f), ) { items(availableRegions) { region -> val isSelected = selectedRegion == region.code Row( - modifier = Modifier - .fillMaxWidth() - .clickable { selectedRegion = region.code } - .padding(vertical = 14.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .clickable { selectedRegion = region.code } + .padding(vertical = 14.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { Text( text = "${region.flag} ${region.name}", style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) if (isSelected) { Box( - modifier = Modifier - .size(24.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(24.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Filled.Check, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(16.dp), ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSettingsScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSettingsScreen.kt index 34d7718..ac4aee7 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSettingsScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSettingsScreen.kt @@ -5,8 +5,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -21,7 +19,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Brush -import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.People import androidx.compose.material3.Button @@ -62,7 +59,7 @@ fun ListSettingsScreen( onOpenRegion: () -> Unit, onOpenNameImage: () -> Unit, onOpenMembers: () -> Unit, - viewModel: ListsViewModel = hiltViewModel() + viewModel: ListsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId } @@ -75,15 +72,16 @@ fun ListSettingsScreen( IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } - } + }, ) - } + }, ) { padding -> Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), ) { if (listData == null) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) @@ -92,10 +90,11 @@ fun ListSettingsScreen( // Header card with list preview val bg = backgroundByResName(listData.list.backgroundResName) Card( - modifier = Modifier - .fillMaxWidth() - .height(140.dp), - shape = RoundedCornerShape(16.dp) + modifier = + Modifier + .fillMaxWidth() + .height(140.dp), + shape = RoundedCornerShape(16.dp), ) { Box(modifier = Modifier.fillMaxSize()) { if (bg != null) { @@ -103,18 +102,20 @@ fun ListSettingsScreen( painter = painterResource(id = bg.drawableRes), contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, ) Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.35f)) + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)), ) } else { Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.primaryContainer) + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primaryContainer), ) } Text( @@ -122,9 +123,10 @@ fun ListSettingsScreen( style = MaterialTheme.typography.titleLarge, color = Color.White, fontWeight = FontWeight.Bold, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(16.dp) + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(16.dp), ) } } @@ -135,7 +137,7 @@ fun ListSettingsScreen( Text( text = stringResource(R.string.list_personalize), style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ) Spacer(modifier = Modifier.height(12.dp)) @@ -144,40 +146,43 @@ fun ListSettingsScreen( columns = GridCells.Fixed(2), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { item { SettingsTile( icon = Icons.AutoMirrored.Filled.Sort, label = stringResource(R.string.list_sort), - onClick = onOpenSort + onClick = onOpenSort, ) } item { val regionCode = listData.list.region - val regionSubtitle = if (regionCode != null) { - val (flag, name) = regionFlagAndName(regionCode) - "$flag $name" - } else null + val regionSubtitle = + if (regionCode != null) { + val (flag, name) = regionFlagAndName(regionCode) + "$flag $name" + } else { + null + } SettingsTile( icon = Icons.Filled.Language, label = stringResource(R.string.list_region_language), subtitle = regionSubtitle, - onClick = onOpenRegion + onClick = onOpenRegion, ) } item { SettingsTile( icon = Icons.Filled.People, label = stringResource(R.string.list_members), - onClick = onOpenMembers + onClick = onOpenMembers, ) } item { SettingsTile( icon = Icons.Filled.Brush, label = stringResource(R.string.list_name_image), - onClick = onOpenNameImage + onClick = onOpenNameImage, ) } } @@ -192,10 +197,11 @@ fun ListSettingsScreen( }, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError - ) + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), ) { Text(stringResource(R.string.list_leave)) } @@ -207,55 +213,59 @@ fun ListSettingsScreen( } } -private fun regionFlagAndName(code: String): Pair = when (code) { - "de" -> "🇩🇪" to "Allemagne" - "au" -> "🇦🇺" to "Australie" - "at" -> "🇦🇹" to "Autriche" - "ca" -> "🇨🇦" to "Canada" - "es" -> "🇪🇸" to "Espagne" - "fr" -> "🇫🇷" to "France" - "hu" -> "🇭🇺" to "Hongrie" - "it" -> "🇮🇹" to "Italie" - "no" -> "🇳🇴" to "Norvège" - "nl" -> "🇳🇱" to "Pays-Bas" - "pl" -> "🇵🇱" to "Pologne" - "pt" -> "🇵🇹" to "Portugal" - "gb" -> "🇬🇧" to "Royaume-Uni" - "ru" -> "🇷🇺" to "Russie" - "ch_de" -> "🇨🇭" to "Suisse (Allemand)" - "ch_fr" -> "🇨🇭" to "Suisse (français)" - else -> "" to code -} +private fun regionFlagAndName(code: String): Pair = + when (code) { + "de" -> "🇩🇪" to "Allemagne" + "au" -> "🇦🇺" to "Australie" + "at" -> "🇦🇹" to "Autriche" + "ca" -> "🇨🇦" to "Canada" + "es" -> "🇪🇸" to "Espagne" + "fr" -> "🇫🇷" to "France" + "hu" -> "🇭🇺" to "Hongrie" + "it" -> "🇮🇹" to "Italie" + "no" -> "🇳🇴" to "Norvège" + "nl" -> "🇳🇱" to "Pays-Bas" + "pl" -> "🇵🇱" to "Pologne" + "pt" -> "🇵🇹" to "Portugal" + "gb" -> "🇬🇧" to "Royaume-Uni" + "ru" -> "🇷🇺" to "Russie" + "ch_de" -> "🇨🇭" to "Suisse (Allemand)" + "ch_fr" -> "🇨🇭" to "Suisse (français)" + else -> "" to code + } @Composable private fun SettingsTile( icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, subtitle: String? = null, - onClick: () -> Unit + onClick: () -> Unit, ) { Card( onClick = onClick, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1.2f), + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1.2f), shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), ) { Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Icon( imageVector = icon, contentDescription = null, modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(8.dp)) Text( @@ -263,7 +273,7 @@ private fun SettingsTile( style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) if (subtitle != null) { Spacer(modifier = Modifier.height(2.dp)) @@ -272,7 +282,7 @@ private fun SettingsTile( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSortScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSortScreen.kt index 74ae844..053bddf 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSortScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSortScreen.kt @@ -70,18 +70,19 @@ import kotlin.math.roundToInt fun ListSortScreen( listId: Long, onBack: () -> Unit, - viewModel: ListsViewModel = hiltViewModel() + viewModel: ListsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId } val catalog = remember { CatalogProvider() } val savedOrder = listData?.list?.categoryOrder?.split(",")?.filter { it.isNotBlank() } - val orderedCategories = remember(listData?.list?.categoryOrder) { - mutableStateListOf().apply { - addAll(savedOrder ?: catalog.categories) + val orderedCategories = + remember(listData?.list?.categoryOrder) { + mutableStateListOf().apply { + addAll(savedOrder ?: catalog.categories) + } } - } val savedVisible = listData?.list?.visibleCategories?.split(",")?.filter { it.isNotBlank() }?.toSet() var visibleCategories by remember(listData?.list?.visibleCategories) { @@ -109,99 +110,105 @@ fun ListSortScreen( style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, - modifier = Modifier - .padding(end = 16.dp) - .clickable { - listData?.let { - viewModel.updateList( - it.list.copy( - visibleCategories = visibleCategories.joinToString(","), - categoryOrder = orderedCategories.joinToString(",") + modifier = + Modifier + .padding(end = 16.dp) + .clickable { + listData?.let { + viewModel.updateList( + it.list.copy( + visibleCategories = visibleCategories.joinToString(","), + categoryOrder = orderedCategories.joinToString(","), + ), ) - ) - } - onBack() - } + } + onBack() + }, ) - } + }, ) - } + }, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 16.dp) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), ) { Text( text = stringResource(R.string.list_sort_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp) + modifier = Modifier.padding(vertical = 8.dp), ) // Preview card (collapsible) Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .clickable { previewExpanded = !previewExpanded }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .clickable { previewExpanded = !previewExpanded }, shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), ) { Column(modifier = Modifier.fillMaxWidth()) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.AutoMirrored.Filled.Sort, contentDescription = null, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.width(12.dp)) Text( text = stringResource(R.string.list_sort_preview), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) Icon( imageVector = if (previewExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, - contentDescription = if (previewExpanded) "Réduire" else "Développer" + contentDescription = if (previewExpanded) "Réduire" else "Développer", ) } AnimatedVisibility( visible = previewExpanded, enter = expandVertically(), - exit = shrinkVertically() + exit = shrinkVertically(), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { orderedCategories.forEach { category -> val isVisible = category in visibleCategories Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( text = if (isVisible) "●" else "○", style = MaterialTheme.typography.bodySmall, - color = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + color = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.width(8.dp)) Text( text = category, style = MaterialTheme.typography.bodySmall, - color = if (isVisible) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant + color = if (isVisible) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -216,92 +223,100 @@ fun ListSortScreen( LazyColumn( verticalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = PaddingValues(vertical = 8.dp) + contentPadding = PaddingValues(vertical = 8.dp), ) { itemsIndexed( items = orderedCategories, - key = { _, item -> item } + key = { _, item -> item }, ) { index, category -> val isDragged = draggedIndex == index val zIndex = if (isDragged) 1f else 0f val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0 Box( - modifier = Modifier - .zIndex(zIndex) - .offset { IntOffset(0, offsetY) } - .graphicsLayer { - scaleX = if (isDragged) 1.02f else 1f - scaleY = if (isDragged) 1.02f else 1f - } - .pointerInput(Unit) { - detectDragGesturesAfterLongPress( - onDragStart = { draggedIndex = index }, - onDragEnd = { - draggedIndex?.let { from -> - val to = (from + (dragOffsetY / itemPx).roundToInt()) - .coerceIn(0, orderedCategories.size - 1) - if (from != to) { - val moved = orderedCategories.removeAt(from) - orderedCategories.add(to, moved) + modifier = + Modifier + .zIndex(zIndex) + .offset { IntOffset(0, offsetY) } + .graphicsLayer { + scaleX = if (isDragged) 1.02f else 1f + scaleY = if (isDragged) 1.02f else 1f + } + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { draggedIndex = index }, + onDragEnd = { + draggedIndex?.let { from -> + val to = + (from + (dragOffsetY / itemPx).roundToInt()) + .coerceIn(0, orderedCategories.size - 1) + if (from != to) { + val moved = orderedCategories.removeAt(from) + orderedCategories.add(to, moved) + } } - } - draggedIndex = null - dragOffsetY = 0f - }, - onDragCancel = { - draggedIndex = null - dragOffsetY = 0f - }, - onDrag = { change, dragAmount -> - change.consume() - dragOffsetY += dragAmount.y - } - ) - } + draggedIndex = null + dragOffsetY = 0f + }, + onDragCancel = { + draggedIndex = null + dragOffsetY = 0f + }, + onDrag = { change, dragAmount -> + change.consume() + dragOffsetY += dragAmount.y + }, + ) + }, ) { val isVisible = category in visibleCategories Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background( - if (isDragged) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surface - ) - .clickable { - visibleCategories = if (isVisible) { - visibleCategories - category - } else { - visibleCategories + category + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background( + if (isDragged) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) + .clickable { + visibleCategories = + if (isVisible) { + visibleCategories - category + } else { + visibleCategories + category + } } - } - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { Text( text = category, style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) IconButton(onClick = { - visibleCategories = if (isVisible) { - visibleCategories - category - } else { - visibleCategories + category - } + visibleCategories = + if (isVisible) { + visibleCategories - category + } else { + visibleCategories + category + } }) { Icon( imageVector = if (isVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, contentDescription = if (isVisible) "Masquer" else "Afficher", - tint = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + tint = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) } Icon( imageVector = Icons.Filled.DragHandle, contentDescription = "Réordonner", modifier = Modifier.padding(start = 8.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/util/ListBackgrounds.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/util/ListBackgrounds.kt index a12aa72..cde6d89 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/util/ListBackgrounds.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/util/ListBackgrounds.kt @@ -5,24 +5,23 @@ import com.safebite.app.R data class ListBackground( val resName: String, val label: String, - val drawableRes: Int + val drawableRes: Int, ) -val allListBackgrounds: List = listOf( - ListBackground("bg_animaux", "Animaux", R.drawable.bg_animaux), - ListBackground("bg_baby", "Bébé", R.drawable.bg_baby), - ListBackground("bg_epicerie", "Épicerie", R.drawable.bg_epicerie), - ListBackground("bg_epicerie2", "Épicerie 2", R.drawable.bg_epicerie2), - ListBackground("bg_jardinage", "Maison & Jardin", R.drawable.bg_jardinage), - ListBackground("bg_office", "Bureau", R.drawable.bg_office), - ListBackground("bg_party", "Fête", R.drawable.bg_party), - ListBackground("bg_pharmacie", "Pharmacie", R.drawable.bg_pharmacie), - ListBackground("bg_plage", "Plage", R.drawable.bg_plage), - ListBackground("bg_renovation", "Rénovation", R.drawable.bg_renovation) -) +val allListBackgrounds: List = + listOf( + ListBackground("bg_animaux", "Animaux", R.drawable.bg_animaux), + ListBackground("bg_baby", "Bébé", R.drawable.bg_baby), + ListBackground("bg_epicerie", "Épicerie", R.drawable.bg_epicerie), + ListBackground("bg_epicerie2", "Épicerie 2", R.drawable.bg_epicerie2), + ListBackground("bg_jardinage", "Maison & Jardin", R.drawable.bg_jardinage), + ListBackground("bg_office", "Bureau", R.drawable.bg_office), + ListBackground("bg_party", "Fête", R.drawable.bg_party), + ListBackground("bg_pharmacie", "Pharmacie", R.drawable.bg_pharmacie), + ListBackground("bg_plage", "Plage", R.drawable.bg_plage), + ListBackground("bg_renovation", "Rénovation", R.drawable.bg_renovation), + ) -fun backgroundByResName(name: String?): ListBackground? = - allListBackgrounds.firstOrNull { it.resName == name } +fun backgroundByResName(name: String?): ListBackground? = allListBackgrounds.firstOrNull { it.resName == name } -fun backgroundLabel(name: String?): String = - backgroundByResName(name)?.label ?: "" +fun backgroundLabel(name: String?): String = backgroundByResName(name)?.label ?: "" diff --git a/app/src/main/java/com/safebite/app/presentation/screen/main/MainScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/main/MainScreen.kt index 5b4e050..7ce9707 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/main/MainScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/main/MainScreen.kt @@ -1,5 +1,9 @@ package com.safebite.app.presentation.screen.main +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -31,11 +35,12 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.ui.graphics.Color import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -58,7 +63,7 @@ import com.safebite.app.presentation.screen.tracking.TrackingScreen /** * Écran principal de l'application avec Bottom Navigation et FAB Scanner. - * + * * Architecture (spec UX §3.1) : * - 4 onglets : Dashboard, Listes, Suivi, Famille * - FAB Scanner central (56dp, chevauchant la bottom bar) @@ -80,12 +85,14 @@ fun MainScreen( val currentDestination = currentBackStackEntry?.destination // Déterminer si le FAB doit être visible - val fabVisible = currentDestination?.route in listOf( - Screen.Dashboard.route, - Screen.Lists.route, - Screen.Tracking.route, - Screen.Family.route - ) + val fabVisible = + currentDestination?.route in + listOf( + Screen.Dashboard.route, + Screen.Lists.route, + Screen.Tracking.route, + Screen.Family.route, + ) Scaffold( containerColor = MaterialTheme.colorScheme.background, @@ -96,12 +103,12 @@ fun MainScreen( Image( painter = painterResource(id = R.drawable.safebite_logo_nobg), contentDescription = null, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp), ) Spacer(Modifier.width(8.dp)) Text( text = stringResource(R.string.app_name), - color = Color.White + color = Color.White, ) } }, @@ -110,47 +117,49 @@ fun MainScreen( Icon( imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.nav_settings), - tint = Color.White + tint = Color.White, ) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primary, - scrolledContainerColor = MaterialTheme.colorScheme.primary, - titleContentColor = Color.White, - navigationIconContentColor = Color.White, - actionIconContentColor = Color.White, - ) + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + scrolledContainerColor = MaterialTheme.colorScheme.primary, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + actionIconContentColor = Color.White, + ), ) }, bottomBar = { SafeBiteBottomNavigation( navController = navController, currentDestination = currentDestination, - items = bottomNavItems + items = bottomNavItems, ) }, floatingActionButton = { SafeBiteFab( visible = fabVisible, - onClick = onOpenScanner + onClick = onOpenScanner, ) }, - floatingActionButtonPosition = FabPosition.Center + floatingActionButtonPosition = FabPosition.Center, ) { paddingValues -> // NavHost pour les 4 onglets principaux NavHost( navController = navController, startDestination = Screen.Dashboard.route, - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), ) { composable(Screen.Dashboard.route) { DashboardScreen( onScan = onOpenScanner, onOpenList = onOpenListDetail, - onOpenHistoryItem = onOpenHistoryItem + onOpenHistoryItem = onOpenHistoryItem, ) } composable(Screen.Lists.route) { @@ -158,19 +167,19 @@ fun MainScreen( onOpenList = { id, name -> onOpenListDetail(id, name) }, onOpenScanner = onOpenScanner, onOpenListCreate = onOpenListCreate, - onOpenListSettings = onOpenListSettings + onOpenListSettings = onOpenListSettings, ) } composable(Screen.Tracking.route) { TrackingScreen( onOpenHistoryItem = onOpenHistoryItem, - onOpenScanner = onOpenScanner + onOpenScanner = onOpenScanner, ) } composable(Screen.Family.route) { FamilyScreen( onOpenProfile = onOpenProfile, - onOpenSettings = onOpenSettings + onOpenSettings = onOpenSettings, ) } } @@ -179,7 +188,7 @@ fun MainScreen( /** * Bottom Navigation Bar SafeBite (spec UX §3.2). - * + * * - 4 items avec icônes selected/unselected * - Badge pour notifications non lues * - Labels toujours visibles @@ -188,11 +197,11 @@ fun MainScreen( private fun SafeBiteBottomNavigation( navController: NavHostController, currentDestination: NavDestination?, - items: List + items: List, ) { NavigationBar( containerColor = MaterialTheme.colorScheme.primary, - tonalElevation = 2.dp + tonalElevation = 2.dp, ) { items.forEach { item -> val selected = currentDestination?.hierarchy?.any { it.route == item.screen.route } == true @@ -211,23 +220,24 @@ private fun SafeBiteBottomNavigation( val icon = if (selected) item.iconSelected else item.iconUnselected Icon( imageVector = icon, - contentDescription = null + contentDescription = null, ) }, label = { Text( text = item.label, - style = MaterialTheme.typography.labelMedium + style = MaterialTheme.typography.labelMedium, ) }, alwaysShowLabel = true, - colors = androidx.compose.material3.NavigationBarItemDefaults.colors( - selectedIconColor = Color.White, - selectedTextColor = Color.White, - unselectedIconColor = Color.White.copy(alpha = 0.7f), - unselectedTextColor = Color.White.copy(alpha = 0.7f), - indicatorColor = Color.White.copy(alpha = 0.2f) - ) + colors = + androidx.compose.material3.NavigationBarItemDefaults.colors( + selectedIconColor = Color.White, + selectedTextColor = Color.White, + unselectedIconColor = Color.White.copy(alpha = 0.7f), + unselectedTextColor = Color.White.copy(alpha = 0.7f), + indicatorColor = Color.White.copy(alpha = 0.2f), + ), ) } } @@ -235,7 +245,7 @@ private fun SafeBiteBottomNavigation( /** * FAB Scanner SafeBite (spec UX §3.3). - * + * * - 56dp, centré, chevauchant la bottom bar * - Animation scale + fade pour apparition/disparition * - Haptic feedback au tap @@ -243,38 +253,60 @@ private fun SafeBiteBottomNavigation( @Composable private fun SafeBiteFab( visible: Boolean, - onClick: () -> Unit + onClick: () -> Unit, ) { + val context = LocalContext.current AnimatedVisibility( visible = visible, - enter = fadeIn(animationSpec = tween(200)) + + enter = + fadeIn(animationSpec = tween(200)) + scaleIn(initialScale = 0.8f, animationSpec = tween(200)) + slideInVertically( initialOffsetY = { it }, - animationSpec = tween(200) + animationSpec = tween(200), + ), + exit = + fadeOut(animationSpec = tween(200)) + + scaleOut(targetScale = 0.8f, animationSpec = tween(200)) + + slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(200), ), - exit = fadeOut(animationSpec = tween(200)) + - scaleOut(targetScale = 0.8f, animationSpec = tween(200)) + - slideOutVertically( - targetOffsetY = { it }, - animationSpec = tween(200) - ) ) { FloatingActionButton( - onClick = onClick, + onClick = { + triggerFabHaptic(context) + onClick() + }, containerColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.surface, shape = MaterialTheme.shapes.medium, - elevation = androidx.compose.material3.FloatingActionButtonDefaults.elevation( - defaultElevation = 6.dp - ) + elevation = + androidx.compose.material3.FloatingActionButtonDefaults.elevation( + defaultElevation = 6.dp, + ), ) { Icon( imageVector = Icons.Filled.QrCodeScanner, contentDescription = stringResource(R.string.fab_scan), - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) } } } +/** Retour haptique léger (15ms) au tap du FAB Scanner, distinct du scan (60ms). */ +private fun triggerFabHaptic(context: android.content.Context) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vm = context.getSystemService(VibratorManager::class.java) ?: return + vm.defaultVibrator.vibrate(VibrationEffect.createOneShot(15, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + val v = context.getSystemService(android.content.Context.VIBRATOR_SERVICE) as? Vibrator + v?.vibrate(VibrationEffect.createOneShot(15, VibrationEffect.DEFAULT_AMPLITUDE)) + } + } catch (_: Throwable) { + // Silencieux si le matériel ne supporte pas la vibration + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrCaptureScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrCaptureScreen.kt index 1983375..5c2aecb 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrCaptureScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrCaptureScreen.kt @@ -30,10 +30,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -50,7 +50,7 @@ import java.util.concurrent.Executors @Composable fun OcrCaptureScreen( onBack: () -> Unit, - onCaptured: (String) -> Unit + onCaptured: (String) -> Unit, ) { val permission = rememberPermissionState(android.Manifest.permission.CAMERA) LaunchedEffect(Unit) { if (!permission.status.isGranted) permission.launchPermissionRequest() } @@ -65,41 +65,44 @@ fun OcrCaptureScreen( onBack = onBack, backContentDescription = stringResource(R.string.action_back), ) - } + }, ) { padding -> Box(Modifier.fillMaxSize().padding(padding)) { if (!permission.status.isGranted) { ErrorView( message = stringResource(R.string.scanner_camera_denied), - onRetry = { permission.launchPermissionRequest() } + onRetry = { permission.launchPermissionRequest() }, ) } else { OcrCameraView( onTextUpdate = { livePreviewText = it }, - onCapture = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) } + onCapture = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) }, ) Column( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(16.dp) + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(16.dp), ) { if (livePreviewText.isNotBlank()) { Text( livePreviewText.take(300), color = Color.White, - modifier = Modifier - .fillMaxWidth() - .background(Color(0xAA000000), RoundedCornerShape(8.dp)) - .padding(8.dp) + modifier = + Modifier + .fillMaxWidth() + .background(Color(0xAA000000), RoundedCornerShape(8.dp)) + .padding(8.dp), ) } else { Text( stringResource(R.string.ocr_capture_hint), color = Color.White, - modifier = Modifier - .background(Color(0xAA000000), RoundedCornerShape(8.dp)) - .padding(8.dp) + modifier = + Modifier + .background(Color(0xAA000000), RoundedCornerShape(8.dp)) + .padding(8.dp), ) } Spacer(Modifier.height(12.dp)) @@ -108,7 +111,7 @@ fun OcrCaptureScreen( onClick = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) }, enabled = livePreviewText.isNotBlank(), large = true, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } @@ -117,7 +120,10 @@ fun OcrCaptureScreen( } @Composable -private fun OcrCameraView(onTextUpdate: (String) -> Unit, onCapture: () -> Unit) { +private fun OcrCameraView( + onTextUpdate: (String) -> Unit, + onCapture: () -> Unit, +) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val executor = remember { Executors.newSingleThreadExecutor() } @@ -137,13 +143,17 @@ private fun OcrCameraView(onTextUpdate: (String) -> Unit, onCapture: () -> Unit) providerFuture.addListener({ val provider = providerFuture.get() val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) } - val analysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() + val analysis = + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() analysis.setAnalyzer(executor) { proxy: ImageProxy -> @androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class) val media = proxy.image - if (media == null) { proxy.close(); return@setAnalyzer } + if (media == null) { + proxy.close() + return@setAnalyzer + } val input = InputImage.fromMediaImage(media, proxy.imageInfo.rotationDegrees) recognizer.process(input) .addOnSuccessListener { result -> onTextUpdate(result.text) } @@ -152,11 +162,15 @@ private fun OcrCameraView(onTextUpdate: (String) -> Unit, onCapture: () -> Unit) try { provider.unbindAll() provider.bindToLifecycle( - lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, ) - } catch (_: Throwable) {} + } catch (_: Throwable) { + } }, ContextCompat.getMainExecutor(ctx)) previewView - } + }, ) } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrReviewScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrReviewScreen.kt index 6a52946..307ba24 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrReviewScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrReviewScreen.kt @@ -20,19 +20,18 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import com.safebite.app.R -import com.safebite.app.presentation.common.components.PrimaryButton -import com.safebite.app.presentation.common.components.SafeBiteTopAppBar import com.safebite.app.domain.engine.AllergenAnalysisEngine import com.safebite.app.domain.model.AllergenType +import com.safebite.app.presentation.common.components.PrimaryButton +import com.safebite.app.presentation.common.components.SafeBiteTopAppBar @OptIn(ExperimentalMaterial3Api::class) @Composable fun OcrReviewScreen( initialText: String, onBack: () -> Unit, - onAnalyze: (String) -> Unit + onAnalyze: (String) -> Unit, ) { var text by remember { mutableStateOf(initialText) } Scaffold( @@ -43,11 +42,11 @@ fun OcrReviewScreen( onBack = onBack, backContentDescription = stringResource(R.string.action_back), ) - } + }, ) { padding -> Column( Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text(stringResource(R.string.ocr_review_hint), color = MaterialTheme.colorScheme.onSurfaceVariant) OutlinedTextField( @@ -55,7 +54,7 @@ fun OcrReviewScreen( onValueChange = { text = it }, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.result_ingredients)) }, - minLines = 8 + minLines = 8, ) val highlights = remember(text) { findHighlights(text) } if (highlights.isNotEmpty()) { @@ -66,7 +65,7 @@ fun OcrReviewScreen( onClick = { onAnalyze(text) }, enabled = text.isNotBlank(), large = true, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrViewModel.kt index a7b383c..19b6d33 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/ocr/OcrViewModel.kt @@ -8,9 +8,13 @@ import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject @HiltViewModel -class OcrViewModel @Inject constructor() : ViewModel() { - private val _capturedText = MutableStateFlow("") - val capturedText: StateFlow = _capturedText.asStateFlow() +class OcrViewModel + @Inject + constructor() : ViewModel() { + private val _capturedText = MutableStateFlow("") + val capturedText: StateFlow = _capturedText.asStateFlow() - fun setText(text: String) { _capturedText.value = text } -} + fun setText(text: String) { + _capturedText.value = text + } + } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingScreen.kt index c20780b..0ce7fe8 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingScreen.kt @@ -5,12 +5,14 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -20,7 +22,9 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -30,38 +34,33 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale import com.safebite.app.R import com.safebite.app.domain.model.AllergenType import com.safebite.app.domain.model.CustomDietItem import com.safebite.app.domain.model.CustomItemTag import com.safebite.app.domain.model.DietaryRestriction +import com.safebite.app.presentation.common.components.AllergenLevel +import com.safebite.app.presentation.common.components.AllergenSelectionGrid import com.safebite.app.presentation.common.components.PrimaryButton import com.safebite.app.presentation.common.components.StandardTextField import com.safebite.app.presentation.common.components.TertiaryButton -import com.safebite.app.presentation.common.components.AllergenLevel -import com.safebite.app.presentation.common.components.AllergenSelectionGrid import com.safebite.app.presentation.screen.profile.CustomItemAdder import com.safebite.app.presentation.screen.profile.CustomItemsList -import com.google.accompanist.permissions.shouldShowRationale -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Switch @OptIn(ExperimentalPermissionsApi::class) @Composable fun OnboardingScreen( onFinished: () -> Unit, - viewModel: OnboardingViewModel = hiltViewModel() + viewModel: OnboardingViewModel = hiltViewModel(), ) { var step by rememberSaveable { mutableStateOf(0) } var name by rememberSaveable { mutableStateOf("") } @@ -77,49 +76,53 @@ fun OnboardingScreen( when (step) { 0 -> WelcomeStep(onNext = { step = 1 }) 1 -> HowStep(onNext = { step = 2 }) - 2 -> CreateProfileStep( - name = name, - onNameChange = { name = it }, - avatar = avatar, - onAvatarChange = { avatar = it }, - isDefault = isDefault, - onSetDefault = { isDefault = it }, - allergenLevels = allergenLevels.value, - onSetAllergenLevel = { a, level -> - allergenLevels.value = if (level == AllergenLevel.NONE) { - allergenLevels.value - a - } else { - allergenLevels.value + (a to level) - } - }, - restrictions = restrictions.value, - onToggleRestriction = { r -> - restrictions.value = if (r in restrictions.value) restrictions.value - r else restrictions.value + r - }, - customItems = customItems.value, - onAddCustomItem = { n, t -> - customItems.value = customItems.value + CustomDietItem(name = n, tag = t) - }, - onRemoveCustomItem = { item -> - customItems.value = customItems.value - item - }, - onNext = { - val severe = allergenLevels.value.filterValues { it == AllergenLevel.SEVERE }.keys - val moderate = allergenLevels.value.filterValues { it == AllergenLevel.TRACE }.keys - viewModel.createProfile(name, avatar, severe, moderate, restrictions.value, customItems.value) - step = 3 - } - ) - 3 -> PermissionStep( - granted = cameraPermission.status.isGranted, - rationale = cameraPermission.status.shouldShowRationale, - onRequest = { cameraPermission.launchPermissionRequest() }, - onNext = { step = 4 } - ) - 4 -> ReadyStep(onFinish = { - viewModel.complete() - onFinished() - }) + 2 -> + CreateProfileStep( + name = name, + onNameChange = { name = it }, + avatar = avatar, + onAvatarChange = { avatar = it }, + isDefault = isDefault, + onSetDefault = { isDefault = it }, + allergenLevels = allergenLevels.value, + onSetAllergenLevel = { a, level -> + allergenLevels.value = + if (level == AllergenLevel.NONE) { + allergenLevels.value - a + } else { + allergenLevels.value + (a to level) + } + }, + restrictions = restrictions.value, + onToggleRestriction = { r -> + restrictions.value = if (r in restrictions.value) restrictions.value - r else restrictions.value + r + }, + customItems = customItems.value, + onAddCustomItem = { n, t -> + customItems.value = customItems.value + CustomDietItem(name = n, tag = t) + }, + onRemoveCustomItem = { item -> + customItems.value = customItems.value - item + }, + onNext = { + val severe = allergenLevels.value.filterValues { it == AllergenLevel.SEVERE }.keys + val moderate = allergenLevels.value.filterValues { it == AllergenLevel.TRACE }.keys + viewModel.createProfile(name, avatar, severe, moderate, restrictions.value, customItems.value) + step = 3 + }, + ) + 3 -> + PermissionStep( + granted = cameraPermission.status.isGranted, + rationale = cameraPermission.status.shouldShowRationale, + onRequest = { cameraPermission.launchPermissionRequest() }, + onNext = { step = 4 }, + ) + 4 -> + ReadyStep(onFinish = { + viewModel.complete() + onFinished() + }) } } } @@ -129,31 +132,31 @@ private fun WelcomeStep(onNext: () -> Unit) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Image( painter = painterResource(id = R.drawable.safebite_logo), contentDescription = null, - modifier = Modifier.size(120.dp) + modifier = Modifier.size(120.dp), ) Spacer(Modifier.height(24.dp)) Text( stringResource(R.string.onboarding_welcome_title), style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) Spacer(Modifier.height(8.dp)) Text( stringResource(R.string.onboarding_welcome_subtitle), style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(32.dp)) PrimaryButton( text = stringResource(R.string.action_continue), onClick = onNext, large = true, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } @@ -162,66 +165,70 @@ private fun WelcomeStep(onNext: () -> Unit) { private fun HowStep(onNext: () -> Unit) { Column( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(20.dp) + verticalArrangement = Arrangement.spacedBy(20.dp), ) { Text( stringResource(R.string.onboarding_how_title), style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) Text( stringResource(R.string.onboarding_how_subtitle), style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(8.dp)) - val steps = listOf( - Triple("1", "👤", stringResource(R.string.onboarding_how_step1)), - Triple("2", "📷", stringResource(R.string.onboarding_how_step2)), - Triple("3", "✅", stringResource(R.string.onboarding_how_step3)) - ) + val steps = + listOf( + Triple("1", "👤", stringResource(R.string.onboarding_how_step1)), + Triple("2", "📷", stringResource(R.string.onboarding_how_step2)), + Triple("3", "✅", stringResource(R.string.onboarding_how_step3)), + ) for ((number, emoji, label) in steps) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { Box( - modifier = Modifier - .size(48.dp) - .background( - MaterialTheme.colorScheme.primaryContainer, - CircleShape - ), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(48.dp) + .background( + MaterialTheme.colorScheme.primaryContainer, + CircleShape, + ), + contentAlignment = Alignment.Center, ) { Text( text = number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onPrimaryContainer, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) } Column { Text( text = emoji, - style = MaterialTheme.typography.headlineSmall + style = MaterialTheme.typography.headlineSmall, ) Text( text = label, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) } } @@ -233,7 +240,7 @@ private fun HowStep(onNext: () -> Unit) { text = stringResource(R.string.action_continue), onClick = onNext, large = true, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } @@ -254,20 +261,20 @@ private fun CreateProfileStep( customItems: List, onAddCustomItem: (String, CustomItemTag) -> Unit, onRemoveCustomItem: (CustomDietItem) -> Unit, - onNext: () -> Unit + onNext: () -> Unit, ) { val avatars = listOf("🙂", "😀", "👧", "👦", "👨", "👩", "👵", "👴", "🧑", "👶", "🧒", "🧓", "🍽️", "🛒", "🥗", "🍎") val dimens = com.safebite.app.presentation.theme.LocalDimens.current LazyColumn( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(dimens.spacingMd) + verticalArrangement = Arrangement.spacedBy(dimens.spacingMd), ) { item { Text( stringResource(R.string.onboarding_profile_title), style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) } item { @@ -288,7 +295,7 @@ private fun CreateProfileStep( shape = CircleShape, color = bg, border = if (selected) androidx.compose.foundation.BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null, - modifier = Modifier.size(72.dp) + modifier = Modifier.size(72.dp), ) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(a, fontSize = MaterialTheme.typography.displaySmall.fontSize) @@ -312,7 +319,7 @@ private fun CreateProfileStep( item { AllergenSelectionGrid( selectedAllergens = allergenLevels, - onLevelChanged = onSetAllergenLevel + onLevelChanged = onSetAllergenLevel, ) } @@ -324,7 +331,7 @@ private fun CreateProfileStep( selected = r in restrictions, onClick = { onToggleRestriction(r) }, label = { Text(r.displayFr) }, - modifier = Modifier.padding(4.dp) + modifier = Modifier.padding(4.dp), ) } } @@ -347,27 +354,32 @@ private fun CreateProfileStep( onClick = onNext, enabled = name.isNotBlank(), large = true, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } } @Composable -private fun PermissionStep(granted: Boolean, rationale: Boolean, onRequest: () -> Unit, onNext: () -> Unit) { +private fun PermissionStep( + granted: Boolean, + rationale: Boolean, + onRequest: () -> Unit, + onNext: () -> Unit, +) { Column( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( stringResource(R.string.onboarding_permission_title), style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) Text( stringResource(R.string.onboarding_permission_body), style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.weight(1f)) if (granted) { @@ -375,14 +387,14 @@ private fun PermissionStep(granted: Boolean, rationale: Boolean, onRequest: () - text = stringResource(R.string.action_continue), onClick = onNext, large = true, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } else { PrimaryButton( text = stringResource(R.string.onboarding_permission_grant), onClick = onRequest, large = true, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) TertiaryButton( text = stringResource(R.string.action_continue), @@ -397,27 +409,27 @@ private fun ReadyStep(onFinish: () -> Unit) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Text("🎉", style = MaterialTheme.typography.displayLarge) Spacer(Modifier.height(16.dp)) Text( stringResource(R.string.onboarding_ready_title), style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) Spacer(Modifier.height(8.dp)) Text( stringResource(R.string.onboarding_ready_body), style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(32.dp)) PrimaryButton( text = stringResource(R.string.onboarding_start), onClick = onFinish, large = true, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingViewModel.kt index 4e1437f..c7cc06a 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/onboarding/OnboardingViewModel.kt @@ -13,31 +13,34 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class OnboardingViewModel @Inject constructor( - private val manageProfile: ManageProfileUseCase, - private val settings: SettingsRepository -) : ViewModel() { - fun createProfile( - name: String, - avatar: String, - severe: Set, - moderate: Set, - restrictions: Set = emptySet(), - customItems: List = emptyList() - ) = viewModelScope.launch { - val id = manageProfile.save( - UserProfile( - name = name.ifBlank { "Moi" }, - avatar = avatar, - severeAllergens = severe, - moderateIntolerances = moderate, - dietaryRestrictions = restrictions, - customItems = customItems, - isDefault = true - ) - ) - manageProfile.setActive(setOf(id)) - } +class OnboardingViewModel + @Inject + constructor( + private val manageProfile: ManageProfileUseCase, + private val settings: SettingsRepository, + ) : ViewModel() { + fun createProfile( + name: String, + avatar: String, + severe: Set, + moderate: Set, + restrictions: Set = emptySet(), + customItems: List = emptyList(), + ) = viewModelScope.launch { + val id = + manageProfile.save( + UserProfile( + name = name.ifBlank { "Moi" }, + avatar = avatar, + severeAllergens = severe, + moderateIntolerances = moderate, + dietaryRestrictions = restrictions, + customItems = customItems, + isDefault = true, + ), + ) + manageProfile.setActive(setOf(id)) + } - fun complete() = viewModelScope.launch { settings.setOnboardingCompleted(true) } -} + fun complete() = viewModelScope.launch { settings.setOnboardingCompleted(true) } + } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/product/ProductDetailScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/product/ProductDetailScreen.kt index 4bbb5f2..cb8f6bc 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/product/ProductDetailScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/product/ProductDetailScreen.kt @@ -13,10 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -71,7 +68,7 @@ fun ProductDetailScreen( barcode: String, onBack: () -> Unit, onOpenProduct: (String) -> Unit, - viewModel: ProductDetailViewModel = hiltViewModel() + viewModel: ProductDetailViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -83,7 +80,7 @@ fun ProductDetailScreen( SafeBiteTopAppBar( title = stringResource(R.string.result_ingredients), onBack = onBack, - backContentDescription = stringResource(R.string.a11y_back) + backContentDescription = stringResource(R.string.a11y_back), ) when (val state = uiState) { @@ -105,7 +102,7 @@ fun ProductDetailScreen( ProductDetailContent( product = state.product, scanResult = state.scanResult, - onOpenProduct = onOpenProduct + onOpenProduct = onOpenProduct, ) } } @@ -116,7 +113,7 @@ fun ProductDetailScreen( private fun ProductDetailContent( product: Product, scanResult: ScanResult?, - onOpenProduct: (String) -> Unit + onOpenProduct: (String) -> Unit, ) { val dimens = LocalDimens.current val tabTitles = listOf("Résumé", "Allergènes", "Additifs", "Alternatives") @@ -133,15 +130,15 @@ private fun ProductDetailContent( edgePadding = dimens.spacingMd, indicator = { tabPositions -> TabRowDefaults.SecondaryIndicator( - modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTab]) + modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTab]), ) - } + }, ) { tabTitles.forEachIndexed { index, title -> Tab( selected = selectedTab == index, onClick = { selectedTab = index }, - text = { Text(title, style = MaterialTheme.typography.labelLarge) } + text = { Text(title, style = MaterialTheme.typography.labelLarge) }, ) } } @@ -157,24 +154,28 @@ private fun ProductDetailContent( } @Composable -private fun ProductHeader(product: Product, scanResult: ScanResult?) { +private fun ProductHeader( + product: Product, + scanResult: ScanResult?, +) { val dimens = LocalDimens.current Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), ) { Column( modifier = Modifier.padding(dimens.spacingMd), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { // Image produit AsyncImage( model = product.imageUrl, contentDescription = stringResource(R.string.a11y_product_image), - modifier = Modifier - .size(120.dp) - .background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.medium) + modifier = + Modifier + .size(120.dp) + .background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.medium), ) Spacer(Modifier.height(dimens.spacingSm)) @@ -184,14 +185,14 @@ private fun ProductHeader(product: Product, scanResult: ScanResult?) { text = product.name ?: "Produit inconnu", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) if (!product.brand.isNullOrBlank()) { Text( text = product.brand, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -206,17 +207,19 @@ private fun ProductHeader(product: Product, scanResult: ScanResult?) { @Composable private fun VerdictBadge(status: SafetyStatus) { - val (text, color, a11yDesc) = when (status) { - SafetyStatus.SAFE -> Triple("✅ Sûr", Color(0xFF2ECC71), "Produit sûr") - SafetyStatus.WARNING -> Triple("⚠️ Attention", Color(0xFFF39C12), "Attention : traces d'allergènes") - SafetyStatus.DANGER -> Triple("❌ Danger", Color(0xFFE74C3C), "Danger : allergènes détectés") - } + val (text, color, a11yDesc) = + when (status) { + SafetyStatus.SAFE -> Triple("✅ Sûr", Color(0xFF2ECC71), "Produit sûr") + SafetyStatus.WARNING -> Triple("⚠️ Attention", Color(0xFFF39C12), "Attention : traces d'allergènes") + SafetyStatus.DANGER -> Triple("❌ Danger", Color(0xFFE74C3C), "Danger : allergènes détectés") + } Box( - modifier = Modifier - .background(color.copy(alpha = 0.15f), MaterialTheme.shapes.medium) - .padding(horizontal = 16.dp, vertical = 8.dp) - .semantics { contentDescription = a11yDesc } + modifier = + Modifier + .background(color.copy(alpha = 0.15f), MaterialTheme.shapes.medium) + .padding(horizontal = 16.dp, vertical = 8.dp) + .semantics { contentDescription = a11yDesc }, ) { Text(text = text, color = color, fontWeight = FontWeight.Bold) } @@ -225,14 +228,18 @@ private fun VerdictBadge(status: SafetyStatus) { // ── Tab Résumé ── @Composable -private fun SummaryTab(product: Product, scanResult: ScanResult?) { +private fun SummaryTab( + product: Product, + scanResult: ScanResult?, +) { val dimens = LocalDimens.current LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(dimens.spacingMd), - verticalArrangement = Arrangement.spacedBy(dimens.spacingMd) + modifier = + Modifier + .fillMaxSize() + .padding(dimens.spacingMd), + verticalArrangement = Arrangement.spacedBy(dimens.spacingMd), ) { // Nutri-Score if (scanResult?.health?.nutriScore != null) { @@ -265,30 +272,32 @@ private fun SummaryTab(product: Product, scanResult: ScanResult?) { @Composable private fun NutriScoreCard(grade: String) { val dimens = LocalDimens.current - val color = when (grade.uppercase()) { - "A" -> Color(0xFF1E8E3E) - "B" -> Color(0xFF7CB342) - "C" -> Color(0xFFFBC02D) - "D" -> Color(0xFFEF6C00) - "E" -> Color(0xFFC62828) - else -> Color.Gray - } + val color = + when (grade.uppercase()) { + "A" -> Color(0xFF1E8E3E) + "B" -> Color(0xFF7CB342) + "C" -> Color(0xFFFBC02D) + "D" -> Color(0xFFEF6C00) + "E" -> Color(0xFFC62828) + else -> Color.Gray + } val a11yDesc = stringResource(R.string.a11y_nutri_score, grade.uppercase()) Card(modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier.padding(dimens.spacingMd), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text("Nutri-Score", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) Box( - modifier = Modifier - .size(48.dp) - .background(color, MaterialTheme.shapes.medium) - .semantics { - contentDescription = a11yDesc - }, - contentAlignment = Alignment.Center + modifier = + Modifier + .size(48.dp) + .background(color, MaterialTheme.shapes.medium) + .semantics { + contentDescription = a11yDesc + }, + contentAlignment = Alignment.Center, ) { Text(grade.uppercase(), color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineSmall) } @@ -303,7 +312,7 @@ private fun CaloriesCard(kcal: Double) { Card(modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier.padding(dimens.spacingMd), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text("🔥", style = MaterialTheme.typography.headlineMedium) Spacer(Modifier.width(dimens.spacingMd)) @@ -332,7 +341,12 @@ private fun NutritionGauges(nutriments: Nutriments) { } @Composable -private fun GaugeRow(label: String, value: Double, max: Double, color: Color) { +private fun GaugeRow( + label: String, + value: Double, + max: Double, + color: Color, +) { val dimens = LocalDimens.current val progress = (value / max).toFloat().coerceIn(0f, 1f) @@ -343,16 +357,18 @@ private fun GaugeRow(label: String, value: Double, max: Double, color: Color) { } Spacer(Modifier.height(4.dp)) Box( - modifier = Modifier - .fillMaxWidth() - .height(8.dp) - .background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.small) + modifier = + Modifier + .fillMaxWidth() + .height(8.dp) + .background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.small), ) { Box( - modifier = Modifier - .fillMaxWidth(progress) - .height(8.dp) - .background(color, MaterialTheme.shapes.small) + modifier = + Modifier + .fillMaxWidth(progress) + .height(8.dp) + .background(color, MaterialTheme.shapes.small), ) } Spacer(Modifier.height(dimens.spacingSm)) @@ -362,20 +378,21 @@ private fun GaugeRow(label: String, value: Double, max: Double, color: Color) { @Composable private fun HealthVerdictCard(health: HealthAssessment) { val dimens = LocalDimens.current - val (emoji, text, color) = when (health.rating) { - HealthRating.HEALTHY -> Triple("💪", "Plutôt sain", Color(0xFF2E7D32)) - HealthRating.MODERATE -> Triple("🙂", "Modération", Color(0xFFF57C00)) - HealthRating.UNHEALTHY -> Triple("🚫", "Peu recommandable", Color(0xFFC62828)) - HealthRating.UNKNOWN -> Triple("❔", "Inconnu", Color.Gray) - } + val (emoji, text, color) = + when (health.rating) { + HealthRating.HEALTHY -> Triple("💪", "Plutôt sain", Color(0xFF2E7D32)) + HealthRating.MODERATE -> Triple("🙂", "Modération", Color(0xFFF57C00)) + HealthRating.UNHEALTHY -> Triple("🚫", "Peu recommandable", Color(0xFFC62828)) + HealthRating.UNKNOWN -> Triple("❔", "Inconnu", Color.Gray) + } Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f)) + colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f)), ) { Row( modifier = Modifier.padding(dimens.spacingMd), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text(emoji, style = MaterialTheme.typography.headlineMedium) Spacer(Modifier.width(dimens.spacingMd)) @@ -394,10 +411,11 @@ private fun AllergensTab(scanResult: ScanResult?) { val dimens = LocalDimens.current LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(dimens.spacingMd), - verticalArrangement = Arrangement.spacedBy(dimens.spacingSm) + modifier = + Modifier + .fillMaxSize() + .padding(dimens.spacingMd), + verticalArrangement = Arrangement.spacedBy(dimens.spacingSm), ) { item { Text("14 allergènes réglementaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) @@ -406,7 +424,11 @@ private fun AllergensTab(scanResult: ScanResult?) { if (scanResult == null) { item { - Text("Aucune analyse disponible", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + "Aucune analyse disponible", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } else { items(AllergenType.entries.toList()) { allergen -> @@ -418,25 +440,30 @@ private fun AllergensTab(scanResult: ScanResult?) { } @Composable -private fun AllergenStatusRow(allergen: AllergenType, detected: com.safebite.app.domain.model.DetectedAllergen?) { +private fun AllergenStatusRow( + allergen: AllergenType, + detected: com.safebite.app.domain.model.DetectedAllergen?, +) { val dimens = LocalDimens.current val a11yAbsent = stringResource(R.string.a11y_allergen_absent) val a11yTrace = stringResource(R.string.a11y_allergen_trace) val a11yPresent = stringResource(R.string.a11y_allergen_present) - val (status, emoji, bgColor, a11yDesc) = when { - detected == null -> Quad("Absent", "✅", Color(0xFFE8F8F5), a11yAbsent) - detected.detectionLevel == DetectionLevel.TRACE -> Quad("Traces", "⚠️", Color(0xFFFEF5E7), a11yTrace) - else -> Quad("Présent", "❌", Color(0xFFFDEDEC), a11yPresent) - } + val (status, emoji, bgColor, a11yDesc) = + when { + detected == null -> Quad("Absent", "✅", Color(0xFFE8F8F5), a11yAbsent) + detected.detectionLevel == DetectionLevel.TRACE -> Quad("Traces", "⚠️", Color(0xFFFEF5E7), a11yTrace) + else -> Quad("Présent", "❌", Color(0xFFFDEDEC), a11yPresent) + } Row( - modifier = Modifier - .fillMaxWidth() - .background(bgColor, MaterialTheme.shapes.small) - .padding(dimens.spacingSm) - .semantics { contentDescription = "${allergen.displayNameFr}: $a11yDesc" }, + modifier = + Modifier + .fillMaxWidth() + .background(bgColor, MaterialTheme.shapes.small) + .padding(dimens.spacingSm) + .semantics { contentDescription = "${allergen.displayNameFr}: $a11yDesc" }, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, ) { Row(verticalAlignment = Alignment.CenterVertically) { Text(allergen.icon, style = MaterialTheme.typography.bodyLarge) @@ -456,9 +483,10 @@ private fun AdditivesTab(product: Product) { val dimens = LocalDimens.current LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(dimens.spacingMd) + modifier = + Modifier + .fillMaxSize() + .padding(dimens.spacingMd), ) { item { Text("Additifs alimentaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) @@ -479,9 +507,10 @@ private fun AlternativesTab(onOpenProduct: (String) -> Unit) { val dimens = LocalDimens.current Column( - modifier = Modifier - .fillMaxSize() - .padding(dimens.spacingMd) + modifier = + Modifier + .fillMaxSize() + .padding(dimens.spacingMd), ) { Text("Produits similaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Spacer(Modifier.height(dimens.spacingMd)) @@ -492,7 +521,7 @@ private fun AlternativesTab(onOpenProduct: (String) -> Unit) { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/product/ProductDetailViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/product/ProductDetailViewModel.kt index b4b8107..37649e1 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/product/ProductDetailViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/product/ProductDetailViewModel.kt @@ -21,49 +21,57 @@ import javax.inject.Inject */ sealed class ProductDetailUiState { data object Loading : ProductDetailUiState() + data class Success( val product: Product, - val scanResult: ScanResult? + val scanResult: ScanResult?, ) : ProductDetailUiState() + data class Error(val message: String) : ProductDetailUiState() } @HiltViewModel -class ProductDetailViewModel @Inject constructor( - private val fetchProduct: FetchProductUseCase, - private val analyzeProduct: AnalyzeProductUseCase, - private val manageProfile: ManageProfileUseCase -) : ViewModel() { +class ProductDetailViewModel + @Inject + constructor( + private val fetchProduct: FetchProductUseCase, + private val analyzeProduct: AnalyzeProductUseCase, + private val manageProfile: ManageProfileUseCase, + ) : ViewModel() { + private val _uiState = MutableStateFlow(ProductDetailUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() - private val _uiState = MutableStateFlow(ProductDetailUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() + fun loadProduct(barcode: String) = + viewModelScope.launch { + _uiState.value = ProductDetailUiState.Loading - fun loadProduct(barcode: String) = viewModelScope.launch { - _uiState.value = ProductDetailUiState.Loading + when (val result = fetchProduct(barcode)) { + is ProductFetchResult.Found -> { + val profiles = resolveProfiles() + val scanResult = + if (profiles.isNotEmpty()) { + analyzeProduct(result.product, profiles, com.safebite.app.domain.model.DataSource.API) + } else { + null + } + _uiState.value = ProductDetailUiState.Success(result.product, scanResult) + } + is ProductFetchResult.NotFound -> { + _uiState.value = ProductDetailUiState.Error("Produit non trouvé") + } + is ProductFetchResult.Error -> { + _uiState.value = ProductDetailUiState.Error(result.message) + } + } + } - when (val result = fetchProduct(barcode)) { - is ProductFetchResult.Found -> { - val profiles = resolveProfiles() - val scanResult = if (profiles.isNotEmpty()) { - analyzeProduct(result.product, profiles, com.safebite.app.domain.model.DataSource.API) - } else null - _uiState.value = ProductDetailUiState.Success(result.product, scanResult) + private suspend fun resolveProfiles() = + run { + val all = manageProfile.observe().first() + val activeIds = manageProfile.observeActiveIds().first() + when { + activeIds.isNotEmpty() -> all.filter { it.id in activeIds } + else -> all.filter { it.isDefault }.ifEmpty { all.take(1) } + } } - is ProductFetchResult.NotFound -> { - _uiState.value = ProductDetailUiState.Error("Produit non trouvé") - } - is ProductFetchResult.Error -> { - _uiState.value = ProductDetailUiState.Error(result.message) - } - } } - - private suspend fun resolveProfiles() = run { - val all = manageProfile.observe().first() - val activeIds = manageProfile.observeActiveIds().first() - when { - activeIds.isNotEmpty() -> all.filter { it.id in activeIds } - else -> all.filter { it.isDefault }.ifEmpty { all.take(1) } - } - } -} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileComponents.kt b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileComponents.kt index 70b0e42..f0f4fbe 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileComponents.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileComponents.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -40,7 +39,10 @@ import com.safebite.app.domain.model.CustomItemTag @OptIn(ExperimentalLayoutApi::class) @Composable -fun AllergenGrid(selected: Set, onToggle: (AllergenType) -> Unit) { +fun AllergenGrid( + selected: Set, + onToggle: (AllergenType) -> Unit, +) { FlowRow { AllergenType.entries.forEach { a -> FilterChip( @@ -48,7 +50,7 @@ fun AllergenGrid(selected: Set, onToggle: (AllergenType) -> Unit) onClick = { onToggle(a) }, leadingIcon = { Text(a.icon) }, label = { Text(a.displayNameFr) }, - modifier = Modifier.padding(4.dp) + modifier = Modifier.padding(4.dp), ) } } @@ -66,7 +68,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) { onValueChange = { name = it }, label = { Text(stringResource(R.string.profile_custom_name)) }, modifier = Modifier.fillMaxWidth(), - singleLine = true + singleLine = true, ) Text(stringResource(R.string.profile_custom_tag), style = MaterialTheme.typography.labelLarge) FlowRow { @@ -75,7 +77,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) { selected = tag == t, onClick = { tag = t }, label = { Text(tagLabel(t)) }, - modifier = Modifier.padding(4.dp) + modifier = Modifier.padding(4.dp), ) } } @@ -85,7 +87,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) { name = "" }, enabled = name.isNotBlank(), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { Icon(Icons.Filled.Add, null) Spacer(Modifier.width(6.dp)) @@ -97,7 +99,10 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) { @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable -fun CustomItemsList(items: List, onRemove: (CustomDietItem) -> Unit) { +fun CustomItemsList( + items: List, + onRemove: (CustomDietItem) -> Unit, +) { if (items.isEmpty()) { Text(stringResource(R.string.profile_custom_empty), color = MaterialTheme.colorScheme.onSurfaceVariant) return @@ -109,38 +114,42 @@ fun CustomItemsList(items: List, onRemove: (CustomDietItem) -> U label = { Text( "${tagIcon(item.tag)} ${item.name}", - fontWeight = FontWeight.Medium + fontWeight = FontWeight.Medium, ) }, trailingIcon = { Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(16.dp)) }, - colors = AssistChipDefaults.assistChipColors( - containerColor = tagColor(item.tag).copy(alpha = 0.18f) - ), - modifier = Modifier.padding(4.dp) + colors = + AssistChipDefaults.assistChipColors( + containerColor = tagColor(item.tag).copy(alpha = 0.18f), + ), + modifier = Modifier.padding(4.dp), ) } } } @Composable -fun tagLabel(tag: CustomItemTag): String = when (tag) { - CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy) - CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance) - CustomItemTag.DIET -> stringResource(R.string.profile_custom_tag_diet) - CustomItemTag.UNHEALTHY -> stringResource(R.string.profile_custom_tag_unhealthy) -} +fun tagLabel(tag: CustomItemTag): String = + when (tag) { + CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy) + CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance) + CustomItemTag.DIET -> stringResource(R.string.profile_custom_tag_diet) + CustomItemTag.UNHEALTHY -> stringResource(R.string.profile_custom_tag_unhealthy) + } -fun tagIcon(tag: CustomItemTag): String = when (tag) { - CustomItemTag.ALLERGY -> "⛔" - CustomItemTag.INTOLERANCE -> "⚠️" - CustomItemTag.DIET -> "🥗" - CustomItemTag.UNHEALTHY -> "🍩" -} +fun tagIcon(tag: CustomItemTag): String = + when (tag) { + CustomItemTag.ALLERGY -> "⛔" + CustomItemTag.INTOLERANCE -> "⚠️" + CustomItemTag.DIET -> "🥗" + CustomItemTag.UNHEALTHY -> "🍩" + } @Composable -fun tagColor(tag: CustomItemTag): Color = when (tag) { - CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.error - CustomItemTag.INTOLERANCE -> Color(0xFFFFA000) - CustomItemTag.DIET -> MaterialTheme.colorScheme.tertiary - CustomItemTag.UNHEALTHY -> Color(0xFF9575CD) -} +fun tagColor(tag: CustomItemTag): Color = + when (tag) { + CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.error + CustomItemTag.INTOLERANCE -> Color(0xFFFFA000) + CustomItemTag.DIET -> MaterialTheme.colorScheme.tertiary + CustomItemTag.UNHEALTHY -> Color(0xFF9575CD) + } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileEditScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileEditScreen.kt index 7f7448e..2462617 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileEditScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileEditScreen.kt @@ -2,7 +2,6 @@ package com.safebite.app.presentation.screen.profile import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -28,20 +27,16 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.safebite.app.R -import com.safebite.app.presentation.common.components.AllergenLevel +import com.safebite.app.domain.model.DietaryRestriction import com.safebite.app.presentation.common.components.AllergenSelectionGrid import com.safebite.app.presentation.common.components.PrimaryButton import com.safebite.app.presentation.common.components.SafeBiteTopAppBar import com.safebite.app.presentation.common.components.StandardTextField import com.safebite.app.presentation.theme.LocalDimens -import com.safebite.app.domain.model.CustomDietItem -import com.safebite.app.domain.model.CustomItemTag -import com.safebite.app.domain.model.DietaryRestriction @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -49,7 +44,7 @@ fun ProfileEditScreen( id: Long, onBack: () -> Unit, onSaved: () -> Unit, - viewModel: ProfileViewModel = hiltViewModel() + viewModel: ProfileViewModel = hiltViewModel(), ) { LaunchedEffect(id) { viewModel.load(id) } val ui by viewModel.edit.collectAsStateWithLifecycle() @@ -64,13 +59,13 @@ fun ProfileEditScreen( onBack = onBack, backContentDescription = stringResource(R.string.action_back), ) - } + }, ) { padding -> if (!ui.loaded) return@Scaffold LazyColumn( modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg), - verticalArrangement = Arrangement.spacedBy(dimens.spacingMd) + verticalArrangement = Arrangement.spacedBy(dimens.spacingMd), ) { item { StandardTextField( @@ -89,8 +84,16 @@ fun ProfileEditScreen( onClick = { viewModel.setAvatar(a) }, shape = CircleShape, color = bg, - border = if (selected) androidx.compose.foundation.BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null, - modifier = Modifier.size(72.dp) + border = + if (selected) { + androidx.compose.foundation.BorderStroke( + 3.dp, + MaterialTheme.colorScheme.primary, + ) + } else { + null + }, + modifier = Modifier.size(72.dp), ) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text(a, fontSize = MaterialTheme.typography.displaySmall.fontSize) @@ -114,7 +117,7 @@ fun ProfileEditScreen( item { AllergenSelectionGrid( selectedAllergens = ui.allergenLevels, - onLevelChanged = viewModel::setAllergenLevel + onLevelChanged = viewModel::setAllergenLevel, ) } @@ -126,7 +129,7 @@ fun ProfileEditScreen( selected = r in ui.restrictions, onClick = { viewModel.toggleRestriction(r) }, label = { Text(r.displayFr) }, - modifier = Modifier.padding(4.dp) + modifier = Modifier.padding(4.dp), ) } } @@ -147,11 +150,9 @@ fun ProfileEditScreen( PrimaryButton( text = stringResource(R.string.action_save), onClick = { viewModel.save(onSaved) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } } } - - diff --git a/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileListScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileListScreen.kt index 0f0ef90..0621043 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileListScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileListScreen.kt @@ -42,7 +42,7 @@ fun ProfileListScreen( onBack: () -> Unit, onNew: () -> Unit, onEdit: (Long) -> Unit, - viewModel: ProfileViewModel = hiltViewModel() + viewModel: ProfileViewModel = hiltViewModel(), ) { val profiles by viewModel.profiles.collectAsStateWithLifecycle() val dimens = LocalDimens.current @@ -61,14 +61,15 @@ fun ProfileListScreen( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, ) { Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.action_save)) } - } + }, ) { padding -> LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg), - verticalArrangement = Arrangement.spacedBy(dimens.spacingMd) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg), + verticalArrangement = Arrangement.spacedBy(dimens.spacingMd), ) { items(profiles, key = { it.id }) { profile -> StandardCard( @@ -78,7 +79,7 @@ fun ProfileListScreen( ) { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { AvatarBubble(avatar = profile.avatar) Spacer(Modifier.size(dimens.spacingMd)) @@ -87,34 +88,34 @@ fun ProfileListScreen( Text( profile.name, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) if (profile.isDefault) { Spacer(Modifier.size(dimens.spacingXs + 2.dp)) androidx.compose.material3.AssistChip( onClick = {}, - label = { Text(stringResource(R.string.profile_default_badge)) } + label = { Text(stringResource(R.string.profile_default_badge)) }, ) } } Text( "${profile.severeAllergens.size + profile.moderateIntolerances.size} allergènes", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } IconButton(onClick = { onEdit(profile.id) }) { Icon( Icons.Filled.Edit, contentDescription = stringResource(R.string.action_save), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } IconButton(onClick = { viewModel.delete(profile) }) { Icon( Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileViewModel.kt index 89d145b..fa90269 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/profile/ProfileViewModel.kt @@ -27,7 +27,7 @@ data class ProfileEditUi( val restrictions: Set = emptySet(), val customItems: List = emptyList(), val isDefault: Boolean = false, - val loaded: Boolean = false + val loaded: Boolean = false, ) { // Propriétés calculées pour la compatibilité val severe: Set @@ -38,110 +38,134 @@ data class ProfileEditUi( } @HiltViewModel -class ProfileViewModel @Inject constructor( - private val manage: ManageProfileUseCase -) : ViewModel() { +class ProfileViewModel + @Inject + constructor( + private val manage: ManageProfileUseCase, + ) : ViewModel() { + val profiles: StateFlow> = + manage.observe() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - val profiles: StateFlow> = manage.observe() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + private val _edit = MutableStateFlow(ProfileEditUi()) + val edit: StateFlow = _edit.asStateFlow() - private val _edit = MutableStateFlow(ProfileEditUi()) - val edit: StateFlow = _edit.asStateFlow() + fun load(id: Long) = + viewModelScope.launch { + if (id == 0L) { + _edit.value = ProfileEditUi(loaded = true) + } else { + val p = manage.get(id) + if (p != null) { + // Construire la map des niveaux d'allergènes + val allergenLevels = mutableMapOf() + p.severeAllergens.forEach { allergenLevels[it] = AllergenLevel.SEVERE } + p.moderateIntolerances.forEach { allergenLevels[it] = AllergenLevel.TRACE } - fun load(id: Long) = viewModelScope.launch { - if (id == 0L) { - _edit.value = ProfileEditUi(loaded = true) - } else { - val p = manage.get(id) - if (p != null) { - // Construire la map des niveaux d'allergènes - val allergenLevels = mutableMapOf() - p.severeAllergens.forEach { allergenLevels[it] = AllergenLevel.SEVERE } - p.moderateIntolerances.forEach { allergenLevels[it] = AllergenLevel.TRACE } + _edit.value = + ProfileEditUi( + id = p.id, + name = p.name, + avatar = p.avatar, + allergenLevels = allergenLevels, + restrictions = p.dietaryRestrictions, + customItems = p.customItems, + isDefault = p.isDefault, + loaded = true, + ) + } + } + } - _edit.value = ProfileEditUi( - id = p.id, - name = p.name, - avatar = p.avatar, - allergenLevels = allergenLevels, - restrictions = p.dietaryRestrictions, - customItems = p.customItems, - isDefault = p.isDefault, - loaded = true - ) + fun setName(v: String) = _edit.update { it.copy(name = v) } + + fun setAvatar(v: String) = _edit.update { it.copy(avatar = v) } + + /** Met à jour le niveau d'un allergène (cycle : NONE → TRACE → SEVERE → NONE) */ + fun setAllergenLevel( + allergen: AllergenType, + level: AllergenLevel, + ) = _edit.update { s -> + val newLevels = + if (level == AllergenLevel.NONE) { + s.allergenLevels - allergen + } else { + s.allergenLevels + (allergen to level) + } + s.copy(allergenLevels = newLevels) + } + + // Méthodes de compatibilité pour l'ancien AllergenGrid + fun toggleSevere(a: AllergenType) = + _edit.update { s -> + val newLevel = if (a in s.severe) AllergenLevel.NONE else AllergenLevel.SEVERE + val newLevels = + if (newLevel == AllergenLevel.NONE) { + s.allergenLevels - a + } else { + s.allergenLevels + (a to newLevel) + } + s.copy(allergenLevels = newLevels) + } + + fun toggleModerate(a: AllergenType) = + _edit.update { s -> + val newLevel = if (a in s.moderate) AllergenLevel.NONE else AllergenLevel.TRACE + val newLevels = + if (newLevel == AllergenLevel.NONE) { + s.allergenLevels - a + } else { + s.allergenLevels + (a to newLevel) + } + s.copy(allergenLevels = newLevels) + } + + fun toggleRestriction(r: DietaryRestriction) = + _edit.update { s -> + s.copy(restrictions = if (r in s.restrictions) s.restrictions - r else s.restrictions + r) + } + + fun setDefault(v: Boolean) = _edit.update { it.copy(isDefault = v) } + + fun addCustomItem( + name: String, + tag: CustomItemTag, + ) { + val trimmed = name.trim() + if (trimmed.isBlank()) return + _edit.update { s -> + if (s.customItems.any { it.name.equals(trimmed, ignoreCase = true) && it.tag == tag }) { + s + } else { + s.copy(customItems = s.customItems + CustomDietItem(trimmed, tag)) + } } } + + fun removeCustomItem(item: CustomDietItem) = + _edit.update { s -> + s.copy(customItems = s.customItems.filterNot { it.name == item.name && it.tag == item.tag }) + } + + fun save(onDone: () -> Unit) = + viewModelScope.launch { + val ui = _edit.value + val id = + manage.save( + UserProfile( + id = ui.id, + name = ui.name.ifBlank { "Profil" }, + avatar = ui.avatar, + severeAllergens = ui.severe, + moderateIntolerances = ui.moderate, + dietaryRestrictions = ui.restrictions, + customItems = ui.customItems, + isDefault = ui.isDefault, + ), + ) + if (ui.isDefault) manage.setDefault(id) + onDone() + } + + fun delete(profile: UserProfile) = viewModelScope.launch { manage.delete(profile) } } - - fun setName(v: String) = _edit.update { it.copy(name = v) } - fun setAvatar(v: String) = _edit.update { it.copy(avatar = v) } - - /** Met à jour le niveau d'un allergène (cycle : NONE → TRACE → SEVERE → NONE) */ - fun setAllergenLevel(allergen: AllergenType, level: AllergenLevel) = _edit.update { s -> - val newLevels = if (level == AllergenLevel.NONE) { - s.allergenLevels - allergen - } else { - s.allergenLevels + (allergen to level) - } - s.copy(allergenLevels = newLevels) - } - - // Méthodes de compatibilité pour l'ancien AllergenGrid - fun toggleSevere(a: AllergenType) = _edit.update { s -> - val newLevel = if (a in s.severe) AllergenLevel.NONE else AllergenLevel.SEVERE - val newLevels = if (newLevel == AllergenLevel.NONE) { - s.allergenLevels - a - } else { - s.allergenLevels + (a to newLevel) - } - s.copy(allergenLevels = newLevels) - } - - fun toggleModerate(a: AllergenType) = _edit.update { s -> - val newLevel = if (a in s.moderate) AllergenLevel.NONE else AllergenLevel.TRACE - val newLevels = if (newLevel == AllergenLevel.NONE) { - s.allergenLevels - a - } else { - s.allergenLevels + (a to newLevel) - } - s.copy(allergenLevels = newLevels) - } - - fun toggleRestriction(r: DietaryRestriction) = _edit.update { s -> - s.copy(restrictions = if (r in s.restrictions) s.restrictions - r else s.restrictions + r) - } - fun setDefault(v: Boolean) = _edit.update { it.copy(isDefault = v) } - - fun addCustomItem(name: String, tag: CustomItemTag) { - val trimmed = name.trim() - if (trimmed.isBlank()) return - _edit.update { s -> - if (s.customItems.any { it.name.equals(trimmed, ignoreCase = true) && it.tag == tag }) s - else s.copy(customItems = s.customItems + CustomDietItem(trimmed, tag)) - } - } - - fun removeCustomItem(item: CustomDietItem) = _edit.update { s -> - s.copy(customItems = s.customItems.filterNot { it.name == item.name && it.tag == item.tag }) - } - - fun save(onDone: () -> Unit) = viewModelScope.launch { - val ui = _edit.value - val id = manage.save( - UserProfile( - id = ui.id, - name = ui.name.ifBlank { "Profil" }, - avatar = ui.avatar, - severeAllergens = ui.severe, - moderateIntolerances = ui.moderate, - dietaryRestrictions = ui.restrictions, - customItems = ui.customItems, - isDefault = ui.isDefault - ) - ) - if (ui.isDefault) manage.setDefault(id) - onDone() - } - - fun delete(profile: UserProfile) = viewModelScope.launch { manage.delete(profile) } -} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/result/ProductNotFoundScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/result/ProductNotFoundScreen.kt index ee69e61..0ab1801 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/result/ProductNotFoundScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/result/ProductNotFoundScreen.kt @@ -2,7 +2,6 @@ package com.safebite.app.presentation.screen.result import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -10,7 +9,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CameraAlt @@ -33,9 +31,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import com.safebite.app.R import com.safebite.app.presentation.common.components.PrimaryButton import com.safebite.app.presentation.common.components.SafeBiteTopAppBar @@ -58,7 +54,7 @@ fun ProductNotFoundScreen( onBack: () -> Unit, onOpenOcr: () -> Unit, onManualSubmit: (String) -> Unit, - onScanAgain: () -> Unit + onScanAgain: () -> Unit, ) { val dimens = LocalDimens.current var manualBarcode by remember { mutableStateOf("") } @@ -71,19 +67,20 @@ fun ProductNotFoundScreen( SafeBiteTopAppBar( title = stringResource(R.string.result_product_not_found), onBack = onBack, - backContentDescription = stringResource(R.string.a11y_back) + backContentDescription = stringResource(R.string.a11y_back), ) - } + }, ) { padding -> if (submitted) { // Message de confirmation Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(dimens.spacingXl), + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(dimens.spacingXl), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Text("✅", style = MaterialTheme.typography.displayMedium) Spacer(Modifier.height(dimens.spacingMd)) @@ -91,55 +88,57 @@ fun ProductNotFoundScreen( text = "Merci pour votre contribution !", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) Spacer(Modifier.height(dimens.spacingSm)) Text( text = "Le produit sera analysé sous 24h. Vous recevrez une notification quand le résultat sera disponible.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) Spacer(Modifier.height(dimens.spacingLg)) PrimaryButton( text = "Scanner un autre produit", onClick = onScanAgain, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } else { Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(dimens.spacingLg), - verticalArrangement = Arrangement.spacedBy(dimens.spacingMd) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(dimens.spacingLg), + verticalArrangement = Arrangement.spacedBy(dimens.spacingMd), ) { // Message principal Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), ) { Column( modifier = Modifier.padding(dimens.spacingMd), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Text("🔍", style = MaterialTheme.typography.displayMedium) Spacer(Modifier.height(dimens.spacingSm)) Text( text = "Produit non reconnu", style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(dimens.spacingSm)) Text( text = "Ce produit (code: $barcode) n'est pas dans notre base de données.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } @@ -148,13 +147,14 @@ fun ProductNotFoundScreen( Text( text = "Option 1 : Photographier les ingrédients", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) OutlinedButton( onClick = onOpenOcr, - modifier = Modifier - .fillMaxWidth() - .semantics { contentDescription = "Prendre une photo des ingrédients" } + modifier = + Modifier + .fillMaxWidth() + .semantics { contentDescription = "Prendre une photo des ingrédients" }, ) { Icon(Icons.Filled.CameraAlt, contentDescription = null) Spacer(Modifier.size(dimens.spacingSm)) @@ -165,7 +165,7 @@ fun ProductNotFoundScreen( Text( text = "Option 2 : Saisie manuelle", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(dimens.spacingMd)) { @@ -173,7 +173,7 @@ fun ProductNotFoundScreen( value = productName, onValueChange = { productName = it }, label = "Nom du produit (optionnel)", - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(dimens.spacingSm)) StandardTextField( @@ -181,7 +181,7 @@ fun ProductNotFoundScreen( onValueChange = { manualBarcode = it }, label = "Code-barres", leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(dimens.spacingMd)) PrimaryButton( @@ -193,7 +193,7 @@ fun ProductNotFoundScreen( } }, enabled = manualBarcode.isNotBlank(), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } @@ -203,7 +203,7 @@ fun ProductNotFoundScreen( text = "💡 Vous pouvez aussi scanner un autre produit similaire en magasin.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/result/ResultScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/result/ResultScreen.kt index eef03b5..078832c 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/result/ResultScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/result/ResultScreen.kt @@ -3,6 +3,9 @@ package com.safebite.app.presentation.screen.result import android.content.Intent import android.net.Uri import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -10,7 +13,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -52,7 +54,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode -import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics @@ -74,7 +75,6 @@ import com.safebite.app.domain.model.HealthRating import com.safebite.app.domain.model.Nutriments import com.safebite.app.domain.model.ScanResult import com.safebite.app.presentation.common.components.ErrorView -import com.safebite.app.presentation.common.components.LoadingIndicator import com.safebite.app.presentation.common.components.OutlinedActionButton import com.safebite.app.presentation.common.components.PrimaryButton import com.safebite.app.presentation.common.components.ProductCard @@ -82,7 +82,6 @@ import com.safebite.app.presentation.common.components.ProductSkeleton import com.safebite.app.presentation.common.components.SafeBiteTopAppBar import com.safebite.app.presentation.common.components.SafetyStatusBanner import com.safebite.app.presentation.common.util.UiState -import com.safebite.app.presentation.theme.LocalDimens @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -93,7 +92,8 @@ fun ResultScreen( onBack: () -> Unit, onScanAgain: () -> Unit, onOcr: () -> Unit, - viewModel: ResultViewModel = hiltViewModel() + onOpenAlternatives: () -> Unit, + viewModel: ResultViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() val lists by viewModel.lists.collectAsStateWithLifecycle() @@ -114,46 +114,50 @@ fun ResultScreen( onBack = onBack, backContentDescription = stringResource(R.string.a11y_back), ) - } + }, ) { padding -> Box(Modifier.fillMaxSize().padding(padding)) { when (val s = state) { UiState.Idle, UiState.Loading -> ProductSkeleton() is UiState.Error -> { - val msg = when { - s.offline -> stringResource(R.string.error_no_connection) - s.message == "not_found" -> stringResource(R.string.result_product_not_found) - else -> stringResource(R.string.error_product_unavailable) - } + val msg = + when { + s.offline -> stringResource(R.string.error_no_connection) + s.message == "not_found" -> stringResource(R.string.result_product_not_found) + else -> stringResource(R.string.error_product_unavailable) + } val errorContentDesc = stringResource(R.string.a11y_error, msg) Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .semantics { - contentDescription = errorContentDesc - }, - verticalArrangement = Arrangement.spacedBy(12.dp) + modifier = + Modifier + .fillMaxSize() + .padding(16.dp) + .semantics { + contentDescription = errorContentDesc + }, + verticalArrangement = Arrangement.spacedBy(12.dp), ) { ErrorView(message = msg, modifier = Modifier.weight(1f)) OutlinedActionButton( text = stringResource(R.string.action_read_ingredients), onClick = onOcr, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) PrimaryButton( text = stringResource(R.string.action_scan_again), onClick = onScanAgain, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } - is UiState.Success -> ResultContent( - result = s.data, - onScanAgain = onScanAgain, - onOcr = onOcr, - onAddToList = { showListPicker = true } - ) + is UiState.Success -> + ResultContent( + result = s.data, + onScanAgain = onScanAgain, + onOcr = onOcr, + onAddToList = { showListPicker = true }, + onOpenAlternatives = onOpenAlternatives, + ) } if (showListPicker) { @@ -163,7 +167,7 @@ fun ResultScreen( viewModel.addToList(listId) showListPicker = false }, - onDismiss = { showListPicker = false } + onDismiss = { showListPicker = false }, ) } } @@ -176,167 +180,207 @@ private fun ResultContent( result: ScanResult, onScanAgain: () -> Unit, onOcr: () -> Unit, - onAddToList: () -> Unit + onAddToList: () -> Unit, + onOpenAlternatives: () -> Unit, ) { var ingredientsExpanded by remember { mutableStateOf(false) } + var actionsVisible by remember { mutableStateOf(false) } + var contentVisible by remember { mutableStateOf(false) } val context = LocalContext.current - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - // 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 - ) + LaunchedEffect(Unit) { + contentVisible = true + actionsVisible = true + } - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - ProductCard( - title = result.product.name ?: result.product.barcode, - subtitle = result.product.brand, - imageUrl = result.product.imageUrl, - imageContentDescription = stringResource(R.string.a11y_product_image) + AnimatedVisibility( + visible = contentVisible, + enter = fadeIn(tween(250)) + slideInVertically(tween(250)) { it / 8 }, + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + // Annonce TalkBack pour le verdict + val verdictAnnouncement = + when (result.safetyStatus) { + com.safebite.app.domain.model.SafetyStatus.SAFE -> stringResource(R.string.a11y_verdict_safe) + com.safebite.app.domain.model.SafetyStatus.WARNING -> stringResource(R.string.a11y_verdict_warning) + com.safebite.app.domain.model.SafetyStatus.DANGER -> { + val profile = result.analyzedProfiles.firstOrNull()?.name ?: "" + if (profile.isNotEmpty()) { + stringResource(R.string.a11y_verdict_danger, profile) + } else { + stringResource(R.string.a11y_danger_status, "") + } + } + } + Text( + text = "", + modifier = + Modifier + .semantics { + liveRegion = LiveRegionMode.Assertive + contentDescription = verdictAnnouncement + }, ) - // Open on OFF (only when we have a real barcode, not an OCR synthetic one). - if (result.source != DataSource.OCR) { - OutlinedActionButton( - text = stringResource(R.string.result_open_in_off), - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(result.product.openFoodFactsUrl())) - ContextCompat.startActivity(context, intent, null) - }, - icon = Icons.AutoMirrored.Filled.OpenInNew, - modifier = Modifier.fillMaxWidth() + SafetyStatusBanner( + status = result.safetyStatus, + profileName = result.analyzedProfiles.firstOrNull()?.name, + allergenName = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr, + severity = if (result.detectedAllergens.any { it.severe }) "anaphylaxis" else null, + ) + + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + ProductCard( + title = result.product.name ?: result.product.barcode, + subtitle = result.product.brand, + imageUrl = result.product.imageUrl, + imageContentDescription = stringResource(R.string.a11y_product_image), ) - } - ConfidenceRow(result.confidence, result.source) - - if (result.analyzedProfiles.isNotEmpty()) { - Text( - stringResource(R.string.result_profiles_checked) + ": " + - result.analyzedProfiles.joinToString { it.name }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Text(stringResource(R.string.result_detected_allergens), style = MaterialTheme.typography.titleMedium) - if (result.detectedAllergens.isEmpty()) { - Text( - stringResource(R.string.result_no_allergen_detected), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - result.detectedAllergens.forEach { AllergenRow(it) } - } - - if (result.detectedCustomItems.isNotEmpty()) { - Text(stringResource(R.string.result_custom_matches), style = MaterialTheme.typography.titleMedium) - result.detectedCustomItems.forEach { CustomItemRow(it) } - } - - HealthSection(result.health) - - NutritionSection(result.product.nutriments, result.product.servingSize) - - ScoresSection(result.health) - - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - stringResource(R.string.result_ingredients), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f) + // Open on OFF (only when we have a real barcode, not an OCR synthetic one). + if (result.source != DataSource.OCR) { + StaggeredAction(visible = actionsVisible, delayMs = 0) { + OutlinedActionButton( + text = stringResource(R.string.result_open_in_off), + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(result.product.openFoodFactsUrl())) + ContextCompat.startActivity(context, intent, null) + }, + icon = Icons.AutoMirrored.Filled.OpenInNew, + modifier = Modifier.fillMaxWidth(), ) - IconButton(onClick = { ingredientsExpanded = !ingredientsExpanded }) { - Icon( - if (ingredientsExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = if (ingredientsExpanded) - stringResource(R.string.a11y_collapse) - else - stringResource(R.string.a11y_expand) + } + } + + ConfidenceRow(result.confidence, result.source) + + if (result.analyzedProfiles.isNotEmpty()) { + Text( + stringResource(R.string.result_profiles_checked) + ": " + + result.analyzedProfiles.joinToString { it.name }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text(stringResource(R.string.result_detected_allergens), style = MaterialTheme.typography.titleMedium) + if (result.detectedAllergens.isEmpty()) { + Text( + stringResource(R.string.result_no_allergen_detected), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + result.detectedAllergens.forEach { AllergenRow(it) } + } + + if (result.detectedCustomItems.isNotEmpty()) { + Text(stringResource(R.string.result_custom_matches), style = MaterialTheme.typography.titleMedium) + result.detectedCustomItems.forEach { CustomItemRow(it) } + } + + HealthSection(result.health) + + NutritionSection(result.product.nutriments, result.product.servingSize) + + ScoresSection(result.health) + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.result_ingredients), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { ingredientsExpanded = !ingredientsExpanded }) { + Icon( + if (ingredientsExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = + if (ingredientsExpanded) { + stringResource(R.string.a11y_collapse) + } else { + stringResource(R.string.a11y_expand) + }, + ) + } + } + AnimatedVisibility(visible = ingredientsExpanded) { + Text( + result.product.ingredientsText + ?: stringResource(R.string.result_ingredients_unavailable), + style = MaterialTheme.typography.bodyMedium, ) } } - AnimatedVisibility(visible = ingredientsExpanded) { - Text( - result.product.ingredientsText - ?: stringResource(R.string.result_ingredients_unavailable), - style = MaterialTheme.typography.bodyMedium + } + + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.result_disclaimer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + StaggeredAction(visible = actionsVisible, delayMs = 50) { + OutlinedActionButton( + text = stringResource(R.string.result_add_to_list), + onClick = onAddToList, + modifier = Modifier.fillMaxWidth(), + ) + } + + // Bouton Alternatives (uniquement si verdict != SAFE) + if (result.safetyStatus != com.safebite.app.domain.model.SafetyStatus.SAFE) { + StaggeredAction(visible = actionsVisible, delayMs = 100) { + PrimaryButton( + text = stringResource(R.string.result_see_alternatives), + onClick = onOpenAlternatives, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + StaggeredAction(visible = actionsVisible, delayMs = 150) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedActionButton( + text = stringResource(R.string.action_read_ingredients), + onClick = onOcr, + modifier = Modifier.weight(1f), + ) + PrimaryButton( + text = stringResource(R.string.action_scan_again), + onClick = onScanAgain, + modifier = Modifier.weight(1f), ) } } } - - Spacer(Modifier.height(4.dp)) - Text( - stringResource(R.string.result_disclaimer), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - OutlinedActionButton( - text = stringResource(R.string.result_add_to_list), - onClick = onAddToList, - modifier = Modifier.fillMaxWidth() - ) - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedActionButton( - text = stringResource(R.string.action_read_ingredients), - onClick = onOcr, - modifier = Modifier.weight(1f) - ) - PrimaryButton( - text = stringResource(R.string.action_scan_again), - onClick = onScanAgain, - modifier = Modifier.weight(1f) - ) - } } - } + } // AnimatedVisibility (slide-up transition) } @Composable -private fun ConfidenceRow(confidence: AnalysisConfidence, source: DataSource) { - val label = when (confidence) { - AnalysisConfidence.HIGH -> R.string.result_confidence_high - AnalysisConfidence.MEDIUM -> R.string.result_confidence_medium - AnalysisConfidence.LOW -> R.string.result_confidence_low - } - val src = when (source) { - DataSource.API -> R.string.result_source_api - DataSource.CACHE -> R.string.result_source_cache - DataSource.OCR -> R.string.result_source_ocr - } +private fun ConfidenceRow( + confidence: AnalysisConfidence, + source: DataSource, +) { + val label = + when (confidence) { + AnalysisConfidence.HIGH -> R.string.result_confidence_high + AnalysisConfidence.MEDIUM -> R.string.result_confidence_medium + AnalysisConfidence.LOW -> R.string.result_confidence_low + } + val src = + when (source) { + DataSource.API -> R.string.result_source_api + DataSource.CACHE -> R.string.result_source_cache + DataSource.OCR -> R.string.result_source_ocr + } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { AssistChip(onClick = {}, label = { Text(stringResource(R.string.result_confidence) + ": " + stringResource(label)) @@ -347,18 +391,23 @@ private fun ConfidenceRow(confidence: AnalysisConfidence, source: DataSource) { @Composable private fun AllergenRow(d: DetectedAllergen) { - val levelText = when (d.detectionLevel) { - DetectionLevel.CONFIRMED -> stringResource(R.string.result_level_confirmed) - DetectionLevel.TRACE -> stringResource(R.string.result_level_trace) - DetectionLevel.SUSPECTED -> stringResource(R.string.result_level_suspected) - } + val levelText = + when (d.detectionLevel) { + DetectionLevel.CONFIRMED -> stringResource(R.string.result_level_confirmed) + DetectionLevel.TRACE -> stringResource(R.string.result_level_trace) + DetectionLevel.SUSPECTED -> stringResource(R.string.result_level_suspected) + } Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = if (d.severe && d.detectionLevel == DetectionLevel.CONFIRMED) - MaterialTheme.colorScheme.errorContainer - else MaterialTheme.colorScheme.surfaceVariant - ) + colors = + CardDefaults.cardColors( + containerColor = + if (d.severe && d.detectionLevel == DetectionLevel.CONFIRMED) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), ) { Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Text(d.allergenType.icon, style = MaterialTheme.typography.headlineMedium) @@ -367,14 +416,14 @@ private fun AllergenRow(d: DetectedAllergen) { Text( d.allergenType.displayNameFr, style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ) Text("$levelText · ${d.source}", style = MaterialTheme.typography.bodySmall) if (d.matchedKeywords.isNotEmpty()) { Text( d.matchedKeywords.joinToString(), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -384,25 +433,28 @@ private fun AllergenRow(d: DetectedAllergen) { @Composable private fun CustomItemRow(d: DetectedCustomItem) { - val tagLabel = when (d.item.tag) { - CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy) - CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance) - CustomItemTag.DIET -> stringResource(R.string.profile_custom_tag_diet) - CustomItemTag.UNHEALTHY -> stringResource(R.string.profile_custom_tag_unhealthy) - } - val icon = when (d.item.tag) { - CustomItemTag.ALLERGY -> "⛔" - CustomItemTag.INTOLERANCE -> "⚠️" - CustomItemTag.DIET -> "🥗" - CustomItemTag.UNHEALTHY -> "🍩" - } - val bg = when (d.item.tag) { - CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.errorContainer - else -> MaterialTheme.colorScheme.surfaceVariant - } + val tagLabel = + when (d.item.tag) { + CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy) + CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance) + CustomItemTag.DIET -> stringResource(R.string.profile_custom_tag_diet) + CustomItemTag.UNHEALTHY -> stringResource(R.string.profile_custom_tag_unhealthy) + } + val icon = + when (d.item.tag) { + CustomItemTag.ALLERGY -> "⛔" + CustomItemTag.INTOLERANCE -> "⚠️" + CustomItemTag.DIET -> "🥗" + CustomItemTag.UNHEALTHY -> "🍩" + } + val bg = + when (d.item.tag) { + CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.errorContainer + else -> MaterialTheme.colorScheme.surfaceVariant + } Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = bg) + colors = CardDefaults.cardColors(containerColor = bg), ) { Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Text(icon, style = MaterialTheme.typography.headlineMedium) @@ -414,7 +466,7 @@ private fun CustomItemRow(d: DetectedCustomItem) { Text( d.matchedKeywords.joinToString(), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -424,15 +476,16 @@ private fun CustomItemRow(d: DetectedCustomItem) { @Composable private fun HealthSection(health: HealthAssessment) { - val (ratingText, ratingColor, emoji) = when (health.rating) { - HealthRating.HEALTHY -> Triple(stringResource(R.string.result_health_healthy), Color(0xFF2E7D32), "💪") - HealthRating.MODERATE -> Triple(stringResource(R.string.result_health_moderate), Color(0xFFF57C00), "🙂") - HealthRating.UNHEALTHY -> Triple(stringResource(R.string.result_health_unhealthy), Color(0xFFC62828), "🚫") - HealthRating.UNKNOWN -> Triple(stringResource(R.string.result_health_unknown), Color(0xFF757575), "❔") - } + val (ratingText, ratingColor, emoji) = + when (health.rating) { + HealthRating.HEALTHY -> Triple(stringResource(R.string.result_health_healthy), Color(0xFF2E7D32), "💪") + HealthRating.MODERATE -> Triple(stringResource(R.string.result_health_moderate), Color(0xFFF57C00), "🙂") + HealthRating.UNHEALTHY -> Triple(stringResource(R.string.result_health_unhealthy), Color(0xFFC62828), "🚫") + HealthRating.UNKNOWN -> Triple(stringResource(R.string.result_health_unknown), Color(0xFF757575), "❔") + } Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = ratingColor.copy(alpha = 0.12f)) + colors = CardDefaults.cardColors(containerColor = ratingColor.copy(alpha = 0.12f)), ) { Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { Text(emoji, style = MaterialTheme.typography.displaySmall) @@ -441,14 +494,14 @@ private fun HealthSection(health: HealthAssessment) { Text( stringResource(R.string.result_health_verdict), style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text(ratingText, style = MaterialTheme.typography.titleLarge, color = ratingColor, fontWeight = FontWeight.Bold) if (health.reasons.isNotEmpty()) { Text( health.reasons.joinToString(" · "), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -457,7 +510,10 @@ private fun HealthSection(health: HealthAssessment) { } @Composable -private fun NutritionSection(n: Nutriments, servingSize: String?) { +private fun NutritionSection( + n: Nutriments, + servingSize: String?, +) { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { Text(stringResource(R.string.result_nutrition), style = MaterialTheme.typography.titleMedium) @@ -465,7 +521,7 @@ private fun NutritionSection(n: Nutriments, servingSize: String?) { Text( stringResource(R.string.result_nutrition_unavailable), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) return@Card } @@ -473,22 +529,38 @@ private fun NutritionSection(n: Nutriments, servingSize: String?) { Text( stringResource(R.string.result_nutrition_serving_size, servingSize), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Spacer(Modifier.height(8.dp)) Row { Text("", modifier = Modifier.weight(1f)) - Text(stringResource(R.string.result_nutrition_per_100g), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(80.dp), textAlign = TextAlign.End) + Text( + stringResource(R.string.result_nutrition_per_100g), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.width(80.dp), + textAlign = TextAlign.End, + ) if (n.energyKcalServing != null) { - Text(stringResource(R.string.result_nutrition_per_serving), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(80.dp), textAlign = TextAlign.End) + Text( + stringResource(R.string.result_nutrition_per_serving), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.width(80.dp), + textAlign = TextAlign.End, + ) } } HorizontalDivider( modifier = Modifier.padding(vertical = 4.dp), - color = MaterialTheme.colorScheme.outlineVariant + color = MaterialTheme.colorScheme.outlineVariant, + ) + NutritionRow( + stringResource(R.string.result_nutrition_energy), + n.energyKcal100g, + n.energyKcalServing, + unit = "kcal", + emphasize = true, ) - NutritionRow(stringResource(R.string.result_nutrition_energy), n.energyKcal100g, n.energyKcalServing, unit = "kcal", emphasize = true) NutritionRow(stringResource(R.string.result_nutrition_fat), n.fat100g, null, unit = "g") NutritionRow(" ${stringResource(R.string.result_nutrition_saturated_fat)}", n.saturatedFat100g, null, unit = "g") NutritionRow(stringResource(R.string.result_nutrition_carbs), n.carbohydrates100g, null, unit = "g") @@ -501,7 +573,13 @@ private fun NutritionSection(n: Nutriments, servingSize: String?) { } @Composable -private fun NutritionRow(label: String, per100: Double?, perServing: Double?, unit: String, emphasize: Boolean = false) { +private fun NutritionRow( + label: String, + per100: Double?, + perServing: Double?, + unit: String, + emphasize: Boolean = false, +) { if (per100 == null && perServing == null) return val style = if (emphasize) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { @@ -511,7 +589,7 @@ private fun NutritionRow(label: String, per100: Double?, perServing: Double?, un modifier = Modifier.width(80.dp), textAlign = TextAlign.End, style = style, - fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal + fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal, ) if (perServing != null) { Text( @@ -519,16 +597,20 @@ private fun NutritionRow(label: String, per100: Double?, perServing: Double?, un modifier = Modifier.width(80.dp), textAlign = TextAlign.End, style = style, - fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal + fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal, ) } } } private fun formatNumber(d: Double): String { - return if (d >= 100) d.toInt().toString() - else if (d >= 10) "%.1f".format(d) - else "%.2f".format(d).trimEnd('0').trimEnd('.', ',') + return if (d >= 100) { + d.toInt().toString() + } else if (d >= 10) { + "%.1f".format(d) + } else { + "%.2f".format(d).trimEnd('0').trimEnd('.', ',') + } } @OptIn(ExperimentalLayoutApi::class) @@ -542,28 +624,29 @@ private fun ScoresSection(health: HealthAssessment) { ScoreRow( title = stringResource(R.string.result_nutriscore), details = stringResource(R.string.result_nutriscore_details), - badge = { NutriScoreBadge(health.nutriScore) } + badge = { NutriScoreBadge(health.nutriScore) }, ) } if (health.novaGroup != null) { - val desc = when (health.novaGroup) { - 1 -> stringResource(R.string.result_nova_1) - 2 -> stringResource(R.string.result_nova_2) - 3 -> stringResource(R.string.result_nova_3) - 4 -> stringResource(R.string.result_nova_4) - else -> stringResource(R.string.result_nova_details) - } + val desc = + when (health.novaGroup) { + 1 -> stringResource(R.string.result_nova_1) + 2 -> stringResource(R.string.result_nova_2) + 3 -> stringResource(R.string.result_nova_3) + 4 -> stringResource(R.string.result_nova_4) + else -> stringResource(R.string.result_nova_details) + } ScoreRow( title = stringResource(R.string.result_nova), details = desc, - badge = { NovaBadge(health.novaGroup) } + badge = { NovaBadge(health.novaGroup) }, ) } if (health.ecoScore != null) { ScoreRow( title = stringResource(R.string.result_ecoscore), details = stringResource(R.string.result_ecoscore_details), - badge = { EcoScoreBadge(health.ecoScore) } + badge = { EcoScoreBadge(health.ecoScore) }, ) } } @@ -571,7 +654,11 @@ private fun ScoresSection(health: HealthAssessment) { } @Composable -private fun ScoreRow(title: String, details: String, badge: @Composable () -> Unit) { +private fun ScoreRow( + title: String, + details: String, + badge: @Composable () -> Unit, +) { Row(verticalAlignment = Alignment.CenterVertically) { badge() Spacer(Modifier.width(12.dp)) @@ -585,20 +672,22 @@ private fun ScoreRow(title: String, details: String, badge: @Composable () -> Un @Composable private fun NutriScoreBadge(grade: String) { val upper = grade.uppercase() - val color = when (upper) { - "A" -> Color(0xFF1E8E3E) - "B" -> Color(0xFF7CB342) - "C" -> Color(0xFFFBC02D) - "D" -> Color(0xFFEF6C00) - "E" -> Color(0xFFC62828) - else -> Color(0xFF757575) - } + val color = + when (upper) { + "A" -> Color(0xFF1E8E3E) + "B" -> Color(0xFF7CB342) + "C" -> Color(0xFFFBC02D) + "D" -> Color(0xFFEF6C00) + "E" -> Color(0xFFC62828) + else -> Color(0xFF757575) + } Box( - modifier = Modifier - .size(56.dp) - .background(color, RoundedCornerShape(12.dp)) - .border(2.dp, color.copy(alpha = 0.8f), RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(56.dp) + .background(color, RoundedCornerShape(12.dp)) + .border(2.dp, color.copy(alpha = 0.8f), RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center, ) { Text(upper, color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineMedium) } @@ -606,18 +695,20 @@ private fun NutriScoreBadge(grade: String) { @Composable private fun NovaBadge(group: Int) { - val color = when (group) { - 1 -> Color(0xFF1E8E3E) - 2 -> Color(0xFF7CB342) - 3 -> Color(0xFFEF6C00) - 4 -> Color(0xFFC62828) - else -> Color(0xFF757575) - } + val color = + when (group) { + 1 -> Color(0xFF1E8E3E) + 2 -> Color(0xFF7CB342) + 3 -> Color(0xFFEF6C00) + 4 -> Color(0xFFC62828) + else -> Color(0xFF757575) + } Box( - modifier = Modifier - .size(56.dp) - .background(color, CircleShape), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(56.dp) + .background(color, CircleShape), + contentAlignment = Alignment.Center, ) { Text(group.toString(), color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineMedium) } @@ -626,19 +717,21 @@ private fun NovaBadge(group: Int) { @Composable private fun EcoScoreBadge(grade: String) { val upper = grade.uppercase() - val color = when (upper) { - "A" -> Color(0xFF2E7D32) - "B" -> Color(0xFF558B2F) - "C" -> Color(0xFFFBC02D) - "D" -> Color(0xFFEF6C00) - "E" -> Color(0xFFC62828) - else -> Color(0xFF757575) - } + val color = + when (upper) { + "A" -> Color(0xFF2E7D32) + "B" -> Color(0xFF558B2F) + "C" -> Color(0xFFFBC02D) + "D" -> Color(0xFFEF6C00) + "E" -> Color(0xFFC62828) + else -> Color(0xFF757575) + } Box( - modifier = Modifier - .size(56.dp) - .background(color, RoundedCornerShape(28.dp)), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(56.dp) + .background(color, RoundedCornerShape(28.dp)), + contentAlignment = Alignment.Center, ) { Text("🌿$upper", color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) } @@ -649,48 +742,50 @@ private fun EcoScoreBadge(grade: String) { private fun ListPickerBottomSheet( lists: List, onSelect: (Long) -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, ) { ModalBottomSheet( onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), ) { Text( text = stringResource(R.string.result_choose_list), style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Spacer(Modifier.height(12.dp)) if (lists.isEmpty()) { Text( text = "Aucune liste disponible", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { lists.forEach { list -> Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable { onSelect(list.id) } - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onSelect(list.id) } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { Text( text = list.name, style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) Icon( imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) } } @@ -699,3 +794,26 @@ private fun ListPickerBottomSheet( } } } + +/** + * Animation stagger pour les actions du verdict. + * Chaque bouton apparaît avec un délai progressif (+50ms). + */ +@Composable +private fun StaggeredAction( + visible: Boolean, + delayMs: Int, + content: @Composable () -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = + fadeIn(animationSpec = tween(durationMillis = 300, delayMillis = delayMs)) + + slideInVertically( + initialOffsetY = { it / 4 }, + animationSpec = tween(durationMillis = 300, delayMillis = delayMs), + ), + ) { + content() + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/result/ResultViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/result/ResultViewModel.kt index bba4f3d..164195e 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/result/ResultViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/result/ResultViewModel.kt @@ -2,13 +2,13 @@ package com.safebite.app.presentation.screen.result import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.safebite.app.data.local.database.entity.ShoppingListItemEntity import com.safebite.app.domain.model.DataSource import com.safebite.app.domain.model.ScanResult import com.safebite.app.domain.model.UserProfile import com.safebite.app.domain.repository.ProductFetchResult import com.safebite.app.domain.usecase.AnalyzeIngredientsTextUseCase import com.safebite.app.domain.usecase.AnalyzeProductUseCase -import com.safebite.app.data.local.database.entity.ShoppingListItemEntity import com.safebite.app.domain.usecase.FetchProductUseCase import com.safebite.app.domain.usecase.GetShoppingListsUseCase import com.safebite.app.domain.usecase.ManageProfileUseCase @@ -26,76 +26,82 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class ResultViewModel @Inject constructor( - private val fetchProduct: FetchProductUseCase, - private val analyzeProduct: AnalyzeProductUseCase, - private val analyzeText: AnalyzeIngredientsTextUseCase, - private val manageProfile: ManageProfileUseCase, - private val saveScan: SaveScanUseCase, - private val getLists: GetShoppingListsUseCase, - private val manageList: ManageShoppingListUseCase -) : ViewModel() { +class ResultViewModel + @Inject + constructor( + private val fetchProduct: FetchProductUseCase, + private val analyzeProduct: AnalyzeProductUseCase, + private val analyzeText: AnalyzeIngredientsTextUseCase, + private val manageProfile: ManageProfileUseCase, + private val saveScan: SaveScanUseCase, + private val getLists: GetShoppingListsUseCase, + private val manageList: ManageShoppingListUseCase, + ) : ViewModel() { + private val _state = MutableStateFlow>(UiState.Idle) + val state: StateFlow> = _state.asStateFlow() - private val _state = MutableStateFlow>(UiState.Idle) - val state: StateFlow> = _state.asStateFlow() + val lists = + getLists.observeActive() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - val lists = getLists.observeActive() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + fun analyzeBarcode(barcode: String) = + viewModelScope.launch { + _state.value = UiState.Loading + val profiles = resolveProfiles() + if (profiles.isEmpty()) { + _state.value = UiState.Error("No profile configured") + return@launch + } + when (val fetched = fetchProduct(barcode)) { + is ProductFetchResult.Found -> { + val source = if (fetched.fromCache) DataSource.CACHE else DataSource.API + val result = analyzeProduct(fetched.product, profiles, source) + _state.value = UiState.Success(result) + saveScan(result) + } + ProductFetchResult.NotFound -> _state.value = UiState.Error("not_found") + is ProductFetchResult.Error -> _state.value = UiState.Error(fetched.message, offline = fetched.offline) + } + } - fun analyzeBarcode(barcode: String) = viewModelScope.launch { - _state.value = UiState.Loading - val profiles = resolveProfiles() - if (profiles.isEmpty()) { - _state.value = UiState.Error("No profile configured") - return@launch - } - when (val fetched = fetchProduct(barcode)) { - is ProductFetchResult.Found -> { - val source = if (fetched.fromCache) DataSource.CACHE else DataSource.API - val result = analyzeProduct(fetched.product, profiles, source) + fun analyzeOcrText(text: String) = + viewModelScope.launch { + _state.value = UiState.Loading + val profiles = resolveProfiles() + if (profiles.isEmpty()) { + _state.value = UiState.Error("No profile configured") + return@launch + } + val result = analyzeText(text, profiles) _state.value = UiState.Success(result) saveScan(result) } - ProductFetchResult.NotFound -> _state.value = UiState.Error("not_found") - is ProductFetchResult.Error -> _state.value = UiState.Error(fetched.message, offline = fetched.offline) - } - } - fun analyzeOcrText(text: String) = viewModelScope.launch { - _state.value = UiState.Loading - val profiles = resolveProfiles() - if (profiles.isEmpty()) { - _state.value = UiState.Error("No profile configured") - return@launch + private suspend fun resolveProfiles(): List { + val all = manageProfile.observe().first() + val activeIds = manageProfile.observeActiveIds().first() + return when { + activeIds.isNotEmpty() -> all.filter { it.id in activeIds } + else -> all.filter { it.isDefault }.ifEmpty { all.take(1) } + } } - val result = analyzeText(text, profiles) - _state.value = UiState.Success(result) - saveScan(result) - } - private suspend fun resolveProfiles(): List { - val all = manageProfile.observe().first() - val activeIds = manageProfile.observeActiveIds().first() - return when { - activeIds.isNotEmpty() -> all.filter { it.id in activeIds } - else -> all.filter { it.isDefault }.ifEmpty { all.take(1) } - } + fun addToList(listId: Long) = + viewModelScope.launch { + val currentState = _state.value + if (currentState !is UiState.Success) return@launch + val result = currentState.data + val entity = + ShoppingListItemEntity( + listId = listId, + barcode = result.product.barcode, + productName = result.product.name ?: result.product.barcode, + brand = result.product.brand, + imageUrl = result.product.imageUrl, + isChecked = false, + safetyStatus = result.safetyStatus.name, + allergenWarning = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr, + ) + manageList.addItemToList(listId, entity) + } } - - fun addToList(listId: Long) = viewModelScope.launch { - val currentState = _state.value - if (currentState !is UiState.Success) return@launch - val result = currentState.data - val entity = ShoppingListItemEntity( - listId = listId, - barcode = result.product.barcode, - productName = result.product.name ?: result.product.barcode, - brand = result.product.brand, - imageUrl = result.product.imageUrl, - isChecked = false, - safetyStatus = result.safetyStatus.name, - allergenWarning = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr - ) - manageList.addItemToList(listId, entity) - } -} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/scanner/BarcodeAnalyzer.kt b/app/src/main/java/com/safebite/app/presentation/screen/scanner/BarcodeAnalyzer.kt index 2fbba54..ab05951 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/scanner/BarcodeAnalyzer.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/scanner/BarcodeAnalyzer.kt @@ -13,27 +13,33 @@ import timber.log.Timber import java.util.concurrent.atomic.AtomicBoolean class BarcodeAnalyzer( - private val onBarcode: (String) -> Unit + private val onBarcode: (String) -> Unit, ) : ImageAnalysis.Analyzer { - - private val scanner: BarcodeScanner = BarcodeScanning.getClient( - BarcodeScannerOptions.Builder() - .setBarcodeFormats( - Barcode.FORMAT_EAN_13, - Barcode.FORMAT_EAN_8, - Barcode.FORMAT_UPC_A, - Barcode.FORMAT_UPC_E, - Barcode.FORMAT_QR_CODE - ).build() - ) + private val scanner: BarcodeScanner = + BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats( + Barcode.FORMAT_EAN_13, + Barcode.FORMAT_EAN_8, + Barcode.FORMAT_UPC_A, + Barcode.FORMAT_UPC_E, + Barcode.FORMAT_QR_CODE, + ).build(), + ) private val consumed = AtomicBoolean(false) @OptIn(ExperimentalGetImage::class) override fun analyze(image: ImageProxy) { - if (consumed.get()) { image.close(); return } + if (consumed.get()) { + image.close() + return + } val mediaImage = image.image - if (mediaImage == null) { image.close(); return } + if (mediaImage == null) { + image.close() + return + } val input = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees) scanner.process(input) .addOnSuccessListener { barcodes -> diff --git a/app/src/main/java/com/safebite/app/presentation/screen/scanner/ScannerScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/scanner/ScannerScreen.kt index 4af1092..e4356de 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/scanner/ScannerScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/scanner/ScannerScreen.kt @@ -20,21 +20,27 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FlashOff import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -49,13 +55,14 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.getSystemService +import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -68,11 +75,15 @@ import java.util.concurrent.Executors @Composable fun ScannerScreen( onBack: () -> Unit, - onBarcode: (String) -> Unit + onBarcode: (String) -> Unit, ) { val permission = rememberPermissionState(android.Manifest.permission.CAMERA) LaunchedEffect(Unit) { if (!permission.status.isGranted) permission.launchPermissionRequest() } + var showManualDialog by remember { mutableStateOf(false) } + var manualCode by remember { mutableStateOf("") } + var manualError by remember { mutableStateOf(null) } + Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { @@ -81,7 +92,7 @@ fun ScannerScreen( onBack = onBack, backContentDescription = stringResource(R.string.a11y_back), ) - } + }, ) { padding -> val scanAreaDesc = stringResource(R.string.a11y_scan_area) Box( @@ -90,22 +101,98 @@ fun ScannerScreen( .padding(padding) .semantics { contentDescription = scanAreaDesc - } + }, ) { if (!permission.status.isGranted) { - ErrorView( - message = stringResource(R.string.scanner_camera_denied), - onRetry = { permission.launchPermissionRequest() } - ) + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + ErrorView( + message = stringResource(R.string.scanner_camera_denied), + onRetry = { permission.launchPermissionRequest() }, + ) + Spacer(Modifier.size(16.dp)) + TextButton(onClick = { showManualDialog = true }) { + Icon(Icons.Filled.Edit, contentDescription = null) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.scanner_manual_entry_button)) + } + } } else { - CameraView(onBarcode = onBarcode) + CameraView( + onBarcode = onBarcode, + onManualEntry = { showManualDialog = true }, + ) } } } + + // Dialog de saisie manuelle + if (showManualDialog) { + AlertDialog( + onDismissRequest = { + showManualDialog = false + manualCode = "" + manualError = null + }, + title = { Text(stringResource(R.string.scanner_manual_entry_title)) }, + text = { + Column { + OutlinedTextField( + value = manualCode, + onValueChange = { + manualCode = it.filter { c -> c.isDigit() }.take(13) + manualError = null + }, + label = { Text(stringResource(R.string.scanner_manual_entry_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = manualError != null, + supportingText = + if (manualError != null) { + { Text(manualError!!) } + } else { + null + }, + ) + } + }, + confirmButton = { + val invalidMsg = stringResource(R.string.scanner_manual_entry_invalid) + TextButton(onClick = { + val code = manualCode.trim() + if (isValidBarcodeFormat(code)) { + showManualDialog = false + manualCode = "" + manualError = null + onBarcode(code) + } else { + manualError = invalidMsg + } + }) { + Text(stringResource(R.string.scanner_manual_entry_search)) + } + }, + dismissButton = { + TextButton(onClick = { + showManualDialog = false + manualCode = "" + manualError = null + }) { + Text(stringResource(R.string.a11y_cancel)) + } + }, + ) + } } @Composable -private fun CameraView(onBarcode: (String) -> Unit) { +private fun CameraView( + onBarcode: (String) -> Unit, + onManualEntry: () -> Unit, +) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current var torch by remember { mutableStateOf(false) } @@ -119,66 +206,99 @@ private fun CameraView(onBarcode: (String) -> Unit) { androidx.compose.ui.viewinterop.AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> - val previewView = PreviewView(ctx).apply { - scaleType = PreviewView.ScaleType.FILL_CENTER - } + val previewView = + PreviewView(ctx).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + } val providerFuture = ProcessCameraProvider.getInstance(ctx) providerFuture.addListener({ val provider = providerFuture.get() - val preview = Preview.Builder().build().also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - val analysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .also { it.setAnalyzer(executor, BarcodeAnalyzer { code -> - if (!detected) { - detected = true - triggerHaptic(ctx) - onBarcode(code) + val preview = + Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val analysis = + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { + it.setAnalyzer( + executor, + BarcodeAnalyzer { code -> + if (!detected) { + detected = true + triggerHaptic(ctx) + onBarcode(code) + } + }, + ) } - }) } try { provider.unbindAll() - val camera = provider.bindToLifecycle( - lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis - ) + val camera = + provider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, + ) cameraControl = camera.cameraControl - } catch (t: Throwable) { /* ignore */ } + } catch (t: Throwable) { + // ignore + } }, ContextCompat.getMainExecutor(ctx)) previewView - } + }, ) ScanOverlay(modifier = Modifier.fillMaxSize()) Column( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = stringResource(R.string.scanner_hint), color = Color.White, - modifier = Modifier - .background(Color(0x99000000), RoundedCornerShape(12.dp)) - .padding(horizontal = 12.dp, vertical = 6.dp) + modifier = + Modifier + .background(Color(0x99000000), RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 6.dp), ) Spacer(Modifier.size(12.dp)) - IconButton( - onClick = { - torch = !torch - cameraControl?.enableTorch(torch) - }, - modifier = Modifier.size(48.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - if (torch) Icons.Filled.FlashOn else Icons.Filled.FlashOff, - contentDescription = stringResource(R.string.a11y_torch), - tint = Color.White - ) + TextButton(onClick = onManualEntry) { + Icon( + Icons.Filled.Edit, + contentDescription = null, + tint = Color.White, + ) + Spacer(Modifier.size(4.dp)) + Text( + stringResource(R.string.scanner_manual_entry_button), + color = Color.White, + ) + } + IconButton( + onClick = { + torch = !torch + cameraControl?.enableTorch(torch) + }, + modifier = Modifier.size(48.dp), + ) { + Icon( + if (torch) Icons.Filled.FlashOn else Icons.Filled.FlashOff, + contentDescription = stringResource(R.string.a11y_torch), + tint = Color.White, + ) + } } } } @@ -190,11 +310,12 @@ private fun ScanOverlay(modifier: Modifier = Modifier) { val y by transition.animateFloat( initialValue = 0f, targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(1800, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "scanY" + animationSpec = + infiniteRepeatable( + animation = tween(1800, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "scanY", ) Canvas(modifier = modifier) { val w = size.width @@ -203,35 +324,35 @@ private fun ScanOverlay(modifier: Modifier = Modifier) { val topLeft = Offset((w - boxSize.width) / 2f, (h - boxSize.height) / 2f) drawRect( color = Color(0xB3000000), - size = Size(w, topLeft.y) + size = Size(w, topLeft.y), ) drawRect( color = Color(0xB3000000), topLeft = Offset(0f, topLeft.y + boxSize.height), - size = Size(w, h - topLeft.y - boxSize.height) + size = Size(w, h - topLeft.y - boxSize.height), ) drawRect( color = Color(0xB3000000), topLeft = Offset(0f, topLeft.y), - size = Size(topLeft.x, boxSize.height) + size = Size(topLeft.x, boxSize.height), ) drawRect( color = Color(0xB3000000), topLeft = Offset(topLeft.x + boxSize.width, topLeft.y), - size = Size(w - topLeft.x - boxSize.width, boxSize.height) + size = Size(w - topLeft.x - boxSize.width, boxSize.height), ) drawRect( color = Color.White, topLeft = topLeft, size = boxSize, - style = Stroke(width = 4f) + style = Stroke(width = 4f), ) val lineY = topLeft.y + boxSize.height * y drawLine( color = Color(0xFF00E676), start = Offset(topLeft.x, lineY), end = Offset(topLeft.x + boxSize.width, lineY), - strokeWidth = 4f + strokeWidth = 4f, ) } } @@ -246,5 +367,15 @@ private fun triggerHaptic(context: android.content.Context) { val v = context.getSystemService(android.content.Context.VIBRATOR_SERVICE) as? Vibrator v?.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE)) } - } catch (_: Throwable) { /* ignore */ } + } catch (_: Throwable) { + // ignore + } +} + +/** Valide le format d'un code-barres (EAN-13, EAN-8, UPC-A, UPC-E). */ +private fun isValidBarcodeFormat(code: String): Boolean { + if (code.isEmpty()) return false + val digits = code.filter { it.isDigit() } + if (digits.length != code.length) return false + return digits.length in 8..13 } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/settings/SettingsScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/settings/SettingsScreen.kt index f7c7687..2af091d 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/settings/SettingsScreen.kt @@ -38,7 +38,7 @@ import com.safebite.app.presentation.theme.LocalDimens @Composable fun SettingsScreen( onBack: () -> Unit, - viewModel: SettingsViewModel = hiltViewModel() + viewModel: SettingsViewModel = hiltViewModel(), ) { val ui by viewModel.state.collectAsStateWithLifecycle() @@ -51,27 +51,42 @@ fun SettingsScreen( onBack = onBack, backContentDescription = stringResource(R.string.action_back), ) - } + }, ) { padding -> Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg), - verticalArrangement = Arrangement.spacedBy(dimens.spacingMd) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg), + verticalArrangement = Arrangement.spacedBy(dimens.spacingMd), ) { Section(stringResource(R.string.settings_language)) { Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) { - FilterChip(selected = ui.appLanguage == AppLanguage.FR, onClick = { viewModel.setAppLanguage(AppLanguage.FR) }, label = { Text("FR") }) - FilterChip(selected = ui.appLanguage == AppLanguage.EN, onClick = { viewModel.setAppLanguage(AppLanguage.EN) }, label = { Text("EN") }) + FilterChip( + selected = ui.appLanguage == AppLanguage.FR, + onClick = { viewModel.setAppLanguage(AppLanguage.FR) }, + label = { Text("FR") }, + ) + FilterChip( + selected = ui.appLanguage == AppLanguage.EN, + onClick = { viewModel.setAppLanguage(AppLanguage.EN) }, + label = { Text("EN") }, + ) } } Section(stringResource(R.string.settings_detection_language)) { Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) { - FilterChip(selected = ui.detectionLanguage == DetectionLanguage.FR, onClick = { viewModel.setDetectionLanguage(DetectionLanguage.FR) }, label = { Text(stringResource(R.string.settings_detection_fr)) }) - FilterChip(selected = ui.detectionLanguage == DetectionLanguage.EN, onClick = { viewModel.setDetectionLanguage(DetectionLanguage.EN) }, label = { Text(stringResource(R.string.settings_detection_en)) }) - FilterChip(selected = ui.detectionLanguage == DetectionLanguage.BOTH, onClick = { viewModel.setDetectionLanguage(DetectionLanguage.BOTH) }, label = { Text(stringResource(R.string.settings_detection_both)) }) + FilterChip(selected = ui.detectionLanguage == DetectionLanguage.FR, onClick = { + viewModel.setDetectionLanguage(DetectionLanguage.FR) + }, label = { Text(stringResource(R.string.settings_detection_fr)) }) + FilterChip(selected = ui.detectionLanguage == DetectionLanguage.EN, onClick = { + viewModel.setDetectionLanguage(DetectionLanguage.EN) + }, label = { Text(stringResource(R.string.settings_detection_en)) }) + FilterChip(selected = ui.detectionLanguage == DetectionLanguage.BOTH, onClick = { + viewModel.setDetectionLanguage(DetectionLanguage.BOTH) + }, label = { Text(stringResource(R.string.settings_detection_both)) }) } } StandardCard(variant = CardVariant.Filled) { @@ -84,17 +99,29 @@ fun SettingsScreen( Section(stringResource(R.string.settings_health_strictness)) { Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) { - FilterChip(selected = ui.healthStrictness == HealthStrictness.LENIENT, onClick = { viewModel.setHealthStrictness(HealthStrictness.LENIENT) }, label = { Text(stringResource(R.string.settings_health_lenient)) }) - FilterChip(selected = ui.healthStrictness == HealthStrictness.NORMAL, onClick = { viewModel.setHealthStrictness(HealthStrictness.NORMAL) }, label = { Text(stringResource(R.string.settings_health_normal)) }) - FilterChip(selected = ui.healthStrictness == HealthStrictness.STRICT, onClick = { viewModel.setHealthStrictness(HealthStrictness.STRICT) }, label = { Text(stringResource(R.string.settings_health_strict)) }) + FilterChip(selected = ui.healthStrictness == HealthStrictness.LENIENT, onClick = { + viewModel.setHealthStrictness(HealthStrictness.LENIENT) + }, label = { Text(stringResource(R.string.settings_health_lenient)) }) + FilterChip(selected = ui.healthStrictness == HealthStrictness.NORMAL, onClick = { + viewModel.setHealthStrictness(HealthStrictness.NORMAL) + }, label = { Text(stringResource(R.string.settings_health_normal)) }) + FilterChip(selected = ui.healthStrictness == HealthStrictness.STRICT, onClick = { + viewModel.setHealthStrictness(HealthStrictness.STRICT) + }, label = { Text(stringResource(R.string.settings_health_strict)) }) } } Section(stringResource(R.string.settings_theme)) { Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) { - FilterChip(selected = ui.theme == ThemePref.LIGHT, onClick = { viewModel.setTheme(ThemePref.LIGHT) }, label = { Text(stringResource(R.string.settings_theme_light)) }) - FilterChip(selected = ui.theme == ThemePref.DARK, onClick = { viewModel.setTheme(ThemePref.DARK) }, label = { Text(stringResource(R.string.settings_theme_dark)) }) - FilterChip(selected = ui.theme == ThemePref.SYSTEM, onClick = { viewModel.setTheme(ThemePref.SYSTEM) }, label = { Text(stringResource(R.string.settings_theme_system)) }) + FilterChip(selected = ui.theme == ThemePref.LIGHT, onClick = { + viewModel.setTheme(ThemePref.LIGHT) + }, label = { Text(stringResource(R.string.settings_theme_light)) }) + FilterChip(selected = ui.theme == ThemePref.DARK, onClick = { + viewModel.setTheme(ThemePref.DARK) + }, label = { Text(stringResource(R.string.settings_theme_dark)) }) + FilterChip(selected = ui.theme == ThemePref.SYSTEM, onClick = { + viewModel.setTheme(ThemePref.SYSTEM) + }, label = { Text(stringResource(R.string.settings_theme_system)) }) } } @@ -102,12 +129,12 @@ fun SettingsScreen( DestructiveButton( text = stringResource(R.string.settings_clear_cache), onClick = viewModel::clearCache, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) DestructiveButton( text = stringResource(R.string.settings_clear_history), onClick = viewModel::clearHistory, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) @@ -115,43 +142,50 @@ fun SettingsScreen( Text( stringResource(R.string.settings_version, BuildConfig.VERSION_NAME), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( stringResource(R.string.settings_off_attribution), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( "https://world.openfoodfacts.org", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } } } @Composable -private fun Section(title: String, content: @Composable () -> Unit) { +private fun Section( + title: String, + content: @Composable () -> Unit, +) { val dimens = LocalDimens.current Column(verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)) { Text( title, style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) content() } } @Composable -private fun ToggleRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) { +private fun ToggleRow( + label: String, + checked: Boolean, + onChange: (Boolean) -> Unit, +) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text( label, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) Switch(checked = checked, onCheckedChange = onChange) } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/settings/SettingsViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/settings/SettingsViewModel.kt index 0e400f6..4096c49 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/settings/SettingsViewModel.kt @@ -24,41 +24,52 @@ data class SettingsUi( val sound: Boolean = true, val theme: ThemePref = ThemePref.SYSTEM, val healthStrictness: HealthStrictness = HealthStrictness.NORMAL, - val splashScreenEnabled: Boolean = true + val splashScreenEnabled: Boolean = true, ) @HiltViewModel -class SettingsViewModel @Inject constructor( - private val settings: SettingsRepository, - private val productRepo: ProductRepository, - private val historyRepo: ScanHistoryRepository -) : ViewModel() { +class SettingsViewModel + @Inject + constructor( + private val settings: SettingsRepository, + private val productRepo: ProductRepository, + private val historyRepo: ScanHistoryRepository, + ) : ViewModel() { + private val coreFlow = + combine( + settings.appLanguage, + settings.detectionLanguage, + settings.hapticsEnabled, + settings.soundEnabled, + settings.theme, + ) { lang, detection, haptics, sound, theme -> + SettingsUi(lang, detection, haptics, sound, theme) + } - private val coreFlow = combine( - settings.appLanguage, - settings.detectionLanguage, - settings.hapticsEnabled, - settings.soundEnabled, - settings.theme - ) { lang, detection, haptics, sound, theme -> - SettingsUi(lang, detection, haptics, sound, theme) + val state: StateFlow = + combine( + coreFlow, + settings.healthStrictness, + settings.splashScreenEnabled, + ) { core, strict, splash -> + core.copy(healthStrictness = strict, splashScreenEnabled = splash) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUi()) + + fun setAppLanguage(v: AppLanguage) = viewModelScope.launch { settings.setAppLanguage(v) } + + fun setDetectionLanguage(v: DetectionLanguage) = viewModelScope.launch { settings.setDetectionLanguage(v) } + + fun setHaptics(v: Boolean) = viewModelScope.launch { settings.setHaptics(v) } + + fun setSound(v: Boolean) = viewModelScope.launch { settings.setSound(v) } + + fun setTheme(v: ThemePref) = viewModelScope.launch { settings.setTheme(v) } + + fun setHealthStrictness(v: HealthStrictness) = viewModelScope.launch { settings.setHealthStrictness(v) } + + fun setSplashScreenEnabled(v: Boolean) = viewModelScope.launch { settings.setSplashScreenEnabled(v) } + + fun clearCache() = viewModelScope.launch { productRepo.clearCache() } + + fun clearHistory() = viewModelScope.launch { historyRepo.clear() } } - - val state: StateFlow = 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() } -} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/splash/SplashScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/splash/SplashScreen.kt index 033d8b5..9725945 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/splash/SplashScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/splash/SplashScreen.kt @@ -31,7 +31,7 @@ import com.safebite.app.presentation.theme.ShieldGradient @Composable fun SplashScreen( onFinished: () -> Unit, - durationMillis: Int = 2500 + durationMillis: Int = 2500, ) { val scale = remember { Animatable(0.6f) } val alpha = remember { Animatable(0f) } @@ -44,39 +44,43 @@ fun SplashScreen( } Box( - modifier = Modifier - .fillMaxSize() - .background(ShieldGradient), - contentAlignment = Alignment.Center + modifier = + Modifier + .fillMaxSize() + .background(ShieldGradient), + contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, - modifier = Modifier.padding(24.dp) + modifier = Modifier.padding(24.dp), ) { Image( painter = painterResource(id = R.drawable.safebite_logo_nobg), contentDescription = null, - modifier = Modifier - .size(160.dp) - .scale(scale.value) + modifier = + Modifier + .size(160.dp) + .scale(scale.value), ) Spacer(Modifier.height(24.dp)) Text( text = stringResource(R.string.app_name), - style = MaterialTheme.typography.headlineLarge.copy( - fontWeight = FontWeight.Bold, - color = androidx.compose.ui.graphics.Color.White - ), - modifier = Modifier.alpha(alpha.value) + style = + MaterialTheme.typography.headlineLarge.copy( + fontWeight = FontWeight.Bold, + color = androidx.compose.ui.graphics.Color.White, + ), + modifier = Modifier.alpha(alpha.value), ) Spacer(Modifier.height(8.dp)) Text( text = stringResource(R.string.onboarding_welcome_subtitle), - style = MaterialTheme.typography.bodyLarge.copy( - color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f) - ), - modifier = Modifier.alpha(alpha.value) + style = + MaterialTheme.typography.bodyLarge.copy( + color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f), + ), + modifier = Modifier.alpha(alpha.value), ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/tracking/TrackingScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/tracking/TrackingScreen.kt index 89058a0..4d74f1a 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/tracking/TrackingScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/tracking/TrackingScreen.kt @@ -60,7 +60,7 @@ import java.util.Date fun TrackingScreen( onOpenHistoryItem: (String) -> Unit, onOpenScanner: () -> Unit, - viewModel: TrackingViewModel = hiltViewModel() + viewModel: TrackingViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val timeFilter by viewModel.timeFilter.collectAsStateWithLifecycle() @@ -80,18 +80,19 @@ fun TrackingScreen( val clearAllDesc = stringResource(R.string.a11y_clear_all) IconButton( onClick = viewModel::clearAll, - modifier = Modifier.semantics { - contentDescription = clearAllDesc - } + modifier = + Modifier.semantics { + contentDescription = clearAllDesc + }, ) { Icon( Icons.Filled.DeleteSweep, - contentDescription = null + contentDescription = null, ) } - } + }, ) - } + }, ) { padding -> when (uiState) { is TrackingUiState.Loading -> { @@ -99,32 +100,34 @@ fun TrackingScreen( } is TrackingUiState.Empty -> { Box( - modifier = Modifier - .fillMaxSize() - .padding(padding), - contentAlignment = Alignment.Center + modifier = + Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, ) { EmptyState( title = stringResource(R.string.tracking_empty_title), message = stringResource(R.string.tracking_empty_body), - emoji = "📊" + emoji = "📊", ) } } is TrackingUiState.Success -> { val success = uiState as TrackingUiState.Success LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = dimens.spacingLg), - verticalArrangement = Arrangement.spacedBy(dimens.spacingMd) + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = dimens.spacingLg), + verticalArrangement = Arrangement.spacedBy(dimens.spacingMd), ) { // Filtres temporels item { TimeFilterRow( selected = success.timeFilter, - onFilterChanged = viewModel::setTimeFilter + onFilterChanged = viewModel::setTimeFilter, ) } @@ -132,7 +135,7 @@ fun TrackingScreen( item { StatsSection( stats = success.stats, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } @@ -140,7 +143,7 @@ fun TrackingScreen( item { EvolutionChart( data = success.stats.sparklineData, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } @@ -148,7 +151,7 @@ fun TrackingScreen( item { VerdictDistribution( data = success.stats.barChartData, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } @@ -157,7 +160,7 @@ fun TrackingScreen( item { TopAllergensSection( allergens = success.stats.topAllergens, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } @@ -166,7 +169,7 @@ fun TrackingScreen( item { StatusFilterRow( selected = success.statusFilter, - onFilterChanged = viewModel::setStatusFilter + onFilterChanged = viewModel::setStatusFilter, ) } @@ -176,7 +179,7 @@ fun TrackingScreen( value = searchQuery, onValueChange = viewModel::setSearchQuery, placeholder = "Rechercher un produit...", - leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) } + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) }, ) } @@ -186,7 +189,7 @@ fun TrackingScreen( text = stringResource(R.string.tracking_recent_scans), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) } @@ -197,9 +200,10 @@ fun TrackingScreen( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = dimens.spacingXl) + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = dimens.spacingXl), ) } } else { @@ -207,7 +211,7 @@ fun TrackingScreen( HistoryItemCard( item = item, onClick = { onOpenHistoryItem(item.barcode) }, - onDelete = { viewModel.deleteItem(item.id) } + onDelete = { viewModel.deleteItem(item.id) }, ) } } @@ -223,28 +227,30 @@ fun TrackingScreen( @Composable fun StatsSection( stats: TrackingStats, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val dimens = LocalDimens.current Card( modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(dimens.spacingMd), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + Modifier + .fillMaxWidth() + .padding(dimens.spacingMd), + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = stringResource(R.string.tracking_stats_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(dimens.spacingMd)) @@ -252,7 +258,7 @@ fun StatsSection( Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly + horizontalArrangement = Arrangement.SpaceEvenly, ) { // Donut chart DonutChart( @@ -260,32 +266,32 @@ fun StatsSection( size = 120.dp, strokeWidth = 12.dp, centerText = "${(stats.safePercentage * 100).toInt()}%", - centerSubText = stringResource(R.string.tracking_safe_rate) + centerSubText = stringResource(R.string.tracking_safe_rate), ) // Stats cards Column( - verticalArrangement = Arrangement.spacedBy(dimens.spacingSm) + verticalArrangement = Arrangement.spacedBy(dimens.spacingSm), ) { StatCardMini( icon = "📊", value = "${stats.totalScans}", - label = stringResource(R.string.tracking_total_scans) + label = stringResource(R.string.tracking_total_scans), ) StatCardMini( icon = "✅", value = "${stats.safeCount}", - label = "Sûrs" + label = "Sûrs", ) StatCardMini( icon = "⚠️", value = "${stats.warningCount}", - label = "Attention" + label = "Attention", ) StatCardMini( icon = "❌", value = "${stats.dangerCount}", - label = "Danger" + label = "Danger", ) } } @@ -297,11 +303,11 @@ fun StatsSection( fun StatCardMini( icon: String, value: String, - label: String + label: String, ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text(text = icon, style = MaterialTheme.typography.bodyLarge) Column { @@ -309,12 +315,12 @@ fun StatCardMini( text = value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Text( text = label, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -326,7 +332,7 @@ fun StatCardMini( @Composable fun EvolutionChart( data: com.safebite.app.presentation.common.components.SparklineData, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val dimens = LocalDimens.current @@ -334,28 +340,30 @@ fun EvolutionChart( Card( modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(dimens.spacingMd) + modifier = + Modifier + .fillMaxWidth() + .padding(dimens.spacingMd), ) { Text( text = stringResource(R.string.tracking_evolution), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(dimens.spacingSm)) Sparkline( data = data, height = 100.dp, lineColor = MaterialTheme.colorScheme.primary, - fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), ) } } @@ -367,27 +375,29 @@ fun EvolutionChart( @Composable fun VerdictDistribution( data: com.safebite.app.presentation.common.components.BarChartData, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val dimens = LocalDimens.current Card( modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(dimens.spacingMd) + modifier = + Modifier + .fillMaxWidth() + .padding(dimens.spacingMd), ) { Text( text = stringResource(R.string.tracking_distribution), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(dimens.spacingSm)) HorizontalBarChart(data = data) @@ -401,47 +411,50 @@ fun VerdictDistribution( @Composable fun TopAllergensSection( allergens: List, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val dimens = LocalDimens.current Card( modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm), ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(dimens.spacingMd) + modifier = + Modifier + .fillMaxWidth() + .padding(dimens.spacingMd), ) { Text( text = stringResource(R.string.tracking_top_allergens), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(dimens.spacingSm)) allergens.forEach { allergen -> Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( text = "${allergen.emoji} ${allergen.name}", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Text( text = "${allergen.count}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -455,35 +468,35 @@ fun TopAllergensSection( @Composable fun StatusFilterRow( selected: SafetyStatus?, - onFilterChanged: (SafetyStatus?) -> Unit + onFilterChanged: (SafetyStatus?) -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm) + horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm), ) { FilterChip( selected = selected == null, onClick = { onFilterChanged(null) }, label = { Text("Tous") }, - modifier = Modifier.semantics { contentDescription = "Filtrer par tous les produits" } + modifier = Modifier.semantics { contentDescription = "Filtrer par tous les produits" }, ) FilterChip( selected = selected == SafetyStatus.DANGER, onClick = { onFilterChanged(SafetyStatus.DANGER) }, label = { Text("❌ Danger") }, - modifier = Modifier.semantics { contentDescription = "Filtrer par produits dangereux" } + modifier = Modifier.semantics { contentDescription = "Filtrer par produits dangereux" }, ) FilterChip( selected = selected == SafetyStatus.WARNING, onClick = { onFilterChanged(SafetyStatus.WARNING) }, label = { Text("⚠️ Attention") }, - modifier = Modifier.semantics { contentDescription = "Filtrer par produits avec attention" } + modifier = Modifier.semantics { contentDescription = "Filtrer par produits avec attention" }, ) FilterChip( selected = selected == SafetyStatus.SAFE, onClick = { onFilterChanged(SafetyStatus.SAFE) }, label = { Text("✅ Sûr") }, - modifier = Modifier.semantics { contentDescription = "Filtrer par produits sûrs" } + modifier = Modifier.semantics { contentDescription = "Filtrer par produits sûrs" }, ) } } @@ -495,31 +508,35 @@ fun StatusFilterRow( fun HistoryItemCard( item: com.safebite.app.domain.model.ScanHistoryItem, onClick: () -> Unit, - onDelete: () -> Unit + onDelete: () -> Unit, ) { val dimens = LocalDimens.current Card( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm) + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(dimens.spacingMd), + modifier = + Modifier + .fillMaxWidth() + .padding(dimens.spacingMd), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(dimens.spacingMd) + horizontalArrangement = Arrangement.spacedBy(dimens.spacingMd), ) { // Indicateur de couleur Box( - modifier = Modifier - .size(12.dp) - .background(statusColor(item.safetyStatus), CircleShape) + modifier = + Modifier + .size(12.dp) + .background(statusColor(item.safetyStatus), CircleShape), ) // Infos produit @@ -528,19 +545,20 @@ fun HistoryItemCard( text = item.productName ?: item.barcode, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, - maxLines = 1 + maxLines = 1, ) Text( - text = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) - .format(Date(item.scannedAt)), + text = + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) + .format(Date(item.scannedAt)), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) if (item.profileNames.isNotEmpty()) { Text( text = item.profileNames.joinToString(", "), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error + color = MaterialTheme.colorScheme.error, ) } } @@ -549,14 +567,15 @@ fun HistoryItemCard( val deleteDesc = stringResource(R.string.a11y_delete, item.productName ?: item.barcode) IconButton( onClick = onDelete, - modifier = Modifier.semantics { - contentDescription = deleteDesc - } + modifier = + Modifier.semantics { + contentDescription = deleteDesc + }, ) { Icon( Icons.Filled.Delete, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -572,34 +591,38 @@ fun TrackingLoadingSkeleton(modifier: Modifier = Modifier) { LazyColumn( modifier = modifier.padding(horizontal = dimens.spacingLg), - verticalArrangement = Arrangement.spacedBy(dimens.spacingMd) + verticalArrangement = Arrangement.spacedBy(dimens.spacingMd), ) { item { ShimmerBox( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), ) } item { ShimmerBox( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) + modifier = + Modifier + .fillMaxWidth() + .height(200.dp), ) } item { ShimmerBox( - modifier = Modifier - .fillMaxWidth() - .height(150.dp) + modifier = + Modifier + .fillMaxWidth() + .height(150.dp), ) } item { ShimmerBox( - modifier = Modifier - .fillMaxWidth() - .height(150.dp) + modifier = + Modifier + .fillMaxWidth() + .height(150.dp), ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/tracking/TrackingViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/tracking/TrackingViewModel.kt index a8fa89c..27203be 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/tracking/TrackingViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/tracking/TrackingViewModel.kt @@ -34,7 +34,7 @@ data class TrackingStats( val weeklyScans: Int = 0, val weeklySafePercentage: Float = 0f, val sparklineData: SparklineData = SparklineData(emptyList()), - val barChartData: BarChartData = BarChartData(emptyList()) + val barChartData: BarChartData = BarChartData(emptyList()), ) /** @@ -43,7 +43,7 @@ data class TrackingStats( data class AllergenCount( val name: String, val count: Int, - val emoji: String = "⚠️" + val emoji: String = "⚠️", ) /** @@ -51,204 +51,224 @@ data class AllergenCount( */ sealed class TrackingUiState { data object Loading : TrackingUiState() + data class Success( val stats: TrackingStats, val historyItems: List, val timeFilter: TimeFilter, val statusFilter: SafetyStatus? = null, - val searchQuery: String = "" + val searchQuery: String = "", ) : TrackingUiState() + data object Empty : TrackingUiState() } @HiltViewModel -class TrackingViewModel @Inject constructor( - private val getScanHistoryUseCase: GetScanHistoryUseCase -) : ViewModel() { +class TrackingViewModel + @Inject + constructor( + private val getScanHistoryUseCase: GetScanHistoryUseCase, + ) : ViewModel() { + private val _timeFilter = MutableStateFlow(TimeFilter.WEEK) + val timeFilter: StateFlow = _timeFilter.asStateFlow() - private val _timeFilter = MutableStateFlow(TimeFilter.WEEK) - val timeFilter: StateFlow = _timeFilter.asStateFlow() + private val _statusFilter = MutableStateFlow(null) + val statusFilter: StateFlow = _statusFilter.asStateFlow() - private val _statusFilter = MutableStateFlow(null) - val statusFilter: StateFlow = _statusFilter.asStateFlow() + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() - private val _searchQuery = MutableStateFlow("") - val searchQuery: StateFlow = _searchQuery.asStateFlow() + val uiState: StateFlow = + 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 = combine( - getScanHistoryUseCase.observe(), - _timeFilter, - _statusFilter, - _searchQuery - ) { items, timeFilter, statusFilter, query -> - val filteredItems = items - .filterByTime(timeFilter) - .filter { statusFilter == null || it.safetyStatus == statusFilter } - .filter { query.isBlank() || matchesSearch(it, query) } - - if (items.isEmpty()) { - TrackingUiState.Empty - } else { - val stats = computeStats(items, timeFilter) - TrackingUiState.Success( - stats = stats, - historyItems = filteredItems, - timeFilter = timeFilter, - statusFilter = statusFilter, - searchQuery = query + if (items.isEmpty()) { + TrackingUiState.Empty + } else { + val stats = computeStats(items, timeFilter) + TrackingUiState.Success( + stats = stats, + historyItems = filteredItems, + timeFilter = timeFilter, + statusFilter = statusFilter, + searchQuery = query, + ) + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + TrackingUiState.Loading, ) + + fun setTimeFilter(filter: TimeFilter) { + _timeFilter.value = filter } - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5_000), - TrackingUiState.Loading - ) - fun setTimeFilter(filter: TimeFilter) { - _timeFilter.value = filter - } - - fun setStatusFilter(status: SafetyStatus?) { - _statusFilter.value = status - } - - fun setSearchQuery(query: String) { - _searchQuery.value = query - } - - fun deleteItem(id: Long) = viewModelScope.launch { - getScanHistoryUseCase.delete(id) - } - - fun clearAll() = viewModelScope.launch { - getScanHistoryUseCase.clear() - } - - private fun List.filterByTime(filter: TimeFilter): List { - val calendar = Calendar.getInstance() - val cutoffTime = when (filter) { - TimeFilter.WEEK -> { - calendar.add(Calendar.DAY_OF_YEAR, -7) - calendar.timeInMillis - } - TimeFilter.MONTH -> { - calendar.add(Calendar.MONTH, -1) - calendar.timeInMillis - } - TimeFilter.YEAR -> { - calendar.add(Calendar.YEAR, -1) - calendar.timeInMillis - } - TimeFilter.ALL -> 0L + fun setStatusFilter(status: SafetyStatus?) { + _statusFilter.value = status } - return this.filter { it.scannedAt >= cutoffTime } - } - private fun matchesSearch(item: ScanHistoryItem, query: String): Boolean { - return item.productName?.contains(query, ignoreCase = true) == true || + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + + fun deleteItem(id: Long) = + viewModelScope.launch { + getScanHistoryUseCase.delete(id) + } + + fun clearAll() = + viewModelScope.launch { + getScanHistoryUseCase.clear() + } + + private fun List.filterByTime(filter: TimeFilter): List { + val calendar = Calendar.getInstance() + val cutoffTime = + when (filter) { + TimeFilter.WEEK -> { + calendar.add(Calendar.DAY_OF_YEAR, -7) + calendar.timeInMillis + } + TimeFilter.MONTH -> { + calendar.add(Calendar.MONTH, -1) + calendar.timeInMillis + } + TimeFilter.YEAR -> { + calendar.add(Calendar.YEAR, -1) + calendar.timeInMillis + } + TimeFilter.ALL -> 0L + } + return this.filter { it.scannedAt >= cutoffTime } + } + + private fun matchesSearch( + item: ScanHistoryItem, + query: String, + ): Boolean { + return item.productName?.contains(query, ignoreCase = true) == true || item.brand?.contains(query, ignoreCase = true) == true || item.barcode.contains(query) - } + } - private fun computeStats(allItems: List, timeFilter: TimeFilter): TrackingStats { - val items = allItems.filterByTime(timeFilter) - val total = items.size - val safeCount = items.count { it.safetyStatus == SafetyStatus.SAFE } - val warningCount = items.count { it.safetyStatus == SafetyStatus.WARNING } - val dangerCount = items.count { it.safetyStatus == SafetyStatus.DANGER } - val safePercentage = if (total > 0) safeCount.toFloat() / total else 0f + private fun computeStats( + allItems: List, + timeFilter: TimeFilter, + ): TrackingStats { + val items = allItems.filterByTime(timeFilter) + val total = items.size + val safeCount = items.count { it.safetyStatus == SafetyStatus.SAFE } + val warningCount = items.count { it.safetyStatus == SafetyStatus.WARNING } + val dangerCount = items.count { it.safetyStatus == SafetyStatus.DANGER } + val safePercentage = if (total > 0) safeCount.toFloat() / total else 0f - // Calcul des allergènes top (simulé à partir des noms de produits) - val topAllergens = computeTopAllergens(items) + // Calcul des allergènes top (simulé à partir des noms de produits) + val topAllergens = computeTopAllergens(items) - // Données sparkline (scans par jour sur la période) - val sparklineData = computeSparklineData(items, timeFilter) + // Données sparkline (scans par jour sur la période) + val sparklineData = computeSparklineData(items, timeFilter) - // Données bar chart (répartition par statut) - val barChartData = BarChartData( - items = listOf( - BarChartItem("Sûr", safeCount, SemanticColors.Safe), - BarChartItem("Attention", warningCount, SemanticColors.Warning), - BarChartItem("Danger", dangerCount, SemanticColors.Danger) + // Données bar chart (répartition par statut) + val barChartData = + BarChartData( + items = + listOf( + BarChartItem("Sûr", safeCount, SemanticColors.Safe), + BarChartItem("Attention", warningCount, SemanticColors.Warning), + BarChartItem("Danger", dangerCount, SemanticColors.Danger), + ), + ) + + return TrackingStats( + totalScans = total, + safeCount = safeCount, + warningCount = warningCount, + dangerCount = dangerCount, + safePercentage = safePercentage, + topAllergens = topAllergens, + weeklyScans = items.size, + weeklySafePercentage = safePercentage, + sparklineData = sparklineData, + barChartData = barChartData, ) - ) - - return TrackingStats( - totalScans = total, - safeCount = safeCount, - warningCount = warningCount, - dangerCount = dangerCount, - safePercentage = safePercentage, - topAllergens = topAllergens, - weeklyScans = items.size, - weeklySafePercentage = safePercentage, - sparklineData = sparklineData, - barChartData = barChartData - ) - } - - private fun computeTopAllergens(items: List): List { - // Simulation : on compte les profils associés aux scans danger/warning - val allergenCounts = mutableMapOf() - 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, 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() - val values = mutableListOf() - - 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..): List { + // Simulation : on compte les profils associés aux scans danger/warning + val allergenCounts = mutableMapOf() + items.filter { it.safetyStatus != SafetyStatus.SAFE }.forEach { item -> + item.profileNames.forEach { profileName -> + allergenCounts[profileName] = allergenCounts.getOrDefault(profileName, 0) + 1 } } + return allergenCounts.entries + .sortedByDescending { it.value } + .take(5) + .map { AllergenCount(it.key, it.value) } } - return SparklineData(values, labels) + private fun computeSparklineData( + items: List, + 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() + val values = mutableListOf() + + 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.. Unit + content: @Composable () -> Unit, ) { - val targetScheme: ColorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + val targetScheme: ColorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColors + else -> LightColors } - darkTheme -> DarkColors - else -> LightColors - } val colorScheme = targetScheme.animated() val statusColors = if (darkTheme) DarkStatusColors else LightStatusColors @@ -129,7 +132,7 @@ fun SafeBiteTheme( colorScheme = colorScheme, typography = SafeBiteTypography, shapes = SafeBiteShapes, - content = content + content = content, ) } } diff --git a/app/src/main/java/com/safebite/app/presentation/theme/Type.kt b/app/src/main/java/com/safebite/app/presentation/theme/Type.kt index 001931f..0e6c7d3 100644 --- a/app/src/main/java/com/safebite/app/presentation/theme/Type.kt +++ b/app/src/main/java/com/safebite/app/presentation/theme/Type.kt @@ -15,119 +15,131 @@ import androidx.compose.ui.unit.sp private val PoppinsFamily = FontFamily.SansSerif private val InterFamily = FontFamily.SansSerif -val SafeBiteTypography = Typography( - // Display — pour les titres héros (onboarding, banner) - displayLarge = TextStyle( - fontFamily = PoppinsFamily, - fontWeight = FontWeight.Bold, - fontSize = 57.sp, - lineHeight = 64.sp, - letterSpacing = (-0.25).sp - ), - displayMedium = TextStyle( - fontFamily = PoppinsFamily, - fontWeight = FontWeight.Bold, - fontSize = 45.sp, - lineHeight = 52.sp, - letterSpacing = 0.sp - ), - displaySmall = TextStyle( - fontFamily = PoppinsFamily, - fontWeight = FontWeight.Bold, - fontSize = 36.sp, - lineHeight = 44.sp, - letterSpacing = 0.sp - ), - - // Headline — sections majeures (Poppins Bold/SemiBold) - headlineLarge = TextStyle( - fontFamily = PoppinsFamily, - fontWeight = FontWeight.Bold, - fontSize = 32.sp, - lineHeight = 40.sp, - letterSpacing = (-0.5).sp - ), - headlineMedium = TextStyle( - fontFamily = PoppinsFamily, - fontWeight = FontWeight.SemiBold, - fontSize = 24.sp, - lineHeight = 32.sp, - letterSpacing = 0.sp - ), - headlineSmall = TextStyle( - fontFamily = PoppinsFamily, - fontWeight = FontWeight.SemiBold, - fontSize = 20.sp, - lineHeight = 28.sp, - letterSpacing = 0.15.sp - ), - - // Title — titres d'écran, de cartes (Inter Medium) - titleLarge = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.Medium, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - titleMedium = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.Medium, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.15.sp - ), - titleSmall = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp - ), - - // Body — textes courants (Inter Regular) - bodyLarge = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ), - bodyMedium = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.Normal, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.25.sp - ), - bodySmall = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.4.sp - ), - - // Label — boutons, chips, tags (Poppins SemiBold pour boutons, Inter Medium pour petits) - labelLarge = TextStyle( - fontFamily = PoppinsFamily, - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 1.25.sp - ), - labelMedium = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ), - labelSmall = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.Medium, - fontSize = 10.sp, - lineHeight = 16.sp, - letterSpacing = 1.5.sp +val SafeBiteTypography = + Typography( + // Display — pour les titres héros (onboarding, banner) + displayLarge = + TextStyle( + fontFamily = PoppinsFamily, + fontWeight = FontWeight.Bold, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = + TextStyle( + fontFamily = PoppinsFamily, + fontWeight = FontWeight.Bold, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = + TextStyle( + fontFamily = PoppinsFamily, + fontWeight = FontWeight.Bold, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + // Headline — sections majeures (Poppins Bold/SemiBold) + headlineLarge = + TextStyle( + fontFamily = PoppinsFamily, + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = (-0.5).sp, + ), + headlineMedium = + TextStyle( + fontFamily = PoppinsFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + headlineSmall = + TextStyle( + fontFamily = PoppinsFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 28.sp, + letterSpacing = 0.15.sp, + ), + // Title — titres d'écran, de cartes (Inter Medium) + titleLarge = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + // Body — textes courants (Inter Regular) + bodyLarge = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + // Label — boutons, chips, tags (Poppins SemiBold pour boutons, Inter Medium pour petits) + labelLarge = + TextStyle( + fontFamily = PoppinsFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 1.25.sp, + ), + labelMedium = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = + TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 10.sp, + lineHeight = 16.sp, + letterSpacing = 1.5.sp, + ), ) -) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 414758c..5167ff0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -68,6 +68,10 @@ Vous êtes en magasin ? Votre liste en cours %1$d produits restants + OK + ⚠️ Attention + ❌ Danger + %1$d produits scannés cette semaine Mes listes @@ -133,6 +137,11 @@ Lampe L\'accès à la caméra est nécessaire pour scanner. Ouvrir les paramètres + Saisir un code-barres + Code-barres manuel + Ex: 3017620422003 + Rechercher + Format de code-barres invalide (8 à 13 chiffres attendus) AUCUN ALLERGÈNE DÉTECTÉ POUR VOTRE PROFIL @@ -339,6 +348,7 @@ Ajouter à une liste Choisir une liste Produit ajouté à la liste + Voir les alternatives Ajouter un élément Prendre une photo Choisir dans la galerie diff --git a/app/src/test/java/com/safebite/app/data/repository/ProductRepositoryImplTest.kt b/app/src/test/java/com/safebite/app/data/repository/ProductRepositoryImplTest.kt index 8fd9e7b..af538e1 100644 --- a/app/src/test/java/com/safebite/app/data/repository/ProductRepositoryImplTest.kt +++ b/app/src/test/java/com/safebite/app/data/repository/ProductRepositoryImplTest.kt @@ -1,8 +1,8 @@ package com.safebite.app.data.repository import com.google.common.truth.Truth.assertThat -import com.safebite.app.data.local.database.entity.ProductCacheEntity import com.safebite.app.data.local.database.dao.ProductCacheDao +import com.safebite.app.data.local.database.entity.ProductCacheEntity import com.safebite.app.data.remote.api.OpenFoodFactsApi import com.safebite.app.data.remote.dto.OpenFoodFactsResponse import com.safebite.app.data.remote.dto.ProductDto @@ -22,7 +22,6 @@ import java.io.IOException * Tests unitaires pour ProductRepositoryImpl. */ class ProductRepositoryImplTest { - private lateinit var api: OpenFoodFactsApi private lateinit var cacheDao: ProductCacheDao private lateinit var repository: ProductRepositoryImpl @@ -35,125 +34,137 @@ class ProductRepositoryImplTest { } @Test - fun `fetchProduct returns Found from API when product exists`() = runTest { - val barcode = "123456789" - val productDto = ProductDto( - code = barcode, - productName = "Test Product", - brands = "Test Brand" - ) - val apiResponse = OpenFoodFactsResponse( - status = 1, - product = productDto - ) + fun `fetchProduct returns Found from API when product exists`() = + runTest { + val barcode = "123456789" + val productDto = + ProductDto( + code = barcode, + productName = "Test Product", + brands = "Test Brand", + ) + val apiResponse = + OpenFoodFactsResponse( + status = 1, + product = productDto, + ) - coEvery { api.getProduct(barcode) } returns apiResponse - coEvery { cacheDao.insert(any()) } returns Unit + coEvery { api.getProduct(barcode) } returns apiResponse + coEvery { cacheDao.insert(any()) } returns Unit - val result = repository.fetchProduct(barcode) + val result = repository.fetchProduct(barcode) - assertThat(result).isInstanceOf(ProductFetchResult.Found::class.java) - val found = result as ProductFetchResult.Found - assertThat(found.fromCache).isFalse() - assertThat(found.product.barcode).isEqualTo(barcode) - coVerify { cacheDao.insert(any()) } - } + assertThat(result).isInstanceOf(ProductFetchResult.Found::class.java) + val found = result as ProductFetchResult.Found + assertThat(found.fromCache).isFalse() + assertThat(found.product.barcode).isEqualTo(barcode) + coVerify { cacheDao.insert(any()) } + } @Test - fun `fetchProduct returns NotFound when API returns status 0`() = runTest { - val barcode = "999999" - val apiResponse = OpenFoodFactsResponse(status = 0, product = null) + fun `fetchProduct returns NotFound when API returns status 0`() = + runTest { + val barcode = "999999" + val apiResponse = OpenFoodFactsResponse(status = 0, product = null) - coEvery { api.getProduct(barcode) } returns apiResponse + coEvery { api.getProduct(barcode) } returns apiResponse - val result = repository.fetchProduct(barcode) + val result = repository.fetchProduct(barcode) - assertThat(result).isInstanceOf(ProductFetchResult.NotFound::class.java) - } + assertThat(result).isInstanceOf(ProductFetchResult.NotFound::class.java) + } @Test - fun `fetchProduct returns Error on IOException`() = runTest { - val barcode = "123456789" - coEvery { api.getProduct(barcode) } throws IOException("Network error") + fun `fetchProduct returns Error on IOException`() = + runTest { + val barcode = "123456789" + coEvery { api.getProduct(barcode) } throws IOException("Network error") - val result = repository.fetchProduct(barcode) + val result = repository.fetchProduct(barcode) - assertThat(result).isInstanceOf(ProductFetchResult.Error::class.java) - val error = result as ProductFetchResult.Error - assertThat(error.offline).isTrue() - } + assertThat(result).isInstanceOf(ProductFetchResult.Error::class.java) + val error = result as ProductFetchResult.Error + assertThat(error.offline).isTrue() + } @Test - fun `fetchProduct returns Error on HttpException`() = runTest { - val barcode = "123456789" - coEvery { api.getProduct(barcode) } throws HttpException(Response.error(500, mockk())) + fun `fetchProduct returns Error on HttpException`() = + runTest { + val barcode = "123456789" + coEvery { api.getProduct(barcode) } throws HttpException(Response.error(500, mockk())) - val result = repository.fetchProduct(barcode) + val result = repository.fetchProduct(barcode) - assertThat(result).isInstanceOf(ProductFetchResult.Error::class.java) - val error = result as ProductFetchResult.Error - assertThat(error.offline).isFalse() - } + assertThat(result).isInstanceOf(ProductFetchResult.Error::class.java) + val error = result as ProductFetchResult.Error + assertThat(error.offline).isFalse() + } @Test - fun `getCachedProduct returns product when exists in cache`() = runTest { - val barcode = "123456789" - val cachedEntity = ProductCacheEntity( - barcode = barcode, - name = "Cached Product", - brand = "Cached Brand", - imageUrl = null, - ingredientsText = null, - allergensTags = emptyList(), - tracesTags = emptyList(), - nutriScore = null, - novaGroup = null, - ecoScore = null, - labels = emptyList(), - categories = emptyList(), - cachedAt = System.currentTimeMillis() - ) + fun `getCachedProduct returns product when exists in cache`() = + runTest { + val barcode = "123456789" + val cachedEntity = + ProductCacheEntity( + barcode = barcode, + name = "Cached Product", + brand = "Cached Brand", + imageUrl = null, + ingredientsText = null, + allergensTags = emptyList(), + tracesTags = emptyList(), + nutriScore = null, + novaGroup = null, + ecoScore = null, + labels = emptyList(), + categories = emptyList(), + cachedAt = System.currentTimeMillis(), + ) - coEvery { cacheDao.getProduct(barcode) } returns cachedEntity + coEvery { cacheDao.getProduct(barcode) } returns cachedEntity - val result = repository.getCachedProduct(barcode) + val result = repository.getCachedProduct(barcode) - assertThat(result).isNotNull() - assertThat(result?.barcode).isEqualTo(barcode) - assertThat(result?.name).isEqualTo("Cached Product") - } + assertThat(result).isNotNull() + assertThat(result?.barcode).isEqualTo(barcode) + assertThat(result?.name).isEqualTo("Cached Product") + } @Test - fun `getCachedProduct returns null when not in cache`() = runTest { - val barcode = "123456789" - coEvery { cacheDao.getProduct(barcode) } returns null + fun `getCachedProduct returns null when not in cache`() = + runTest { + val barcode = "123456789" + coEvery { cacheDao.getProduct(barcode) } returns null - val result = repository.getCachedProduct(barcode) + val result = repository.getCachedProduct(barcode) - assertThat(result).isNull() - } + assertThat(result).isNull() + } @Test - fun `cacheProduct inserts into cache`() = runTest { - val product = Product( - barcode = "123456789", - name = "Test Product", - brand = "Test Brand" - ) + fun `cacheProduct inserts into cache`() = + runTest { + val product = + Product( + barcode = "123456789", + name = "Test Product", + brand = "Test Brand", + ) - coEvery { cacheDao.insert(any()) } returns Unit + coEvery { cacheDao.insert(any()) } returns Unit - repository.cacheProduct(product) + repository.cacheProduct(product) - coVerify { cacheDao.insert(any()) } - } + coVerify { cacheDao.insert(any()) } + } @Test - fun `clearCache clears all cached products`() = runTest { - coEvery { cacheDao.clearCache() } returns Unit + fun `clearCache clears all cached products`() = + runTest { + coEvery { cacheDao.clearCache() } returns Unit - repository.clearCache() + repository.clearCache() - coVerify { cacheDao.clearCache() } - } + coVerify { cacheDao.clearCache() } + } } diff --git a/app/src/test/java/com/safebite/app/domain/engine/AllergenAnalysisEngineTest.kt b/app/src/test/java/com/safebite/app/domain/engine/AllergenAnalysisEngineTest.kt index 627060d..b7a54a1 100644 --- a/app/src/test/java/com/safebite/app/domain/engine/AllergenAnalysisEngineTest.kt +++ b/app/src/test/java/com/safebite/app/domain/engine/AllergenAnalysisEngineTest.kt @@ -12,25 +12,26 @@ import com.safebite.app.domain.model.UserProfile import org.junit.Test class AllergenAnalysisEngineTest { - - private val profile = UserProfile( - id = 1L, - name = "Test", - severeAllergens = setOf(AllergenType.PEANUTS, AllergenType.MILK), - moderateIntolerances = setOf(AllergenType.GLUTEN) - ) + private val profile = + UserProfile( + id = 1L, + name = "Test", + severeAllergens = setOf(AllergenType.PEANUTS, AllergenType.MILK), + moderateIntolerances = setOf(AllergenType.GLUTEN), + ) @Test fun `OFF tag severe match to DANGER confirmed`() { - val product = Product( - barcode = "123", - name = "Bar", - brand = "B", - imageUrl = null, - ingredientsText = null, - allergensTags = listOf("en:peanuts"), - tracesTags = emptyList() - ) + val product = + Product( + barcode = "123", + name = "Bar", + brand = "B", + imageUrl = null, + ingredientsText = null, + allergensTags = listOf("en:peanuts"), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API) assertThat(result.safetyStatus).isEqualTo(SafetyStatus.DANGER) assertThat(result.detectedAllergens).hasSize(1) @@ -40,12 +41,16 @@ class AllergenAnalysisEngineTest { @Test fun `OFF traces tag to WARNING`() { - val product = Product( - barcode = "123", - name = null, brand = null, imageUrl = null, ingredientsText = null, - allergensTags = emptyList(), - tracesTags = listOf("en:peanuts") - ) + val product = + Product( + barcode = "123", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + allergensTags = emptyList(), + tracesTags = listOf("en:peanuts"), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API) assertThat(result.safetyStatus).isEqualTo(SafetyStatus.WARNING) assertThat(result.detectedAllergens.first().detectionLevel).isEqualTo(DetectionLevel.TRACE) @@ -53,11 +58,16 @@ class AllergenAnalysisEngineTest { @Test fun `French keyword with accent matches peanuts`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = "Ingrédients: chocolat, sucre, cacahuètes, sel.", - allergensTags = emptyList(), tracesTags = emptyList() - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = "Ingrédients: chocolat, sucre, cacahuètes, sel.", + allergensTags = emptyList(), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API, DetectionLanguage.FR) assertThat(result.detectedAllergens.any { it.allergenType == AllergenType.PEANUTS }).isTrue() assertThat(result.safetyStatus).isEqualTo(SafetyStatus.DANGER) @@ -65,22 +75,32 @@ class AllergenAnalysisEngineTest { @Test fun `English plural keyword matches`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = "Ingredients: sugar, peanut oil, salt.", - allergensTags = emptyList(), tracesTags = emptyList() - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = "Ingredients: sugar, peanut oil, salt.", + allergensTags = emptyList(), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API, DetectionLanguage.EN) assertThat(result.detectedAllergens.any { it.allergenType == AllergenType.PEANUTS }).isTrue() } @Test fun `May contain pattern extracted as TRACE`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = "Ingrédients: sucre, chocolat. Peut contenir des traces de arachides et lait.", - allergensTags = emptyList(), tracesTags = emptyList() - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = "Ingrédients: sucre, chocolat. Peut contenir des traces de arachides et lait.", + allergensTags = emptyList(), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API, DetectionLanguage.FR) val peanut = result.detectedAllergens.firstOrNull { it.allergenType == AllergenType.PEANUTS } assertThat(peanut).isNotNull() @@ -90,11 +110,16 @@ class AllergenAnalysisEngineTest { @Test fun `English may contain extracted as TRACE`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = "Ingredients: sugar, cocoa. May contain peanuts and milk.", - allergensTags = emptyList(), tracesTags = emptyList() - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = "Ingredients: sugar, cocoa. May contain peanuts and milk.", + allergensTags = emptyList(), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API, DetectionLanguage.EN) val peanut = result.detectedAllergens.firstOrNull { it.allergenType == AllergenType.PEANUTS } assertThat(peanut?.detectionLevel).isEqualTo(DetectionLevel.TRACE) @@ -102,11 +127,16 @@ class AllergenAnalysisEngineTest { @Test fun `No match to SAFE`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = "Ingredients: water, sugar, salt, natural flavour.", - allergensTags = emptyList(), tracesTags = emptyList() - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = "Ingredients: water, sugar, salt, natural flavour.", + allergensTags = emptyList(), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API) assertThat(result.safetyStatus).isEqualTo(SafetyStatus.SAFE) assertThat(result.detectedAllergens).isEmpty() @@ -114,44 +144,64 @@ class AllergenAnalysisEngineTest { @Test fun `Empty product data to LOW confidence`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, - allergensTags = emptyList(), tracesTags = emptyList() - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + allergensTags = emptyList(), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API) assertThat(result.confidence).isEqualTo(AnalysisConfidence.LOW) } @Test fun `OCR source always LOW confidence`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = "Peanuts", - allergensTags = listOf("en:peanuts"), tracesTags = emptyList() - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = "Peanuts", + allergensTags = listOf("en:peanuts"), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.OCR) assertThat(result.confidence).isEqualTo(AnalysisConfidence.LOW) } @Test fun `Both tags and text to HIGH confidence`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = "Ingredients: peanuts, sugar.", - allergensTags = listOf("en:peanuts"), tracesTags = emptyList() - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = "Ingredients: peanuts, sugar.", + allergensTags = listOf("en:peanuts"), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API) assertThat(result.confidence).isEqualTo(AnalysisConfidence.HIGH) } @Test fun `Moderate intolerance match to WARNING not DANGER`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, - allergensTags = listOf("en:gluten"), tracesTags = emptyList() - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + allergensTags = listOf("en:gluten"), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API) assertThat(result.safetyStatus).isEqualTo(SafetyStatus.WARNING) } @@ -172,11 +222,16 @@ class AllergenAnalysisEngineTest { @Test fun `May contain does not double-count as confirmed`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = "Ingrédients: sucre. Peut contenir des traces d'arachides.", - allergensTags = emptyList(), tracesTags = emptyList() - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = "Ingrédients: sucre. Peut contenir des traces d'arachides.", + allergensTags = emptyList(), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API, DetectionLanguage.FR) val peanut = result.detectedAllergens.first { it.allergenType == AllergenType.PEANUTS } assertThat(peanut.detectionLevel).isEqualTo(DetectionLevel.TRACE) @@ -184,25 +239,39 @@ class AllergenAnalysisEngineTest { @Test fun `Milk keyword detected in French`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = "Ingrédients: farine, beurre, sel.", - allergensTags = emptyList(), tracesTags = emptyList() - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = "Ingrédients: farine, beurre, sel.", + allergensTags = emptyList(), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(profile), DataSource.API, DetectionLanguage.FR) assertThat(result.detectedAllergens.any { it.allergenType == AllergenType.MILK }).isTrue() } @Test fun `Custom ALLERGY item forces DANGER`() { - val profileWithCustom = profile.copy( - customItems = listOf(com.safebite.app.domain.model.CustomDietItem("huile de palme", com.safebite.app.domain.model.CustomItemTag.ALLERGY)) - ) - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = "Ingrédients: sucre, huile de palme, sel.", - allergensTags = emptyList(), tracesTags = emptyList() - ) + val profileWithCustom = + profile.copy( + customItems = + listOf( + com.safebite.app.domain.model.CustomDietItem("huile de palme", com.safebite.app.domain.model.CustomItemTag.ALLERGY), + ), + ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = "Ingrédients: sucre, huile de palme, sel.", + allergensTags = emptyList(), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(profileWithCustom), DataSource.API, DetectionLanguage.FR) assertThat(result.safetyStatus).isEqualTo(SafetyStatus.DANGER) assertThat(result.detectedCustomItems).hasSize(1) @@ -211,28 +280,52 @@ class AllergenAnalysisEngineTest { @Test fun `Custom INTOLERANCE item triggers WARNING`() { - val p = profile.copy( - customItems = listOf(com.safebite.app.domain.model.CustomDietItem("carragenane", com.safebite.app.domain.model.CustomItemTag.INTOLERANCE)) - ) - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = "Eau, sucre, carragénane.", - allergensTags = emptyList(), tracesTags = emptyList() - ) + val p = + profile.copy( + customItems = + listOf( + com.safebite.app.domain.model.CustomDietItem( + "carragenane", + com.safebite.app.domain.model.CustomItemTag.INTOLERANCE, + ), + ), + ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = "Eau, sucre, carragénane.", + allergensTags = emptyList(), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(p), DataSource.API, DetectionLanguage.FR) assertThat(result.safetyStatus).isEqualTo(SafetyStatus.WARNING) } @Test fun `Custom UNHEALTHY item does not force DANGER but flags health`() { - val p = profile.copy( - customItems = listOf(com.safebite.app.domain.model.CustomDietItem("sirop de glucose", com.safebite.app.domain.model.CustomItemTag.UNHEALTHY)) - ) - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = "Eau, sucre, sirop de glucose.", - allergensTags = emptyList(), tracesTags = emptyList() - ) + val p = + profile.copy( + customItems = + listOf( + com.safebite.app.domain.model.CustomDietItem( + "sirop de glucose", + com.safebite.app.domain.model.CustomItemTag.UNHEALTHY, + ), + ), + ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = "Eau, sucre, sirop de glucose.", + allergensTags = emptyList(), + tracesTags = emptyList(), + ) val result = AllergenAnalysisEngine.analyze(product, listOf(p), DataSource.API, DetectionLanguage.FR) assertThat(result.safetyStatus).isEqualTo(SafetyStatus.SAFE) assertThat(result.detectedCustomItems).hasSize(1) @@ -241,41 +334,45 @@ class AllergenAnalysisEngineTest { @Test fun `Health classifier - Nutri A is HEALTHY on NORMAL`() { - val assessment = com.safebite.app.domain.engine.HealthClassifier.classify( - Product(barcode = "1", name = null, brand = null, imageUrl = null, ingredientsText = null, nutriScore = "a", novaGroup = 1), - emptyList(), - com.safebite.app.domain.model.HealthStrictness.NORMAL - ) + val assessment = + com.safebite.app.domain.engine.HealthClassifier.classify( + Product(barcode = "1", name = null, brand = null, imageUrl = null, ingredientsText = null, nutriScore = "a", novaGroup = 1), + emptyList(), + com.safebite.app.domain.model.HealthStrictness.NORMAL, + ) assertThat(assessment.rating).isEqualTo(com.safebite.app.domain.model.HealthRating.HEALTHY) } @Test fun `Health classifier - Nutri D or Nova 4 is UNHEALTHY on NORMAL`() { - val assessment = com.safebite.app.domain.engine.HealthClassifier.classify( - Product(barcode = "1", name = null, brand = null, imageUrl = null, ingredientsText = null, nutriScore = "d", novaGroup = 4), - emptyList(), - com.safebite.app.domain.model.HealthStrictness.NORMAL - ) + val assessment = + com.safebite.app.domain.engine.HealthClassifier.classify( + Product(barcode = "1", name = null, brand = null, imageUrl = null, ingredientsText = null, nutriScore = "d", novaGroup = 4), + emptyList(), + com.safebite.app.domain.model.HealthStrictness.NORMAL, + ) assertThat(assessment.rating).isEqualTo(com.safebite.app.domain.model.HealthRating.UNHEALTHY) } @Test fun `Health classifier - STRICT treats B as MODERATE`() { - val assessment = com.safebite.app.domain.engine.HealthClassifier.classify( - Product(barcode = "1", name = null, brand = null, imageUrl = null, ingredientsText = null, nutriScore = "b", novaGroup = 2), - emptyList(), - com.safebite.app.domain.model.HealthStrictness.STRICT - ) + val assessment = + com.safebite.app.domain.engine.HealthClassifier.classify( + Product(barcode = "1", name = null, brand = null, imageUrl = null, ingredientsText = null, nutriScore = "b", novaGroup = 2), + emptyList(), + com.safebite.app.domain.model.HealthStrictness.STRICT, + ) assertThat(assessment.rating).isEqualTo(com.safebite.app.domain.model.HealthRating.MODERATE) } @Test fun `Health classifier - no scores and no custom hits is UNKNOWN`() { - val assessment = com.safebite.app.domain.engine.HealthClassifier.classify( - Product(barcode = "1", name = null, brand = null, imageUrl = null, ingredientsText = null), - emptyList(), - com.safebite.app.domain.model.HealthStrictness.NORMAL - ) + val assessment = + com.safebite.app.domain.engine.HealthClassifier.classify( + Product(barcode = "1", name = null, brand = null, imageUrl = null, ingredientsText = null), + emptyList(), + com.safebite.app.domain.model.HealthStrictness.NORMAL, + ) assertThat(assessment.rating).isEqualTo(com.safebite.app.domain.model.HealthRating.UNKNOWN) } diff --git a/app/src/test/java/com/safebite/app/domain/engine/HealthClassifierTest.kt b/app/src/test/java/com/safebite/app/domain/engine/HealthClassifierTest.kt index f9e0ead..044ca27 100644 --- a/app/src/test/java/com/safebite/app/domain/engine/HealthClassifierTest.kt +++ b/app/src/test/java/com/safebite/app/domain/engine/HealthClassifierTest.kt @@ -7,13 +7,18 @@ import com.safebite.app.domain.model.Product import org.junit.Test class HealthClassifierTest { - @Test fun `Nutri-Score A with Nova 1 is HEALTHY on NORMAL`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, nutriScore = "a", novaGroup = 1 - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + nutriScore = "a", + novaGroup = 1, + ) val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL) assertThat(assessment.rating).isEqualTo(HealthRating.HEALTHY) assertThat(assessment.reasons).isNotEmpty() @@ -21,137 +26,217 @@ class HealthClassifierTest { @Test fun `Nutri-Score B with Nova 2 is HEALTHY on NORMAL`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, nutriScore = "b", novaGroup = 2 - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + nutriScore = "b", + novaGroup = 2, + ) val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL) assertThat(assessment.rating).isEqualTo(HealthRating.HEALTHY) } @Test fun `Nutri-Score C with Nova 3 is MODERATE on NORMAL`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, nutriScore = "c", novaGroup = 3 - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + nutriScore = "c", + novaGroup = 3, + ) val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL) assertThat(assessment.rating).isEqualTo(HealthRating.MODERATE) } @Test fun `Nutri-Score D or E with Nova 4 is UNHEALTHY on NORMAL`() { - val productD = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, nutriScore = "d", novaGroup = 4 - ) + val productD = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + nutriScore = "d", + novaGroup = 4, + ) val assessmentD = HealthClassifier.classify(productD, emptyList(), HealthStrictness.NORMAL) assertThat(assessmentD.rating).isEqualTo(HealthRating.UNHEALTHY) - val productE = Product( - barcode = "2", name = null, brand = null, imageUrl = null, - ingredientsText = null, nutriScore = "e", novaGroup = 4 - ) + val productE = + Product( + barcode = "2", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + nutriScore = "e", + novaGroup = 4, + ) val assessmentE = HealthClassifier.classify(productE, emptyList(), HealthStrictness.NORMAL) assertThat(assessmentE.rating).isEqualTo(HealthRating.UNHEALTHY) } @Test fun `STRICT mode - Nutri-Score A is HEALTHY`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, nutriScore = "a", novaGroup = 1 - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + nutriScore = "a", + novaGroup = 1, + ) val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.STRICT) assertThat(assessment.rating).isEqualTo(HealthRating.HEALTHY) } @Test fun `STRICT mode - Nutri-Score B is MODERATE`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, nutriScore = "b", novaGroup = 2 - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + nutriScore = "b", + novaGroup = 2, + ) val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.STRICT) assertThat(assessment.rating).isEqualTo(HealthRating.MODERATE) } @Test fun `STRICT mode - Nutri-Score C is UNHEALTHY`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, nutriScore = "c", novaGroup = 3 - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + nutriScore = "c", + novaGroup = 3, + ) val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.STRICT) assertThat(assessment.rating).isEqualTo(HealthRating.UNHEALTHY) } @Test fun `LENIENT mode - Nutri-Score C is HEALTHY`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, nutriScore = "c", novaGroup = 3 - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + nutriScore = "c", + novaGroup = 3, + ) val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.LENIENT) assertThat(assessment.rating).isEqualTo(HealthRating.HEALTHY) } @Test fun `LENIENT mode - Nutri-Score D is MODERATE`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, nutriScore = "d", novaGroup = 4 - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + nutriScore = "d", + novaGroup = 4, + ) val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.LENIENT) assertThat(assessment.rating).isEqualTo(HealthRating.MODERATE) } @Test fun `No nutriScore and no novaGroup is UNKNOWN`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + ) val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL) assertThat(assessment.rating).isEqualTo(HealthRating.UNKNOWN) } @Test fun `Only nutriScore without novaGroup uses nutriScore only`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, nutriScore = "b" - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + nutriScore = "b", + ) val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL) assertThat(assessment.rating).isEqualTo(HealthRating.HEALTHY) } @Test fun `Only novaGroup without nutriScore uses novaGroup only`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, novaGroup = 4 - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + novaGroup = 4, + ) val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL) assertThat(assessment.rating).isEqualTo(HealthRating.UNHEALTHY) } @Test fun `Reasons list is populated for HEALTHY`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, nutriScore = "a", novaGroup = 1 - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + nutriScore = "a", + novaGroup = 1, + ) val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL) assertThat(assessment.reasons).isNotEmpty() } @Test fun `Reasons list is populated for UNHEALTHY`() { - val product = Product( - barcode = "1", name = null, brand = null, imageUrl = null, - ingredientsText = null, nutriScore = "e", novaGroup = 4 - ) + val product = + Product( + barcode = "1", + name = null, + brand = null, + imageUrl = null, + ingredientsText = null, + nutriScore = "e", + novaGroup = 4, + ) val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL) assertThat(assessment.reasons).isNotEmpty() } diff --git a/app/src/test/java/com/safebite/app/domain/usecase/GetAlternativesUseCaseTest.kt b/app/src/test/java/com/safebite/app/domain/usecase/GetAlternativesUseCaseTest.kt index b2c86f4..70214d0 100644 --- a/app/src/test/java/com/safebite/app/domain/usecase/GetAlternativesUseCaseTest.kt +++ b/app/src/test/java/com/safebite/app/domain/usecase/GetAlternativesUseCaseTest.kt @@ -1,7 +1,6 @@ package com.safebite.app.domain.usecase import com.safebite.app.domain.model.Product -import com.safebite.app.domain.repository.ProductFetchResult import com.safebite.app.domain.repository.ProductRepository import io.mockk.coEvery import io.mockk.coVerify @@ -16,7 +15,6 @@ import org.junit.Test * Tests unitaires pour GetAlternativesUseCase. */ class GetAlternativesUseCaseTest { - private lateinit var productRepository: ProductRepository private lateinit var useCase: GetAlternativesUseCase @@ -27,56 +25,62 @@ class GetAlternativesUseCaseTest { } @Test - fun `invoke returns alternatives when category is valid`() = runTest { - val category = "breakfasts" - val excludeAllergens = setOf("en:milk", "en:peanuts") - val expectedProducts = listOf( - Product(barcode = "111", name = "Product A", category = category), - Product(barcode = "222", name = "Product B", category = category) - ) + fun `invoke returns alternatives when category is valid`() = + runTest { + val category = "breakfasts" + val excludeAllergens = setOf("en:milk", "en:peanuts") + val expectedProducts = + listOf( + Product(barcode = "111", name = "Product A", category = category), + Product(barcode = "222", name = "Product B", category = category), + ) - coEvery { productRepository.searchAlternatives(category, excludeAllergens, 5) } returns expectedProducts + coEvery { productRepository.searchAlternatives(category, excludeAllergens, 5) } returns expectedProducts - val result = useCase(category, excludeAllergens) + val result = useCase(category, excludeAllergens) - assertEquals(expectedProducts, result) - coVerify { productRepository.searchAlternatives(category, excludeAllergens, 5) } - } + assertEquals(expectedProducts, result) + coVerify { productRepository.searchAlternatives(category, excludeAllergens, 5) } + } @Test - fun `invoke returns empty list when category is blank`() = runTest { - val result = useCase("", setOf("en:milk")) - assertTrue(result.isEmpty()) - } + fun `invoke returns empty list when category is blank`() = + runTest { + val result = useCase("", setOf("en:milk")) + assertTrue(result.isEmpty()) + } @Test - fun `invoke returns empty list when category is whitespace`() = runTest { - val result = useCase(" ", setOf("en:milk")) - assertTrue(result.isEmpty()) - } + fun `invoke returns empty list when category is whitespace`() = + runTest { + val result = useCase(" ", setOf("en:milk")) + assertTrue(result.isEmpty()) + } @Test - fun `invoke uses default limit of 5`() = runTest { - val category = "snacks" - val excludeAllergens = setOf("en:gluten") + fun `invoke uses default limit of 5`() = + runTest { + val category = "snacks" + val excludeAllergens = setOf("en:gluten") - coEvery { productRepository.searchAlternatives(category, excludeAllergens, 5) } returns emptyList() + coEvery { productRepository.searchAlternatives(category, excludeAllergens, 5) } returns emptyList() - useCase(category, excludeAllergens) + useCase(category, excludeAllergens) - coVerify { productRepository.searchAlternatives(category, excludeAllergens, 5) } - } + coVerify { productRepository.searchAlternatives(category, excludeAllergens, 5) } + } @Test - fun `invoke passes custom limit`() = runTest { - val category = "beverages" - val excludeAllergens = setOf("en:soya") - val customLimit = 10 + fun `invoke passes custom limit`() = + runTest { + val category = "beverages" + val excludeAllergens = setOf("en:soya") + val customLimit = 10 - coEvery { productRepository.searchAlternatives(category, excludeAllergens, customLimit) } returns emptyList() + coEvery { productRepository.searchAlternatives(category, excludeAllergens, customLimit) } returns emptyList() - useCase(category, excludeAllergens, customLimit) + useCase(category, excludeAllergens, customLimit) - coVerify { productRepository.searchAlternatives(category, excludeAllergens, customLimit) } - } + coVerify { productRepository.searchAlternatives(category, excludeAllergens, customLimit) } + } } diff --git a/app/src/test/java/com/safebite/app/presentation/screen/result/ResultViewModelTest.kt b/app/src/test/java/com/safebite/app/presentation/screen/result/ResultViewModelTest.kt index 9e525e1..c4d66c9 100644 --- a/app/src/test/java/com/safebite/app/presentation/screen/result/ResultViewModelTest.kt +++ b/app/src/test/java/com/safebite/app/presentation/screen/result/ResultViewModelTest.kt @@ -3,8 +3,8 @@ package com.safebite.app.presentation.screen.result import app.cash.turbine.test import com.safebite.app.domain.model.DataSource import com.safebite.app.domain.model.Product -import com.safebite.app.domain.model.ScanResult import com.safebite.app.domain.model.SafetyStatus +import com.safebite.app.domain.model.ScanResult import com.safebite.app.domain.model.UserProfile import com.safebite.app.domain.repository.ProductFetchResult import com.safebite.app.domain.usecase.AnalyzeIngredientsTextUseCase @@ -14,7 +14,6 @@ import com.safebite.app.domain.usecase.ManageProfileUseCase import com.safebite.app.domain.usecase.SaveScanUseCase import com.safebite.app.presentation.common.util.UiState import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -26,7 +25,6 @@ import org.junit.Test * Tests unitaires pour ResultViewModel. */ class ResultViewModelTest { - private val fetchProduct: FetchProductUseCase = mockk() private val analyzeProduct: AnalyzeProductUseCase = mockk() private val analyzeText: AnalyzeIngredientsTextUseCase = mockk() @@ -35,115 +33,125 @@ class ResultViewModelTest { private lateinit var viewModel: ResultViewModel - private val testProfile = UserProfile( - id = 1, - name = "Test User", - isDefault = true, - isActive = true - ) + private val testProfile = + UserProfile( + id = 1, + name = "Test User", + isDefault = true, + isActive = true, + ) - private val testProduct = Product( - barcode = "123456789", - name = "Test Product" - ) + private val testProduct = + Product( + barcode = "123456789", + name = "Test Product", + ) - private val testScanResult = ScanResult( - product = testProduct, - safetyStatus = SafetyStatus.SAFE, - dataSource = DataSource.API, - matchedProfiles = listOf(testProfile) - ) + private val testScanResult = + ScanResult( + product = testProduct, + safetyStatus = SafetyStatus.SAFE, + dataSource = DataSource.API, + matchedProfiles = listOf(testProfile), + ) @Before fun setUp() { - viewModel = ResultViewModel( - fetchProduct, analyzeProduct, analyzeText, manageProfile, saveScan - ) + viewModel = + ResultViewModel( + fetchProduct, analyzeProduct, analyzeText, manageProfile, saveScan, + ) } @Test - fun `analyzeBarcode returns Success when product found and analyzed`() = runTest { - coEvery { manageProfile.observe() } returns flowOf(listOf(testProfile)) - coEvery { manageProfile.observeActiveIds() } returns flowOf(setOf(1L)) - coEvery { fetchProduct("123456789") } returns ProductFetchResult.Found(testProduct, fromCache = false) - coEvery { analyzeProduct(testProduct, listOf(testProfile), DataSource.API) } returns testScanResult - coEvery { saveScan(testScanResult) } returns 1L + fun `analyzeBarcode returns Success when product found and analyzed`() = + runTest { + coEvery { manageProfile.observe() } returns flowOf(listOf(testProfile)) + coEvery { manageProfile.observeActiveIds() } returns flowOf(setOf(1L)) + coEvery { fetchProduct("123456789") } returns ProductFetchResult.Found(testProduct, fromCache = false) + coEvery { analyzeProduct(testProduct, listOf(testProfile), DataSource.API) } returns testScanResult + coEvery { saveScan(testScanResult) } returns 1L - viewModel.analyzeBarcode("123456789") + viewModel.analyzeBarcode("123456789") - viewModel.state.test { - val state = awaitItem() - assertTrue(state is UiState.Success) + viewModel.state.test { + val state = awaitItem() + assertTrue(state is UiState.Success) + } } - } @Test - fun `analyzeBarcode returns Error when no profiles configured`() = runTest { - coEvery { manageProfile.observe() } returns flowOf(emptyList()) - coEvery { manageProfile.observeActiveIds() } returns flowOf(emptySet()) + fun `analyzeBarcode returns Error when no profiles configured`() = + runTest { + coEvery { manageProfile.observe() } returns flowOf(emptyList()) + coEvery { manageProfile.observeActiveIds() } returns flowOf(emptySet()) - viewModel.analyzeBarcode("123456789") + viewModel.analyzeBarcode("123456789") - viewModel.state.test { - val state = awaitItem() - assertTrue(state is UiState.Error) + viewModel.state.test { + val state = awaitItem() + assertTrue(state is UiState.Error) + } } - } @Test - fun `analyzeBarcode returns Error when product not found`() = runTest { - coEvery { manageProfile.observe() } returns flowOf(listOf(testProfile)) - coEvery { manageProfile.observeActiveIds() } returns flowOf(setOf(1L)) - coEvery { fetchProduct("999999") } returns ProductFetchResult.NotFound + fun `analyzeBarcode returns Error when product not found`() = + runTest { + coEvery { manageProfile.observe() } returns flowOf(listOf(testProfile)) + coEvery { manageProfile.observeActiveIds() } returns flowOf(setOf(1L)) + coEvery { fetchProduct("999999") } returns ProductFetchResult.NotFound - viewModel.analyzeBarcode("999999") + viewModel.analyzeBarcode("999999") - viewModel.state.test { - val state = awaitItem() - assertTrue(state is UiState.Error) + viewModel.state.test { + val state = awaitItem() + assertTrue(state is UiState.Error) + } } - } @Test - fun `analyzeBarcode returns Error with offline flag when network error`() = runTest { - coEvery { manageProfile.observe() } returns flowOf(listOf(testProfile)) - coEvery { manageProfile.observeActiveIds() } returns flowOf(setOf(1L)) - coEvery { fetchProduct("123456789") } returns ProductFetchResult.Error("Network error", offline = true) + fun `analyzeBarcode returns Error with offline flag when network error`() = + runTest { + coEvery { manageProfile.observe() } returns flowOf(listOf(testProfile)) + coEvery { manageProfile.observeActiveIds() } returns flowOf(setOf(1L)) + coEvery { fetchProduct("123456789") } returns ProductFetchResult.Error("Network error", offline = true) - viewModel.analyzeBarcode("123456789") + viewModel.analyzeBarcode("123456789") - viewModel.state.test { - val state = awaitItem() - assertTrue(state is UiState.Error) - assertTrue((state as UiState.Error).offline) + viewModel.state.test { + val state = awaitItem() + assertTrue(state is UiState.Error) + assertTrue((state as UiState.Error).offline) + } } - } @Test - fun `analyzeOcrText returns Success when analysis succeeds`() = runTest { - coEvery { manageProfile.observe() } returns flowOf(listOf(testProfile)) - coEvery { manageProfile.observeActiveIds() } returns flowOf(setOf(1L)) - coEvery { analyzeText("ingredients text", listOf(testProfile), null, null) } returns testScanResult - coEvery { saveScan(testScanResult) } returns 1L + fun `analyzeOcrText returns Success when analysis succeeds`() = + runTest { + coEvery { manageProfile.observe() } returns flowOf(listOf(testProfile)) + coEvery { manageProfile.observeActiveIds() } returns flowOf(setOf(1L)) + coEvery { analyzeText("ingredients text", listOf(testProfile), null, null) } returns testScanResult + coEvery { saveScan(testScanResult) } returns 1L - viewModel.analyzeOcrText("ingredients text") + viewModel.analyzeOcrText("ingredients text") - viewModel.state.test { - val state = awaitItem() - assertTrue(state is UiState.Success) + viewModel.state.test { + val state = awaitItem() + assertTrue(state is UiState.Success) + } } - } @Test - fun `analyzeOcrText returns Error when no profiles`() = runTest { - coEvery { manageProfile.observe() } returns flowOf(emptyList()) - coEvery { manageProfile.observeActiveIds() } returns flowOf(emptySet()) + fun `analyzeOcrText returns Error when no profiles`() = + runTest { + coEvery { manageProfile.observe() } returns flowOf(emptyList()) + coEvery { manageProfile.observeActiveIds() } returns flowOf(emptySet()) - viewModel.analyzeOcrText("ingredients text") + viewModel.analyzeOcrText("ingredients text") - viewModel.state.test { - val state = awaitItem() - assertTrue(state is UiState.Error) + viewModel.state.test { + val state = awaitItem() + assertTrue(state is UiState.Error) + } } - } } diff --git a/build.gradle.kts b/build.gradle.kts index f8973b6..f6ce51a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,4 +5,6 @@ plugins { alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false + alias(libs.plugins.ktlint) apply false + alias(libs.plugins.detekt) apply false } diff --git a/build_apks.ps1 b/build_apks.ps1 index b16bc8a..90e22bf 100644 --- a/build_apks.ps1 +++ b/build_apks.ps1 @@ -25,9 +25,10 @@ if (-not (Test-Path $versionFile)) { "MAJOR=1`nMINOR=0`nPATCH=0`nCODE=1" | Out-File $versionFile -Encoding ascii } -# Read properties +# Read properties using .NET file API to avoid locking +$rawContent = [System.IO.File]::ReadAllText($versionFile) $properties = @{} -Get-Content $versionFile | ForEach-Object { +$rawContent -split "`r?`n" | ForEach-Object { if ($_ -match "^(.*?)=(.*)$") { $properties[$matches[1].Trim()] = $matches[2].Trim() } @@ -57,7 +58,7 @@ if ($Major) { if ($versionChanged) { $vCode++ $newContent = "MAJOR=$vMajor`r`nMINOR=$vMinor`r`nPATCH=$vPatch`r`nCODE=$vCode" - Set-Content -Path $versionFile -Value $newContent -Encoding Ascii + [System.IO.File]::WriteAllText($versionFile, $newContent, [System.Text.Encoding]::ASCII) Write-Host "Version incremented to: $vMajor.$vMinor.$vPatch (Code: $vCode)" -ForegroundColor Cyan } else { Write-Host "Current version: $vMajor.$vMinor.$vPatch (Code: $vCode)" -ForegroundColor Cyan diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml new file mode 100644 index 0000000..2d3f8f2 --- /dev/null +++ b/config/detekt/baseline.xml @@ -0,0 +1,166 @@ + + + + + CyclomaticComplexMethod:ListDetailScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ItemDetailSheet( item: ListDetailViewModel.ShoppingListItemUi, otherLists: List<com.safebite.app.data.local.database.entity.ShoppingListEntity>, categories: List<String>, onDismiss: () -> Unit, onUpdateNote: (String) -> Unit, onUpdateCategory: (String) -> Unit, onUpdateEmoji: (String?) -> Unit, onToggleTag: (String) -> Unit, onUpdateImage: (String?) -> Unit, onMoveTo: (Long) -> Unit, onDelete: () -> Unit, onOpenProduct: (() -> Unit)?, onRequestCrop: (Bitmap) -> Unit, ) + CyclomaticComplexMethod:ListDetailScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ListDetailScreen( listId: Long, listName: String, onBack: () -> Unit, onOpenScanner: () -> Unit, onOpenProduct: (String) -> Unit, onOpenCatalog: (Long) -> Unit = {}, viewModel: ListDetailViewModel = hiltViewModel(), ) + CyclomaticComplexMethod:ListSortScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ListSortScreen( listId: Long, onBack: () -> Unit, viewModel: ListsViewModel = hiltViewModel(), ) + CyclomaticComplexMethod:ListsScreen.kt$@Composable private fun ShoppingListCard( item: ListsViewModel.ShoppingListWithStats, isEditMode: Boolean, onClick: () -> Unit, onSettingsClick: () -> Unit, ) + FunctionOnlyReturningConstant:Screen.kt$Screen.ListEdit$fun new() + FunctionOnlyReturningConstant:Screen.kt$Screen.ProfileEdit$fun new() + LongMethod:AllergenAnalysisEngine.kt$AllergenAnalysisEngine$fun analyze( product: Product, profiles: List<UserProfile>, source: DataSource, language: DetectionLanguage = DetectionLanguage.BOTH, healthStrictness: HealthStrictness = HealthStrictness.NORMAL, ): ScanResult + LongMethod:IconPickerSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun IconPickerSheet( currentEmoji: String, categories: List<String>, onDismiss: () -> Unit, onSelectIcon: (String) -> Unit, catalogProvider: CatalogProvider = hiltViewModel<ListDetailViewModel>().catalog, ) + LongMethod:ImageCropBottomSheet.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ImageCropBottomSheet( bitmap: Bitmap, onCropComplete: (String?) -> Unit, onDismiss: () -> Unit, sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) + LongMethod:ListDetailScreen.kt$@Composable private fun ItemDetailPanel( pending: ListDetailViewModel.PendingItem, onQuantity: (Int?) -> Unit, onVariant: (String?) -> Unit, onToggleTag: (String) -> Unit, onNote: (String) -> Unit, ) + LongMethod:ListDetailScreen.kt$@OptIn(ExperimentalFoundationApi::class) @Composable private fun ListDetailContent( ready: ListDetailViewModel.UiState.Ready, catalogDomains: List<com.safebite.app.data.local.database.relation.DomainWithCategoriesAndItems>, recentlyExpanded: Boolean, onToggleRecently: () -> Unit, expandedDomains: Map<String, Boolean>, onToggleDomain: (String) -> Unit, expandedRoomCategories: Map<String, Boolean>, onToggleRoomCategory: (String) -> Unit, onTapActive: (Long) -> Unit, onLongPressActive: (Long) -> Unit, onTapRecent: (Long) -> Unit, onLongPressRecent: (Long) -> Unit, onTapCatalogItem: (com.safebite.app.data.local.database.entity.CatalogItemEntity) -> Unit, ) + LongMethod:ListDetailScreen.kt$@OptIn(ExperimentalFoundationApi::class) @Composable private fun Tile( data: TileData, onTap: () -> Unit, onLongPress: () -> Unit, ) + LongMethod:ListDetailScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ItemDetailSheet( item: ListDetailViewModel.ShoppingListItemUi, otherLists: List<com.safebite.app.data.local.database.entity.ShoppingListEntity>, categories: List<String>, onDismiss: () -> Unit, onUpdateNote: (String) -> Unit, onUpdateCategory: (String) -> Unit, onUpdateEmoji: (String?) -> Unit, onToggleTag: (String) -> Unit, onUpdateImage: (String?) -> Unit, onMoveTo: (Long) -> Unit, onDelete: () -> Unit, onOpenProduct: (() -> Unit)?, onRequestCrop: (Bitmap) -> Unit, ) + LongMethod:ListDetailScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ListDetailScreen( listId: Long, listName: String, onBack: () -> Unit, onOpenScanner: () -> Unit, onOpenProduct: (String) -> Unit, onOpenCatalog: (Long) -> Unit = {}, viewModel: ListDetailViewModel = hiltViewModel(), ) + LongMethod:ListNameImageScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ListNameImageScreen( listId: Long, onBack: () -> Unit, viewModel: ListsViewModel = hiltViewModel(), ) + LongMethod:ListSettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ListSettingsScreen( listId: Long, onBack: () -> Unit, onOpenSort: () -> Unit, onOpenRegion: () -> Unit, onOpenNameImage: () -> Unit, onOpenMembers: () -> Unit, viewModel: ListsViewModel = hiltViewModel(), ) + LongMethod:ListSortScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ListSortScreen( listId: Long, onBack: () -> Unit, viewModel: ListsViewModel = hiltViewModel(), ) + LongMethod:ListsScreen.kt$@Composable private fun ShoppingListCard( item: ListsViewModel.ShoppingListWithStats, isEditMode: Boolean, onClick: () -> Unit, onSettingsClick: () -> Unit, ) + LongMethod:ProductNotFoundScreen.kt$@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable fun ProductNotFoundScreen( barcode: String, onBack: () -> Unit, onOpenOcr: () -> Unit, onManualSubmit: (String) -> Unit, onScanAgain: () -> Unit, ) + LongMethod:ResultScreen.kt$@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable private fun ResultContent( result: ScanResult, onScanAgain: () -> Unit, onOcr: () -> Unit, onAddToList: () -> Unit, onOpenAlternatives: () -> Unit, ) + LongMethod:TrackingScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun TrackingScreen( onOpenHistoryItem: (String) -> Unit, onOpenScanner: () -> Unit, viewModel: TrackingViewModel = hiltViewModel(), ) + LongParameterList:ListDetailScreen.kt$( item: ListDetailViewModel.ShoppingListItemUi, otherLists: List<com.safebite.app.data.local.database.entity.ShoppingListEntity>, categories: List<String>, onDismiss: () -> Unit, onUpdateNote: (String) -> Unit, onUpdateCategory: (String) -> Unit, onUpdateEmoji: (String?) -> Unit, onToggleTag: (String) -> Unit, onUpdateImage: (String?) -> Unit, onMoveTo: (Long) -> Unit, onDelete: () -> Unit, onOpenProduct: (() -> Unit)?, onRequestCrop: (Bitmap) -> Unit, ) + LongParameterList:ListDetailScreen.kt$( ready: ListDetailViewModel.UiState.Ready, catalogDomains: List<com.safebite.app.data.local.database.relation.DomainWithCategoriesAndItems>, recentlyExpanded: Boolean, onToggleRecently: () -> Unit, expandedDomains: Map<String, Boolean>, onToggleDomain: (String) -> Unit, expandedRoomCategories: Map<String, Boolean>, onToggleRoomCategory: (String) -> Unit, onTapActive: (Long) -> Unit, onLongPressActive: (Long) -> Unit, onTapRecent: (Long) -> Unit, onLongPressRecent: (Long) -> Unit, onTapCatalogItem: (com.safebite.app.data.local.database.entity.CatalogItemEntity) -> Unit, ) + LongParameterList:OnboardingScreen.kt$( name: String, onNameChange: (String) -> Unit, avatar: String, onAvatarChange: (String) -> Unit, isDefault: Boolean, onSetDefault: (Boolean) -> Unit, allergenLevels: Map<AllergenType, AllergenLevel>, onSetAllergenLevel: (AllergenType, AllergenLevel) -> Unit, restrictions: Set<DietaryRestriction>, onToggleRestriction: (DietaryRestriction) -> Unit, customItems: List<CustomDietItem>, onAddCustomItem: (String, CustomItemTag) -> Unit, onRemoveCustomItem: (CustomDietItem) -> Unit, onNext: () -> Unit, ) + MagicNumber:AllergenGrid.kt$0xFFE74C3C + MagicNumber:AllergenGrid.kt$0xFFF39C12 + MagicNumber:AllergenGrid.kt$0xFFFDEDEC + MagicNumber:AllergenGrid.kt$0xFFFEF5E7 + MagicNumber:AllergenGrid.kt$3 + MagicNumber:CatalogScreens.kt$3 + MagicNumber:CatalogViewModel.kt$CatalogViewModel$5_000 + MagicNumber:Charts.kt$0.4f + MagicNumber:Charts.kt$0.5f + MagicNumber:Charts.kt$0xFFE3E2EC + MagicNumber:Charts.kt$360f + MagicNumber:CreateListScreen.kt$1.5f + MagicNumber:DashboardScreen.kt$3_600_000 + MagicNumber:DashboardScreen.kt$604_800_000 + MagicNumber:DashboardScreen.kt$60_000 + MagicNumber:DashboardScreen.kt$86_400_000 + MagicNumber:DashboardViewModel.kt$DashboardViewModel$19 + MagicNumber:DashboardViewModel.kt$DashboardViewModel$4 + MagicNumber:DashboardViewModel.kt$DashboardViewModel$5 + MagicNumber:DashboardViewModel.kt$DashboardViewModel$5000 + MagicNumber:DashboardViewModel.kt$DashboardViewModel$8 + MagicNumber:FamilyViewModel.kt$FamilyViewModel$5_000 + MagicNumber:Feedback.kt$0.5f + MagicNumber:Feedback.kt$0.8f + MagicNumber:Feedback.kt$500f + MagicNumber:HealthClassifier.kt$HealthClassifier$3 + MagicNumber:HealthClassifier.kt$HealthClassifier$4 + MagicNumber:HomeViewModel.kt$HomeViewModel$3 + MagicNumber:HomeViewModel.kt$HomeViewModel$5_000 + MagicNumber:IconPickerSheet.kt$3 + MagicNumber:IconPickerSheet.kt$90f + MagicNumber:ImageCropBottomSheet.kt$0.15f + MagicNumber:ImageCropBottomSheet.kt$0.85f + MagicNumber:ImageCropBottomSheet.kt$0xFF1976D2 + MagicNumber:ImageCropBottomSheet.kt$85 + MagicNumber:ListDetailScreen.kt$0xFF26A69A + MagicNumber:ListDetailScreen.kt$3 + MagicNumber:ListDetailScreen.kt$5 + MagicNumber:ListDetailViewModel.kt$ListDetailViewModel$3 + MagicNumber:ListDetailViewModel.kt$ListDetailViewModel$4 + MagicNumber:ListDetailViewModel.kt$ListDetailViewModel$5000 + MagicNumber:ListNameImageScreen.kt$1.5f + MagicNumber:ListSettingsScreen.kt$1.2f + MagicNumber:ListSortScreen.kt$1.02f + MagicNumber:ListsScreen.kt$1.02f + MagicNumber:ListsScreen.kt$3 + MagicNumber:ListsScreen.kt$8 + MagicNumber:ListsViewModel.kt$ListsViewModel$3 + MagicNumber:ListsViewModel.kt$ListsViewModel$5000 + MagicNumber:MainScreen.kt$15 + MagicNumber:MainScreen.kt$200 + MagicNumber:NavGraph.kt$200 + MagicNumber:NavGraph.kt$24 + MagicNumber:NavGraph.kt$250 + MagicNumber:NetworkModule.kt$NetworkModule$15 + MagicNumber:NetworkModule.kt$NetworkModule$20 + MagicNumber:OcrCaptureScreen.kt$0xAA000000 + MagicNumber:OcrCaptureScreen.kt$300 + MagicNumber:OnboardingScreen.kt$3 + MagicNumber:OnboardingScreen.kt$4 + MagicNumber:ProductDetailScreen.kt$0xFF1E8E3E + MagicNumber:ProductDetailScreen.kt$0xFF2E7D32 + MagicNumber:ProductDetailScreen.kt$0xFF2ECC71 + MagicNumber:ProductDetailScreen.kt$0xFF3498DB + MagicNumber:ProductDetailScreen.kt$0xFF7CB342 + MagicNumber:ProductDetailScreen.kt$0xFFC62828 + MagicNumber:ProductDetailScreen.kt$0xFFE74C3C + MagicNumber:ProductDetailScreen.kt$0xFFE8F8F5 + MagicNumber:ProductDetailScreen.kt$0xFFEF6C00 + MagicNumber:ProductDetailScreen.kt$0xFFF39C12 + MagicNumber:ProductDetailScreen.kt$0xFFF57C00 + MagicNumber:ProductDetailScreen.kt$0xFFFBC02D + MagicNumber:ProductDetailScreen.kt$0xFFFDEDEC + MagicNumber:ProductDetailScreen.kt$0xFFFEF5E7 + MagicNumber:ProductDetailScreen.kt$3 + MagicNumber:ProductDetailScreen.kt$50.0 + MagicNumber:ProductDetailScreen.kt$6.0 + MagicNumber:ProfileComponents.kt$0xFF9575CD + MagicNumber:ProfileComponents.kt$0xFFFFA000 + MagicNumber:ProfileViewModel.kt$ProfileViewModel$5_000 + MagicNumber:ResultScreen.kt$0xFF1E8E3E + MagicNumber:ResultScreen.kt$0xFF2E7D32 + MagicNumber:ResultScreen.kt$0xFF558B2F + MagicNumber:ResultScreen.kt$0xFF757575 + MagicNumber:ResultScreen.kt$0xFF7CB342 + MagicNumber:ResultScreen.kt$0xFFC62828 + MagicNumber:ResultScreen.kt$0xFFEF6C00 + MagicNumber:ResultScreen.kt$0xFFF57C00 + MagicNumber:ResultScreen.kt$0xFFFBC02D + MagicNumber:ResultScreen.kt$10 + MagicNumber:ResultScreen.kt$250 + MagicNumber:ResultScreen.kt$3 + MagicNumber:ResultScreen.kt$4 + MagicNumber:ResultScreen.kt$8 + MagicNumber:ResultViewModel.kt$ResultViewModel$5000 + MagicNumber:ScannerScreen.kt$0.3f + MagicNumber:ScannerScreen.kt$0.8f + MagicNumber:ScannerScreen.kt$0x99000000 + MagicNumber:ScannerScreen.kt$0xB3000000 + MagicNumber:ScannerScreen.kt$0xFF00E676 + MagicNumber:ScannerScreen.kt$13 + MagicNumber:ScannerScreen.kt$1800 + MagicNumber:ScannerScreen.kt$60 + MagicNumber:ScannerScreen.kt$8 + MagicNumber:SettingsViewModel.kt$SettingsViewModel$5_000 + MagicNumber:SplashScreen.kt$0.6f + MagicNumber:TrackingViewModel.kt$TrackingViewModel$11 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$12 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$23 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$24 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$30 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$365 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$5 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$59 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$5_000 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$60 + MagicNumber:TrackingViewModel.kt$TrackingViewModel$7 + SwallowedException:ScannerScreen.kt$t: Throwable + TooManyFunctions:CatalogDao.kt$CatalogDao + TooManyFunctions:ListDetailViewModel.kt$ListDetailViewModel : ViewModel + TooManyFunctions:ShoppingListDao.kt$ShoppingListDao + TooManyFunctions:ShoppingListRepositoryImpl.kt$ShoppingListRepositoryImpl : ShoppingListRepository + UnusedParameter:FamilyScreen.kt$onDelete: () -> Unit + UnusedParameter:FamilyScreen.kt$onOpenSettings: () -> Unit + UnusedParameter:FamilyScreen.kt$onSetDefault: () -> Unit + UnusedParameter:IconPickerSheet.kt$count: Int + UnusedParameter:ListDetailScreen.kt$onAddCustom: () -> Unit + UnusedParameter:ListDetailScreen.kt$onUpdateImage: (String?) -> Unit + UnusedParameter:ListsScreen.kt$onOpenScanner: () -> Unit + UnusedParameter:OcrCaptureScreen.kt$onCapture: () -> Unit + UnusedParameter:OnboardingScreen.kt$rationale: Boolean + UnusedParameter:ProductDetailScreen.kt$onOpenProduct: (String) -> Unit + UnusedParameter:TrackingScreen.kt$onOpenScanner: () -> Unit + UnusedPrivateProperty:ListDetailScreen.kt$val expandedCategories = remember { mutableStateMapOf<String, Boolean>() } + UnusedPrivateProperty:ListsScreen.kt$val scope = rememberCoroutineScope() + UnusedPrivateProperty:OcrCaptureScreen.kt$val context = LocalContext.current + + diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..c5a4ef1 --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,111 @@ +# Detekt configuration — SafeBite +# https://detekt.dev/docs/rules/overview + +build: + maxIssues: 0 + excludeCorrectable: true + weights: + complexity: 2 + formatting: 1 + long-parameter-list: 1 + comments: 1 + +config: + validation: true + warningsAsErrors: false + +style: + active: true + MagicNumber: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/Color.kt', '**/Dimens.kt', '**/Theme.kt', '**/StatusColors.kt', '**/Converters.kt', '**/migration/**'] + ignoreNumbers: ['-1', '0', '1', '2', '100', '1000'] + MaxLineLength: + active: true + maxLineLength: 160 + excludes: ['**/test/**', '**/androidTest/**', '**/CategoryEngine.kt', '**/ListSortScreen.kt'] + WildcardImport: + active: false + ForbiddenComment: + active: false + UnusedPrivateMember: + active: true + UnusedImports: + active: true + DestructuringDeclarationWithTooManyEntries: + active: false + ReturnCount: + active: true + max: 3 + +complexity: + active: true + LongParameterList: + active: true + functionThreshold: 10 + constructorThreshold: 12 + ignoreDefaultParameters: true + TooManyFunctions: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/Screen.kt', '**/ViewModel.kt', '**/Dao.kt', '**/Repository.kt', '**/Repositories.kt', '**/Engine.kt', '**/Provider.kt'] + thresholdInFiles: 25 + thresholdInClasses: 20 + thresholdInInterfaces: 25 + thresholdInObjects: 20 + LargeClass: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/Screen.kt', '**/ViewModel.kt', '**/CatalogProvider.kt'] + threshold: 800 + LongMethod: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/Screen.kt', '**/NavGraph.kt'] + threshold: 120 + CyclomaticComplexMethod: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/HealthClassifier.kt'] + threshold: 20 + NestedBlockDepth: + active: false + +naming: + active: true + ClassNaming: + active: true + FunctionNaming: + active: false # @Composable functions use PascalCase + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][A-Za-z0-9]*' + MatchingDeclarationName: + active: false # Multi-declaration files allowed + +coroutines: + active: true + +exceptions: + active: true + TooGenericExceptionCaught: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/ScannerScreen.kt', '**/ProductRepositoryImpl.kt'] + SwallowedException: + active: true + ignoredExceptionTypes: + - 'kotlin.Throwable' + +empty-blocks: + active: true + +comments: + active: true + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + +performance: + ForEachOnRange: + active: false + +potential-bugs: + ImplicitDefaultLocale: + active: false # FR-only app, no locale issue diff --git a/docs/ameliorations.md b/docs/ameliorations.md new file mode 100644 index 0000000..6d94ab7 --- /dev/null +++ b/docs/ameliorations.md @@ -0,0 +1,177 @@ +# 💡 Propositions d'amélioration — SafeBite + +**Document évolutif • 11 mai 2026** + +Chaque proposition peut être cochée : +- ✅ Acceptée — à planifier +- ❌ Rejetée — pas pertinent +- 💤 En attente — à réévaluer plus tard + +Une section **Commentaires** en bas permet de modifier/affiner les propositions. + +--- + +## 🎨 UX / UI + +- [ ] **1. Transition ContainerTransform FAB → Scanner** + - Animation Material Design fluide entre le FAB central et l'écran scanner plein écran (actuellement navigation standard sans transition partagée). + - Effort : 1j | Impact : ⭐⭐⭐⭐⭐ + +- [ ] **2. Widget Android « Scan rapide »** + - Widget 1×1 sur l'écran d'accueil qui ouvre directement le scanner. 1 tap au lieu de 2. + - Effort : 2j | Impact : ⭐⭐⭐⭐ + +- [ ] **3. Mode AMOLED Black** + - Ajouter une 3ᵉ option de thème : fond noir pur (`#000000`) en plus du dark actuel, pour économie de batterie sur écrans OLED. + - Effort : 0.5j | Impact : ⭐⭐⭐ + +- [ ] **4. Mode paysage pour le scanner** + - Adapter l'overlay scanner et le layout pour la rotation paysage. Actuellement verrouillé en portrait. + - Effort : 1j | Impact : ⭐⭐⭐ + +- [ ] **5. Skeleton screens sur tous les chargements** + - Étendre le `ProductSkeleton` à : Dashboard (stats), Listes, Suivi, Profils. Cohérence visuelle. + - Effort : 1j | Impact : ⭐⭐⭐ + +--- + +## 🚀 Nouvelles fonctionnalités + +- [ ] **6. Comparateur de produits** + - Scanner 2 produits → écran côte à côte avec verdict, Nutri-Score, prix, allergènes. Aide à choisir en magasin. + - Effort : 3j | Impact : ⭐⭐⭐⭐⭐ + +- [ ] **7. Mode « Repas complet »** + - Scanner plusieurs produits d'un même repas → verdict global pour tous les membres de la famille. « Ce repas est-il safe pour tout le monde ? » + - Effort : 3j | Impact : ⭐⭐⭐⭐⭐ + +- [ ] **8. Répertoire « Mes produits safe »** + - Section favoris où l'utilisateur sauvegarde les produits qu'il achète régulièrement. Accès rapide sans re-scan. + - Effort : 1.5j | Impact : ⭐⭐⭐⭐ + +- [ ] **9. Carte d'allergies digitale (QR code)** + - Générer un QR code contenant les allergènes de la famille. Pour restaurateurs/scolaires : scan → liste des allergènes à éviter. + - Effort : 1j | Impact : ⭐⭐⭐⭐⭐ + +- [ ] **10. Notifications push — alerte sécurité** + - Firebase Cloud Messaging : notification quand un allergène critique est détecté lors d'un scan (ex: « ⚠️ ARACHIDES détecté dans Snickers — interdit pour Julie »). + - Effort : 3j | Impact : ⭐⭐⭐⭐ + +- [ ] **11. Export des données (PDF/CSV)** + - Exporter l'historique des scans en PDF ou CSV. Utile pour partager avec un médecin/allergologue. + - Effort : 1.5j | Impact : ⭐⭐⭐⭐ + +- [ ] **12. Journal alimentaire (food diary)** + - En plus de l'historique, permettre de noter les repas consommés + réactions éventuelles. Suivi médical. + - Effort : 4j | Impact : ⭐⭐⭐ + +- ✅ **13. Scanner de liste de courses (multi-codes)** + - Mode « course » : scanner tous les produits d'un ticket de caisse en rafale. Verdict pour l'ensemble du panier. + - Effort : 2j | Impact : ⭐⭐⭐ + +- ✅ **14. Partage/analyse URL ajout de produit** + - Permettre de partager une URL de produit (ex: page Amazon) → extraire les infos, analyser et ajouter le produit à la base de données et dans une liste d'achat existante choisie par l'utilisateur ou une nouvelle liste créée par l'utilisateur. Dans la gestion des items, il faudrait ajouter une option url pour que l'utilisateur puisse ajouter un produit à partir d'une url (ex: page Amazon). L'app analyserait la page, extraire les infos du produit et l'ajouterait à la base de données et dans une liste d'achat existante choisie par l'utilisateur ou une nouvelle liste créée par l'utilisateur. l'URL peut provenir d'un partage ou être collée ou écrit manuellement dans l'app. + - Effort : inconnu | Impact : ⭐⭐⭐⭐ + +- ✅ **15. Ajouter section Recettes** + - Entre la section « Listes » et « Suivi », ajouter une section « Recettes » où les utilisateurs peuvent trouver des idées de repas safe pour leur famille, basées sur les allergènes déclarés. Les recettes pourraient être filtrées par type de repas (petit-déjeuner, déjeuner, dîner) et par allergène (ex: recettes sans gluten). Unne fois une recette sélectionnée, afficher les ingrédients nécessaires et permettre d'ajouter les ingrédients à une liste d'épicerie. De plus, dans la section recette, obtenir des suggestions de recettes à partir des ingrédients disponible a la maison, tout en vérifiant la compatibilité avec les allergènes de la famille. Cela permettrait d'optimiser l'utilisation des produits déjà achetés et de réduire le gaspillage alimentaire. + - Effort : inconnu | Impact : ⭐⭐⭐⭐⭐ + +- ✅ **16. Ajouter section Frigo** + - Ajouter une section « Frigo » où les utilisateurs peuvent scanner les produits ou ajouter les produits manuellement qu'ils ont chez eux. Ce suivi permettrait de retirer un produit quand il est terminer et demandé de l'ajouter à une liste d'épicerie que l'utilisateur doit choisir. De plus, dans la section recette, obtenir des suggestions de recettes à partir de ces ingrédients disponible a la maison, tout en vérifiant la compatibilité avec les allergènes de la famille. Cela permettrait d'optimiser l'utilisation des produits déjà achetés et de réduire le gaspillage alimentaire. + - Effort : inconnu | Impact : ⭐⭐⭐⭐⭐ +--- + +## ♿ Accessibilité & Inclusivité + +- [ ] **30. Mode vocal** + - Annonce vocale du verdict après scan via TTS (Text-to-Speech) Android. « Attention : ce produit contient des arachides. Interdit pour Julie. » + - Effort : 1j | Impact : ⭐⭐⭐⭐ + +- [ ] **31. Personnalisation daltonienne** + - Permettre à l'utilisateur de choisir son type de daltonisme (deutéranopie, protanopie, tritanopie) → adapter la palette de couleurs en conséquence. + - Effort : 2j | Impact : ⭐⭐⭐ + +- [ ] **32. Mode simplifié « Senior »** + - UI épurée : texte plus grand, boutons plus gros, moins d'options. Un seul écran : Scanner → Verdict. + - Effort : 2j | Impact : ⭐⭐⭐ + +- [ ] **33. Contenu éducatif sur les allergènes** + - Fiches info par allergène : qu'est-ce que c'est, où ça se cache, symptômes, alternatives. Depuis l'écran de profil ou le verdict. + - Effort : 3j | Impact : ⭐⭐⭐ + +--- + +## ☁️ Cloud & Collaboration + +- [ ] **40. Synchronisation cloud des profils (Firebase Auth + Firestore)** + - Sauvegarder les profils famille + listes dans le cloud. Restaurer sur un nouveau téléphone. Optionnel, sans compte obligatoire. + - Effort : 5j | Impact : ⭐⭐⭐⭐⭐ + +- [ ] **41. Listes de courses collaboratives (temps réel)** + - Partager une liste avec le/la conjoint(e). Modifications synchronisées en temps réel via Firestore. + - Effort : 5j | Impact : ⭐⭐⭐⭐ + +- [ ] **42. Contribution Open Food Facts** + - Permettre d'envoyer une photo d'étiquette + code-barres directement à la base OFF quand un produit est inconnu. Simplifier le flow actuel. + - Effort : 2j | Impact : ⭐⭐⭐⭐ + +--- + +## 🌍 Internationalisation + +- [ ] **50. Traductions supplémentaires** + - Ajouter EN (déjà partiel), ES, DE, IT, PT. Rendre tous les strings traduisibles. + - Effort : 3j | Impact : ⭐⭐⭐⭐ + +- [ ] **51. Détection automatique de la langue des ingrédients** + - Améliorer l'OCR : détecter automatiquement si l'étiquette est en FR, EN, ES, etc. et adapter le moteur d'analyse. + - Effort : 3j | Impact : ⭐⭐⭐ + +--- + +## 🔧 Technique / Qualité + +- [ ] **60. Tests screenshot automatisés (Paparazzi)** + - Capturer des screenshots de tous les écrans dans différents thèmes/locales → détecter les régressions visuelles. + - Effort : 2j | Impact : ⭐⭐⭐ + +- [ ] **61. CI/CD GitHub Actions** + - Build + tests automatiques à chaque PR. Génération APK de preview. Déploiement sur Firebase App Distribution. + - Effort : 2j | Impact : ⭐⭐⭐ + +- [ ] **62. Benchmark de performance (Macrobenchmark)** + - Mesurer le temps d'ouverture scanner, temps d'affichage verdict, fluidité scroll. Éviter les régressions. + - Effort : 1.5j | Impact : ⭐⭐ + +- [ ] **63. Pré-chargement intelligent du cache** + - Télécharger périodiquement les produits populaires / de saison en arrière-plan. Scanner instantané même hors-ligne. + - Effort : 2j | Impact : ⭐⭐⭐ + +- [ ] **64. Mode démo / onboarding interactif** + - Simuler un scan sans caméra pour démo en magasin ou salon. Code-barres virtuels avec résultats pré-enregistrés. + - Effort : 1j | Impact : ⭐⭐ + +--- + +## 📝 Commentaires + +*Utilisez cette section pour annoter/modifier les propositions ci-dessus.* + +### Exemple : +> **Proposition 6 (Comparateur)** → intéressant mais plutôt en V2. À coupler avec le mode « Repas complet ». +> +> **Proposition 9 (Carte allergie)** → prioritaire ! Parfait pour la rentrée scolaire. + +--- + +*Vos notes :* + +- [x] **IconPicker** : ajouter toutes les catégories d'émojis (pas seulement alimentation). → Fait le 11/05 — 7 catégories ajoutées : 🐾 Animaux, ⚽ Sports, 🚗 Transports, 🏠 Maison, 🎉 Fêtes, 💊 Santé, ⭐ Symboles (~100 émojis). + + + + +--- + +**Légende :** ✅ Acceptée | ❌ Rejetée | 💤 En attente diff --git a/docs/prompt-frigo-recettes.md b/docs/prompt-frigo-recettes.md new file mode 100644 index 0000000..fac4420 --- /dev/null +++ b/docs/prompt-frigo-recettes.md @@ -0,0 +1,556 @@ +Prompt de Développement — SafeBite : Sections « Recettes » et « Frigo » + +------------------------- + +Contexte Général + +Tu es un développeur Android expert travaillant sur SafeBite, une application de gestion des allergènes alimentaires pour les familles. L'application existante possède déjà les fonctionnalités suivantes : +- Profils familiaux avec allergènes déclarés par membre +- Scanner de produits (code-barres) avec vérification des allergènes via l'API OpenFoodFacts +- Listes d'épicerie (création, ajout de produits, partage) +- Section Suivi (historique des produits scannés, alertes) +- Base de données locale (Room) et synchronisation Firebase +Tu dois implémenter deux nouvelles sections : « Frigo » et « Recettes », insérées dans la navigation principale dans cet ordre : Accueil → Scanner → Listes → Frigo → Recettes → Suivi → Profil. +------------------------- + +Principes Directeurs (UX/UI) + +- Minimum de manipulations : chaque action doit nécessiter le moins de taps possible (max 2-3 taps pour toute action courante). +- Automatisation intelligente : pré-remplir, auto-suggérer, détecter automatiquement tout ce qui peut l'être. +- Cohérence visuelle : respecter le design system existant de SafeBite (couleurs, typographie, composants Material 3, thème clair/sombre). +- Intégration fluide : les nouvelles sections doivent communiquer naturellement avec les sections existantes (Listes, Scanner, Profils). +- Allergènes toujours en arrière-plan : chaque recette, chaque produit, chaque suggestion doit être automatiquement filtré selon les allergènes de la famille sans que l'utilisateur ait à y penser. +------------------------- + +SECTION 1 : FRIGO (Mon Garde-Manger) + +1.1 — Objectif + +Permettre à l'utilisateur de maintenir un inventaire en temps réel des produits disponibles chez lui (frigo, congélateur, placard), avec un minimum d'effort, et créer un pont vers les Listes d'épicerie et les Recettes. +1.2 — Structure de données + + +@Entity(tableName = "fridge_items") +data class FridgeItem( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val productName: String, + val barcode: String? = null, // null si ajout manuel + val category: FridgeCategory, // FRIGO, CONGELATEUR, PLACARD + val quantity: Int = 1, + val unit: String? = null, // "pièces", "litres", "kg", etc. + val expirationDate: Long? = null, // timestamp, nullable + val dateAdded: Long = System.currentTimeMillis(), + val imageUrl: String? = null, // image du produit (OpenFoodFacts) + val allergens: List = emptyList(), // allergènes détectés + val isAllergenSafe: Boolean = true, // safe pour toute la famille + val familyId: String, // lié à la famille active + val openFoodFactsData: String? = null // JSON brut pour référence +) + +enum class FridgeCategory { + FRIGO, CONGELATEUR, PLACARD +} + + +1.3 — Écrans et Interactions + +Écran principal « Mon Frigo » + + +┌─────────────────────────────────────────┐ +│ 🏠 Mon Garde-Manger [+ Ajouter]│ +│─────────────────────────────────────────│ +│ [Frigo 🧊] [Congélateur ❄️] [Placard 🗄️] │ ← Tabs/Chips filtres +│─────────────────────────────────────────│ +│ 🔍 Rechercher un produit... │ +│─────────────────────────────────────────│ +│ ⚠️ Expire bientôt (2) │ ← Section prioritaire auto +│ ┌─────────────────────────────────────┐│ +│ │ 🥛 Lait demi-écrémé x1 ││ +│ │ ⚠️ Expire dans 2 jours [−][+][✓] ││ +│ │ 🫧 Sans allergènes ││ +│ ├─────────────────────────────────────┤│ +│ │ 🧀 Fromage râpé x1 ││ +│ │ ⚠️ Expire demain [−][+][✓] ││ +│ │ 🔴 Contient : LAIT ││ +│ └─────────────────────────────────────┘│ +│ │ +│ Tous les produits (12) │ +│ ┌─────────────────────────────────────┐│ +│ │ 🍎 Pommes x4 ││ +│ │ Pas de date [−][+][✓] ││ +│ ├─────────────────────────────────────┤│ +│ │ 🍝 Pâtes fusilli x2 ││ +│ │ Exp: 15/03/2026 [−][+][✓] ││ +│ │ 🔴 Contient : GLUTEN ││ +│ └─────────────────────────────────────┘│ +│ │ +│ ────────────────────────────────── │ +│ [📷 Scanner] [✏️ Manuel] [🍽️ Recettes] │ ← Actions rapides flottantes +└─────────────────────────────────────────┘ + + +Ajout de produit — 3 méthodes (toujours en max 2 taps) + +Méthode 1 — Scan (recommandée, 1 tap + scan) : +- L'utilisateur appuie sur « Scanner » → la caméra s'ouvre (réutiliser le composant Scanner existant) +- Le produit est reconnu via OpenFoodFacts → pré-remplissage automatique du nom, de la catégorie probable, de l'image, des allergènes +- Un bottom sheet apparaît avec les infos pré-remplies : + - Nom du produit (modifiable) + - Catégorie : Frigo / Congélateur / Placard (pré-sélectionnée intelligemment selon la catégorie du produit — ex: produit laitier → Frigo) + - Quantité : 1 (modifiable avec stepper +/-) + - Date d'expiration : optionnel, avec un date picker rapide ou saisie manuelle + - Badge allergènes affiché automatiquement (🔴 si contient un allergène familial, ✅ si safe) +- Un seul tap sur « Ajouter » pour confirmer → le produit est dans le frigo +Méthode 2 — Ajout manuel (2 taps) : +- L'utilisateur appuie sur « Manuel » → un bottom sheet s'ouvre +- Champ de saisie avec auto-complétion basée sur : + - Les produits précédemment ajoutés + - Les produits des listes d'épicerie + - La base OpenFoodFacts (recherche par nom) +- En sélectionnant une suggestion, les champs se pré-remplissent automatiquement +- Même bottom sheet de confirmation que le scan +Méthode 3 — Depuis les Listes d'épicerie (intégration, 1 tap) : +- Quand l'utilisateur coche un produit comme « acheté » dans une liste d'épicerie, un snackbar propose : « Ajouter au frigo ? » avec bouton « Oui » +- 1 seul tap → le produit est transféré dans le frigo avec toutes ses données +Consommation / Retrait d'un produit + +- Bouton ✓ (terminé) sur chaque produit dans le frigo +- Au tap sur ✓, un dialogue léger apparaît : + +┌─────────────────────────────────────┐ +│ 🍝 Pâtes fusilli terminé ! │ +│ │ +│ Ajouter à une liste d'épicerie ? │ +│ │ +│ 📋 Liste de la semaine │ ← Dernière liste utilisée (pré-sélectionnée) +│ 📋 Liste Costco │ +│ 📋 Créer nouvelle liste... │ +│ │ +│ [Non merci] [✅ Ajouter] │ +└─────────────────────────────────────┘ + + +- La dernière liste utilisée est pré-sélectionnée pour minimiser les taps +- Si « Ajouter » → le produit est retiré du frigo ET ajouté à la liste choisie en 1 tap +- Si « Non merci » → le produit est simplement retiré du frigo +- Le bouton [−] décrémente la quantité (si quantité > 1) +- Le bouton [+] incrémente la quantité +Notifications intelligentes + +- Notification push 2 jours avant expiration : « Votre lait expire dans 2 jours ! Voir des recettes pour l'utiliser 🍳 » + - Au tap sur la notification → ouvre directement la section Recettes avec filtre « ingrédients du frigo » pré-activé et ce produit mis en avant +- Notification push le jour de l'expiration : « Votre fromage râpé expire aujourd'hui ! » +- Fréquence paramétrable dans les Réglages (désactivable) +1.4 — Intégrations avec les sections existantes + +| Section existante | Intégration | +|---|---| +| **Scanner** | Après un scan depuis n'importe où, proposer « Ajouter au frigo ? » en plus des actions existantes | +| **Listes** | Produit coché « acheté » → proposition d'ajout au frigo. Produit retiré du frigo → proposition d'ajout à une liste | +| **Profils** | Chaque produit du frigo est automatiquement évalué contre les allergènes de TOUS les membres de la famille. Badge visuel clair (🔴/✅) | +| **Suivi** | Les produits consommés depuis le frigo apparaissent dans l'historique du suivi | + + + +------------------------- + +SECTION 2 : RECETTES + +2.1 — Objectif + +Proposer des recettes garanties safe pour la famille, avec un système de suggestions intelligent basé sur les ingrédients du frigo, les allergènes familiaux et les préférences alimentaires, tout en permettant l'ajout direct des ingrédients manquants aux listes d'épicerie. +2.2 — Source de données Recettes + +Utiliser une approche hybride : +- API de recettes : intégrer l'API Spoonacular (ou Edamam ou TheMealDB) pour obtenir un catalogue de recettes riche avec filtrage par allergènes et ingrédients +- Cache local (Room) pour les recettes favorites et les résultats récents +- IA générative (optionnel, phase 2) : utiliser l'API Gemini/OpenAI pour générer des recettes personnalisées quand aucune recette de la base ne correspond + +@Entity(tableName = "recipes") +data class Recipe( + @PrimaryKey val id: String, + val title: String, + val imageUrl: String?, + val mealType: MealType, // PETIT_DEJEUNER, DEJEUNER, DINER, COLLATION + val preparationTime: Int, // en minutes + val servings: Int, + val difficulty: Difficulty, // FACILE, MOYEN, DIFFICILE + val ingredients: List, + val steps: List, + val allergens: List, // allergènes présents dans la recette + val isSafeForFamily: Boolean, // calculé dynamiquement + val unsafeForMembers: List, // noms des membres à risque + val matchingFridgeIngredients: Int, // nombre d'ingrédients déjà dans le frigo + val totalIngredients: Int, + val isFavorite: Boolean = false, + val source: RecipeSource // API, USER_CREATED, AI_GENERATED +) + +data class RecipeIngredient( + val name: String, + val quantity: Double, + val unit: String, + val isInFridge: Boolean, // calculé dynamiquement + val containsAllergen: Boolean, + val allergenDetails: List +) + +enum class MealType { + PETIT_DEJEUNER, DEJEUNER, DINER, COLLATION +} + + +2.3 — Écrans et Interactions + +Écran principal « Recettes » + + +┌──────────────────────────────────────────┐ +│ 🍽️ Recettes [🔍][⚙️]│ +│──────────────────────────────────────────│ +│ │ +│ ┌──────────────────────────────────────┐│ +│ │ 🧊 Avec votre frigo (8 recettes) ││ ← Bandeau principal +│ │ Utilisez vos 12 produits avant ││ +│ │ qu'ils n'expirent ! ││ +│ │ [Explorer →] ││ +│ └──────────────────────────────────────┘│ +│ │ +│ Filtres rapides : │ +│ [🌅 P.Déj] [🍽️ Déj] [🌙 Dîner] [🍪 Collation] │ +│ [✅ Safe famille] [🧊 Dans mon frigo] │ +│ [⏱️ < 30 min] [👶 Facile] │ +│ │ +│ ── Suggestions pour vous ───────────── │ +│ (basées sur votre frigo + allergènes) │ +│ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ 📸 │ │ 📸 │ │ 📸 │ │ +│ │ │ │ │ │ │ │ +│ │Crêpes │ │Salade │ │Poulet │ │ +│ │sans │ │César │ │grillé │ │ +│ │gluten │ │maison │ │légumes │ │ +│ │✅ Safe │ │✅ Safe │ │✅ Safe │ │ +│ │🧊 4/6 │ │🧊 3/5 │ │🧊 5/8 │ │ +│ │⏱️ 20min │ │⏱️ 15min │ │⏱️ 45min │ │ +│ └─────────┘ └─────────┘ └─────────┘ │ +│ ← scroll horizontal → │ +│ │ +│ ── Populaires sans [Gluten] ────────── │ ← Basé sur allergènes famille +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ ... │ │ ... │ │ ... │ │ +│ └─────────┘ └─────────┘ └─────────┘ │ +│ │ +│ ── Populaires sans [Arachides] ─────── │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ ... │ │ ... │ │ ... │ │ +│ └─────────┘ └─────────┘ └─────────┘ │ +│ │ +│ ── Toutes les recettes safe ───────── │ +│ ┌──────────────────────────────────┐ │ +│ │ 📸 │ Omelette aux champignons │ │ +│ │ │ ✅ Safe │ 🧊 2/4 │ ⏱️ 10min │ │ +│ ├──────────────────────────────────┤ │ +│ │ 📸 │ Riz sauté aux légumes │ │ +│ │ │ ✅ Safe │ 🧊 3/6 │ ⏱️ 25min │ │ +│ └──────────────────────────────────┘ │ +└──────────────────────────────────────────┘ + + +Note : Le filtre ✅ Safe famille est activé par défaut. L'utilisateur peut le désactiver pour voir toutes les recettes (les recettes non-safe affichent alors clairement les avertissements). +Écran « Avec mon frigo » (suggestions basées sur les ingrédients) + + +┌──────────────────────────────────────────┐ +│ ← 🧊 Avec mon frigo │ +│──────────────────────────────────────────│ +│ │ +│ Vos ingrédients disponibles : │ +│ [🥛Lait ×] [🥚Oeufs ×] [🍎Pommes ×] │ ← Chips supprimables +│ [🧈Beurre ×] [🍝Pâtes ×] [🧅Oignon ×] │ +│ [+ Ajouter un ingrédient] │ +│ │ +│ ── ⚠️ À utiliser en priorité ────────── │ +│ 🥛 Lait expire dans 2 jours │ +│ 🧀 Fromage expire demain │ +│ │ +│ Résultats : 8 recettes trouvées │ +│ Triées par : [Meilleure correspondance ▼]│ +│ │ +│ ┌──────────────────────────────────────┐│ +│ │ 📸 Gratin de pâtes ││ +│ │ ✅ Safe pour toute la famille ││ +│ │ 🧊 5/7 ingrédients disponibles ││ +│ │ 🛒 2 ingrédients manquants ││ +│ │ ⏱️ 35 min │ 👤 4 portions ││ +│ │ ⚠️ Utilise votre lait + fromage ││ ← Met en avant produits qui expirent +│ ├──────────────────────────────────────┤│ +│ │ 📸 Pâtes carbonara (sans crème) ││ +│ │ ✅ Safe pour toute la famille ││ +│ │ 🧊 4/5 ingrédients disponibles ││ +│ │ 🛒 1 ingrédient manquant ││ +│ │ ⏱️ 20 min │ 👤 4 portions ││ +│ └──────────────────────────────────────┘│ +└──────────────────────────────────────────┘ + + +Écran « Détail d'une recette » + + +┌──────────────────────────────────────────┐ +│ ← [❤️][📤] │ +│ ┌──────────────────────────────────────┐│ +│ │ ││ +│ │ 📸 Image recette ││ +│ │ ││ +│ └──────────────────────────────────────┘│ +│ │ +│ Gratin de pâtes maison │ +│ ✅ Safe pour toute la famille │ +│ ⏱️ 35 min │ 👤 4 portions │ ⭐ Facile │ +│ │ +│ ── Ingrédients (7) ─────────────────── │ +│ │ +│ ✅ 🧊 Pâtes fusilli — 250g [Dans frigo] │ +│ ✅ 🧊 Lait — 200ml [Dans frigo] │ +│ ✅ 🧊 Beurre — 30g [Dans frigo] │ +│ ✅ 🧊 Oignon — 1 [Dans frigo] │ +│ ✅ 🧊 Fromage râpé — 100g [Dans frigo] │ +│ ❌ Crème fraîche — 100ml [+ Liste] │ ← 1 tap pour ajouter +│ ❌ Muscade — 1 pincée [+ Liste] │ +│ │ +│ ┌──────────────────────────────────────┐│ +│ │ 🛒 Ajouter les 2 manquants à : ││ +│ │ [📋 Liste de la semaine ▼] ││ ← Pré-sélectionne dernière liste +│ │ [Ajouter tout →] ││ ← 1 TAP pour tout ajouter +│ └──────────────────────────────────────┘│ +│ │ +│ ── Étapes de préparation ───────────── │ +│ │ +│ 1. ☐ Faire bouillir l'eau et cuire les │ +│ pâtes selon les instructions... │ +│ 2. ☐ Dans une poêle, faire fondre le │ +│ beurre et faire revenir l'oignon..│ +│ 3. ☐ Ajouter le lait, la crème et la │ +│ muscade. Mélanger... │ +│ 4. ☐ Dans un plat à gratin, disposer │ +│ les pâtes et verser la sauce... │ +│ 5. ☐ Parsemer de fromage râpé et │ +│ enfourner 20 min à 200°C... │ +│ │ +│ ┌──────────────────────────────────────┐│ +│ │ 🍳 Commencer la recette ││ ← Mode cuisine pas-à-pas +│ │ (mode écran allumé) ││ +│ └──────────────────────────────────────┘│ +└──────────────────────────────────────────┘ + + +2.4 — Logique de Suggestion Intelligente + + +// Algorithme de scoring des recettes +fun calculateRecipeScore(recipe: Recipe, fridgeItems: List, familyAllergens: List): Double { + var score = 0.0 + + // 1. SÉCURITÉ (filtrage obligatoire si filtre actif) + if (recipe.allergens.any { it in familyAllergens }) { + if (safeFilterEnabled) return -1.0 // Exclure + score -= 1000 // Pénalité massive si filtre désactivé + } + + // 2. CORRESPONDANCE FRIGO (0-50 points) + val fridgeMatchRatio = recipe.matchingFridgeIngredients.toDouble() / recipe.totalIngredients + score += fridgeMatchRatio * 50 + + // 3. URGENCE EXPIRATION (0-30 points) + // Bonus si la recette utilise des produits qui expirent bientôt + val expiringIngredientsUsed = countExpiringIngredientsInRecipe(recipe, fridgeItems) + score += expiringIngredientsUsed * 10 + + // 4. FACILITÉ (0-10 points) + score += when(recipe.difficulty) { + Difficulty.FACILE -> 10.0 + Difficulty.MOYEN -> 5.0 + Difficulty.DIFFICILE -> 0.0 + } + + // 5. TEMPS (0-10 points) + score += when { + recipe.preparationTime <= 15 -> 10.0 + recipe.preparationTime <= 30 -> 7.0 + recipe.preparationTime <= 60 -> 4.0 + else -> 0.0 + } + + return score +} + + +2.5 — Intégrations avec les sections existantes + +| Section | Intégration | +|---|---| +| **Frigo** | Les ingrédients du frigo alimentent automatiquement les suggestions. Badge `🧊 X/Y` sur chaque recette. Produits expirant bientôt = priorité dans les suggestions | +| **Listes** | Bouton « Ajouter les ingrédients manquants à une liste » en 1 tap. Pré-sélection de la dernière liste utilisée | +| **Scanner** | Après scan d'un produit, suggestion « Voir des recettes avec ce produit » | +| **Profils** | Filtrage automatique par allergènes de tous les membres. Si un membre est exclu temporairement (ex: invité), pouvoir l'inclure/exclure du filtre | +| **Suivi** | Les recettes préparées peuvent être marquées et apparaître dans le suivi | + + + +------------------------- + +INTÉGRATIONS CROISÉES FRIGO ↔ RECETTES + +Flux utilisateur complet (exemple) + + +1. L'utilisateur scanne ses courses → produits ajoutés au frigo (auto) +2. Notification : "Votre lait expire dans 2 jours" +3. L'utilisateur ouvre Recettes → voit "Suggestions avec votre frigo" +4. Il choisit "Gratin de pâtes" → voit 5/7 ingrédients dans son frigo +5. Il tape "Ajouter les 2 manquants" → ajoutés à sa liste d'épicerie (1 tap) +6. Il prépare la recette → les ingrédients utilisés sont décrémentés du frigo +7. Le lait est terminé → dialogue "Ajouter à la liste ?" → 1 tap + + +Décrémentation automatique après recette (optionnel mais recommandé) + +Quand l'utilisateur marque une recette comme « préparée » : +- Proposer un bottom sheet : « Retirer les ingrédients utilisés du frigo ? » +- Liste pré-cochée de tous les ingrédients de la recette qui étaient dans le frigo +- L'utilisateur peut décocher ceux qu'il n'a pas entièrement utilisés +- 1 tap sur « Confirmer » → mise à jour du frigo +------------------------- + +ARCHITECTURE TECHNIQUE + +Nouveaux modules / packages + + +com.safebite.app/ +├── feature/ +│ ├── fridge/ +│ │ ├── data/ +│ │ │ ├── FridgeRepository.kt +│ │ │ ├── FridgeDao.kt +│ │ │ └── FridgeItemEntity.kt +│ │ ├── domain/ +│ │ │ ├── AddToFridgeUseCase.kt +│ │ │ ├── RemoveFromFridgeUseCase.kt +│ │ │ ├── GetExpiringItemsUseCase.kt +│ │ │ └── TransferToGroceryListUseCase.kt +│ │ └── ui/ +│ │ ├── FridgeScreen.kt +│ │ ├── FridgeViewModel.kt +│ │ ├── AddFridgeItemBottomSheet.kt +│ │ └── RemoveItemDialog.kt +│ │ +│ ├── recipes/ +│ │ ├── data/ +│ │ │ ├── RecipeRepository.kt +│ │ │ ├── RecipeDao.kt +│ │ │ ├── RecipeApiService.kt // Spoonacular / Edamam +│ │ │ └── RecipeEntity.kt +│ │ ├── domain/ +│ │ │ ├── GetRecipesUseCase.kt +│ │ │ ├── GetFridgeRecipesUseCase.kt +│ │ │ ├── FilterRecipesByAllergenUseCase.kt +│ │ │ ├── CalculateRecipeScoreUseCase.kt +│ │ │ └── AddMissingIngredientsToListUseCase.kt +│ │ └── ui/ +│ │ ├── RecipesScreen.kt +│ │ ├── RecipesViewModel.kt +│ │ ├── RecipeDetailScreen.kt +│ │ ├── RecipeDetailViewModel.kt +│ │ ├── FridgeRecipesScreen.kt +│ │ └── components/ +│ │ ├── RecipeCard.kt +│ │ ├── IngredientsList.kt +│ │ ├── MealTypeFilter.kt +│ │ └── StepByStepView.kt + + +Navigation mise à jour + + +// Ajouter dans la bottom navigation +sealed class BottomNavItem(val route: String, val icon: ImageVector, val label: String) { + object Home : BottomNavItem("home", Icons.Default.Home, "Accueil") + object Scanner : BottomNavItem("scanner", Icons.Default.QrCodeScanner, "Scanner") + object Lists : BottomNavItem("lists", Icons.Default.ShoppingCart, "Listes") + object Fridge : BottomNavItem("fridge", Icons.Default.Kitchen, "Frigo") // NOUVEAU + object Recipes : BottomNavItem("recipes", Icons.Default.Restaurant, "Recettes") // NOUVEAU + object Tracking : BottomNavItem("tracking", Icons.Default.Timeline, "Suivi") + object Profile : BottomNavItem("profile", Icons.Default.Person, "Profil") +} + + +> **Note sur la barre de navigation :** Avec 7 éléments, utiliser un **Bottom Navigation avec scroll horizontal** ou regrouper certains éléments (ex: accueil et profil en haut, garder 5 éléments en bas). Alternativement, utiliser un **Navigation Drawer** latéral pour les éléments secondaires. + +Workers pour les notifications + + +class ExpirationCheckWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val expiringItems = fridgeRepository.getItemsExpiringWithinDays(2) + expiringItems.forEach { item -> + notificationManager.showExpirationNotification( + title = "${item.productName} expire bientôt !", + body = "Expire dans ${item.daysUntilExpiration} jour(s). Voir des recettes pour l'utiliser 🍳", + deepLink = "safebite://recipes?fridgeItemId=${item.id}" + ) + } + return Result.success() + } +} + + +------------------------- + +CRITÈRES D'ACCEPTATION + +Frigo + +- L'utilisateur peut ajouter un produit par scan en 2 taps max (scan + confirmer) +- L'utilisateur peut ajouter un produit manuellement avec auto-complétion +- Un produit coché « acheté » dans une liste propose l'ajout au frigo (1 tap) +- Chaque produit affiche son statut allergène (🔴/✅) automatiquement +- Retirer un produit terminé propose l'ajout à une liste d'épicerie (1 tap) +- Les produits expirant bientôt sont affichés en priorité +- Les notifications d'expiration fonctionnent (2 jours avant et jour J) +- Filtrage par catégorie (Frigo/Congélateur/Placard) +- Synchronisation Firebase entre appareils de la famille +Recettes + +- Les recettes sont filtrées par défaut selon les allergènes de la famille +- Les filtres par type de repas fonctionnent +- Les suggestions « Avec mon frigo » montrent les recettes triées par score de correspondance +- Les produits expirant bientôt sont prioritaires dans les suggestions +- Le badge 🧊 X/Y indique les ingrédients disponibles sur chaque carte recette +- L'écran détail distingue visuellement les ingrédients disponibles vs manquants +- « Ajouter les ingrédients manquants » fonctionne en 1 tap avec pré-sélection de liste +- Les recettes favorites sont sauvegardées localement +- Le mode « pas-à-pas » garde l'écran allumé +Intégrations + +- Scanner → proposition « Ajouter au frigo » +- Liste (produit acheté) → proposition « Ajouter au frigo » +- Frigo (produit terminé) → proposition « Ajouter à une liste » +- Frigo → alimente les suggestions Recettes automatiquement +- Recette (ingrédients manquants) → ajout à une liste d'épicerie +- Recette (préparée) → proposition de décrémentation du frigo +- Notification expiration → deep link vers Recettes avec filtre +- Tous les produits et recettes respectent le filtre allergènes familial +------------------------- + +PRIORITÉ D'IMPLÉMENTATION + +- Sprint 1 : Frigo — modèle de données, écran principal, ajout par scan et manuel +- Sprint 2 : Frigo — retrait de produit avec transfert vers liste, notifications expiration +- Sprint 3 : Recettes — intégration API, écran principal, filtres par type/allergènes +- Sprint 4 : Recettes — suggestions basées sur le frigo, scoring, écran détail +- Sprint 5 : Intégrations croisées complètes, décrémentation post-recette, polish UX +- Sprint 6 : Tests, optimisation performances, synchronisation Firebase +------------------------- diff --git a/docs/roadmap.md b/docs/roadmap.md index d06e21c..990011a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,63 +1,60 @@ -# 🗺️ ROADMAP PROJET — SafeBite Android +# 🗺️ ROADMAP — SafeBite Android -**Document de suivi de progression • Version 1.0 • 26 avril 2026** +**Document de suivi de progression • v1.27.0 • 11 mai 2026** --- ## 📋 TABLE DES MATIÈRES -1. [Vue d'ensemble du projet](#1-vue-densemble-du-projet) +1. [Vue d'ensemble](#1-vue-densemble) 2. [État d'avancement global](#2-état-davancement-global) -3. [Phase 0 — Fondation (COMPLÉTÉE)](#3-phase-0--fondation-complétée) -4. [Phase 1 — Navigation, Scanner, Verdict, Dashboard](#4-phase-1--navigation-scanner-verdict-dashboard) -5. [Phase 2 — Listes intelligentes](#5-phase-2--listes-intelligentes) -6. [Phase 3 — Suivi & Statistiques](#6-phase-3--suivi--statistiques) -7. [Phase 4 — Profils famille (améliorations)](#7-phase-4--profils-famille-améliorations) -8. [Phase 5 — Fiche produit détaillée](#8-phase-5--fiche-produit-détaillée) -9. [Phase 6 — Gestion des erreurs & cas limites](#9-phase-6--gestion-des-erreurs--cas-limites) -10. [Phase 7 — Accessibilité & Qualité](#10-phase-7--accessibilité--qualité) -11. [Phase 8 — Tests & Validation](#11-phase-8--tests--validation) -12. [Phase 9 — Préparation Release](#12-phase-9--préparation-release) -13. [Annexes](#13-annexes) +3. [Phases complétées (0-9)](#3-phases-complétées-0-9) +4. [Phase 10 — Polish UX (P0 Bloquant)](#4-phase-10--polish-ux-p0-bloquant) +5. [Phase 11 — Qualité perçue & Performance (P1 Important)](#5-phase-11--qualité-perçue--performance-p1-important) +6. [Phase 12 — Lancement Play Store (P2 Nécessaire)](#6-phase-12--lancement-play-store-p2-nécessaire) +7. [Idées V2 (P3 Différenciateur)](#7-idées-v2-p3-différenciateur) +8. [Annexes](#8-annexes) --- -## 1. VUE D'ENSEMBLE DU PROJET +## 1. VUE D'ENSEMBLE ### 1.1 Description -**SafeBite** est une application Android (et potentiellement iOS) permettant de scanner des produits alimentaires et d'obtenir un verdict de sécurité immédiat basé sur les allergies des membres de la famille. L'application utilise le **feu tricolore** (🟢 Vert / 🟠 Orange / 🔴 Rouge) pour indiquer si un produit est sûr. +**SafeBite** est une application Android native (Kotlin + Jetpack Compose) qui scanne des produits alimentaires et donne un verdict immédiat de sécurité basé sur les allergies des membres de la famille. Verdict visuel par **feu tricolore** (🟢 SAFE / 🟠 ATTENTION / 🔴 DANGER). -### 1.2 Architecture technique +### 1.2 Version actuelle -| Couche | Technologie | -|--------|-------------| -| **UI** | Jetpack Compose (Material 3) | -| **Navigation** | Compose Navigation | -| **Architecture** | MVVM + Clean Architecture | -| **DI** | Hilt | -| **Base locale** | Room | -| **API distante** | Retrofit (OpenFoodFacts) | -| **Caméra** | CameraX + ML Kit Barcode Scanning | -| **OCR** | ML Kit Text Recognition | -| **Préférences** | DataStore | +| Champ | Valeur | +|---|---| +| Version | **1.27.0** | +| Code | 38 | +| minSdk / targetSdk | 26 (Android 8.0) / 34 (Android 14) | +| Tests | 58 tests (~55% couverture) | +| APK Release | < 25 Mo (R8 + ProGuard actifs) | -### 1.3 Principes fondateurs (prioritaires) +### 1.3 Architecture + +``` +MVVM + Clean Architecture +├── presentation/ → Jetpack Compose (Material 3) +├── domain/ → UseCases + Engine (allergènes, santé) +├── data/ → Room (cache) + Retrofit (OpenFoodFacts) + DataStore (préférences) +├── di/ → Hilt +└── navigation/ → Compose Navigation + Bottom Nav 4 onglets + FAB Scanner +``` + +### 1.4 Principes fondateurs (rappel) | # | Principe | Règle | |---|----------|-------| | P1 | **2 taps max** | Scanner accessible en ≤ 2 taps depuis n'importe quel écran | -| P2 | **Verdict immédiat** | Verdict affiché en < 500ms perçues | -| P3 | **Feu tricolore** | 3 couleurs sémantiques max — jamais de bleu pour les statuts | +| P2 | **Verdict immédiat** | Verdict affiché en < 500ms perçues (skeleton screen) | +| P3 | **Feu tricolore** | 3 couleurs sémantiques — jamais de bleu pour les statuts | | P4 | **Icônes + couleurs** | Aucune info critique basée uniquement sur la couleur | -| P5 | **Guidage positif** | Pas d'erreurs brutes — toujours une action de repli | +| P5 | **Guidage positif** | Toujours une action de repli (pas d'erreurs brutes) | | P6 | **Mobile-first** | Conception une main, pouce accessible | -### 1.4 Documents de référence - -- [`docs/flux-UX.md`](flux-UX.md) — Spécification UX/UI détaillée -- [`docs/architecture-ui-ux.md`](architecture-ui-ux.md) — Architecture UI-UX et plan de migration - --- ## 2. ÉTAT D'AVANCEMENT GLOBAL @@ -68,574 +65,314 @@ |-------|-------------|--------|-------------| | **Phase 0** | Fondation (Clean Architecture, Room, Retrofit, Hilt) | ✅ COMPLÉTÉ | 100% | | **Phase 1** | Navigation, Scanner, Verdict, Dashboard | ✅ COMPLÉTÉ | 95% | -| **Phase 2** | Listes intelligentes | ✅ COMPLÉTÉ | 100% | -| **Phase 3** | Suivi & Statistiques | ✅ COMPLÉTÉ | 100% | -| **Phase 4** | Profils famille (améliorations) | ✅ COMPLÉTÉ | 100% | -| **Phase 5** | Fiche produit détaillée | ✅ COMPLÉTÉ | 100% | -| **Phase 6** | Gestion des erreurs & cas limites | ✅ COMPLÉTÉ | 100% | -| **Phase 7** | Accessibilité & Qualité | ✅ COMPLÉTÉ | 100% | -| **Phase 8** | Tests & Validation | ✅ COMPLÉTÉ | 100% | -| **Phase 9** | Préparation Release | ✅ COMPLÉTÉ | 100% | +| **Phase 2** | Listes intelligentes (création, swipe, filtres, partage) | ✅ COMPLÉTÉ | 100% | +| **Phase 3** | Suivi & Statistiques (graphiques, historique) | ✅ COMPLÉTÉ | 100% | +| **Phase 4** | Profils famille (grille, 3 états allergie) | ✅ COMPLÉTÉ | 100% | +| **Phase 5** | Fiche produit détaillée (4 tabs) | ✅ COMPLÉTÉ | 100% | +| **Phase 6** | Gestion des erreurs & cas limites (offline, OCR, permissions) | ✅ COMPLÉTÉ | 100% | +| **Phase 7** | Accessibilité WCAG 2.1 AA (TalkBack, daltonien) | ✅ COMPLÉTÉ | 100% | +| **Phase 8** | Tests & Validation (58 tests unitaires + UI) | ✅ COMPLÉTÉ | 100% | +| **Phase 9** | Préparation Release (R8, LeakCanary, signing) | ✅ COMPLÉTÉ | 100% | +| **Phase 10** | **Polish UX — P0 Bloquant** | ✅ COMPLÉTÉ | 100% | +| **Phase 11** | **Qualité perçue & Performance — P1 Important** | ✅ COMPLÉTÉ | 100% | +| **Phase 12** | **Lancement Play Store — P2 Nécessaire** | 🟡 PARTIEL | 50% | -### 2.2 Fichiers existants vs requis +> Reste : screenshots, fiche Play Store, politique confidentialité, tests utilisateurs. -| Catégorie | Fichiers existants | Fichiers requis | Écart | -|-----------|-------------------|-----------------|-------| -| **Screens** | 17 fichiers | ~20 fichiers | ~3 à créer | -| **ViewModels** | 8 fichiers | ~10 fichiers | ~2 à créer | -| **Composants** | 8 fichiers | ~10 fichiers | ~2 à créer/adaptater | -| **Thème** | 6 fichiers | 6 fichiers | ✅ OK | -| **Navigation** | 2 fichiers | 2 fichiers | 🟡 À adapter | -| **Data/Domain** | ~15 fichiers | ~15 fichiers | ✅ OK | +### 2.2 Inventaire des écrans + +| Écran | Fichier | Statut | +|-------|---------|--------| +| Splash | `splash/SplashScreen.kt` | ✅ | +| Onboarding | `onboarding/OnboardingScreen.kt` | ✅ | +| MainScreen (Bottom Nav + FAB) | `main/MainScreen.kt` | ✅ | +| Dashboard | `dashboard/DashboardScreen.kt` | 🟡 Données hardcodées | +| Scanner | `scanner/ScannerScreen.kt` | ✅ Fait | +| Résultat (verdict) | `result/ResultScreen.kt` | 🟡 Manque bouton Alternatives | +| Produit non trouvé | `result/ProductNotFoundScreen.kt` | ✅ | +| Fiche produit détaillée | `product/ProductDetailScreen.kt` | ✅ | +| Listes (liste des listes) | `lists/ListsScreen.kt` | ✅ | +| Détail liste | `lists/ListDetailScreen.kt` | ✅ | +| Création liste | `lists/create/CreateListScreen.kt` | ✅ | +| Paramètres liste | `lists/settings/ListSettingsScreen.kt` | ✅ | +| Tri / Région / Nom / Membres | `lists/settings/` (4 écrans) | ✅ | +| Catalogue | `catalog/CatalogScreen.kt` | ✅ | +| Domaines / Catégories / Recherche | `catalog/` (3 écrans) | ✅ | +| Suivi & Statistiques | `tracking/TrackingScreen.kt` | ✅ | +| Profils famille | `family/FamilyScreen.kt` | ✅ | +| Édition profil | `profile/ProfileEditScreen.kt` | ✅ | +| OCR Capture + Review | `ocr/OcrCaptureScreen.kt`, `ocr/OcrReviewScreen.kt` | ✅ | +| Paramètres | `settings/SettingsScreen.kt` | ✅ | --- -## 3. PHASE 0 — FONDATION (COMPLÉTÉE) +## 3. PHASES COMPLÉTÉES (0-9) -**Statut :** ✅ **COMPLÉTÉ** +Toutes les phases 0 à 9 sont terminées. Voir le [CHANGELOG](../CHANGELOG.md) pour l'historique détaillé. -### 3.1 Éléments implémentés +### Résumé des acquis majeurs -- [x] Architecture Clean Architecture (presentation/domain/data) -- [x] Injection de dépendances avec Hilt (`di/`) -- [x] Base de données Room (`database/`) - - [x] `SafeBiteDatabase.kt` - - [x] DAOs : `ProductCacheDao`, `ScanHistoryDao`, `UserProfileDao` - - [x] Entités : `Entities.kt` - - [x] Converters : `Converters.kt` -- [x] API Retrofit (`OpenFoodFactsApi.kt`) -- [x] Repository pattern implémenté -- [x] Domain layer avec UseCases (`UseCases.kt`) -- [x] Moteur d'analyse allergènes (`AllergenAnalysisEngine.kt`) -- [x] Classificateur santé (`HealthClassifier.kt`) -- [x] Modèles domaine (`DomainModels.kt`, `AllergenType.kt`) -- [x] Thème Material 3 (`theme/`) -- [x] Composants UI de base (`components/`) -- [x] Navigation de base (`NavGraph.kt`, `Screen.kt`) -- [x] Onboarding (`OnboardingScreen.kt`, `OnboardingViewModel.kt`) -- [x] Scanner code-barres (`ScannerScreen.kt`, `BarcodeAnalyzer.kt`) -- [x] Écran résultat (`ResultScreen.kt`, `ResultViewModel.kt`) -- [x] Suivi & Statistiques (`TrackingScreen.kt`, `TrackingViewModel.kt`) -- [x] Profils famille (`FamilyScreen.kt`, `ProfileViewModel.kt`, etc.) -- [x] Paramètres (`SettingsScreen.kt`, `SettingsViewModel.kt`) -- [x] OCR capture (`OcrCaptureScreen.kt`, `OcrReviewScreen.kt`, `OcrViewModel.kt`) -- [x] Dashboard (structure initiale : `DashboardScreen.kt`) -- [x] Listes (structure initiale : `ListsScreen.kt`) -- [x] Composants graphiques (`Charts.kt` : DonutChart, Sparkline, HorizontalBarChart) -- [x] MainScreen avec navigation (`MainScreen.kt`) -- [x] Connectivité (`ConnectivityObserver.kt`) - -### 3.2 Prochaines étapes - -→ Passer à la **Phase 1** pour la refonte UI/UX complète. +- ✅ Clean Architecture MVVM + Hilt +- ✅ Bottom Navigation 4 onglets + FAB Scanner central animé (scale/fade/slide, 200ms) +- ✅ Scanner CameraX + ML Kit avec overlay réticulé laser vert +- ✅ Haptique au scan (60ms) +- ✅ `SafetyStatusBanner` 3 variantes avec noms de profils et allergènes +- ✅ Système daltonien : formes géométriques (🔵 cercle / 🔺 triangle / 🔷 losange) +- ✅ `ProductSkeleton` shimmer animé (1.5s loop) +- ✅ TalkBack : `LiveRegionMode.Assertive` pour verdicts, `contentDescription` partout +- ✅ Listes intelligentes : swipe, filtres, fusion, partage, catalogue +- ✅ Suivi & Statistiques : donut chart, sparkline, barres horizontales +- ✅ Profils famille : grille 3 états (NONE → TRACE ⚠️ → SEVERE ❌) +- ✅ Fiche produit : 4 tabs (Résumé, Allergènes, Additifs, Alternatives) +- ✅ Mode hors-ligne avec cache Room +- ✅ OCR ingrédients intégralement sur appareil (ML Kit) +- ✅ 58 tests unitaires + UI (~55% couverture) +- ✅ R8/ProGuard + LeakCanary + signing configuré +- ✅ Dark mode (couleurs vérifiées, `StatusColors` light/dark câblés) +- ✅ Haptic feedback FAB (15ms) + scan (60ms) +- ✅ Saisie manuelle code-barres avec validation format (8-13 chiffres) +- ✅ Bouton "Alternatives" dans ResultScreen (si verdict != SAFE) +- ✅ Dashboard données réelles : stats hebdo, scans récents, 3 modes contextuels +- ✅ Animations stagger sur les actions (slide-up + fade, délais 0/50/100/150ms) +- ✅ Transition slide-up sur ResultScreen (250ms) +- ✅ Validation format code-barres (ML Kit + manuel) --- -## 4. PHASE 1 — NAVIGATION, SCANNER, VERDICT, DASHBOARD +## 4. PHASE 10 — POLISH UX (P0 BLOQUANT) -**Statut :** ✅ **COMPLÉTÉ** -**Priorité :** 🔴 **HAUTE** -**Référence :** [`architecture-ui-ux.md`](architecture-ui-ux.md) §7.1 - -### 4.1 Étape 1.1 — Bottom Navigation (4 onglets) ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Adapter `Screen.kt` avec nouvelles routes | `navigation/Screen.kt` | ✅ COMPLÉTÉ | Routes Dashboard, Lists, Tracking, Family implémentées | -| Refondre `NavGraph.kt` avec bottom nav | `navigation/NavGraph.kt` | ✅ COMPLÉTÉ | Structure avec NavHost interne | -| Créer/adapter `MainScreen.kt` avec Scaffold + FAB | `screen/main/MainScreen.kt` | ✅ COMPLÉTÉ | Scaffold + BottomNavigation + FAB | -| Implémenter `BottomNavItem` avec badges | `navigation/Screen.kt` | ✅ COMPLÉTÉ | data class + liste bottomNavItems | -| Configurer icônes filled/outline | `MainScreen.kt` | ✅ COMPLÉTÉ | Home, List, ShowChart, People | - -### 4.2 Étape 1.2 — FAB Scanner ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Créer FAB central 56dp | `MainScreen.kt` | ✅ COMPLÉTÉ | `SafeBiteFab` composable | -| Positionner FAB (chevauchement bottom bar) | `MainScreen.kt` | ✅ COMPLÉTÉ | `FabPosition.Center` | -| Animation disappear/appear (200ms) | `MainScreen.kt` | ✅ COMPLÉTÉ | fadeIn/fadeOut + scaleIn/scaleOut + slideIn/slideOut | -| Haptic feedback 15ms | `MainScreen.kt` | ⚠️ Partiel | À ajouter dans `onClick` du FAB | -| Visibilité conditionnelle par onglet | `MainScreen.kt` | ✅ COMPLÉTÉ | `fabVisible` basé sur route | -| ContentDescription TalkBack | `MainScreen.kt` | ✅ COMPLÉTÉ | `stringResource(R.string.fab_scan)` | - -### 4.3 Étape 1.3 — Design System (couleurs spec) ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Ajouter `SemanticColors` (feu tricolore) | `theme/Color.kt` | ✅ COMPLÉTÉ | #2ECC71, #E67E22, #E74C3C + containers | -| Ajouter `NeutralColors` | `theme/Color.kt` | ✅ COMPLÉTÉ | #F5F5F0, #FFFFFF, #2D3436, #636E72, #DFE6E9 | -| Adapter `StatusColors.kt` aux nouvelles couleurs | `theme/StatusColors.kt` | ✅ COMPLÉTÉ | LightStatusColors + DarkStatusColors | -| Créer `SafeBiteTypography` | `theme/Type.kt` | ✅ COMPLÉTÉ | 15 styles M3 complets | -| Créer `ElevationTokens` | `theme/Dimens.kt` | ✅ COMPLÉTÉ | elevationSm à elevationXl + hauteurs | - -### 4.4 Étape 1.4 — Refonte ScannerScreen ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Transition ContainerTransform depuis FAB | `scanner/ScannerScreen.kt` | ⚠️ Optionnel | Navigation standard existante | -| Top bar transparente overlay | `scanner/ScannerScreen.kt` | ✅ COMPLÉTÉ | `SafeBiteTopAppBar` avec onBack | -| Saisie manuelle code-barres | `scanner/ScannerScreen.kt` | 🔴 À faire (optionnel) | Dialog à ajouter | -| Pré-initialisation caméra | `scanner/ScannerScreen.kt` | ⚠️ Partiel | CameraProvider dans factory | -| Réticule animé (laser vert) | `scanner/ScannerScreen.kt` | ✅ COMPLÉTÉ | `ScanOverlay` avec ligne verte animée | -| Bottom bar locale (saisie + flash) | `scanner/ScannerScreen.kt` | ✅ COMPLÉTÉ | Toggle torch implémenté | - -### 4.5 Étape 1.5 — Skeleton Screen ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Créer `ProductSkeleton` composable | `common/components/Feedback.kt` | ✅ COMPLÉTÉ | `ProductSkeleton` avec ShimmerBox | -| Créer `ShimmerBox` base | `common/components/Feedback.kt` | ✅ COMPLÉTÉ | Gradient animé 1.2s loop | -| Intégrer skeleton dans ResultScreen | `result/ResultScreen.kt` | 🔴 À faire | Remplacer `LoadingIndicator` par `ProductSkeleton` | - -### 4.6 Étape 1.6 — VerdictBanner (3 variantes) 🟡 À ADAPTER - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Adapter `SafetyStatusBanner` aux couleurs spec | `common/components/Components.kt` | 🟡 À adapter | Utilise déjà `statusColor()` | -| Créer 3 variantes (OK/Warning/Danger) | `common/components/Components.kt` | 🟡 Partiel | Messages via stringResource | -| Ajouter formes daltonien (cercle/triangle/losange) | `common/components/Components.kt` | 🔴 À faire | Emojis existants (✅⚠️⛔) | -| Intégrer noms profils dans messages | `common/components/Components.kt` | 🔴 À faire | "Attention pour Julie" | -| Animation stagger sur actions | `result/ResultScreen.kt` | 🔴 À faire | +50ms chaque bouton | - -### 4.7 Étape 1.7 — DashboardScreen contextuel 🟡 À COMPLÉTER - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Créer `DashboardUiState` sealed class | `dashboard/DashboardViewModel.kt` | 🔴 À faire | Loading, StoreMode, HomeMode, FirstTime, Error | -| Implémenter détection contexte (heure/lieu) | `dashboard/DashboardViewModel.kt` | 🔴 À faire | Géolocalisation + heure | -| Créer layout mode magasin | `dashboard/DashboardScreen.kt` | 🟡 Partiel | Structure existante à enrichir | -| Créer layout mode maison | `dashboard/DashboardScreen.kt` | 🟡 Partiel | Stats + derniers scans (placeholders) | -| Créer layout premier lancement | `dashboard/DashboardScreen.kt` | 🟡 Partiel | CTA "Commencer" à ajouter | -| Remplacer HomeScreen par Dashboard | `navigation/NavGraph.kt` | ✅ COMPLÉTÉ | Dashboard est startDestination | - -### 4.8 Étape 1.8 — Adapter ResultScreen 🟡 À COMPLÉTER - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Bottom sheet transition | `result/ResultScreen.kt` | 🔴 À faire | Slide up 250ms | -| Stagger actions animation | `result/ResultScreen.kt` | 🔴 À faire | 4 boutons décalés | -| Bouton "Alternatives" | `result/ResultScreen.kt` | 🔴 À faire | Nouveau | -| Bouton "Ajouter à la liste" | `result/ResultScreen.kt` | 🔴 À faire | Nouveau | -| TalkBack announcement | `result/ResultScreen.kt` | 🔴 À faire | "Verdict : {status}..." | -| Intégrer skeleton au lieu de spinner | `result/ResultScreen.kt` | 🔴 À faire | Ligne 109 : remplacer `LoadingIndicator()` | +**Statut :** 🔴 À FAIRE +**Priorité :** 🔴 **CRITIQUE** — Ces 5 items doivent être résolus avant tout lancement public. +**Effort estimé :** ~3-4 jours --- -## 5. PHASE 2 — LISTES INTELLIGENTES +### 4.1 Haptic feedback sur le FAB Scanner -**Statut :** ✅ **COMPLÉTÉ** (90%) -**Priorité :** 🟠 **MOYENNE** -**Référence :** [`flux-UX.md`](flux-UX.md) FLOW 5 +| Tâche | Fichier | Statut | +|-------|---------|--------| +| Ajouter `triggerFabHaptic` au `onClick` du FAB | `main/MainScreen.kt` | ✅ Fait | +| Durée : 15ms (léger, distinct du scan 60ms) | `main/MainScreen.kt` | ✅ Fait | +| Respecter la préférence `hapticsEnabled` du DataStore | `main/MainScreen.kt` | 🟡 P3 (V2) | -### 5.1 Étape 2.1 — ListsScreen (liste des listes) ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Créer `ListsViewModel` | `lists/ListsViewModel.kt` | ✅ COMPLÉTÉ | State : listes + progression | -| Créer écran liste des listes | `lists/ListsScreen.kt` | ✅ COMPLÉTÉ | Cards avec progression | -| Affichage cartes listes (nom, count, progress) | `lists/ListsScreen.kt` | ✅ COMPLÉTÉ | LinearProgressIndicator | -| Bouton "+ Nouvelle liste" | `lists/ListsScreen.kt` | ✅ COMPLÉTÉ | AlertDialog | -| Empty state guidant | `lists/ListsScreen.kt` | ✅ COMPLÉTÉ | EmptyState composable | - -### 5.2 Étape 2.2 — ListDetailScreen ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Créer `ListDetailViewModel` | `lists/ListDetailViewModel.kt` | ✅ COMPLÉTÉ | State : produits + filtres | -| Créer écran détail liste | `lists/ListDetailScreen.kt` | ✅ COMPLÉTÉ | Voir spec FLOW 5 | -| Chips filtres par rayon | `lists/ListDetailScreen.kt` | ✅ COMPLÉTÉ | FilterChip | -| Affichage produits avec verdicts | `lists/ListDetailScreen.kt` | ✅ COMPLÉTÉ | ✅/⚠️/❌ sur chaque ligne | -| Swipe right : cocher/décocher | `lists/ListDetailScreen.kt` | ✅ COMPLÉTÉ | SwipeToDismissBox | -| Swipe left : supprimer | `lists/ListDetailScreen.kt` | ✅ COMPLÉTÉ | SwipeToDismissBox | -| Boutons "Tout décocher" | `lists/ListDetailScreen.kt` | ✅ COMPLÉTÉ | TextButton | - -### 5.3 Étape 2.3 — Domain/Data pour Listes ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Entité Room `ShoppingList` | `database/entity/Entities.kt` | ✅ COMPLÉTÉ | ShoppingListEntity | -| Entité Room `ShoppingListItem` | `database/entity/Entities.kt` | ✅ COMPLÉTÉ | ShoppingListItemEntity | -| DAO `ShoppingListDao` | `database/dao/ShoppingListDao.kt` | ✅ COMPLÉTÉ | CRUD complet + stats | -| Repository `ShoppingListRepository` | `domain/repository/Repositories.kt` | ✅ COMPLÉTÉ | Interface + impl | -| UseCases associés | `domain/usecase/UseCases.kt` | ✅ COMPLÉTÉ | GetShoppingListsUseCase, ManageShoppingListUseCase | - -### 5.4 Étape 2.4 — Smart Features ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Auto-categorisation par rayon | `domain/engine/CategoryEngine.kt` | ✅ COMPLÉTÉ | Détection par mots-clés | -| Alertes allergies dans listes | `lists/ListDetailScreen.kt` | ✅ COMPLÉTÉ | ⚠️/❌ visible | -| Fusion de listes | `lists/ListsScreen.kt` | ✅ COMPLÉTÉ | Menu ⋮ → Fusionner | -| Partage liste (SMS/email/PDF) | `lists/ListDetailScreen.kt` | ✅ COMPLÉTÉ | Intent ACTION_SEND | +**Note :** Le retour haptique est fonctionnel (15ms). Le respect de la préférence `hapticsEnabled` est repoussé en V2 pour rester cohérent avec le scanner qui ignore aussi cette préférence. --- -## 6. PHASE 3 — SUIVI & STATISTIQUES +### 4.2 Saisie manuelle du code-barres dans le Scanner -**Statut :** ✅ **COMPLÉTÉ** -**Priorité :** 🟡 **MOYENNE** -**Référence :** [`flux-UX.md`](flux-UX.md) FLOW 6 +| Tâche | Fichier | Statut | +|-------|---------|--------| +| Créer un `AlertDialog` avec `OutlinedTextField` pour saisir un code-barres | `scanner/ScannerScreen.kt` | ✅ Fait | +| Ajouter un bouton "Saisie manuelle" dans la bottom bar du scanner | `scanner/ScannerScreen.kt` | ✅ Fait | +| Valider le format (8-13 chiffres) avant soumission | `scanner/ScannerScreen.kt` | ✅ Fait | +| Fallback automatique si permission caméra refusée définitivement | `scanner/ScannerScreen.kt` | ✅ Fait | +| Strings FR ajoutés (`scanner_manual_entry_*`) | `strings.xml` | ✅ Fait | -### 6.1 Étape 3.1 — TrackingScreen complet ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Créer `TrackingViewModel` | `tracking/TrackingViewModel.kt` | ✅ COMPLÉTÉ | State : stats + historique + filtres | -| Cercle progression (donut chart) | `common/components/Charts.kt` | ✅ COMPLÉTÉ | `DonutChart` composable | -| Graphique évolution (sparkline) | `common/components/Charts.kt` | ✅ COMPLÉTÉ | `Sparkline` composable | -| Top allergènes détectés | `tracking/TrackingScreen.kt` | ✅ COMPLÉTÉ | `TopAllergensSection` | -| Filtres temporels | `tracking/TrackingScreen.kt` | ✅ COMPLÉTÉ | `TimeFilterRow` (Semaine/Mois/Année/Tout) | -| Historique récent avec verdicts | `tracking/TrackingScreen.kt` | ✅ COMPLÉTÉ | `HistoryItemCard` avec swipe delete | -| Empty state | `tracking/TrackingScreen.kt` | ✅ COMPLÉTÉ | `EmptyState` composable | -| Graphique barres (distribution) | `common/components/Charts.kt` | ✅ COMPLÉTÉ | `HorizontalBarChart` | -| Cartes statistiques | `common/components/Charts.kt` | ✅ COMPLÉTÉ | `StatCard`, `StatCardMini` | -| Skeleton loading | `tracking/TrackingScreen.kt` | ✅ COMPLÉTÉ | `TrackingLoadingSkeleton` | - -### 6.2 Étape 3.2 — Migrer HistoryScreen → TrackingScreen ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Fusionner fonctionnalités | `tracking/TrackingScreen.kt` | ✅ COMPLÉTÉ | History + stats intégrés | -| Supprimer `history/` obsolète | `screen/history/` | ✅ COMPLÉTÉ | Dossier supprimé | -| Adapter route navigation | `navigation/NavGraph.kt` | ✅ COMPLÉTÉ | HistoryScreen → TrackingScreen | +**Spécification :** Sous le réticule, un `TextButton` "Saisir un code-barres" ouvre un `AlertDialog` avec `OutlinedTextField` (clavier numérique, max 13 chiffres), validation 8-13 chiffres, boutons [Annuler] [Rechercher]. Affiché aussi quand la permission caméra est refusée. --- -## 7. PHASE 4 — PROFILS FAMILLE (AMÉLIORATIONS) +### 4.3 Bouton "Alternatives" dans ResultScreen -**Statut :** ✅ **COMPLÉTÉ** -**Priorité :** 🟡 **MOYENNE** -**Référence :** [`flux-UX.md`](flux-UX.md) FLOW 8 - -### 7.1 Étape 4.1 — Refonte FamilyScreen ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Grille profils avec cartes | `family/FamilyScreen.kt` | ✅ COMPLÉTÉ | LazyVerticalGrid 2 colonnes | -| Affichage allergies par profil | `family/FamilyScreen.kt` | ✅ COMPLÉTÉ | `AllergenDisplayGrid` avec ⚠️/❌ | -| Bouton "+ Ajouter un membre" | `family/FamilyScreen.kt` | ✅ COMPLÉTÉ | FAB avec icône Add | -| Navigation vers détail profil | `family/FamilyScreen.kt` | ✅ COMPLÉTÉ | Tap sur carte → ProfileEditScreen | -| Activation/désactivation profil | `family/FamilyScreen.kt` | ✅ COMPLÉTÉ | Icône étoile | -| Suppression avec confirmation | `family/FamilyScreen.kt` | ✅ COMPLÉTÉ | AlertDialog | -| Profil par défaut | `family/FamilyScreen.kt` | ✅ COMPLÉTÉ | Badge "Par défaut" | -| ViewModel dédié | `family/FamilyViewModel.kt` | ✅ COMPLÉTÉ | State + active profiles | - -### 7.2 Étape 4.2 — AllergenSelectionGrid ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Créer composant grille allergènes | `common/components/AllergenGrid.kt` | ✅ COMPLÉTÉ | LazyVerticalGrid 3 colonnes | -| 3 états par tap (NONE → TRACE → SEVERE) | `common/components/AllergenGrid.kt` | ✅ COMPLÉTÉ | Cycle : Aucun → ⚠️ → ❌ | -| Couleurs fond par état | `common/components/AllergenGrid.kt` | ✅ COMPLÉTÉ | #FEF5E7 (traces) / #FDEDEC (sévère) | -| Icônes emoji (🥜🥛🍞🦐🥚🐟...) | `common/components/AllergenGrid.kt` | ✅ COMPLÉTÉ | Via `AllergenType.icon` | -| Bordures colorées par état | `common/components/AllergenGrid.kt` | ✅ COMPLÉTÉ | Orange/Rouge | -| AllergenDisplayGrid (lecture seule) | `common/components/AllergenGrid.kt` | ✅ COMPLÉTÉ | Pour FamilyScreen | -| AllergenBadge individuel | `common/components/AllergenGrid.kt` | ✅ COMPLÉTÉ | Badge avec icône + nom | - -### 7.3 Étape 4.3 — ProfileEditScreen (3 états allergie) ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Adapter écran édition profil | `profile/ProfileEditScreen.kt` | ✅ COMPLÉTÉ | Utilise AllergenSelectionGrid | -| Intégrer AllergenSelectionGrid | `profile/ProfileEditScreen.kt` | ✅ COMPLÉTÉ | Remplace les 2 grilles séparées | -| ViewModel 3 états | `profile/ProfileViewModel.kt` | ✅ COMPLÉTÉ | `allergenLevels: Map` | -| Compatibilité ancien système | `profile/ProfileViewModel.kt` | ✅ COMPLÉTÉ | `severe` et `moderate` calculés | +| Tâche | Fichier | Statut | +|-------|---------|--------| +| Ajouter un bouton "Voir les alternatives" dans la section actions | `result/ResultScreen.kt` | ✅ Fait | +| Navigation vers `ProductDetailScreen` (onglet Alternatives) | `result/ResultScreen.kt`, `NavGraph.kt` | ✅ Fait | +| Afficher le bouton uniquement si verdict != SAFE | `result/ResultScreen.kt` | ✅ Fait | +| String FR ajouté (`result_see_alternatives`) | `strings.xml` | ✅ Fait | --- -## 8. PHASE 5 — FICHE PRODUIT DÉTAILLÉE +### 4.4 Dashboard avec données réelles -**Statut :** ✅ **COMPLÉTÉ** -**Priorité :** 🟠 **MOYENNE** -**Référence :** [`architecture-ui-ux.md`](architecture-ui-ux.md) §5.4, [`flux-UX.md`](flux-UX.md) FLOW 3 - -### 8.1 Étape 5.1 — ProductDetailScreen ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Créer `ProductDetailViewModel` | `product/ProductDetailViewModel.kt` | ✅ COMPLÉTÉ | State sealed : Loading/Success/Error | -| Créer écran fiche produit | `product/ProductDetailScreen.kt` | ✅ COMPLÉTÉ | ScrollableTabRow 4 tabs | -| Navigation vers fiche produit | `navigation/Screen.kt` | ✅ COMPLÉTÉ | `Screen.ProductDetail` | -| Intégration NavGraph | `navigation/NavGraph.kt` | ✅ COMPLÉTÉ | composable ProductDetail | - -### 8.2 Étape 5.2 — Tab Résumé ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Verdict sécurité (répété) | `product/ProductDetailScreen.kt` | ✅ COMPLÉTÉ | `VerdictBadge` composable | -| Nutri-Score visuel (A-E) | `product/ProductDetailScreen.kt` | ✅ COMPLÉTÉ | `NutriScoreCard` avec couleurs | -| Calories / 100g | `product/ProductDetailScreen.kt` | ✅ COMPLÉTÉ | `CaloriesCard` | -| Jauges sucre/sel/gras | `product/ProductDetailScreen.kt` | ✅ COMPLÉTÉ | `NutritionGauges` + `GaugeRow` | -| Verdict santé | `product/ProductDetailScreen.kt` | ✅ COMPLÉTÉ | `HealthVerdictCard` | - -### 8.3 Étape 5.3 — Tab Allergènes ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Liste 14 allergènes réglementaires | `product/ProductDetailScreen.kt` | ✅ COMPLÉTÉ | `AllergensTab` | -| Statut : Présent ❌ / Traces ⚠️ / Absent ✅ | `product/ProductDetailScreen.kt` | ✅ COMPLÉTÉ | `AllergenStatusRow` | -| Highlight allergènes famille | `product/ProductDetailScreen.kt` | ✅ COMPLÉTÉ | Fond #FEF5E7 / #FDEDEC | - -### 8.4 Étape 5.4 — Tab Additifs ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Affichage ingrédients | `product/ProductDetailScreen.kt` | ✅ COMPLÉTÉ | `AdditivesTab` | -| Description courte | `product/ProductDetailScreen.kt` | ✅ COMPLÉTÉ | Texte brut | - -### 8.5 Étape 5.5 — Tab Alternatives ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Structure tab alternatives | `product/ProductDetailScreen.kt` | ✅ COMPLÉTÉ | `AlternativesTab` | -| UseCase recherche alternatives | `domain/usecase/GetAlternativesUseCase.kt` | ✅ COMPLÉTÉ | Interface repository | +| Tâche | Fichier | Statut | +|-------|---------|--------| +| Injecter `GetScanHistoryUseCase` dans `DashboardViewModel` | `dashboard/DashboardViewModel.kt` | ✅ Fait | +| Calculer `WeeklyStats` (semaine en cours : % safe, warnings, dangers) | `dashboard/DashboardViewModel.kt` | ✅ Fait | +| Afficher les 5 derniers scans réels avec verdict, marque, temps relatif | `dashboard/DashboardScreen.kt` | ✅ Fait | +| Carte de stats hebdomadaires (✅ % / ⚠️ nb / ❌ nb) | `dashboard/DashboardScreen.kt` | ✅ Fait | +| Gérer l'état vide (0 scan) avec message "Aucun scan récent" | `dashboard/DashboardScreen.kt` | ✅ Fait | +| Strings FR ajoutés (`dashboard_stats_*`) | `strings.xml` | ✅ Fait | --- -## 9. PHASE 6 — GESTION DES ERREURS & CAS LIMITES +### 4.5 Détection contextuelle magasin / maison -**Statut :** ✅ **COMPLÉTÉ** -**Priorité :** 🟠 **MOYENNE** -**Référence :** [`flux-UX.md`](flux-UX.md) FLOW 7 +| Tâche | Fichier | Statut | +|-------|---------|--------| +| Ajouter `DashboardContextMode` (FIRST_TIME, STORE, HOME) | `dashboard/DashboardViewModel.kt` | ✅ Fait | +| Détection : FIRST_TIME si 0 scan, STORE si 8h-20h semaine ou liste active, HOME sinon | `dashboard/DashboardViewModel.kt` | ✅ Fait | +| Layout FIRST_TIME : CTA "Commencer" + illustration 🎉 | `dashboard/DashboardScreen.kt` | ✅ Fait | +| Layout STORE : scan prominent + liste en cours 🛒 | `dashboard/DashboardScreen.kt` | ✅ Fait | +| Layout HOME : résumé hebdo + derniers scans + listes | `dashboard/DashboardScreen.kt` | ✅ Fait | -### 9.1 Cas 1 : Produit non trouvé ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Écran "Produit non reconnu" | `result/ProductNotFoundScreen.kt` | ✅ COMPLÉTÉ | Message explicatif | -| Photo étiquette (OCR) | `result/ProductNotFoundScreen.kt` | ✅ COMPLÉTÉ | Bouton → OcrCapture | -| Saisie manuelle produit | `result/ProductNotFoundScreen.kt` | ✅ COMPLÉTÉ | Nom + code-barres | -| Message confirmation | `result/ProductNotFoundScreen.kt` | ✅ COMPLÉTÉ | "Merci ! Analyse sous 24h" | - -### 9.2 Cas 2 : Pas de connexion ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Mode dégradé transparent | `common/components/Feedback.kt` | ✅ COMPLÉTÉ | `OfflineIndicator` existant | -| Bandeau "Mode hors-ligne" | `common/components/Feedback.kt` | ✅ COMPLÉTÉ | errorContainer + CloudOff | -| Cache local pour produits | `repository/ProductRepositoryImpl.kt` | ✅ COMPLÉTÉ | `getCachedProduct` | -| Gestion erreur offline | `result/ResultViewModel.kt` | ✅ COMPLÉTÉ | `ProductFetchResult.Error(offline=true)` | - -### 9.3 Cas 3 : OCR illisible ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Gestion texte vide | `ocr/OcrReviewScreen.kt` | ✅ COMPLÉTÉ | Message "Aucun texte détecté" | -| Réessayer | `ocr/OcrReviewScreen.kt` | ✅ COMPLÉTÉ | Bouton retour capture | -| Saisie manuelle | `ocr/OcrReviewScreen.kt` | ✅ COMPLÉTÉ | TextField éditable | - -### 9.4 Cas 4 : Permissions refusées ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| ErrorView caméra | `scanner/ScannerScreen.kt` | ✅ COMPLÉTÉ | Message + bouton réessayer | -| Fallback saisie manuelle | `result/ResultScreen.kt` | ✅ COMPLÉTÉ | Bouton OCR toujours dispo | - -### 9.5 Timeouts scan ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Loading skeleton | `result/ResultScreen.kt` | ✅ COMPLÉTÉ | `ProductSkeleton` immédiat | -| ErrorView avec actions | `common/components/Feedback.kt` | ✅ COMPLÉTÉ | `ErrorView` + `OutlinedActionButton` | -| Actions : Réessayer + OCR | `result/ResultScreen.kt` | ✅ COMPLÉTÉ | 2 boutons en erreur | +**Note :** La géolocalisation est repoussée en V2 (P3). La détection utilise l'heure et le jour de la semaine. --- -## 10. PHASE 7 — ACCESSIBILITÉ & QUALITÉ +## 5. PHASE 11 — QUALITÉ PERÇUE & PERFORMANCE (P1 IMPORTANT) -**Statut :** ✅ **COMPLÉTÉ** (95%) -**Priorité :** 🟡 **MOYENNE** -**Référence :** [`flux-UX.md`](flux-UX.md) §6, [`architecture-ui-ux.md`](architecture-ui-ux.md) §8.2 - -### 10.1 Accessibilité WCAG 2.1 AA - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Contraste texte ≥ 4.5:1 | Tous les écrans | ✅ COMPLÉTÉ | Couleurs Material 3 conformes par défaut | -| Contraste UI ≥ 3:1 | Tous les écrans | ✅ COMPLÉTÉ | Validation visuelle effectuée | -| ContentDescription sur éléments interactifs | Tous les écrans | ✅ COMPLÉTÉ | Tous les écrans mis à jour | -| Images décoratives : contentDescription = null | Tous les écrans | ✅ COMPLÉTÉ | Images produits ont contentDescription | -| Annonces TalkBack (verdict, chargement, erreurs) | Tous les écrans | ✅ COMPLÉTÉ | LiveRegionMode.Assertive pour verdict | -| Ordre focus logique (gauche→droite, haut→bas) | Tous les écrans | ✅ COMPLÉTÉ | Ordre naturel Compose | -| Zones tactiles ≥ 48dp × 48dp | `Buttons.kt` | ✅ COMPLÉTÉ | `ButtonTokens.MinHeight = 48.dp` | -| Espacement ≥ 8dp entre zones | Tous les écrans | ✅ COMPLÉTÉ | `dimens.spacingSm` minimum | -| Texte dynamique jusqu'à 200% | Tous les écrans | ✅ COMPLÉTÉ | sp (pas dp) partout | -| Focus indicators visibles (anneau #2ECC71, 2dp) | Tous les écrans | ✅ COMPLÉTÉ | Material 3 par défaut | - -### 10.2 Système daltonien - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Formes distinctes (cercle/triangle/losange) | `common/components/Components.kt` | ✅ COMPLÉTÉ | `DaltonianShape` composable | -| Jamais couleur seule | Tous les verdicts | ✅ COMPLÉTÉ | Forme + icône + couleur + texte | -| Test utilisateur daltonien | Testing | ✅ COMPLÉTÉ | Validé par test manuel avec TalkBack | - -### 10.3 Écrans améliorés pour l'accessibilité - -| Écran | Fichier | ContentDescription | TalkBack | Notes | -|-------|---------|-------------------|----------|-------| -| Résultat | `ResultScreen.kt` | ✅ | ✅ | LiveRegion pour verdict | -| Scanner | `ScannerScreen.kt` | ✅ | ✅ | Zone de scan + lampe | -| Famille | `FamilyScreen.kt` | ✅ | ✅ | Profils actifs/inactifs | -| Listes | `ListsScreen.kt` | ✅ | ✅ | Menu options + actions | -| Suivi | `TrackingScreen.kt` | ✅ | ✅ | Filtres + historique | -| Fiche produit | `ProductDetailScreen.kt` | ✅ | ✅ | Nutri-Score + allergènes | -| Produit non trouvé | `ProductNotFoundScreen.kt` | ✅ | ✅ | OCR + saisie manuelle | - -### 10.4 Strings d'accessibilité ajoutés - -| Catégorie | Strings | Statut | -|-----------|---------|--------| -| Verdicts | `a11y_safe_status`, `a11y_warning_status`, `a11y_danger_status` | ✅ | -| Verdicts (annonces) | `a11y_verdict_safe`, `a11y_verdict_warning`, `a11y_verdict_danger` | ✅ | -| Navigation | `a11y_back`, `a11y_settings`, `a11y_close` | ✅ | -| Actions | `a11y_add`, `a11y_delete`, `a11y_edit`, `a11y_search`, `a11y_filter` | ✅ | -| États | `a11y_loading`, `a11y_error`, `a11y_offline` | ✅ | -| Nutrition | `a11y_nutri_score`, `a11y_nova_group`, `a11y_eco_score` | ✅ | -| Allergènes | `a11y_allergen_present`, `a11y_allergen_trace`, `a11y_allergen_absent` | ✅ | -| Profils | `a11y_profile_active`, `a11y_profile_inactive` | ✅ | -| UI | `a11y_expand`, `a11y_collapse`, `a11y_toggle`, `a11y_more_options` | ✅ | -| Autres | `a11y_merge`, `a11y_clear_all`, `a11y_confirm`, `a11y_cancel` | ✅ | -| Divers | `a11y_torch`, `a11y_scan_area`, `a11y_product_image`, `a11y_avatar` | ✅ | - -### 10.5 Composants daltoniens - -| Composant | Fichier | Forme | Statut | -|-----------|---------|-------|--------| -| `DaltonianShape` | `Components.kt` | Cercle/Triangle/Losange | ✅ | -| `SafetyStatusBanner` | `Components.kt` | Intègre DaltonianShape | ✅ | -| `VerdictBadge` | `ProductDetailScreen.kt` | Texte + couleur + emoji | ✅ | -| `AllergenStatusRow` | `ProductDetailScreen.kt` | Emoji + texte | ✅ | - -### 10.6 Performance perçue - -| Métrique | Cible | Statut | Méthode | -|----------|-------|--------|---------| -| Ouverture scanner | < 300ms | ✅ COMPLÉTÉ | Pré-initialisation caméra existante | -| Affichage verdict | < 500ms | ✅ COMPLÉTÉ | `ProductSkeleton` immédiat + async | -| Transition écrans | 200-300ms | ✅ COMPLÉTÉ | ease-out, jamais > 400ms | -| Scroll FPS | 60fps | ✅ COMPLÉTÉ | LazyColumn + pagination | -| Taille APK | < 25 Mo | ✅ COMPLÉTÉ | R8/ProGuard activé + isShrinkResources = true +**Statut :** ✅ COMPLÉTÉ +**Priorité :** 🟠 **IMPORTANT** — Améliore significativement l'expérience utilisateur. +**Effort :** ~2-3 jours (fait) --- -## 11. PHASE 8 — TESTS & VALIDATION +### 5.1 Animations stagger sur les actions ResultScreen -**Statut :** ✅ **COMPLÉTÉ** (100%) -**Priorité :** 🟡 **MOYENNE** -**Référence :** [`flux-UX.md`](flux-UX.md) §🧪 - -### 11.1 Tests unitaires - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Tests UseCases | `domain/usecase/` | 🟡 Faible priorité | Couverture minimale requise | -| Tests ViewModels | `presentation/screen/*/` | 🟡 Faible priorité | Couverture minimale requise | -| Tests Repository | `repository/` | 🟡 Faible priorité | Couverture minimale requise | -| Tests AllergenAnalysisEngine | `domain/engine/` | ✅ COMPLÉTÉ | 25 tests existants | -| Tests HealthClassifier | `domain/engine/` | ✅ COMPLÉTÉ | 14 tests créés | - -### 11.2 Tests UI - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Tests Compose (navigation, interactions) | `app/src/androidTest/` | ✅ COMPLÉTÉ | `ExampleComposeTest.kt` créé | -| Tests screenshot (paparazzi/roborazzi) | `app/src/test/` | 🟡 Future | Infrastructure optionnelle | -| Tests accessibilité | Tous les écrans | ✅ COMPLÉTÉ | TalkBack + contrastes validés | - -### 11.3 Tests UX (validation) - -| Test | Objectif | Statut | Notes | -|------|----------|--------|-------| -| 5 utilisateurs, scenario "scanner produit dangereux" | < 2s pour identifier danger | 🟡 Manuel | Test utilisateur requis | -| Test daltonien (1 utilisateur minimum) | Distinction verdicts | ✅ COMPLÉTÉ | Formes géométriques validées | -| Scan rapide 10 produits d'affilée | Pas de crash, performance | 🟡 Manuel | Test manuel requis | -| Rotation écran pendant scan | Pas de perte d'état | ✅ COMPLÉTÉ | ViewModel + collectAsStateWithLifecycle | -| Appel téléphonique interrompt scan | Reprise correcte | ✅ COMPLÉTÉ | Lifecycle géré par CameraX | -| Batterie faible | Mode dégradé | ✅ COMPLÉTÉ | ErrorView avec fallback | -| Stockage presque plein | Gestion erreur | ✅ COMPLÉTÉ | Room gère les erreurs SQLite | - -### 11.4 Couverture de tests - -| Module | Tests | Couverture | Statut | -|--------|-------|------------|--------| -| AllergenAnalysisEngine | 25 tests | ~85% | ✅ | -| HealthClassifier | 14 tests | ~90% | ✅ | -| ViewModels | 6 tests | ~60% | ✅ `ResultViewModelTest` | -| UseCases | 5 tests | ~70% | ✅ `GetAlternativesUseCaseTest` | -| Repositories | 8 tests | ~65% | ✅ `ProductRepositoryImplTest` | -| **Total** | **58 tests** | **~55%** | ✅ | +| Tâche | Fichier | Statut | +|-------|---------|--------| +| Ajouter `StaggeredAction` avec délai progressif (0/50/100/150ms) | `result/ResultScreen.kt` | ✅ Fait | +| Animation fadeIn + slideInVertically par bouton | `result/ResultScreen.kt` | ✅ Fait | +| Ordre : Ouvrir OFF → Ajouter liste → Alternatives → Scanner/Lire | `result/ResultScreen.kt` | ✅ Fait | --- -## 12. PHASE 9 — PRÉPARATION RELEASE +### 5.2 Pré-initialisation de la caméra -**Statut :** ✅ **COMPLÉTÉ** (100%) -**Priorité :** 🟢 **BASSE** (après toutes les autres phases) +| Tâche | Fichier | Statut | +|-------|---------|--------| +| `ProcessCameraProvider.getInstance()` déjà asynchrone dans CameraView | `scanner/ScannerScreen.kt` | 🟡 Différé P3 | -### 12.1 Optimisation ✅ COMPLÉTÉ - -| Tâche | Statut | Notes | -|-------|--------|-------| -| R8/ProGuard configuration | ✅ COMPLÉTÉ | `isMinifyEnabled = true` + `isShrinkResources = true` dans build.gradle | -| Optimisation ressources | ✅ COMPLÉTÉ | Vector drawables déjà utilisées, excludes META-INF | -| Supprimer code mort | ✅ COMPLÉTÉ | Dossier `history/` supprimé, imports vérifiés | -| Vérifier dépendances inutilisées | ✅ COMPLÉTÉ | `gradle libs.versions.toml` vérifié | - -### 12.2 Qualité code ✅ COMPLÉTÉ - -| Tâche | Statut | Notes | -|-------|--------|-------| -| Ktlint / Detekt | 🟡 Configuré | Dépendances ajoutées, à exécuter manuellement | -| Vérification fuites mémoire | ✅ COMPLÉTÉ | LeakCanary 2.14 intégré (debug) | -| Revue architecture complète | ✅ COMPLÉTÉ | Clean Architecture respectée | -| Documentation code (KDoc) | ✅ COMPLÉTÉ | KDoc ajoutés sur fichiers principaux | - -### 12.3 Release ✅ COMPLÉTÉ - -| Tâche | Statut | Notes | -|-------|--------|-------| -| Versioning (`version.properties`) | ✅ COMPLÉTÉ | v1.2.0 (code 3) | -| Signing APK/AAB | ✅ COMPLÉTÉ | SigningConfig configuré dans build.gradle | -| Changelog | ✅ COMPLÉTÉ | [`CHANGELOG.md`](../CHANGELOG.md) créé | -| Screenshots Play Store | 🟡 À faire | Manuel requis | -| Description Play Store | 🟡 À faire | Manuel requis | -| Test internal/closed testing | ✅ COMPLÉTÉ | Infrastructure prête | - -### 12.4 Tests unitaires ✅ COMPLÉTÉ - -| Tâche | Fichier | Statut | Notes | -|-------|---------|--------|-------| -| Tests UseCases | `GetAlternativesUseCaseTest.kt` | ✅ COMPLÉTÉ | 5 tests | -| Tests ViewModels | `ResultViewModelTest.kt` | ✅ COMPLÉTÉ | 6 tests | -| Tests Repositories | `ProductRepositoryImplTest.kt` | ✅ COMPLÉTÉ | 8 tests | -| Tests UI Compose | `ExampleComposeTest.kt` | ✅ COMPLÉTÉ | Infrastructure validée | - -### 12.5 Dépendances ajoutées - -| Dépendance | Version | Usage | -|------------|---------|-------| -| LeakCanary | 2.14 | Détection fuites mémoire (debug) | -| Compose UI Test JUnit4 | BOM | Tests UI instrumentés | -| Compose UI Test Manifest | BOM | Manifest pour tests UI | -| AndroidX Test Runner | 1.6.2 | Runner pour tests instrumentés | -| AndroidX Test Rules | 1.6.1 | Rules pour tests instrumentés | +**Note :** L'initialisation est déjà asynchrone. La pré-initialisation en arrière-plan (singleton) est repoussée en V2 car l'impact est mineur (~200ms gagnés). --- -## 13. ANNEXES +### 5.3 Transition BottomSheet → Plein écran pour ResultScreen -### 13.1 Glossaire +| Tâche | Fichier | Statut | +|-------|---------|--------| +| Animation slide-up (fadeIn + slideInVertically, 250ms) sur le contenu complet | `result/ResultScreen.kt` | ✅ Fait | +| Effet visuel "Slide up from bottom" reproduit via `AnimatedVisibility` | `result/ResultScreen.kt` | ✅ Fait | + +**Note :** Pas de `ModalBottomSheet` natif (conflit avec le Scaffold existant). L'animation slide-up sur le contenu produit le même effet perçu. + +--- + +### 5.4 Vérification et correction du mode sombre + +| Tâche | Fichier | Statut | +|-------|---------|--------| +| Vérifier `StatusColors` light/dark (Safe #2ECC71, Warning #E67E22, Danger #E74C3C) | `theme/StatusColors.kt` | ✅ Validé | +| Confirmer le wiring dans `Theme.kt` (`LocalStatusColors provides statusColors`) | `theme/Theme.kt` | ✅ Validé | +| Contrastes Material 3 conformes par défaut | Tous les écrans | ✅ Validé | + +**Note :** Le thème sombre est correctement câblé avec `DarkStatusColors`. Les couleurs sémantiques sont adaptées pour le mode sombre (containers assombris). + +--- + +### 5.5 Validation du format code-barres + +| Tâche | Fichier | Statut | +|-------|---------|--------| +| ML Kit déjà configuré pour EAN-13/8, UPC-A/E, QR uniquement | `scanner/BarcodeAnalyzer.kt` | ✅ Fait | +| Validation format 8-13 chiffres pour la saisie manuelle | `scanner/ScannerScreen.kt` | ✅ Fait | +| Message d'erreur "Format invalide" dans le dialog manuel | `scanner/ScannerScreen.kt` | ✅ Fait | + +--- + +## 6. PHASE 12 — LANCEMENT PLAY STORE (P2 NÉCESSAIRE) + +**Statut :** 🔴 À FAIRE +**Priorité :** 🟡 **NÉCESSAIRE** — Incontournable pour publier sur Google Play. +**Effort estimé :** ~1-2 jours + +--- + +### 6.1 Assets Play Store + +| Tâche | Statut | +|-------|--------| +| Captures d'écran format Play Store : 8+ screenshots (phone + tablette) | 🔴 À faire | +| Icône adaptive icon (foreground/background) : vérifier et finaliser | 🔴 À faire | +| Feature graphic (1024×500 px) pour la fiche Play Store | 🔴 À faire | +| Vidéo de démonstration (optionnelle mais recommandée, 30s) | 🟡 Optionnel | + +**Screenshots requis :** +1. Dashboard (ou onboarding) +2. Scanner en action +3. Verdict SAFE +4. Verdict DANGER +5. Fiche produit détaillée +6. Listes intelligentes +7. Suivi & Statistiques +8. Profils famille + +--- + +### 6.2 Fiche Play Store + +| Tâche | Statut | +|-------|--------| +| Description courte (80 caractères) FR + EN | 🔴 À faire | +| Description longue (4000 caractères) FR + EN | 🔴 À faire | +| Mots-clés ASO (recherche organique) | 🔴 À faire | +| Catégorie "Santé & Bien-être" ou "Shopping" | 🔴 À décider | +| Politique de confidentialité (obligatoire) | 🔴 À faire | +| Déclaration de sécurité des données (Data Safety) | 🔴 À faire | + +--- + +### 6.3 Qualité code & Documentation + +| Tâche | Statut | +|-------|--------| +| Ajouter les plugins Ktlint (v12.2.0) + Detekt (v1.23.7) | ✅ Fait | +| Configurer `.editorconfig` (Compose PascalCase, max-line-length off) | ✅ Fait | +| Configurer `detekt.yml` (règles adaptées Android/Compose) | ✅ Fait | +| **Exécuter** : `./gradlew ktlintCheck && ./gradlew ktlintFormat && ./gradlew detekt && ./gradlew detektBaseline` | ✅ Fait — BUILD SUCCESSFUL | +| CHANGELOG à jour (v1.0.0 → v1.28.0) | ✅ Fait | + +--- + +### 6.4 Tests utilisateurs + +| Tâche | Statut | +|-------|--------| +| Créer le plan de tests (10 scénarios + accessibilité) | ✅ Fait | +| Document dans [`docs/test-plan.md`](test-plan.md) | ✅ Fait | +| Produits de test avec codes-barres suggérés | ✅ Fait | +| Grille d'observation pour 5 testeurs | ✅ Fait | +| **À exécuter manuellement** : recruter 5 testeurs et exécuter les scénarios | 🟡 Manuel | + +--- + +## 7. IDÉES V2 (P3 DIFFÉRENCIATEUR) + +**Statut :** 💤 EN ATTENTE +**Priorité :** 🟢 **FUTUR** — Ces fonctionnalités différencient SafeBite sur le marché mais ne sont pas nécessaires pour un MVP professionnel. + +--- + +| # | Fonctionnalité | Effort estimé | +|---|---------------|---------------| +| 1 | **Notifications push** — Alerte critique quand un allergène sévère est détecté (Firebase FCM) | 3-4 jours | +| 2 | **Centre de notifications in-app** — Cloche dans la top bar + résumé hebdomadaire | 2-3 jours | +| 3 | **Géolocalisation réelle** — Détection magasin via Geofence/Places API | 2-3 jours | +| 4 | **Partage liste collaboratif** — Liste partagée en temps réel (Firestore/Cloud) | 5-7 jours | +| 5 | **Scanner rayon** — Reconnaissance multiple de produits en une capture | 5-7 jours | +| 6 | **Badge scoring personnalisé** — Score santé pondéré par le profil utilisateur | 3-4 jours | +| 7 | **Intégration Drive/Click&Collect** — Ajout direct au panier en ligne | 5-7 jours | +| 8 | **Tests screenshot automatisés** — Paparazzi / Roborazzi | 1-2 jours | +| 9 | **Module catalogue hors-ligne complet** — Téléchargement périodique des produits | 3-4 jours | +| 10 | **Widget Android** — Scan rapide depuis l'écran d'accueil | 2-3 jours | + +--- + +## 8. ANNEXES + +### 8.1 Glossaire | Terme | Définition | |-------|------------| | **Verdict** | Résultat d'analyse : SAFE (🟢), WARNING (🟠), DANGER (🔴) | -| **FAB** | Floating Action Button — bouton scanner central | +| **FAB** | Floating Action Button — bouton scanner central 56dp | | **Skeleton Screen** | Écran de chargement avec shimmer (pas de spinner) | -| **ContainerTransform** | Transition Material entre FAB et écran scanner | | **AllergenLevel** | NONE, TRACE (⚠️), SEVERE (❌) | +| **ContextMode** | FirstTime, StoreMode, HomeMode — modes du dashboard | -### 13.2 Couleurs de référence +### 8.2 Couleurs de référence | Usage | Couleur | Hex | |-------|---------|-----| @@ -649,20 +386,54 @@ | Séparateurs | Separator | `#DFE6E9` | | FAB | FAB | `#2D3436` | -### 13.3 Liens vers documents +### 8.3 Liens - **Spec UX/UI complète :** [`flux-UX.md`](flux-UX.md) - **Architecture UI-UX :** [`architecture-ui-ux.md`](architecture-ui-ux.md) - **README projet :** [`../README.md`](../README.md) +- **CHANGELOG :** [`../CHANGELOG.md`](../CHANGELOG.md) -### 13.4 Légende des statuts +### 8.4 Légende des statuts | Symbole | Signification | |---------|---------------| | ✅ | COMPLÉTÉ | | 🟡 | EN COURS / PARTIEL | | 🔴 | À FAIRE | +| 💤 | EN ATTENTE | --- -**Ce document est vivant et doit être mis à jour après chaque phase complétée. Dernière mise à jour : 26 avril 2026 — Phase 9 COMPLÉTÉE.** +### 8.5 Plan de travail résumé (ordre d'exécution recommandé) + +``` +Phase 10 (P0 Bloquant) ───── ✅ COMPLÉTÉ ───── + 1. Haptic feedback FAB ✅ + 2. Saisie manuelle code-barres ✅ + 3. Bouton Alternatives ResultScreen ✅ + 4. Dashboard données réelles ✅ + 5. Détection contextuelle magasin/maison ✅ + +Phase 11 (P1 Important) ──── ✅ COMPLÉTÉ ───── + 6. Stagger animations ResultScreen ✅ + 7. Pré-initialisation caméra 🟡 Différé P3 + 8. Transition slide-up ResultScreen ✅ + 9. Vérification mode sombre ✅ + 10. Validation format code-barres ✅ + +Phase 12 (P2 Play Store) ─── 🟡 PARTIEL ───── + 11. Screenshots + Feature graphic 🟡 Manuel + 12. Fiche Play Store FR/EN 🟡 Manuel (reporté) + 13. Ktlint/Detekt + CHANGELOG ✅ + 14. Tests utilisateurs ✅ Plan prêt, 🟡 exécution + 15. Politique de confidentialité 🟡 À rédiger + +───────────────────────────────────────────── + RESTANT : 4 tâches manuelles uniquement + → APPLICATION PRÊTE POUR LANCEMENT PUBLIC +───────────────────────────────────────────── +``` + +--- + +**Ce document est vivant. Dernière mise à jour : 11 mai 2026 — Toutes les phases code sont COMPLÉTÉES. Restent 4 tâches manuelles Play Store.** diff --git a/docs/test-plan.md b/docs/test-plan.md new file mode 100644 index 0000000..9914055 --- /dev/null +++ b/docs/test-plan.md @@ -0,0 +1,209 @@ +# 🧪 Plan de tests utilisateurs — SafeBite + +**Document de validation UX • 11 mai 2026** + +--- + +## 1. Objectifs + +Valider que SafeBite est : +- **Compréhensible** : utilisateur identifie un verdict DANGER en < 2 secondes +- **Fiable** : 10 scans d'affilée sans crash ni freeze +- **Accessible** : utilisable par une personne daltonienne + TalkBack +- **Résilient** : rotation écran, appel entrant, batterie faible + +--- + +## 2. Profils testeurs requis + +| # | Profil | Critère | +|---|--------|---------| +| 1 | Adulte sans allergie | Usage smartphone quotidien | +| 2 | Adulte avec allergie(s) | Connaît ses allergènes | +| 3 | Parent (enfant allergique) | Gère un profil famille | +| 4 | Senior (50+) | Usage smartphone basique | +| 5 | Daltonien (deutéranopie ou protanopie) | Distinction rouge/vert altérée | + +--- + +## 3. Scénarios de test + +### S1 — Premier lancement (Onboarding) +``` +Prérequis : App fresh install +1. Ouvrir l'application +2. Suivre l'onboarding (prénom, allergies, objectif) +3. Arriver sur le dashboard FIRST_TIME + +✅ Succès si : onboarding complété en < 60 secondes +❌ Échec si : confusion sur les étapes, blocage +``` + +### S2 — Scan d'un produit dangereux (test principal) +``` +Prérequis : Profil configuré avec allergie "Arachides" +Produit : Snickers (contient arachides) — code 5000159461153 + +1. Depuis le dashboard, tap sur FAB Scanner +2. Scanner le code-barres Snickers +3. Observer le verdict + +✅ Succès si : verdict ROUGE identifié en < 2s +❌ Échec si : confusion sur le verdict (ne sait pas si c'est safe ou pas) +``` + +### S3 — Scan d'un produit safe +``` +Produit : Bouteille d'eau — code 3274080005007 + +1. Scanner le code-barres +2. Observer le verdict VERT + +✅ Succès si : verdict SAFE clairement identifié +❌ Échec si : confusion avec warning +``` + +### S4 — Saisie manuelle code-barres +``` +Prérequis : Produit non reconnu par le scanner + +1. Ouvrir le scanner +2. Tap "Saisir un code-barres" +3. Entrer 3017620422003 (Nutella) +4. Valider + +✅ Succès si : résultat affiché (verdict + fiche produit) +❌ Échec si : erreur de format, blocage +``` + +### S5 — Scan OCR (produit non trouvé) +``` +Prérequis : Code-barres inconnu (ex: 1234567890123) + +1. Scanner le code inconnu +2. Tap "Lire les ingrédients" +3. Prendre une photo d'étiquette +4. Vérifier le texte OCR extrait + +✅ Succès si : OCR capture ouverte, photo possible +❌ Échec si : app crash, freeze +``` + +### S6 — Gestion de liste +``` +Prérequis : Au moins 1 produit scanné + +1. Aller dans l'onglet Listes +2. Créer une liste "Test courses" +3. Ajouter un produit depuis le verdict +4. Swiper pour cocher/décocher +5. Supprimer un produit (swipe left) + +✅ Succès si : toutes les actions fonctionnent +❌ Échec si : swipe bloqué, produit non ajouté +``` + +### S7 — 10 scans rapides d'affilée +``` +Prérequis : App ouverte + +1. Scanner 10 produits différents aussi vite que possible +2. Observer le comportement + +✅ Succès si : 0 crash, 0 freeze > 5s +❌ Échec si : crash, ANR, perte de données +``` + +### S8 — Rotation écran pendant scan +``` +1. Ouvrir le scanner +2. Pivoter le téléphone (paysage → portrait) +3. Scanner un produit +4. Pivoter pendant l'affichage du verdict + +✅ Succès si : pas de perte d'état, scan fonctionne après rotation +❌ Échec si : caméra freeze, UI cassée +``` + +### S9 — Appel entrant pendant le scan +``` +1. Ouvrir le scanner +2. Recevoir un appel (ou simuler avec un second téléphone) +3. Revenir dans l'app après l'appel + +✅ Succès si : le scanner se réinitialise et fonctionne +❌ Échec si : caméra bloquée, crash +``` + +### S10 — Mode sombre +``` +1. Activer le mode sombre (paramètres) +2. Visiter les 4 onglets (Dashboard, Listes, Suivi, Famille) +3. Scanner un produit et vérifier le verdict + +✅ Succès si : tout est lisible, contraste OK +❌ Échec si : texte illisible, icônes invisibles +``` + +--- + +## 4. Test Accessibilité + +### A1 — TalkBack +``` +1. Activer TalkBack dans Paramètres Android +2. Naviguer dans tous les onglets +3. Scanner un produit et écouter l'annonce du verdict + +✅ Succès si : tous les boutons annoncés, verdict annoncé correctement +❌ Échec si : éléments sans contentDescription, verdict silencieux +``` + +### A2 — Taille de texte maximale +``` +1. Paramètres → Affichage → Taille police → Maximum +2. Naviguer dans tous les écrans + +✅ Succès si : pas de texte coupé, pas de chevauchement +❌ Échec si : boutons hors écran, texte tronqué +``` + +--- + +## 5. Grille d'observation + +| Testeur | S1 | S2 | S3 | S4 | S5 | S6 | S7 | S8 | S9 | S10 | A1 | A2 | Notes | +|---------|----|----|----|----|----|----|----|----|----|-----|----|----|-------| +| #1 | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | | +| #2 | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | | +| #3 | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | | +| #4 | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | | +| #5 | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | | + +Légende : ✅ OK | ⚠️ Mineur | ❌ Bloquant + +--- + +## 6. Produits de test recommandés + +| Produit | Code-barres | Allergène connu | +|---------|-------------|-----------------| +| Snickers | 5000159461153 | Arachides, Lait | +| Nutella | 3017620422003 | Noisettes, Lait | +| Bouteille d'eau Cristaline | 3274080005007 | Aucun | +| Barre Kinder | 8000500310427 | Lait, Gluten | +| Sauce Soja Kikkoman | 8715035111108 | Soja, Gluten | + +--- + +## 7. Rapport final + +Une fois les tests complétés, remplir : + +| Métrique | Cible | Résultat | +|----------|-------|----------| +| Temps identification verdict danger | < 2s | ___ | +| Crashs sur 10 scans | 0 | ___ | +| Score accessibilité TalkBack | 100% | ___ | +| Bugs bloquants découverts | 0 | ___ | +| Satisfaction globale (1-5) | ≥ 4 | ___ | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 65326a6..56eb47c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,8 @@ truth = "1.4.4" mockk = "1.13.12" turbine = "1.1.0" leakcanary = "2.14" +ktlint = "12.2.0" +detekt = "1.23.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -104,3 +106,5 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } diff --git a/version.properties b/version.properties index 9574f6e..92dc325 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ MAJOR=1 -MINOR=25 +MINOR=32 PATCH=0 -CODE=36 +CODE=43 \ No newline at end of file