feat: add user testing plan for SafeBite app
- Created a comprehensive user testing plan document to validate the app's usability, reliability, accessibility, and resilience. - Included various test scenarios covering onboarding, product scanning, manual barcode entry, and accessibility features. chore: update dependencies for code quality tools - Added ktlint version 12.2.0 for Kotlin code style enforcement. - Added detekt version 1.23.7 for static code analysis. chore: increment version numbers - Updated MINOR version to 32 and CODE to 43 in version.properties to reflect recent changes.
This commit is contained in:
parent
9e021397b7
commit
c4add0a3fe
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@ -0,0 +1,9 @@
|
||||
# SafeBite — Ktlint configuration
|
||||
# https://pinterest.github.io/ktlint/latest/rules/configuration-ktlint/
|
||||
|
||||
[*.kt]
|
||||
# Disable function naming check — Compose @Composable functions use PascalCase
|
||||
ktlint_standard_function-naming = disabled
|
||||
|
||||
# Max line length
|
||||
ktlint_standard_max-line-length = disabled
|
||||
42
CHANGELOG.md
42
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é
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -12,7 +12,6 @@ import org.junit.runner.RunWith
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleComposeTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
|
||||
@ -12,7 +12,6 @@ import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class SafeBiteApplication : Application() {
|
||||
|
||||
@Inject lateinit var catalogSeedManager: CatalogSeedManager
|
||||
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@ -10,16 +10,14 @@ import com.safebite.app.domain.model.SafetyStatus
|
||||
|
||||
class Converters {
|
||||
@TypeConverter
|
||||
fun allergenSetToString(set: Set<AllergenType>?): String =
|
||||
set.orEmpty().joinToString(",") { it.name }
|
||||
fun allergenSetToString(set: Set<AllergenType>?): String = set.orEmpty().joinToString(",") { it.name }
|
||||
|
||||
@TypeConverter
|
||||
fun stringToAllergenSet(raw: String?): Set<AllergenType> =
|
||||
raw.orEmpty().split(',').mapNotNull { AllergenType.fromName(it.trim()) }.toSet()
|
||||
|
||||
@TypeConverter
|
||||
fun restrictionSetToString(set: Set<DietaryRestriction>?): String =
|
||||
set.orEmpty().joinToString(",") { it.name }
|
||||
fun restrictionSetToString(set: Set<DietaryRestriction>?): String = set.orEmpty().joinToString(",") { it.name }
|
||||
|
||||
@TypeConverter
|
||||
fun stringToRestrictionSet(raw: String?): Set<DietaryRestriction> =
|
||||
@ -29,12 +27,10 @@ class Converters {
|
||||
.toSet()
|
||||
|
||||
@TypeConverter
|
||||
fun stringListToString(list: List<String>?): String =
|
||||
list.orEmpty().joinToString("\u0001")
|
||||
fun stringListToString(list: List<String>?): String = list.orEmpty().joinToString("\u0001")
|
||||
|
||||
@TypeConverter
|
||||
fun stringToStringList(raw: String?): List<String> =
|
||||
if (raw.isNullOrEmpty()) emptyList() else raw.split('\u0001')
|
||||
fun stringToStringList(raw: String?): List<String> = if (raw.isNullOrEmpty()) emptyList() else raw.split('\u0001')
|
||||
|
||||
@TypeConverter
|
||||
fun safetyStatusToString(status: SafetyStatus): String = status.name
|
||||
@ -55,7 +51,7 @@ class Converters {
|
||||
listOf(
|
||||
item.name.replace('|', '/').replace('\u0002', ' '),
|
||||
item.tag.name,
|
||||
item.keywords.joinToString(";") { it.replace(';', ',').replace('|', '/') }
|
||||
item.keywords.joinToString(";") { it.replace(';', ',').replace('|', '/') },
|
||||
).joinToString("|")
|
||||
}
|
||||
|
||||
@ -67,9 +63,12 @@ class Converters {
|
||||
if (parts.size < 2) return@mapNotNull null
|
||||
val name = parts[0].trim()
|
||||
val tag = runCatching { CustomItemTag.valueOf(parts[1].trim()) }.getOrNull() ?: return@mapNotNull null
|
||||
val keywords = if (parts.size >= 3 && parts[2].isNotBlank())
|
||||
parts[2].split(';').map { it.trim() }.filter { it.isNotBlank() }
|
||||
else emptyList()
|
||||
val keywords =
|
||||
if (parts.size >= 3 && parts[2].isNotBlank()) {
|
||||
parts[2].split(';').map { it.trim() }.filter { it.isNotBlank() }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
CustomDietItem(name = name, tag = tag, keywords = keywords)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface CatalogDao {
|
||||
|
||||
// ── Domaines ──────────────────────────────────────────────────────────────
|
||||
@Query("SELECT * FROM shopping_domains WHERE isActive = 1 ORDER BY sortOrder")
|
||||
fun getAllDomains(): Flow<List<ShoppingDomainEntity>>
|
||||
@ -67,9 +66,12 @@ interface CatalogDao {
|
||||
popularity DESC,
|
||||
name ASC
|
||||
LIMIT :limit
|
||||
"""
|
||||
""",
|
||||
)
|
||||
fun searchItems(query: String, limit: Int = 20): Flow<List<CatalogItemEntity>>
|
||||
fun searchItems(
|
||||
query: String,
|
||||
limit: Int = 20,
|
||||
): Flow<List<CatalogItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM catalog_items WHERE primaryCategoryId = :categoryId ORDER BY sortOrder, name")
|
||||
fun getItemsForCategory(categoryId: String): Flow<List<CatalogItemEntity>>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -17,7 +17,7 @@ data class UserProfileEntity(
|
||||
val moderateIntolerances: Set<AllergenType>,
|
||||
val dietaryRestrictions: Set<DietaryRestriction>,
|
||||
val customItems: List<CustomDietItem> = emptyList(),
|
||||
val isDefault: Boolean
|
||||
val isDefault: Boolean,
|
||||
)
|
||||
|
||||
@Entity(tableName = "product_cache")
|
||||
@ -45,7 +45,7 @@ data class ProductCacheEntity(
|
||||
val fiber100g: Double? = null,
|
||||
val proteins100g: Double? = null,
|
||||
val carbohydrates100g: Double? = null,
|
||||
val cachedAt: Long
|
||||
val cachedAt: Long,
|
||||
)
|
||||
|
||||
@Entity(tableName = "scan_history")
|
||||
@ -58,7 +58,7 @@ data class ScanHistoryEntity(
|
||||
val safetyStatus: SafetyStatus,
|
||||
val profileNames: List<String>,
|
||||
val scannedAt: Long,
|
||||
val source: DataSource
|
||||
val source: DataSource,
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
@ -77,7 +77,7 @@ data class ShoppingListEntity(
|
||||
val sortType: String = "category",
|
||||
val displayOrder: Int = 0,
|
||||
val visibleCategories: String? = null,
|
||||
val categoryOrder: String? = null
|
||||
val categoryOrder: String? = null,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
@ -87,10 +87,10 @@ data class ShoppingListEntity(
|
||||
entity = ShoppingListEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["listId"],
|
||||
onDelete = androidx.room.ForeignKey.CASCADE
|
||||
)
|
||||
onDelete = androidx.room.ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [androidx.room.Index("listId")]
|
||||
indices = [androidx.room.Index("listId")],
|
||||
)
|
||||
data class ShoppingListItemEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
|
||||
@ -100,13 +100,13 @@ data class ShoppingListItemEntity(
|
||||
val brand: String? = null,
|
||||
val imageUrl: String? = null,
|
||||
val isChecked: Boolean = false,
|
||||
val category: String? = null, // "Frais", "Épicerie", etc.
|
||||
val safetyStatus: String? = null, // "SAFE", "WARNING", "DANGER"
|
||||
val category: String? = null, // "Frais", "Épicerie", etc.
|
||||
val safetyStatus: String? = null, // "SAFE", "WARNING", "DANGER"
|
||||
val allergenWarning: String? = null, // Allergène détecté pour alerte
|
||||
val note: String? = null, // Quantité / description libre (ex: "2 kg")
|
||||
val customEmoji: String? = null, // Emoji personnalisé choisi par l'utilisateur
|
||||
val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever"
|
||||
val addedAt: Long = System.currentTimeMillis()
|
||||
val note: String? = null, // Quantité / description libre (ex: "2 kg")
|
||||
val customEmoji: String? = null, // Emoji personnalisé choisi par l'utilisateur
|
||||
val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever"
|
||||
val addedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Entity(
|
||||
@ -116,10 +116,10 @@ data class ShoppingListItemEntity(
|
||||
entity = ShoppingListEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["listId"],
|
||||
onDelete = androidx.room.ForeignKey.CASCADE
|
||||
)
|
||||
onDelete = androidx.room.ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [androidx.room.Index("listId")]
|
||||
indices = [androidx.room.Index("listId")],
|
||||
)
|
||||
data class ShoppingListMemberEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
|
||||
@ -127,6 +127,6 @@ data class ShoppingListMemberEntity(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val avatarUrl: String? = null,
|
||||
val role: String = "member", // "owner" | "member"
|
||||
val joinedAt: Long = System.currentTimeMillis()
|
||||
val role: String = "member", // "owner" | "member"
|
||||
val joinedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ''",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import com.safebite.app.data.local.database.entity.ShoppingDomainEntity
|
||||
data class DomainWithCategories(
|
||||
@Embedded val domain: ShoppingDomainEntity,
|
||||
@Relation(parentColumn = "domainId", entityColumn = "domainId")
|
||||
val categories: List<CategoryEntity>
|
||||
val categories: List<CategoryEntity>,
|
||||
)
|
||||
|
||||
data class CategoryWithItems(
|
||||
@ -19,13 +19,14 @@ data class CategoryWithItems(
|
||||
@Relation(
|
||||
parentColumn = "categoryId",
|
||||
entityColumn = "itemId",
|
||||
associateBy = Junction(
|
||||
value = ItemCategoryCrossRef::class,
|
||||
parentColumn = "categoryId",
|
||||
entityColumn = "itemId"
|
||||
)
|
||||
associateBy =
|
||||
Junction(
|
||||
value = ItemCategoryCrossRef::class,
|
||||
parentColumn = "categoryId",
|
||||
entityColumn = "itemId",
|
||||
),
|
||||
)
|
||||
val items: List<CatalogItemEntity>
|
||||
val items: List<CatalogItemEntity>,
|
||||
)
|
||||
|
||||
data class DomainWithCategoriesAndItems(
|
||||
@ -33,7 +34,7 @@ data class DomainWithCategoriesAndItems(
|
||||
@Relation(
|
||||
entity = CategoryEntity::class,
|
||||
parentColumn = "domainId",
|
||||
entityColumn = "domainId"
|
||||
entityColumn = "domainId",
|
||||
)
|
||||
val categoriesWithItems: List<CategoryWithItems>
|
||||
val categoriesWithItems: List<CategoryWithItems>,
|
||||
)
|
||||
|
||||
@ -30,41 +30,46 @@ object UserPreferencesKeys {
|
||||
}
|
||||
|
||||
class UserPreferences(private val dataStore: DataStore<Preferences>) {
|
||||
val appLanguage: Flow<AppLanguage> =
|
||||
dataStore.data.map {
|
||||
runCatching { AppLanguage.valueOf(it[UserPreferencesKeys.APP_LANGUAGE] ?: AppLanguage.FR.name) }
|
||||
.getOrDefault(AppLanguage.FR)
|
||||
}
|
||||
|
||||
val appLanguage: Flow<AppLanguage> = dataStore.data.map {
|
||||
runCatching { AppLanguage.valueOf(it[UserPreferencesKeys.APP_LANGUAGE] ?: AppLanguage.FR.name) }
|
||||
.getOrDefault(AppLanguage.FR)
|
||||
}
|
||||
|
||||
val detectionLanguage: Flow<DetectionLanguage> = dataStore.data.map {
|
||||
runCatching { DetectionLanguage.valueOf(it[UserPreferencesKeys.DETECTION_LANGUAGE] ?: DetectionLanguage.BOTH.name) }
|
||||
.getOrDefault(DetectionLanguage.BOTH)
|
||||
}
|
||||
val detectionLanguage: Flow<DetectionLanguage> =
|
||||
dataStore.data.map {
|
||||
runCatching { DetectionLanguage.valueOf(it[UserPreferencesKeys.DETECTION_LANGUAGE] ?: DetectionLanguage.BOTH.name) }
|
||||
.getOrDefault(DetectionLanguage.BOTH)
|
||||
}
|
||||
|
||||
val haptics: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.HAPTICS] ?: true }
|
||||
val sound: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.SOUND] ?: true }
|
||||
|
||||
val theme: Flow<ThemePref> = dataStore.data.map {
|
||||
runCatching { ThemePref.valueOf(it[UserPreferencesKeys.THEME] ?: ThemePref.SYSTEM.name) }
|
||||
.getOrDefault(ThemePref.SYSTEM)
|
||||
}
|
||||
val theme: Flow<ThemePref> =
|
||||
dataStore.data.map {
|
||||
runCatching { ThemePref.valueOf(it[UserPreferencesKeys.THEME] ?: ThemePref.SYSTEM.name) }
|
||||
.getOrDefault(ThemePref.SYSTEM)
|
||||
}
|
||||
|
||||
val onboardingCompleted: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.ONBOARDING_DONE] ?: false }
|
||||
|
||||
val activeProfileIds: Flow<Set<Long>> = dataStore.data.map { prefs ->
|
||||
prefs[UserPreferencesKeys.ACTIVE_PROFILE_IDS].orEmpty()
|
||||
.mapNotNull { it.toLongOrNull() }
|
||||
.toSet()
|
||||
}
|
||||
val activeProfileIds: Flow<Set<Long>> =
|
||||
dataStore.data.map { prefs ->
|
||||
prefs[UserPreferencesKeys.ACTIVE_PROFILE_IDS].orEmpty()
|
||||
.mapNotNull { it.toLongOrNull() }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
val healthStrictness: Flow<HealthStrictness> = dataStore.data.map {
|
||||
runCatching { HealthStrictness.valueOf(it[UserPreferencesKeys.HEALTH_STRICTNESS] ?: HealthStrictness.NORMAL.name) }
|
||||
.getOrDefault(HealthStrictness.NORMAL)
|
||||
}
|
||||
val healthStrictness: Flow<HealthStrictness> =
|
||||
dataStore.data.map {
|
||||
runCatching { HealthStrictness.valueOf(it[UserPreferencesKeys.HEALTH_STRICTNESS] ?: HealthStrictness.NORMAL.name) }
|
||||
.getOrDefault(HealthStrictness.NORMAL)
|
||||
}
|
||||
|
||||
val splashScreenEnabled: Flow<Boolean> = dataStore.data.map {
|
||||
it[UserPreferencesKeys.SPLASH_SCREEN_ENABLED] ?: true
|
||||
}
|
||||
val splashScreenEnabled: Flow<Boolean> =
|
||||
dataStore.data.map {
|
||||
it[UserPreferencesKeys.SPLASH_SCREEN_ENABLED] ?: true
|
||||
}
|
||||
|
||||
suspend fun setAppLanguage(value: AppLanguage) {
|
||||
dataStore.edit { it[UserPreferencesKeys.APP_LANGUAGE] = value.name }
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import com.squareup.moshi.JsonClass
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CatalogSeed(
|
||||
val version: Int,
|
||||
val domains: List<DomainSeed>
|
||||
val domains: List<DomainSeed>,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@ -20,7 +20,7 @@ data class DomainSeed(
|
||||
val emoji: String,
|
||||
val color: String? = null,
|
||||
val sortOrder: Int,
|
||||
val categories: List<CategorySeed>
|
||||
val categories: List<CategorySeed>,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@ -30,7 +30,7 @@ data class CategorySeed(
|
||||
val emoji: String,
|
||||
val color: String? = null,
|
||||
val sortOrder: Int,
|
||||
val items: List<ItemSeed>
|
||||
val items: List<ItemSeed>,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@ -41,5 +41,5 @@ data class ItemSeed(
|
||||
val aliases: String? = null,
|
||||
val tags: String? = null,
|
||||
val variants: String? = null,
|
||||
val barcode: String? = null
|
||||
val barcode: String? = null,
|
||||
)
|
||||
|
||||
@ -7,7 +7,9 @@ import retrofit2.http.Path
|
||||
|
||||
interface OpenFoodFactsApi {
|
||||
@GET("api/v2/product/{barcode}.json")
|
||||
suspend fun getProduct(@Path("barcode") barcode: String): Response<ProductResponse>
|
||||
suspend fun getProduct(
|
||||
@Path("barcode") barcode: String,
|
||||
): Response<ProductResponse>
|
||||
|
||||
companion object {
|
||||
const val BASE_URL = "https://world.openfoodfacts.org/"
|
||||
|
||||
@ -8,7 +8,7 @@ data class ProductResponse(
|
||||
@Json(name = "code") val code: String? = null,
|
||||
@Json(name = "status") val status: Int? = null,
|
||||
@Json(name = "status_verbose") val statusVerbose: String? = null,
|
||||
@Json(name = "product") val product: ProductDto? = null
|
||||
@Json(name = "product") val product: ProductDto? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@ -30,7 +30,7 @@ data class ProductDto(
|
||||
@Json(name = "serving_size") val servingSize: String? = null,
|
||||
@Json(name = "labels_tags") val labelsTags: List<String>? = null,
|
||||
@Json(name = "categories_tags") val categoriesTags: List<String>? = null,
|
||||
@Json(name = "nutriments") val nutriments: NutrimentsDto? = null
|
||||
@Json(name = "nutriments") val nutriments: NutrimentsDto? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@ -44,5 +44,5 @@ data class NutrimentsDto(
|
||||
@Json(name = "sodium_100g") val sodium100g: Double? = null,
|
||||
@Json(name = "fiber_100g") val fiber100g: Double? = null,
|
||||
@Json(name = "proteins_100g") val proteins100g: Double? = null,
|
||||
@Json(name = "carbohydrates_100g") val carbohydrates100g: Double? = null
|
||||
@Json(name = "carbohydrates_100g") val carbohydrates100g: Double? = null,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@ -18,65 +18,67 @@ import javax.inject.Singleton
|
||||
* aux écrans Catalogue, Catégories, Articles et Recherche.
|
||||
*/
|
||||
@Singleton
|
||||
class CatalogRepository @Inject constructor(
|
||||
private val dao: CatalogDao
|
||||
) {
|
||||
class CatalogRepository
|
||||
@Inject
|
||||
constructor(
|
||||
private val dao: CatalogDao,
|
||||
) {
|
||||
fun observeDomains(): Flow<List<ShoppingDomainEntity>> = dao.getAllDomains()
|
||||
|
||||
fun observeDomains(): Flow<List<ShoppingDomainEntity>> = dao.getAllDomains()
|
||||
fun observeDomainsWithCategories(): Flow<List<DomainWithCategories>> = dao.getDomainsWithCategories()
|
||||
|
||||
fun observeDomainsWithCategories(): Flow<List<DomainWithCategories>> =
|
||||
dao.getDomainsWithCategories()
|
||||
fun observeDomainsWithCategoriesAndItems(): Flow<List<DomainWithCategoriesAndItems>> = dao.getDomainsWithCategoriesAndItems()
|
||||
|
||||
fun observeDomainsWithCategoriesAndItems(): Flow<List<DomainWithCategoriesAndItems>> =
|
||||
dao.getDomainsWithCategoriesAndItems()
|
||||
fun observeCategoriesForDomain(domainId: String): Flow<List<CategoryEntity>> = dao.getCategoriesForDomain(domainId)
|
||||
|
||||
fun observeCategoriesForDomain(domainId: String): Flow<List<CategoryEntity>> =
|
||||
dao.getCategoriesForDomain(domainId)
|
||||
fun observeCategoryWithItems(categoryId: String): Flow<CategoryWithItems?> = dao.getCategoryWithItems(categoryId)
|
||||
|
||||
fun observeCategoryWithItems(categoryId: String): Flow<CategoryWithItems?> =
|
||||
dao.getCategoryWithItems(categoryId)
|
||||
fun observeItemsForCategory(categoryId: String): Flow<List<CatalogItemEntity>> = dao.getItemsForCategory(categoryId)
|
||||
|
||||
fun observeItemsForCategory(categoryId: String): Flow<List<CatalogItemEntity>> =
|
||||
dao.getItemsForCategory(categoryId)
|
||||
fun observePopularItems(limit: Int = 15): Flow<List<CatalogItemEntity>> = dao.getPopularItems(limit)
|
||||
|
||||
fun observePopularItems(limit: Int = 15): Flow<List<CatalogItemEntity>> =
|
||||
dao.getPopularItems(limit)
|
||||
fun search(
|
||||
query: String,
|
||||
limit: Int = 20,
|
||||
): Flow<List<CatalogItemEntity>> = dao.searchItems(query.trim(), limit)
|
||||
|
||||
fun search(query: String, limit: Int = 20): Flow<List<CatalogItemEntity>> =
|
||||
dao.searchItems(query.trim(), limit)
|
||||
suspend fun getDomain(domainId: String): ShoppingDomainEntity? = dao.getDomainById(domainId)
|
||||
|
||||
suspend fun getDomain(domainId: String): ShoppingDomainEntity? = dao.getDomainById(domainId)
|
||||
suspend fun getCategory(categoryId: String): CategoryEntity? = dao.getCategoryById(categoryId)
|
||||
suspend fun getItem(itemId: String): CatalogItemEntity? = dao.getItemById(itemId)
|
||||
suspend fun getItemByBarcode(barcode: String): CatalogItemEntity? = dao.getItemByBarcode(barcode)
|
||||
suspend fun incrementPopularity(itemId: String) = dao.incrementPopularity(itemId)
|
||||
suspend fun getCategory(categoryId: String): CategoryEntity? = dao.getCategoryById(categoryId)
|
||||
|
||||
/**
|
||||
* Crée un article personnalisé (généré par l'utilisateur), persisté avec
|
||||
* `isUserCreated = true` afin de pouvoir filtrer / exporter ces ajouts.
|
||||
*/
|
||||
suspend fun createUserItem(
|
||||
name: String,
|
||||
emoji: String,
|
||||
primaryCategoryId: String?,
|
||||
aliases: String = "",
|
||||
tags: String = ""
|
||||
): CatalogItemEntity {
|
||||
val item = CatalogItemEntity(
|
||||
itemId = "user_${UUID.randomUUID()}",
|
||||
name = name,
|
||||
primaryCategoryId = primaryCategoryId,
|
||||
emoji = emoji,
|
||||
aliases = aliases,
|
||||
tags = tags,
|
||||
isUserCreated = true,
|
||||
popularity = 0,
|
||||
sortOrder = 0
|
||||
)
|
||||
dao.insertItem(item)
|
||||
if (primaryCategoryId != null) {
|
||||
dao.insertCrossRefs(listOf(ItemCategoryCrossRef(item.itemId, primaryCategoryId)))
|
||||
suspend fun getItem(itemId: String): CatalogItemEntity? = dao.getItemById(itemId)
|
||||
|
||||
suspend fun getItemByBarcode(barcode: String): CatalogItemEntity? = dao.getItemByBarcode(barcode)
|
||||
|
||||
suspend fun incrementPopularity(itemId: String) = dao.incrementPopularity(itemId)
|
||||
|
||||
/**
|
||||
* Crée un article personnalisé (généré par l'utilisateur), persisté avec
|
||||
* `isUserCreated = true` afin de pouvoir filtrer / exporter ces ajouts.
|
||||
*/
|
||||
suspend fun createUserItem(
|
||||
name: String,
|
||||
emoji: String,
|
||||
primaryCategoryId: String?,
|
||||
aliases: String = "",
|
||||
tags: String = "",
|
||||
): CatalogItemEntity {
|
||||
val item =
|
||||
CatalogItemEntity(
|
||||
itemId = "user_${UUID.randomUUID()}",
|
||||
name = name,
|
||||
primaryCategoryId = primaryCategoryId,
|
||||
emoji = emoji,
|
||||
aliases = aliases,
|
||||
tags = tags,
|
||||
isUserCreated = true,
|
||||
popularity = 0,
|
||||
sortOrder = 0,
|
||||
)
|
||||
dao.insertItem(item)
|
||||
if (primaryCategoryId != null) {
|
||||
dao.insertCrossRefs(listOf(ItemCategoryCrossRef(item.itemId, primaryCategoryId)))
|
||||
}
|
||||
return item
|
||||
}
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,67 +16,72 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ProductRepositoryImpl @Inject constructor(
|
||||
private val api: OpenFoodFactsApi,
|
||||
private val cacheDao: ProductCacheDao,
|
||||
private val connectivity: ConnectivityObserver
|
||||
) : ProductRepository {
|
||||
class ProductRepositoryImpl
|
||||
@Inject
|
||||
constructor(
|
||||
private val api: OpenFoodFactsApi,
|
||||
private val cacheDao: ProductCacheDao,
|
||||
private val connectivity: ConnectivityObserver,
|
||||
) : ProductRepository {
|
||||
override suspend fun fetchProduct(barcode: String): ProductFetchResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val cached = cacheDao.getByBarcode(barcode)?.toDomain()
|
||||
val online = connectivity.isOnline()
|
||||
|
||||
override suspend fun fetchProduct(barcode: String): ProductFetchResult = withContext(Dispatchers.IO) {
|
||||
val cached = cacheDao.getByBarcode(barcode)?.toDomain()
|
||||
val online = connectivity.isOnline()
|
||||
if (!online) {
|
||||
return@withContext if (cached != null) {
|
||||
ProductFetchResult.Found(cached, fromCache = true)
|
||||
} else {
|
||||
ProductFetchResult.Error("offline", offline = true)
|
||||
}
|
||||
}
|
||||
|
||||
if (!online) {
|
||||
return@withContext if (cached != null) {
|
||||
ProductFetchResult.Found(cached, fromCache = true)
|
||||
} else {
|
||||
ProductFetchResult.Error("offline", offline = true)
|
||||
try {
|
||||
val response = api.getProduct(barcode)
|
||||
if (!response.isSuccessful) {
|
||||
Timber.w("OFF returned HTTP ${response.code()} for $barcode")
|
||||
return@withContext cached?.let { ProductFetchResult.Found(it, fromCache = true) }
|
||||
?: ProductFetchResult.Error("http_${response.code()}")
|
||||
}
|
||||
val body = response.body()
|
||||
val status = body?.status ?: 0
|
||||
val dto = body?.product
|
||||
if (status != 1 || dto == null) {
|
||||
return@withContext ProductFetchResult.NotFound
|
||||
}
|
||||
val product = dto.toDomain(barcode)
|
||||
cacheDao.upsert(product.toCacheEntity())
|
||||
ProductFetchResult.Found(product, fromCache = false)
|
||||
} catch (io: IOException) {
|
||||
Timber.w(io, "Network error fetching $barcode")
|
||||
cached?.let { ProductFetchResult.Found(it, fromCache = true) }
|
||||
?: ProductFetchResult.Error(io.message ?: "network_error", offline = true)
|
||||
} catch (t: Throwable) {
|
||||
Timber.e(t, "Unexpected error fetching $barcode")
|
||||
cached?.let { ProductFetchResult.Found(it, fromCache = true) }
|
||||
?: ProductFetchResult.Error(t.message ?: "unknown_error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val response = api.getProduct(barcode)
|
||||
if (!response.isSuccessful) {
|
||||
Timber.w("OFF returned HTTP ${response.code()} for $barcode")
|
||||
return@withContext cached?.let { ProductFetchResult.Found(it, fromCache = true) }
|
||||
?: ProductFetchResult.Error("http_${response.code()}")
|
||||
override suspend fun cacheProduct(product: Product) =
|
||||
withContext(Dispatchers.IO) {
|
||||
cacheDao.upsert(product.toCacheEntity())
|
||||
}
|
||||
val body = response.body()
|
||||
val status = body?.status ?: 0
|
||||
val dto = body?.product
|
||||
if (status != 1 || dto == null) {
|
||||
return@withContext ProductFetchResult.NotFound
|
||||
|
||||
override suspend fun getCachedProduct(barcode: String): Product? =
|
||||
withContext(Dispatchers.IO) {
|
||||
cacheDao.getByBarcode(barcode)?.toDomain()
|
||||
}
|
||||
val product = dto.toDomain(barcode)
|
||||
cacheDao.upsert(product.toCacheEntity())
|
||||
ProductFetchResult.Found(product, fromCache = false)
|
||||
} catch (io: IOException) {
|
||||
Timber.w(io, "Network error fetching $barcode")
|
||||
cached?.let { ProductFetchResult.Found(it, fromCache = true) }
|
||||
?: ProductFetchResult.Error(io.message ?: "network_error", offline = true)
|
||||
} catch (t: Throwable) {
|
||||
Timber.e(t, "Unexpected error fetching $barcode")
|
||||
cached?.let { ProductFetchResult.Found(it, fromCache = true) }
|
||||
?: ProductFetchResult.Error(t.message ?: "unknown_error")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cacheProduct(product: Product) = withContext(Dispatchers.IO) {
|
||||
cacheDao.upsert(product.toCacheEntity())
|
||||
}
|
||||
override suspend fun clearCache() = withContext(Dispatchers.IO) { cacheDao.clear() }
|
||||
|
||||
override suspend fun getCachedProduct(barcode: String): Product? = withContext(Dispatchers.IO) {
|
||||
cacheDao.getByBarcode(barcode)?.toDomain()
|
||||
override suspend fun searchAlternatives(
|
||||
category: String,
|
||||
excludeAllergens: Set<String>,
|
||||
limit: Int,
|
||||
): List<Product> =
|
||||
withContext(Dispatchers.IO) {
|
||||
// TODO: Implémenter la recherche d'alternatives via l'API OFF
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearCache() = withContext(Dispatchers.IO) { cacheDao.clear() }
|
||||
|
||||
override suspend fun searchAlternatives(
|
||||
category: String,
|
||||
excludeAllergens: Set<String>,
|
||||
limit: Int
|
||||
): List<Product> = withContext(Dispatchers.IO) {
|
||||
// TODO: Implémenter la recherche d'alternatives via l'API OFF
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,45 +13,48 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ScanHistoryRepositoryImpl @Inject constructor(
|
||||
private val dao: ScanHistoryDao
|
||||
) : ScanHistoryRepository {
|
||||
class ScanHistoryRepositoryImpl
|
||||
@Inject
|
||||
constructor(
|
||||
private val dao: ScanHistoryDao,
|
||||
) : ScanHistoryRepository {
|
||||
override fun observeHistory(): Flow<List<ScanHistoryItem>> = dao.observeAll().map { list -> list.map { it.toDomain() } }
|
||||
|
||||
override fun observeHistory(): Flow<List<ScanHistoryItem>> =
|
||||
dao.observeAll().map { list -> list.map { it.toDomain() } }
|
||||
override suspend fun save(result: ScanResult): Long =
|
||||
withContext(Dispatchers.IO) {
|
||||
dao.insert(
|
||||
ScanHistoryEntity(
|
||||
barcode = result.product.barcode,
|
||||
productName = result.product.name,
|
||||
brand = result.product.brand,
|
||||
imageUrl = result.product.imageUrl,
|
||||
safetyStatus = result.safetyStatus,
|
||||
profileNames = result.analyzedProfiles.map { it.name },
|
||||
scannedAt = System.currentTimeMillis(),
|
||||
source = result.source,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun save(result: ScanResult): Long = withContext(Dispatchers.IO) {
|
||||
dao.insert(
|
||||
ScanHistoryEntity(
|
||||
barcode = result.product.barcode,
|
||||
productName = result.product.name,
|
||||
brand = result.product.brand,
|
||||
imageUrl = result.product.imageUrl,
|
||||
safetyStatus = result.safetyStatus,
|
||||
profileNames = result.analyzedProfiles.map { it.name },
|
||||
scannedAt = System.currentTimeMillis(),
|
||||
source = result.source
|
||||
)
|
||||
)
|
||||
override suspend fun delete(id: Long) = withContext(Dispatchers.IO) { dao.deleteById(id) }
|
||||
|
||||
override suspend fun clear() = withContext(Dispatchers.IO) { dao.clear() }
|
||||
|
||||
override suspend fun getById(id: Long): ScanHistoryItem? =
|
||||
withContext(Dispatchers.IO) {
|
||||
dao.getById(id)?.toDomain()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Long) = withContext(Dispatchers.IO) { dao.deleteById(id) }
|
||||
|
||||
override suspend fun clear() = withContext(Dispatchers.IO) { dao.clear() }
|
||||
|
||||
override suspend fun getById(id: Long): ScanHistoryItem? = withContext(Dispatchers.IO) {
|
||||
dao.getById(id)?.toDomain()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ScanHistoryEntity.toDomain() = ScanHistoryItem(
|
||||
id = id,
|
||||
barcode = barcode,
|
||||
productName = productName,
|
||||
brand = brand,
|
||||
imageUrl = imageUrl,
|
||||
safetyStatus = safetyStatus,
|
||||
profileNames = profileNames,
|
||||
scannedAt = scannedAt,
|
||||
source = source
|
||||
)
|
||||
private fun ScanHistoryEntity.toDomain() =
|
||||
ScanHistoryItem(
|
||||
id = id,
|
||||
barcode = barcode,
|
||||
productName = productName,
|
||||
brand = brand,
|
||||
imageUrl = imageUrl,
|
||||
safetyStatus = safetyStatus,
|
||||
profileNames = profileNames,
|
||||
scannedAt = scannedAt,
|
||||
source = source,
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -10,95 +10,96 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ShoppingListRepositoryImpl @Inject constructor(
|
||||
private val dao: ShoppingListDao
|
||||
) : ShoppingListRepository {
|
||||
class ShoppingListRepositoryImpl
|
||||
@Inject
|
||||
constructor(
|
||||
private val dao: ShoppingListDao,
|
||||
) : ShoppingListRepository {
|
||||
override fun observeActiveLists(): Flow<List<ShoppingListEntity>> = dao.observeActiveLists()
|
||||
|
||||
override fun observeActiveLists(): Flow<List<ShoppingListEntity>> =
|
||||
dao.observeActiveLists()
|
||||
override fun observeAllLists(): Flow<List<ShoppingListEntity>> = dao.observeAllLists()
|
||||
|
||||
override fun observeAllLists(): Flow<List<ShoppingListEntity>> =
|
||||
dao.observeAllLists()
|
||||
override suspend fun getListById(id: Long): ShoppingListEntity? = dao.getListById(id)
|
||||
|
||||
override suspend fun getListById(id: Long): ShoppingListEntity? =
|
||||
dao.getListById(id)
|
||||
override suspend fun createList(
|
||||
name: String,
|
||||
backgroundResName: String?,
|
||||
): Long {
|
||||
val list =
|
||||
ShoppingListEntity(
|
||||
name = name,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
backgroundResName = backgroundResName,
|
||||
)
|
||||
return dao.insertList(list)
|
||||
}
|
||||
|
||||
override suspend fun createList(name: String, backgroundResName: String?): Long {
|
||||
val list = ShoppingListEntity(
|
||||
name = name,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
backgroundResName = backgroundResName
|
||||
)
|
||||
return dao.insertList(list)
|
||||
override suspend fun updateList(list: ShoppingListEntity) {
|
||||
dao.updateList(list.copy(updatedAt = System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
override suspend fun deleteList(list: ShoppingListEntity) {
|
||||
dao.deleteList(list)
|
||||
}
|
||||
|
||||
override suspend fun archiveList(id: Long) {
|
||||
dao.archiveList(id)
|
||||
}
|
||||
|
||||
override fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>> = dao.observeItems(listId)
|
||||
|
||||
override suspend fun getItems(listId: Long): List<ShoppingListItemEntity> = dao.getItems(listId)
|
||||
|
||||
override suspend fun addItem(item: ShoppingListItemEntity): Long = dao.insertItem(item)
|
||||
|
||||
override suspend fun updateItem(item: ShoppingListItemEntity) {
|
||||
dao.updateItem(item)
|
||||
}
|
||||
|
||||
override suspend fun deleteItem(item: ShoppingListItemEntity) {
|
||||
dao.deleteItem(item)
|
||||
}
|
||||
|
||||
override suspend fun setItemChecked(
|
||||
id: Long,
|
||||
checked: Boolean,
|
||||
) {
|
||||
dao.setItemChecked(id, checked)
|
||||
}
|
||||
|
||||
override suspend fun uncheckAllItems(listId: Long) {
|
||||
dao.uncheckAllItems(listId)
|
||||
}
|
||||
|
||||
override suspend fun deleteAllItems(listId: Long) {
|
||||
dao.deleteAllItems(listId)
|
||||
}
|
||||
|
||||
override fun observeItemCount(listId: Long): Flow<Int> = dao.observeItemCount(listId)
|
||||
|
||||
override fun observeCheckedCount(listId: Long): Flow<Int> = dao.observeCheckedCount(listId)
|
||||
|
||||
override suspend fun addItemToList(
|
||||
listId: Long,
|
||||
item: ShoppingListItemEntity,
|
||||
) {
|
||||
dao.addItemToList(listId, item)
|
||||
}
|
||||
|
||||
override fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>> = dao.observeMembers(listId)
|
||||
|
||||
override suspend fun addMember(member: ShoppingListMemberEntity): Long = dao.insertMember(member)
|
||||
|
||||
override suspend fun updateMember(member: ShoppingListMemberEntity) {
|
||||
dao.updateMember(member)
|
||||
}
|
||||
|
||||
override suspend fun removeMember(member: ShoppingListMemberEntity) {
|
||||
dao.deleteMember(member)
|
||||
}
|
||||
|
||||
override suspend fun deleteAllMembers(listId: Long) {
|
||||
dao.deleteAllMembers(listId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateList(list: ShoppingListEntity) {
|
||||
dao.updateList(list.copy(updatedAt = System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
override suspend fun deleteList(list: ShoppingListEntity) {
|
||||
dao.deleteList(list)
|
||||
}
|
||||
|
||||
override suspend fun archiveList(id: Long) {
|
||||
dao.archiveList(id)
|
||||
}
|
||||
|
||||
override fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>> =
|
||||
dao.observeItems(listId)
|
||||
|
||||
override suspend fun getItems(listId: Long): List<ShoppingListItemEntity> =
|
||||
dao.getItems(listId)
|
||||
|
||||
override suspend fun addItem(item: ShoppingListItemEntity): Long =
|
||||
dao.insertItem(item)
|
||||
|
||||
override suspend fun updateItem(item: ShoppingListItemEntity) {
|
||||
dao.updateItem(item)
|
||||
}
|
||||
|
||||
override suspend fun deleteItem(item: ShoppingListItemEntity) {
|
||||
dao.deleteItem(item)
|
||||
}
|
||||
|
||||
override suspend fun setItemChecked(id: Long, checked: Boolean) {
|
||||
dao.setItemChecked(id, checked)
|
||||
}
|
||||
|
||||
override suspend fun uncheckAllItems(listId: Long) {
|
||||
dao.uncheckAllItems(listId)
|
||||
}
|
||||
|
||||
override suspend fun deleteAllItems(listId: Long) {
|
||||
dao.deleteAllItems(listId)
|
||||
}
|
||||
|
||||
override fun observeItemCount(listId: Long): Flow<Int> =
|
||||
dao.observeItemCount(listId)
|
||||
|
||||
override fun observeCheckedCount(listId: Long): Flow<Int> =
|
||||
dao.observeCheckedCount(listId)
|
||||
|
||||
override suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) {
|
||||
dao.addItemToList(listId, item)
|
||||
}
|
||||
|
||||
override fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>> =
|
||||
dao.observeMembers(listId)
|
||||
|
||||
override suspend fun addMember(member: ShoppingListMemberEntity): Long =
|
||||
dao.insertMember(member)
|
||||
|
||||
override suspend fun updateMember(member: ShoppingListMemberEntity) {
|
||||
dao.updateMember(member)
|
||||
}
|
||||
|
||||
override suspend fun removeMember(member: ShoppingListMemberEntity) {
|
||||
dao.deleteMember(member)
|
||||
}
|
||||
|
||||
override suspend fun deleteAllMembers(listId: Long) {
|
||||
dao.deleteAllMembers(listId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,58 +13,68 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class UserProfileRepositoryImpl @Inject constructor(
|
||||
private val dao: UserProfileDao,
|
||||
private val prefs: UserPreferences
|
||||
) : UserProfileRepository {
|
||||
class UserProfileRepositoryImpl
|
||||
@Inject
|
||||
constructor(
|
||||
private val dao: UserProfileDao,
|
||||
private val prefs: UserPreferences,
|
||||
) : UserProfileRepository {
|
||||
override fun observeProfiles(): Flow<List<UserProfile>> = dao.observeAll().map { list -> list.map { it.toDomain() } }
|
||||
|
||||
override fun observeProfiles(): Flow<List<UserProfile>> =
|
||||
dao.observeAll().map { list -> list.map { it.toDomain() } }
|
||||
override suspend fun getProfile(id: Long): UserProfile? =
|
||||
withContext(Dispatchers.IO) {
|
||||
dao.getById(id)?.toDomain()
|
||||
}
|
||||
|
||||
override suspend fun getProfile(id: Long): UserProfile? = withContext(Dispatchers.IO) {
|
||||
dao.getById(id)?.toDomain()
|
||||
}
|
||||
override suspend fun upsert(profile: UserProfile): Long =
|
||||
withContext(Dispatchers.IO) {
|
||||
val entity = profile.toEntity()
|
||||
if (profile.id == 0L) {
|
||||
dao.insert(entity)
|
||||
} else {
|
||||
dao.update(entity)
|
||||
profile.id
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun upsert(profile: UserProfile): Long = withContext(Dispatchers.IO) {
|
||||
val entity = profile.toEntity()
|
||||
if (profile.id == 0L) dao.insert(entity) else {
|
||||
dao.update(entity)
|
||||
profile.id
|
||||
override suspend fun delete(profile: UserProfile) =
|
||||
withContext(Dispatchers.IO) {
|
||||
dao.delete(profile.toEntity())
|
||||
}
|
||||
|
||||
override suspend fun setDefault(id: Long) =
|
||||
withContext(Dispatchers.IO) {
|
||||
dao.clearDefault()
|
||||
dao.markDefault(id)
|
||||
}
|
||||
|
||||
override fun observeActiveProfileIds(): Flow<Set<Long>> = prefs.activeProfileIds
|
||||
|
||||
override suspend fun setActiveProfileIds(ids: Set<Long>) {
|
||||
prefs.setActiveProfileIds(ids)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(profile: UserProfile) = withContext(Dispatchers.IO) {
|
||||
dao.delete(profile.toEntity())
|
||||
}
|
||||
private fun UserProfileEntity.toDomain(): UserProfile =
|
||||
UserProfile(
|
||||
id = id,
|
||||
name = name,
|
||||
avatar = avatar,
|
||||
severeAllergens = severeAllergens,
|
||||
moderateIntolerances = moderateIntolerances,
|
||||
dietaryRestrictions = dietaryRestrictions,
|
||||
customItems = customItems,
|
||||
isDefault = isDefault,
|
||||
)
|
||||
|
||||
override suspend fun setDefault(id: Long) = withContext(Dispatchers.IO) {
|
||||
dao.clearDefault()
|
||||
dao.markDefault(id)
|
||||
}
|
||||
|
||||
override fun observeActiveProfileIds(): Flow<Set<Long>> = prefs.activeProfileIds
|
||||
|
||||
override suspend fun setActiveProfileIds(ids: Set<Long>) { prefs.setActiveProfileIds(ids) }
|
||||
}
|
||||
|
||||
private fun UserProfileEntity.toDomain(): UserProfile = UserProfile(
|
||||
id = id,
|
||||
name = name,
|
||||
avatar = avatar,
|
||||
severeAllergens = severeAllergens,
|
||||
moderateIntolerances = moderateIntolerances,
|
||||
dietaryRestrictions = dietaryRestrictions,
|
||||
customItems = customItems,
|
||||
isDefault = isDefault
|
||||
)
|
||||
|
||||
private fun UserProfile.toEntity(): UserProfileEntity = UserProfileEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
avatar = avatar,
|
||||
severeAllergens = severeAllergens,
|
||||
moderateIntolerances = moderateIntolerances,
|
||||
dietaryRestrictions = dietaryRestrictions,
|
||||
customItems = customItems,
|
||||
isDefault = isDefault
|
||||
)
|
||||
private fun UserProfile.toEntity(): UserProfileEntity =
|
||||
UserProfileEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
avatar = avatar,
|
||||
severeAllergens = severeAllergens,
|
||||
moderateIntolerances = moderateIntolerances,
|
||||
dietaryRestrictions = dietaryRestrictions,
|
||||
customItems = customItems,
|
||||
isDefault = isDefault,
|
||||
)
|
||||
|
||||
@ -11,7 +11,6 @@ import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
class ConnectivityObserver(private val context: Context) {
|
||||
|
||||
fun isOnline(): Boolean {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
|
||||
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
|
||||
@ -19,18 +18,29 @@ class ConnectivityObserver(private val context: Context) {
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
}
|
||||
|
||||
fun observe(): Flow<Boolean> = callbackFlow {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) { trySend(true) }
|
||||
override fun onLost(network: Network) { trySend(false) }
|
||||
override fun onUnavailable() { trySend(false) }
|
||||
}
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
cm.registerNetworkCallback(request, callback)
|
||||
trySend(isOnline())
|
||||
awaitClose { cm.unregisterNetworkCallback(callback) }
|
||||
}.distinctUntilChanged()
|
||||
fun observe(): Flow<Boolean> =
|
||||
callbackFlow {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val callback =
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(true)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(false)
|
||||
}
|
||||
|
||||
override fun onUnavailable() {
|
||||
trySend(false)
|
||||
}
|
||||
}
|
||||
val request =
|
||||
NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
cm.registerNetworkCallback(request, callback)
|
||||
trySend(isOnline())
|
||||
awaitClose { cm.unregisterNetworkCallback(callback) }
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@ import javax.inject.Singleton
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object EngineModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCategoryEngine(): CategoryEngine = CategoryEngine()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -19,7 +19,6 @@ import javax.inject.Singleton
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository
|
||||
|
||||
|
||||
@ -9,8 +9,6 @@ import com.safebite.app.domain.model.DetectedAllergen
|
||||
import com.safebite.app.domain.model.DetectedCustomItem
|
||||
import com.safebite.app.domain.model.DetectionLanguage
|
||||
import com.safebite.app.domain.model.DetectionLevel
|
||||
import com.safebite.app.domain.model.HealthAssessment
|
||||
import com.safebite.app.domain.model.HealthRating
|
||||
import com.safebite.app.domain.model.HealthStrictness
|
||||
import com.safebite.app.domain.model.Product
|
||||
import com.safebite.app.domain.model.SafetyStatus
|
||||
@ -27,16 +25,22 @@ import java.text.Normalizer
|
||||
* 3. "May contain / traces" pattern extraction from the ingredients text.
|
||||
*/
|
||||
object AllergenAnalysisEngine {
|
||||
|
||||
/** Regexes for "may contain" disclosures, in French and English. */
|
||||
private val MAY_CONTAIN_PATTERNS = listOf(
|
||||
Regex("peut contenir(?:\\s+des\\s+traces\\s+de)?\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE),
|
||||
Regex("traces?\\s+possibles?\\s+de\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE),
|
||||
Regex("fabriqué\\s+dans\\s+un\\s+(?:atelier|environnement|établissement)\\s+(?:contenant|utilisant|qui\\s+utilise)[^.]{1,200}", RegexOption.IGNORE_CASE),
|
||||
Regex("may\\s+contain\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE),
|
||||
Regex("manufactured\\s+in\\s+a\\s+facility\\s+that\\s+(?:processes|also\\s+processes|handles)[^.]{1,200}", RegexOption.IGNORE_CASE),
|
||||
Regex("produced\\s+in\\s+a\\s+plant\\s+that\\s+also\\s+handles[^.]{1,200}", RegexOption.IGNORE_CASE)
|
||||
)
|
||||
private val MAY_CONTAIN_PATTERNS =
|
||||
listOf(
|
||||
Regex("peut contenir(?:\\s+des\\s+traces\\s+de)?\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE),
|
||||
Regex("traces?\\s+possibles?\\s+de\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE),
|
||||
Regex(
|
||||
"fabriqué\\s+dans\\s+un\\s+(?:atelier|environnement|établissement)\\s+(?:contenant|utilisant|qui\\s+utilise)[^.]{1,200}",
|
||||
RegexOption.IGNORE_CASE,
|
||||
),
|
||||
Regex("may\\s+contain\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE),
|
||||
Regex(
|
||||
"manufactured\\s+in\\s+a\\s+facility\\s+that\\s+(?:processes|also\\s+processes|handles)[^.]{1,200}",
|
||||
RegexOption.IGNORE_CASE,
|
||||
),
|
||||
Regex("produced\\s+in\\s+a\\s+plant\\s+that\\s+also\\s+handles[^.]{1,200}", RegexOption.IGNORE_CASE),
|
||||
)
|
||||
|
||||
/**
|
||||
* Analyze a product against the given profiles.
|
||||
@ -48,7 +52,7 @@ object AllergenAnalysisEngine {
|
||||
profiles: List<UserProfile>,
|
||||
source: DataSource,
|
||||
language: DetectionLanguage = DetectionLanguage.BOTH,
|
||||
healthStrictness: HealthStrictness = HealthStrictness.NORMAL
|
||||
healthStrictness: HealthStrictness = HealthStrictness.NORMAL,
|
||||
): ScanResult {
|
||||
if (profiles.isEmpty()) {
|
||||
return ScanResult(
|
||||
@ -59,7 +63,7 @@ object AllergenAnalysisEngine {
|
||||
health = HealthClassifier.classify(product, emptyList(), healthStrictness),
|
||||
analyzedProfiles = emptyList(),
|
||||
confidence = AnalysisConfidence.LOW,
|
||||
source = source
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
@ -73,26 +77,28 @@ object AllergenAnalysisEngine {
|
||||
for (allergen in watched) {
|
||||
val tagHits = matchTags(product.allergensTags, allergen.openFoodFactsTags)
|
||||
if (tagHits.isNotEmpty()) {
|
||||
detections[allergen] = DetectedAllergen(
|
||||
allergenType = allergen,
|
||||
detectionLevel = DetectionLevel.CONFIRMED,
|
||||
matchedKeywords = tagHits,
|
||||
source = "API allergens_tags",
|
||||
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
||||
severe = allergen in severeSet
|
||||
)
|
||||
detections[allergen] =
|
||||
DetectedAllergen(
|
||||
allergenType = allergen,
|
||||
detectionLevel = DetectionLevel.CONFIRMED,
|
||||
matchedKeywords = tagHits,
|
||||
source = "API allergens_tags",
|
||||
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
||||
severe = allergen in severeSet,
|
||||
)
|
||||
}
|
||||
val traceTagHits = matchTags(product.tracesTags, allergen.openFoodFactsTags)
|
||||
if (traceTagHits.isNotEmpty()) {
|
||||
detections.compute(allergen) { _, existing ->
|
||||
val hit = DetectedAllergen(
|
||||
allergenType = allergen,
|
||||
detectionLevel = DetectionLevel.TRACE,
|
||||
matchedKeywords = traceTagHits,
|
||||
source = "API traces_tags",
|
||||
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
||||
severe = allergen in severeSet
|
||||
)
|
||||
val hit =
|
||||
DetectedAllergen(
|
||||
allergenType = allergen,
|
||||
detectionLevel = DetectionLevel.TRACE,
|
||||
matchedKeywords = traceTagHits,
|
||||
source = "API traces_tags",
|
||||
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
||||
severe = allergen in severeSet,
|
||||
)
|
||||
merge(existing, hit)
|
||||
}
|
||||
}
|
||||
@ -110,32 +116,35 @@ object AllergenAnalysisEngine {
|
||||
|
||||
val ingMatches = findKeywordMatches(ingredientsOnly, keywords)
|
||||
if (ingMatches.isNotEmpty()) {
|
||||
val level = if (detections[allergen]?.detectionLevel == DetectionLevel.CONFIRMED) {
|
||||
DetectionLevel.CONFIRMED
|
||||
} else {
|
||||
DetectionLevel.SUSPECTED
|
||||
}
|
||||
val hit = DetectedAllergen(
|
||||
allergenType = allergen,
|
||||
detectionLevel = level,
|
||||
matchedKeywords = ingMatches,
|
||||
source = "Ingredients text",
|
||||
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
||||
severe = allergen in severeSet
|
||||
)
|
||||
val level =
|
||||
if (detections[allergen]?.detectionLevel == DetectionLevel.CONFIRMED) {
|
||||
DetectionLevel.CONFIRMED
|
||||
} else {
|
||||
DetectionLevel.SUSPECTED
|
||||
}
|
||||
val hit =
|
||||
DetectedAllergen(
|
||||
allergenType = allergen,
|
||||
detectionLevel = level,
|
||||
matchedKeywords = ingMatches,
|
||||
source = "Ingredients text",
|
||||
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
||||
severe = allergen in severeSet,
|
||||
)
|
||||
detections.compute(allergen) { _, existing -> merge(existing, hit) }
|
||||
}
|
||||
|
||||
val traceMatches = traceRegions.flatMap { region -> findKeywordMatches(region, keywords) }
|
||||
if (traceMatches.isNotEmpty()) {
|
||||
val hit = DetectedAllergen(
|
||||
allergenType = allergen,
|
||||
detectionLevel = DetectionLevel.TRACE,
|
||||
matchedKeywords = traceMatches.distinct(),
|
||||
source = "May-contain mention",
|
||||
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
||||
severe = allergen in severeSet
|
||||
)
|
||||
val hit =
|
||||
DetectedAllergen(
|
||||
allergenType = allergen,
|
||||
detectionLevel = DetectionLevel.TRACE,
|
||||
matchedKeywords = traceMatches.distinct(),
|
||||
source = "May-contain mention",
|
||||
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
||||
severe = allergen in severeSet,
|
||||
)
|
||||
detections.compute(allergen) { _, existing -> merge(existing, hit) }
|
||||
}
|
||||
}
|
||||
@ -144,41 +153,44 @@ object AllergenAnalysisEngine {
|
||||
val detected = detections.values.toList()
|
||||
|
||||
// Custom items: per profile, scan against ingredients text + OFF tags / labels / categories.
|
||||
val searchable = buildString {
|
||||
append(normalizedIngredients)
|
||||
append(' ')
|
||||
append(product.allergensTags.joinToString(" ") { normalize(it) })
|
||||
append(' ')
|
||||
append(product.tracesTags.joinToString(" ") { normalize(it) })
|
||||
append(' ')
|
||||
append(product.labels.joinToString(" ") { normalize(it) })
|
||||
append(' ')
|
||||
append(product.categories.joinToString(" ") { normalize(it) })
|
||||
append(' ')
|
||||
append(normalize(product.name.orEmpty()))
|
||||
}
|
||||
val searchable =
|
||||
buildString {
|
||||
append(normalizedIngredients)
|
||||
append(' ')
|
||||
append(product.allergensTags.joinToString(" ") { normalize(it) })
|
||||
append(' ')
|
||||
append(product.tracesTags.joinToString(" ") { normalize(it) })
|
||||
append(' ')
|
||||
append(product.labels.joinToString(" ") { normalize(it) })
|
||||
append(' ')
|
||||
append(product.categories.joinToString(" ") { normalize(it) })
|
||||
append(' ')
|
||||
append(normalize(product.name.orEmpty()))
|
||||
}
|
||||
val customDetections = detectCustomItems(searchable, profiles)
|
||||
|
||||
val status = computeStatus(detected, severeSet, customDetections)
|
||||
val confidence = computeConfidence(product, source, hasAnyData = detected.isNotEmpty() || normalizedIngredients.isNotBlank())
|
||||
|
||||
val unhealthyCustomNames = customDetections
|
||||
.filter { it.item.tag == CustomItemTag.UNHEALTHY }
|
||||
.map { it.item.name }
|
||||
val unhealthyCustomNames =
|
||||
customDetections
|
||||
.filter { it.item.tag == CustomItemTag.UNHEALTHY }
|
||||
.map { it.item.name }
|
||||
val health = HealthClassifier.classify(product, unhealthyCustomNames, healthStrictness)
|
||||
|
||||
return ScanResult(
|
||||
product = product,
|
||||
safetyStatus = status,
|
||||
detectedAllergens = detected.sortedWith(
|
||||
compareByDescending<DetectedAllergen> { it.detectionLevel.ordinal == DetectionLevel.CONFIRMED.ordinal }
|
||||
.thenByDescending { it.severe }
|
||||
),
|
||||
detectedAllergens =
|
||||
detected.sortedWith(
|
||||
compareByDescending<DetectedAllergen> { it.detectionLevel.ordinal == DetectionLevel.CONFIRMED.ordinal }
|
||||
.thenByDescending { it.severe },
|
||||
),
|
||||
detectedCustomItems = customDetections,
|
||||
health = health,
|
||||
analyzedProfiles = profiles,
|
||||
confidence = confidence,
|
||||
source = source
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
@ -186,7 +198,10 @@ object AllergenAnalysisEngine {
|
||||
* Match each profile's custom items against the pre-normalized [searchable] text
|
||||
* (ingredients + tags + labels + categories + product name).
|
||||
*/
|
||||
private fun detectCustomItems(searchable: String, profiles: List<UserProfile>): List<DetectedCustomItem> {
|
||||
private fun detectCustomItems(
|
||||
searchable: String,
|
||||
profiles: List<UserProfile>,
|
||||
): List<DetectedCustomItem> {
|
||||
if (searchable.isBlank()) return emptyList()
|
||||
// Group by (name, tag) so the same item across two profiles becomes a single detection
|
||||
// with the union of profile IDs.
|
||||
@ -207,8 +222,8 @@ object AllergenAnalysisEngine {
|
||||
DetectedCustomItem(
|
||||
item = first,
|
||||
matchedKeywords = hits,
|
||||
profileIds = pairs.map { it.first.id }.distinct()
|
||||
)
|
||||
profileIds = pairs.map { it.first.id }.distinct(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -220,9 +235,10 @@ object AllergenAnalysisEngine {
|
||||
/** Lower-case, strip accents, expand common ligatures, collapse punctuation. */
|
||||
fun normalize(raw: String): String {
|
||||
if (raw.isBlank()) return ""
|
||||
val lowered = raw.lowercase()
|
||||
.replace("œ", "oe")
|
||||
.replace("æ", "ae")
|
||||
val lowered =
|
||||
raw.lowercase()
|
||||
.replace("œ", "oe")
|
||||
.replace("æ", "ae")
|
||||
val decomposed = Normalizer.normalize(lowered, Normalizer.Form.NFD)
|
||||
val withoutAccents = decomposed.replace(Regex("\\p{Mn}+"), "")
|
||||
// Replace various apostrophe/quote styles with a space, normalize whitespace.
|
||||
@ -233,14 +249,20 @@ object AllergenAnalysisEngine {
|
||||
.trim()
|
||||
}
|
||||
|
||||
private fun keywordsFor(allergen: AllergenType, language: DetectionLanguage): List<String> =
|
||||
private fun keywordsFor(
|
||||
allergen: AllergenType,
|
||||
language: DetectionLanguage,
|
||||
): List<String> =
|
||||
when (language) {
|
||||
DetectionLanguage.FR -> allergen.keywordsFr
|
||||
DetectionLanguage.EN -> allergen.keywordsEn
|
||||
DetectionLanguage.BOTH -> allergen.keywordsFr + allergen.keywordsEn
|
||||
}.map { normalize(it) }.filter { it.isNotBlank() }.distinct()
|
||||
|
||||
private fun matchTags(productTags: List<String>, allergenTags: List<String>): List<String> {
|
||||
private fun matchTags(
|
||||
productTags: List<String>,
|
||||
allergenTags: List<String>,
|
||||
): List<String> {
|
||||
if (productTags.isEmpty()) return emptyList()
|
||||
val lowered = productTags.map { it.lowercase() }
|
||||
return allergenTags.filter { tag -> lowered.any { it == tag.lowercase() } }
|
||||
@ -250,7 +272,10 @@ object AllergenAnalysisEngine {
|
||||
* Word-boundary aware keyword matcher. Handles plurals by also matching the keyword
|
||||
* followed by an "s". Supports multi-word keywords.
|
||||
*/
|
||||
private fun findKeywordMatches(normalized: String, keywords: List<String>): List<String> {
|
||||
private fun findKeywordMatches(
|
||||
normalized: String,
|
||||
keywords: List<String>,
|
||||
): List<String> {
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
val hits = mutableListOf<String>()
|
||||
for (kw in keywords) {
|
||||
@ -273,7 +298,10 @@ object AllergenAnalysisEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private fun stripRegions(text: String, regions: List<String>): String {
|
||||
private fun stripRegions(
|
||||
text: String,
|
||||
regions: List<String>,
|
||||
): String {
|
||||
var result = text
|
||||
for (region in regions) {
|
||||
result = result.replace(region, " ")
|
||||
@ -283,7 +311,10 @@ object AllergenAnalysisEngine {
|
||||
|
||||
// endregion
|
||||
|
||||
private fun merge(existing: DetectedAllergen?, incoming: DetectedAllergen): DetectedAllergen {
|
||||
private fun merge(
|
||||
existing: DetectedAllergen?,
|
||||
incoming: DetectedAllergen,
|
||||
): DetectedAllergen {
|
||||
if (existing == null) return incoming
|
||||
// Keep the most severe detection level: CONFIRMED > TRACE > SUSPECTED.
|
||||
val priority: (DetectionLevel) -> Int = {
|
||||
@ -297,28 +328,34 @@ object AllergenAnalysisEngine {
|
||||
return best.copy(
|
||||
matchedKeywords = (existing.matchedKeywords + incoming.matchedKeywords).distinct(),
|
||||
source = if (best == existing) existing.source else incoming.source,
|
||||
profileIds = (existing.profileIds + incoming.profileIds).distinct()
|
||||
profileIds = (existing.profileIds + incoming.profileIds).distinct(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun computeStatus(
|
||||
detected: List<DetectedAllergen>,
|
||||
severeSet: Set<AllergenType>,
|
||||
customDetections: List<DetectedCustomItem>
|
||||
customDetections: List<DetectedCustomItem>,
|
||||
): SafetyStatus {
|
||||
val hasSevereConfirmed = detected.any {
|
||||
it.detectionLevel != DetectionLevel.TRACE && it.allergenType in severeSet
|
||||
}
|
||||
val hasSevereConfirmed =
|
||||
detected.any {
|
||||
it.detectionLevel != DetectionLevel.TRACE && it.allergenType in severeSet
|
||||
}
|
||||
val customHasAllergy = customDetections.any { it.item.tag == CustomItemTag.ALLERGY }
|
||||
if (hasSevereConfirmed || customHasAllergy) return SafetyStatus.DANGER
|
||||
val customTriggersWarning = customDetections.any {
|
||||
it.item.tag == CustomItemTag.INTOLERANCE || it.item.tag == CustomItemTag.DIET
|
||||
}
|
||||
val customTriggersWarning =
|
||||
customDetections.any {
|
||||
it.item.tag == CustomItemTag.INTOLERANCE || it.item.tag == CustomItemTag.DIET
|
||||
}
|
||||
if (detected.isEmpty() && !customTriggersWarning) return SafetyStatus.SAFE
|
||||
return SafetyStatus.WARNING
|
||||
}
|
||||
|
||||
private fun computeConfidence(product: Product, source: DataSource, hasAnyData: Boolean): AnalysisConfidence {
|
||||
private fun computeConfidence(
|
||||
product: Product,
|
||||
source: DataSource,
|
||||
hasAnyData: Boolean,
|
||||
): AnalysisConfidence {
|
||||
return when (source) {
|
||||
DataSource.OCR -> AnalysisConfidence.LOW
|
||||
DataSource.API, DataSource.CACHE -> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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<String> = emptyList(),
|
||||
): String {
|
||||
val text = (listOf(productName) + categories).joinToString(" ").lowercase()
|
||||
|
||||
/**
|
||||
* Détecte le rayon d'un produit basé sur son nom et ses catégories.
|
||||
*/
|
||||
fun detectCategory(productName: String, categories: List<String> = emptyList()): String {
|
||||
val text = (listOf(productName) + categories).joinToString(" ").lowercase()
|
||||
|
||||
return when {
|
||||
text.containsAny(freshKeywords) -> "Frais"
|
||||
text.containsAny(fruitKeywords) -> "Fruits & Légumes"
|
||||
text.containsAny(bakeryKeywords) -> "Boulangerie"
|
||||
text.containsAny(meatKeywords) -> "Boucherie"
|
||||
text.containsAny(dairyKeywords) -> "Produits laitiers"
|
||||
text.containsAny(groceryKeywords) -> "Épicerie"
|
||||
text.containsAny(beverageKeywords) -> "Boissons"
|
||||
text.containsAny(frozenKeywords) -> "Surgelés"
|
||||
text.containsAny(hygieneKeywords) -> "Hygiène"
|
||||
text.containsAny(babyKeywords) -> "Bébé"
|
||||
text.containsAny(petKeywords) -> "Animaux"
|
||||
text.containsAny(cleaningKeywords) -> "Entretien"
|
||||
else -> "Autre"
|
||||
return when {
|
||||
text.containsAny(freshKeywords) -> "Frais"
|
||||
text.containsAny(fruitKeywords) -> "Fruits & Légumes"
|
||||
text.containsAny(bakeryKeywords) -> "Boulangerie"
|
||||
text.containsAny(meatKeywords) -> "Boucherie"
|
||||
text.containsAny(dairyKeywords) -> "Produits laitiers"
|
||||
text.containsAny(groceryKeywords) -> "Épicerie"
|
||||
text.containsAny(beverageKeywords) -> "Boissons"
|
||||
text.containsAny(frozenKeywords) -> "Surgelés"
|
||||
text.containsAny(hygieneKeywords) -> "Hygiène"
|
||||
text.containsAny(babyKeywords) -> "Bébé"
|
||||
text.containsAny(petKeywords) -> "Animaux"
|
||||
text.containsAny(cleaningKeywords) -> "Entretien"
|
||||
else -> "Autre"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Catégorise une liste de produits et retourne un map catégorie -> produits.
|
||||
*/
|
||||
fun categorizeProducts(products: List<ProductInfo>): Map<String, List<ProductInfo>> {
|
||||
return products
|
||||
.groupBy { detectCategory(it.name, it.categories) }
|
||||
.toSortedMap(compareBy { it })
|
||||
}
|
||||
|
||||
data class ProductInfo(
|
||||
val name: String,
|
||||
val categories: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
private fun String.containsAny(keywords: List<String>): Boolean = keywords.any { this.contains(it) }
|
||||
|
||||
// ── Mots-clés par catégorie ─────────────────────────────────────────────
|
||||
|
||||
private val freshKeywords = listOf("frais", "fraise", "framboise", "myrtille", "salade", "tomate", "concombre", "crudite")
|
||||
|
||||
private val fruitKeywords =
|
||||
listOf("pomme", "poire", "banane", "orange", "citron", "raisin", "fraise", "fruit", "abricot", "peche", "cerise", "melon", "pasteque", "ananas", "mangue", "kiwi", "legume", "carotte", "poireau", "epinard", "brocoli", "chou", "courgette", "aubergine", "poivron", "avocat", "haricot", "artichaut", "asperge", "betterave", "myrtille", "bok choy", "chou de bruxelles", "courge", "chou frise", "poiree", "tomate cerise", "chataigne", "chicoree", "piment", "ciboulette", "coriandre", "clementine", "canneberge", "cresson", "groseille", "aneth", "pitaya", "echalote", "edamame", "fenouil", "feve", "graines de lin", "baies de goji", "groseille a maquereau", "goyave", "citrouille", "herbes", "mache", "citron vert", "litchi", "mandarine", "marjolaine", "nectarine", "oignon", "olive", "papaye", "panais", "fruit de la passion", "patate", "pourpier", "coing", "chou rouge", "rhubarbe", "sauge", "salade au chou", "chou de milan", "carambole", "tomates sechees", "basilic thai", "thym", "herbe de ble", "salsifis", "girolle", "orange sanguine", "baies", "acai", "grenade", "prune", "figue", "datte", "navet", "radis", "celeri", "menthe", "persil", "basilic")
|
||||
|
||||
private val bakeryKeywords =
|
||||
listOf("pain", "baguette", "biscotte", "croissant", "brioche", "boulang", "patisserie", "gateau", "tarte")
|
||||
|
||||
private val meatKeywords =
|
||||
listOf("poulet", "boeuf", "porc", "agneau", "dinde", "veau", "jambon", "saucisse", "steak", "viande", "merguez", "lard", "bacon")
|
||||
|
||||
private val dairyKeywords =
|
||||
listOf("lait", "yaourt", "fromage", "beurre", "creme", "oeuf", "laitier", "camembert", "emmental", "brie", "chevre", "mozzarella", "parmesan")
|
||||
|
||||
private val groceryKeywords =
|
||||
listOf("riz", "pates", "spaghetti", "semoule", "quinoa", "lentille", "pois chiche", "haricot sec", "farine", "sucre", "sel", "huile", "vinaigre", "moutarde", "ketchup", "sauce", "epice", "herbe", "conserv", "soupe", "biscuit", "chocolat", "confiture", "miel", "cereale", "muesli")
|
||||
|
||||
private val beverageKeywords =
|
||||
listOf("eau", "jus", "soda", "cola", "the", "cafe", "vin", "biere", "cidre", "champagne", "sirop", "nectar", "limonade", "boisson")
|
||||
|
||||
private val frozenKeywords = listOf("surgel", "congel", "glace", "sorbet", "pizza", "frite", "legume surgel", "fruit surgel")
|
||||
|
||||
private val hygieneKeywords =
|
||||
listOf("savon", "shampoing", "dentifrice", "gel douche", "deodorant", "papier toilette", "mouchoir", "lingette", "creme solaire", "maquillage")
|
||||
|
||||
private val babyKeywords = listOf("bebe", "couche", "biberon", "lait bebe", "compote bebe", "petit suisse")
|
||||
|
||||
private val petKeywords = listOf("chien", "chat", "croquette", "patee", "oiseau", "poisson", "animal")
|
||||
|
||||
private val cleaningKeywords =
|
||||
listOf("lessive", "adoucissant", "liquide vaisselle", "eponge", "chiffon", "javel", "nettoyant", "desinfectant", "aspirateur")
|
||||
}
|
||||
|
||||
/**
|
||||
* Catégorise une liste de produits et retourne un map catégorie -> produits.
|
||||
*/
|
||||
fun categorizeProducts(products: List<ProductInfo>): Map<String, List<ProductInfo>> {
|
||||
return products
|
||||
.groupBy { detectCategory(it.name, it.categories) }
|
||||
.toSortedMap(compareBy { it })
|
||||
}
|
||||
|
||||
data class ProductInfo(
|
||||
val name: String,
|
||||
val categories: List<String> = emptyList()
|
||||
)
|
||||
|
||||
private fun String.containsAny(keywords: List<String>): Boolean =
|
||||
keywords.any { this.contains(it) }
|
||||
|
||||
// ── Mots-clés par catégorie ─────────────────────────────────────────────
|
||||
|
||||
private val freshKeywords = listOf("frais", "fraise", "framboise", "myrtille", "salade", "tomate", "concombre", "crudite")
|
||||
|
||||
private val fruitKeywords = listOf("pomme", "poire", "banane", "orange", "citron", "raisin", "fraise", "fruit", "abricot", "peche", "cerise", "melon", "pasteque", "ananas", "mangue", "kiwi", "legume", "carotte", "poireau", "epinard", "brocoli", "chou", "courgette", "aubergine", "poivron", "avocat", "haricot", "artichaut", "asperge", "betterave", "myrtille", "bok choy", "chou de bruxelles", "courge", "chou frise", "poiree", "tomate cerise", "chataigne", "chicoree", "piment", "ciboulette", "coriandre", "clementine", "canneberge", "cresson", "groseille", "aneth", "pitaya", "echalote", "edamame", "fenouil", "feve", "graines de lin", "baies de goji", "groseille a maquereau", "goyave", "citrouille", "herbes", "mache", "citron vert", "litchi", "mandarine", "marjolaine", "nectarine", "oignon", "olive", "papaye", "panais", "fruit de la passion", "patate", "pourpier", "coing", "chou rouge", "rhubarbe", "sauge", "salade au chou", "chou de milan", "carambole", "tomates sechees", "basilic thai", "thym", "herbe de ble", "salsifis", "girolle", "orange sanguine", "baies", "acai", "grenade", "prune", "figue", "datte", "navet", "radis", "celeri", "menthe", "persil", "basilic")
|
||||
|
||||
private val bakeryKeywords = listOf("pain", "baguette", "biscotte", "croissant", "brioche", "boulang", "patisserie", "gateau", "tarte")
|
||||
|
||||
private val meatKeywords = listOf("poulet", "boeuf", "porc", "agneau", "dinde", "veau", "jambon", "saucisse", "steak", "viande", "merguez", "lard", "bacon")
|
||||
|
||||
private val dairyKeywords = listOf("lait", "yaourt", "fromage", "beurre", "creme", "oeuf", "laitier", "camembert", "emmental", "brie", "chevre", "mozzarella", "parmesan")
|
||||
|
||||
private val groceryKeywords = listOf("riz", "pates", "spaghetti", "semoule", "quinoa", "lentille", "pois chiche", "haricot sec", "farine", "sucre", "sel", "huile", "vinaigre", "moutarde", "ketchup", "sauce", "epice", "herbe", "conserv", "soupe", "biscuit", "chocolat", "confiture", "miel", "cereale", "muesli")
|
||||
|
||||
private val beverageKeywords = listOf("eau", "jus", "soda", "cola", "the", "cafe", "vin", "biere", "cidre", "champagne", "sirop", "nectar", "limonade", "boisson")
|
||||
|
||||
private val frozenKeywords = listOf("surgel", "congel", "glace", "sorbet", "pizza", "frite", "legume surgel", "fruit surgel")
|
||||
|
||||
private val hygieneKeywords = listOf("savon", "shampoing", "dentifrice", "gel douche", "deodorant", "papier toilette", "mouchoir", "lingette", "creme solaire", "maquillage")
|
||||
|
||||
private val babyKeywords = listOf("bebe", "couche", "biberon", "lait bebe", "compote bebe", "petit suisse")
|
||||
|
||||
private val petKeywords = listOf("chien", "chat", "croquette", "patee", "oiseau", "poisson", "animal")
|
||||
|
||||
private val cleaningKeywords = listOf("lessive", "adoucissant", "liquide vaisselle", "eponge", "chiffon", "javel", "nettoyant", "desinfectant", "aspirateur")
|
||||
}
|
||||
|
||||
@ -15,11 +15,10 @@ import com.safebite.app.domain.model.Product
|
||||
* - STRICT: only A + Nova ≤ 2 → HEALTHY; B → MODERATE; C/D/E or Nova ≥ 3 → UNHEALTHY.
|
||||
*/
|
||||
object HealthClassifier {
|
||||
|
||||
fun classify(
|
||||
product: Product,
|
||||
unhealthyCustomHits: List<String>,
|
||||
strictness: HealthStrictness
|
||||
strictness: HealthStrictness,
|
||||
): HealthAssessment {
|
||||
val nutri = product.nutriScore?.lowercase()?.takeIf { it in listOf("a", "b", "c", "d", "e") }
|
||||
val nova = product.novaGroup?.takeIf { it in 1..4 }
|
||||
@ -30,13 +29,15 @@ object HealthClassifier {
|
||||
|
||||
if (unhealthyCustomHits.isNotEmpty()) {
|
||||
reasons += "Contient: ${unhealthyCustomHits.joinToString()}"
|
||||
rating = when (strictness) {
|
||||
HealthStrictness.LENIENT -> when (rating) {
|
||||
HealthRating.HEALTHY, HealthRating.UNKNOWN -> HealthRating.MODERATE
|
||||
else -> rating
|
||||
rating =
|
||||
when (strictness) {
|
||||
HealthStrictness.LENIENT ->
|
||||
when (rating) {
|
||||
HealthRating.HEALTHY, HealthRating.UNKNOWN -> HealthRating.MODERATE
|
||||
else -> rating
|
||||
}
|
||||
HealthStrictness.NORMAL, HealthStrictness.STRICT -> HealthRating.UNHEALTHY
|
||||
}
|
||||
HealthStrictness.NORMAL, HealthStrictness.STRICT -> HealthRating.UNHEALTHY
|
||||
}
|
||||
}
|
||||
|
||||
// If we truly have nothing to judge on, keep UNKNOWN.
|
||||
@ -49,7 +50,7 @@ object HealthClassifier {
|
||||
reasons = reasons,
|
||||
nutriScore = nutri,
|
||||
novaGroup = nova,
|
||||
ecoScore = eco
|
||||
ecoScore = eco,
|
||||
)
|
||||
}
|
||||
|
||||
@ -57,7 +58,7 @@ object HealthClassifier {
|
||||
nutri: String?,
|
||||
nova: Int?,
|
||||
strictness: HealthStrictness,
|
||||
reasons: MutableList<String>
|
||||
reasons: MutableList<String>,
|
||||
): HealthRating {
|
||||
// Score nutri (a=0 best, e=4 worst)
|
||||
val nutriScoreValue = nutri?.let { it[0].code - 'a'.code }
|
||||
@ -67,35 +68,38 @@ object HealthClassifier {
|
||||
if (nova != null) reasons += "NOVA $nova"
|
||||
|
||||
return when (strictness) {
|
||||
HealthStrictness.LENIENT -> when {
|
||||
nutriScoreValue != null && nutriScoreValue >= 3 && novaValue == 4 -> HealthRating.UNHEALTHY
|
||||
nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.MODERATE
|
||||
novaValue == 4 -> HealthRating.MODERATE
|
||||
nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.HEALTHY
|
||||
nutriScoreValue != null -> HealthRating.MODERATE
|
||||
novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY
|
||||
else -> HealthRating.UNKNOWN
|
||||
}
|
||||
HealthStrictness.NORMAL -> when {
|
||||
nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.UNHEALTHY
|
||||
novaValue != null && novaValue >= 4 -> HealthRating.UNHEALTHY
|
||||
nutriScoreValue == 2 && (novaValue ?: 1) >= 3 -> HealthRating.UNHEALTHY
|
||||
nutriScoreValue == 2 -> HealthRating.MODERATE
|
||||
novaValue == 3 && nutriScoreValue == null -> HealthRating.MODERATE
|
||||
nutriScoreValue != null && nutriScoreValue <= 1 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY
|
||||
nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.MODERATE
|
||||
novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY
|
||||
else -> HealthRating.UNKNOWN
|
||||
}
|
||||
HealthStrictness.STRICT -> when {
|
||||
nutriScoreValue != null && nutriScoreValue >= 2 -> HealthRating.UNHEALTHY
|
||||
novaValue != null && novaValue >= 3 -> HealthRating.UNHEALTHY
|
||||
nutriScoreValue == 1 -> HealthRating.MODERATE
|
||||
nutriScoreValue == 0 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY
|
||||
nutriScoreValue == 0 -> HealthRating.MODERATE
|
||||
novaValue != null && novaValue <= 2 -> HealthRating.MODERATE
|
||||
else -> HealthRating.UNKNOWN
|
||||
}
|
||||
HealthStrictness.LENIENT ->
|
||||
when {
|
||||
nutriScoreValue != null && nutriScoreValue >= 3 && novaValue == 4 -> HealthRating.UNHEALTHY
|
||||
nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.MODERATE
|
||||
novaValue == 4 -> HealthRating.MODERATE
|
||||
nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.HEALTHY
|
||||
nutriScoreValue != null -> HealthRating.MODERATE
|
||||
novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY
|
||||
else -> HealthRating.UNKNOWN
|
||||
}
|
||||
HealthStrictness.NORMAL ->
|
||||
when {
|
||||
nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.UNHEALTHY
|
||||
novaValue != null && novaValue >= 4 -> HealthRating.UNHEALTHY
|
||||
nutriScoreValue == 2 && (novaValue ?: 1) >= 3 -> HealthRating.UNHEALTHY
|
||||
nutriScoreValue == 2 -> HealthRating.MODERATE
|
||||
novaValue == 3 && nutriScoreValue == null -> HealthRating.MODERATE
|
||||
nutriScoreValue != null && nutriScoreValue <= 1 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY
|
||||
nutriScoreValue != null && nutriScoreValue <= 1 -> HealthRating.MODERATE
|
||||
novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY
|
||||
else -> HealthRating.UNKNOWN
|
||||
}
|
||||
HealthStrictness.STRICT ->
|
||||
when {
|
||||
nutriScoreValue != null && nutriScoreValue >= 2 -> HealthRating.UNHEALTHY
|
||||
novaValue != null && novaValue >= 3 -> HealthRating.UNHEALTHY
|
||||
nutriScoreValue == 1 -> HealthRating.MODERATE
|
||||
nutriScoreValue == 0 && (novaValue ?: 1) <= 2 -> HealthRating.HEALTHY
|
||||
nutriScoreValue == 0 -> HealthRating.MODERATE
|
||||
novaValue != null && novaValue <= 2 -> HealthRating.MODERATE
|
||||
else -> HealthRating.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,192 +11,247 @@ enum class AllergenType(
|
||||
val icon: String,
|
||||
val openFoodFactsTags: List<String>,
|
||||
val keywordsFr: List<String>,
|
||||
val keywordsEn: List<String>
|
||||
val keywordsEn: List<String>,
|
||||
) {
|
||||
GLUTEN(
|
||||
"Gluten", "Gluten", "🌾",
|
||||
"Gluten",
|
||||
"Gluten",
|
||||
"🌾",
|
||||
listOf("en:gluten"),
|
||||
listOf(
|
||||
"gluten", "blé", "froment", "seigle", "orge", "avoine",
|
||||
"épeautre", "kamut", "triticale", "malt", "amidon de blé",
|
||||
"farine de blé", "farine d'orge", "farine de seigle",
|
||||
"protéine de blé", "seitan"
|
||||
"protéine de blé", "seitan",
|
||||
),
|
||||
listOf(
|
||||
"gluten", "wheat", "rye", "barley", "oats", "spelt",
|
||||
"kamut", "triticale", "malt", "wheat starch", "wheat flour"
|
||||
)
|
||||
"kamut", "triticale", "malt", "wheat starch", "wheat flour",
|
||||
),
|
||||
),
|
||||
PEANUTS(
|
||||
"Arachides", "Peanuts", "🥜",
|
||||
"Arachides",
|
||||
"Peanuts",
|
||||
"🥜",
|
||||
listOf("en:peanuts"),
|
||||
listOf(
|
||||
"arachide", "arachides", "cacahuète", "cacahuètes",
|
||||
"beurre d'arachide", "huile d'arachide"
|
||||
"arachide",
|
||||
"arachides",
|
||||
"cacahuète",
|
||||
"cacahuètes",
|
||||
"beurre d'arachide",
|
||||
"huile d'arachide",
|
||||
),
|
||||
listOf(
|
||||
"peanut", "peanuts", "peanut butter", "peanut oil",
|
||||
"groundnut", "groundnuts"
|
||||
)
|
||||
"peanut",
|
||||
"peanuts",
|
||||
"peanut butter",
|
||||
"peanut oil",
|
||||
"groundnut",
|
||||
"groundnuts",
|
||||
),
|
||||
),
|
||||
TREE_NUTS(
|
||||
"Noix", "Tree Nuts", "🌰",
|
||||
"Noix",
|
||||
"Tree Nuts",
|
||||
"🌰",
|
||||
listOf("en:nuts", "en:tree-nuts"),
|
||||
listOf(
|
||||
"noix", "amande", "amandes", "noisette", "noisettes",
|
||||
"cajou", "noix de cajou", "pistache", "pistaches",
|
||||
"noix de pécan", "pécan", "noix du brésil", "macadamia",
|
||||
"noix de macadamia", "pralin", "praliné", "massepain",
|
||||
"pâte d'amande", "poudre d'amande"
|
||||
"pâte d'amande", "poudre d'amande",
|
||||
),
|
||||
listOf(
|
||||
"nut", "nuts", "almond", "almonds", "hazelnut", "hazelnuts",
|
||||
"cashew", "cashews", "pistachio", "pecan", "pecans",
|
||||
"brazil nut", "macadamia", "walnut", "walnuts", "praline",
|
||||
"marzipan", "almond paste"
|
||||
)
|
||||
"marzipan", "almond paste",
|
||||
),
|
||||
),
|
||||
MILK(
|
||||
"Lait", "Milk", "🥛",
|
||||
"Lait",
|
||||
"Milk",
|
||||
"🥛",
|
||||
listOf("en:milk"),
|
||||
listOf(
|
||||
"lait", "lactose", "caséine", "caséinate", "lactosérum",
|
||||
"petit-lait", "beurre", "crème", "fromage", "yogourt",
|
||||
"babeurre", "ghee", "lactalbumine", "lactoglobuline",
|
||||
"protéine de lait", "poudre de lait", "lait écrémé",
|
||||
"lait entier", "concentré de protéines de lait"
|
||||
"lait entier", "concentré de protéines de lait",
|
||||
),
|
||||
listOf(
|
||||
"milk", "lactose", "casein", "caseinate", "whey",
|
||||
"butter", "cream", "cheese", "yogurt", "buttermilk",
|
||||
"ghee", "lactalbumin", "lactoglobulin", "milk protein",
|
||||
"milk powder", "skim milk", "whole milk"
|
||||
)
|
||||
"milk powder", "skim milk", "whole milk",
|
||||
),
|
||||
),
|
||||
EGGS(
|
||||
"Œufs", "Eggs", "🥚",
|
||||
"Œufs",
|
||||
"Eggs",
|
||||
"🥚",
|
||||
listOf("en:eggs"),
|
||||
listOf(
|
||||
"œuf", "oeuf", "œufs", "oeufs", "albumine", "ovomucine",
|
||||
"ovomucoïde", "ovalbumine", "lécithine d'œuf",
|
||||
"lysozyme", "jaune d'œuf", "blanc d'œuf", "poudre d'œuf",
|
||||
"œuf entier"
|
||||
"œuf entier",
|
||||
),
|
||||
listOf(
|
||||
"egg", "eggs", "albumin", "ovomucin", "ovomucoid",
|
||||
"ovalbumin", "egg lecithin", "lysozyme", "egg yolk",
|
||||
"egg white", "egg powder", "whole egg"
|
||||
)
|
||||
"egg white", "egg powder", "whole egg",
|
||||
),
|
||||
),
|
||||
SOY(
|
||||
"Soja", "Soy", "🫘",
|
||||
"Soja",
|
||||
"Soy",
|
||||
"🫘",
|
||||
listOf("en:soybeans"),
|
||||
listOf(
|
||||
"soja", "soya", "lécithine de soja", "protéine de soja",
|
||||
"tofu", "tempeh", "edamame", "fève de soja",
|
||||
"huile de soja", "sauce soja", "miso"
|
||||
"huile de soja", "sauce soja", "miso",
|
||||
),
|
||||
listOf(
|
||||
"soy", "soya", "soybean", "soybeans", "soy lecithin",
|
||||
"soy protein", "tofu", "tempeh", "edamame",
|
||||
"soybean oil", "soy sauce", "miso"
|
||||
)
|
||||
"soybean oil", "soy sauce", "miso",
|
||||
),
|
||||
),
|
||||
FISH(
|
||||
"Poisson", "Fish", "🐟",
|
||||
"Poisson",
|
||||
"Fish",
|
||||
"🐟",
|
||||
listOf("en:fish"),
|
||||
listOf(
|
||||
"poisson", "anchois", "bar", "cabillaud", "colin",
|
||||
"dorade", "flétan", "hareng", "maquereau", "merlu",
|
||||
"morue", "perche", "sardine", "saumon", "sole",
|
||||
"thon", "truite", "huile de poisson", "sauce de poisson",
|
||||
"surimi", "gélatine de poisson"
|
||||
"surimi", "gélatine de poisson",
|
||||
),
|
||||
listOf(
|
||||
"fish", "anchovy", "anchovies", "bass", "cod", "haddock",
|
||||
"halibut", "herring", "mackerel", "perch", "salmon",
|
||||
"sardine", "sole", "trout", "tuna", "fish oil",
|
||||
"fish sauce", "surimi", "fish gelatin"
|
||||
)
|
||||
"fish sauce", "surimi", "fish gelatin",
|
||||
),
|
||||
),
|
||||
CRUSTACEANS(
|
||||
"Crustacés", "Crustaceans", "🦐",
|
||||
"Crustacés",
|
||||
"Crustaceans",
|
||||
"🦐",
|
||||
listOf("en:crustaceans"),
|
||||
listOf(
|
||||
"crustacé", "crustacés", "crevette", "crevettes",
|
||||
"homard", "crabe", "langouste", "langoustine",
|
||||
"écrevisse", "fruits de mer"
|
||||
"écrevisse", "fruits de mer",
|
||||
),
|
||||
listOf(
|
||||
"crustacean", "crustaceans", "shrimp", "lobster",
|
||||
"crab", "crayfish", "prawn", "langoustine", "seafood"
|
||||
)
|
||||
"crab", "crayfish", "prawn", "langoustine", "seafood",
|
||||
),
|
||||
),
|
||||
SESAME(
|
||||
"Sésame", "Sesame", "⚪",
|
||||
"Sésame",
|
||||
"Sesame",
|
||||
"⚪",
|
||||
listOf("en:sesame-seeds"),
|
||||
listOf(
|
||||
"sésame", "graines de sésame", "huile de sésame",
|
||||
"tahini", "tahina", "halva"
|
||||
"sésame",
|
||||
"graines de sésame",
|
||||
"huile de sésame",
|
||||
"tahini",
|
||||
"tahina",
|
||||
"halva",
|
||||
),
|
||||
listOf(
|
||||
"sesame", "sesame seeds", "sesame oil", "tahini",
|
||||
"tahina", "halva"
|
||||
)
|
||||
"sesame",
|
||||
"sesame seeds",
|
||||
"sesame oil",
|
||||
"tahini",
|
||||
"tahina",
|
||||
"halva",
|
||||
),
|
||||
),
|
||||
MUSTARD(
|
||||
"Moutarde", "Mustard", "🟡",
|
||||
"Moutarde",
|
||||
"Mustard",
|
||||
"🟡",
|
||||
listOf("en:mustard"),
|
||||
listOf(
|
||||
"moutarde", "graines de moutarde", "huile de moutarde",
|
||||
"farine de moutarde"
|
||||
"moutarde",
|
||||
"graines de moutarde",
|
||||
"huile de moutarde",
|
||||
"farine de moutarde",
|
||||
),
|
||||
listOf("mustard", "mustard seeds", "mustard oil", "mustard flour")
|
||||
listOf("mustard", "mustard seeds", "mustard oil", "mustard flour"),
|
||||
),
|
||||
SULPHITES(
|
||||
"Sulfites", "Sulphites", "🟣",
|
||||
"Sulfites",
|
||||
"Sulphites",
|
||||
"🟣",
|
||||
listOf("en:sulphur-dioxide-and-sulphites"),
|
||||
listOf(
|
||||
"sulfite", "sulfites", "dioxyde de soufre", "bisulfite",
|
||||
"métabisulfite", "anhydride sulfureux",
|
||||
"e220", "e221", "e222", "e223", "e224", "e225", "e226", "e228"
|
||||
"e220", "e221", "e222", "e223", "e224", "e225", "e226", "e228",
|
||||
),
|
||||
listOf(
|
||||
"sulphite", "sulphites", "sulfite", "sulfites",
|
||||
"sulphur dioxide", "bisulphite", "metabisulphite"
|
||||
)
|
||||
"sulphite",
|
||||
"sulphites",
|
||||
"sulfite",
|
||||
"sulfites",
|
||||
"sulphur dioxide",
|
||||
"bisulphite",
|
||||
"metabisulphite",
|
||||
),
|
||||
),
|
||||
LUPIN(
|
||||
"Lupin", "Lupin", "💐",
|
||||
"Lupin",
|
||||
"Lupin",
|
||||
"💐",
|
||||
listOf("en:lupin"),
|
||||
listOf("lupin", "lupins", "farine de lupin"),
|
||||
listOf("lupin", "lupine", "lupin flour")
|
||||
listOf("lupin", "lupine", "lupin flour"),
|
||||
),
|
||||
MOLLUSCS(
|
||||
"Mollusques", "Molluscs", "🐚",
|
||||
"Mollusques",
|
||||
"Molluscs",
|
||||
"🐚",
|
||||
listOf("en:molluscs"),
|
||||
listOf(
|
||||
"mollusque", "mollusques", "huître", "moule", "moules",
|
||||
"palourde", "pétoncle", "calmar", "calamar", "pieuvre",
|
||||
"poulpe", "escargot", "coquille saint-jacques"
|
||||
"poulpe", "escargot", "coquille saint-jacques",
|
||||
),
|
||||
listOf(
|
||||
"mollusc", "molluscs", "mollusk", "oyster", "mussel",
|
||||
"clam", "scallop", "squid", "octopus", "snail"
|
||||
)
|
||||
"clam", "scallop", "squid", "octopus", "snail",
|
||||
),
|
||||
),
|
||||
CELERY(
|
||||
"Céleri", "Celery", "🥬",
|
||||
"Céleri",
|
||||
"Celery",
|
||||
"🥬",
|
||||
listOf("en:celery"),
|
||||
listOf(
|
||||
"céleri", "celeri", "sel de céleri", "graines de céleri",
|
||||
"celeriac", "céleri-rave"
|
||||
"céleri",
|
||||
"celeri",
|
||||
"sel de céleri",
|
||||
"graines de céleri",
|
||||
"celeriac",
|
||||
"céleri-rave",
|
||||
),
|
||||
listOf("celery", "celeriac", "celery salt", "celery seed")
|
||||
);
|
||||
listOf("celery", "celeriac", "celery salt", "celery seed"),
|
||||
),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromName(name: String): AllergenType? =
|
||||
values().firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||
fun fromName(name: String): AllergenType? = values().firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ enum class DietaryRestriction(val displayFr: String, val displayEn: String) {
|
||||
VEGETARIAN("Végétarien", "Vegetarian"),
|
||||
HALAL("Halal", "Halal"),
|
||||
KOSHER("Casher", "Kosher"),
|
||||
NO_PORK("Sans porc", "No pork")
|
||||
NO_PORK("Sans porc", "No pork"),
|
||||
}
|
||||
|
||||
enum class DetectionLanguage { FR, EN, BOTH }
|
||||
@ -31,7 +31,7 @@ enum class CustomItemTag(val displayFr: String, val displayEn: String) {
|
||||
ALLERGY("Allergie", "Allergy"),
|
||||
INTOLERANCE("Intolérance", "Intolerance"),
|
||||
DIET("Diète", "Diet"),
|
||||
UNHEALTHY("Non-santé", "Unhealthy")
|
||||
UNHEALTHY("Non-santé", "Unhealthy"),
|
||||
}
|
||||
|
||||
/** A user-defined ingredient/substance to watch for (e.g. "huile de palme"). */
|
||||
@ -39,10 +39,9 @@ data class CustomDietItem(
|
||||
val name: String,
|
||||
val tag: CustomItemTag,
|
||||
/** Optional additional keywords; if empty, [name] is used. */
|
||||
val keywords: List<String> = emptyList()
|
||||
val keywords: List<String> = emptyList(),
|
||||
) {
|
||||
fun allKeywords(): List<String> =
|
||||
(listOf(name) + keywords).filter { it.isNotBlank() }.distinct()
|
||||
fun allKeywords(): List<String> = (listOf(name) + keywords).filter { it.isNotBlank() }.distinct()
|
||||
}
|
||||
|
||||
/** A user's allergy profile. */
|
||||
@ -54,7 +53,7 @@ data class UserProfile(
|
||||
val moderateIntolerances: Set<AllergenType> = emptySet(),
|
||||
val dietaryRestrictions: Set<DietaryRestriction> = emptySet(),
|
||||
val customItems: List<CustomDietItem> = emptyList(),
|
||||
val isDefault: Boolean = false
|
||||
val isDefault: Boolean = false,
|
||||
) {
|
||||
/** Returns every allergen (severe + moderate) referenced by this profile. */
|
||||
fun allAllergens(): Set<AllergenType> = severeAllergens + moderateIntolerances
|
||||
@ -71,12 +70,13 @@ data class Nutriments(
|
||||
val sodium100g: Double? = null,
|
||||
val fiber100g: Double? = null,
|
||||
val proteins100g: Double? = null,
|
||||
val carbohydrates100g: Double? = null
|
||||
val carbohydrates100g: Double? = null,
|
||||
) {
|
||||
fun isEmpty(): Boolean = listOf(
|
||||
energyKcal100g, energyKcalServing, fat100g, saturatedFat100g,
|
||||
sugars100g, salt100g, sodium100g, fiber100g, proteins100g, carbohydrates100g
|
||||
).all { it == null }
|
||||
fun isEmpty(): Boolean =
|
||||
listOf(
|
||||
energyKcal100g, energyKcalServing, fat100g, saturatedFat100g,
|
||||
sugars100g, salt100g, sodium100g, fiber100g, proteins100g, carbohydrates100g,
|
||||
).all { it == null }
|
||||
}
|
||||
|
||||
/** A product fetched from Open Food Facts (or reconstructed from OCR). */
|
||||
@ -94,7 +94,7 @@ data class Product(
|
||||
val servingSize: String? = null,
|
||||
val nutriments: Nutriments = Nutriments(),
|
||||
val labels: List<String> = emptyList(),
|
||||
val categories: List<String> = emptyList()
|
||||
val categories: List<String> = emptyList(),
|
||||
) {
|
||||
/** Public Open Food Facts product page URL. */
|
||||
fun openFoodFactsUrl(): String = "https://world.openfoodfacts.org/product/$barcode"
|
||||
@ -104,7 +104,7 @@ data class Product(
|
||||
data class DetectedCustomItem(
|
||||
val item: CustomDietItem,
|
||||
val matchedKeywords: List<String>,
|
||||
val profileIds: List<Long> = emptyList()
|
||||
val profileIds: List<Long> = emptyList(),
|
||||
)
|
||||
|
||||
/** High-level health verdict computed from Nutri-Score, Nova, Eco-Score + custom rules. */
|
||||
@ -113,7 +113,7 @@ data class HealthAssessment(
|
||||
val reasons: List<String> = emptyList(),
|
||||
val nutriScore: String? = null,
|
||||
val novaGroup: Int? = null,
|
||||
val ecoScore: String? = null
|
||||
val ecoScore: String? = null,
|
||||
)
|
||||
|
||||
/** Describes a single allergen that was detected during analysis. */
|
||||
@ -125,7 +125,7 @@ data class DetectedAllergen(
|
||||
/** Which profiles this detection concerns (useful for multi-profile scans). */
|
||||
val profileIds: List<Long> = emptyList(),
|
||||
/** True when at least one profile lists this as a *severe* allergy. */
|
||||
val severe: Boolean = true
|
||||
val severe: Boolean = true,
|
||||
)
|
||||
|
||||
data class ScanResult(
|
||||
@ -136,7 +136,7 @@ data class ScanResult(
|
||||
val health: HealthAssessment = HealthAssessment(),
|
||||
val analyzedProfiles: List<UserProfile>,
|
||||
val confidence: AnalysisConfidence,
|
||||
val source: DataSource
|
||||
val source: DataSource,
|
||||
)
|
||||
|
||||
data class ScanHistoryItem(
|
||||
@ -148,5 +148,5 @@ data class ScanHistoryItem(
|
||||
val safetyStatus: SafetyStatus,
|
||||
val profileNames: List<String>,
|
||||
val scannedAt: Long,
|
||||
val source: DataSource
|
||||
val source: DataSource,
|
||||
)
|
||||
|
||||
@ -15,38 +15,54 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
sealed class ProductFetchResult {
|
||||
data class Found(val product: Product, val fromCache: Boolean) : ProductFetchResult()
|
||||
|
||||
data object NotFound : ProductFetchResult()
|
||||
|
||||
data class Error(val message: String, val offline: Boolean = false) : ProductFetchResult()
|
||||
}
|
||||
|
||||
interface ProductRepository {
|
||||
suspend fun fetchProduct(barcode: String): ProductFetchResult
|
||||
|
||||
suspend fun cacheProduct(product: Product)
|
||||
|
||||
suspend fun getCachedProduct(barcode: String): Product?
|
||||
|
||||
suspend fun clearCache()
|
||||
|
||||
/** Search for products in the same category without specific allergens. */
|
||||
suspend fun searchAlternatives(
|
||||
category: String,
|
||||
excludeAllergens: Set<String>,
|
||||
limit: Int = 5
|
||||
limit: Int = 5,
|
||||
): List<Product>
|
||||
}
|
||||
|
||||
interface UserProfileRepository {
|
||||
fun observeProfiles(): Flow<List<UserProfile>>
|
||||
|
||||
suspend fun getProfile(id: Long): UserProfile?
|
||||
|
||||
suspend fun upsert(profile: UserProfile): Long
|
||||
|
||||
suspend fun delete(profile: UserProfile)
|
||||
|
||||
suspend fun setDefault(id: Long)
|
||||
|
||||
fun observeActiveProfileIds(): Flow<Set<Long>>
|
||||
|
||||
suspend fun setActiveProfileIds(ids: Set<Long>)
|
||||
}
|
||||
|
||||
interface ScanHistoryRepository {
|
||||
fun observeHistory(): Flow<List<ScanHistoryItem>>
|
||||
|
||||
suspend fun save(result: ScanResult): Long
|
||||
|
||||
suspend fun delete(id: Long)
|
||||
|
||||
suspend fun clear()
|
||||
|
||||
suspend fun getById(id: Long): ScanHistoryItem?
|
||||
}
|
||||
|
||||
@ -61,12 +77,19 @@ interface SettingsRepository {
|
||||
val splashScreenEnabled: Flow<Boolean>
|
||||
|
||||
suspend fun setAppLanguage(value: AppLanguage)
|
||||
|
||||
suspend fun setDetectionLanguage(value: DetectionLanguage)
|
||||
|
||||
suspend fun setHaptics(enabled: Boolean)
|
||||
|
||||
suspend fun setSound(enabled: Boolean)
|
||||
|
||||
suspend fun setTheme(value: ThemePref)
|
||||
|
||||
suspend fun setOnboardingCompleted(value: Boolean)
|
||||
|
||||
suspend fun setHealthStrictness(value: HealthStrictness)
|
||||
|
||||
suspend fun setSplashScreenEnabled(enabled: Boolean)
|
||||
}
|
||||
|
||||
@ -77,34 +100,61 @@ interface SettingsRepository {
|
||||
interface ShoppingListRepository {
|
||||
// Lists
|
||||
fun observeActiveLists(): Flow<List<ShoppingListEntity>>
|
||||
|
||||
fun observeAllLists(): Flow<List<ShoppingListEntity>>
|
||||
|
||||
suspend fun getListById(id: Long): ShoppingListEntity?
|
||||
suspend fun createList(name: String, backgroundResName: String? = null): Long
|
||||
|
||||
suspend fun createList(
|
||||
name: String,
|
||||
backgroundResName: String? = null,
|
||||
): Long
|
||||
|
||||
suspend fun updateList(list: ShoppingListEntity)
|
||||
|
||||
suspend fun deleteList(list: ShoppingListEntity)
|
||||
|
||||
suspend fun archiveList(id: Long)
|
||||
|
||||
// Items
|
||||
fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>>
|
||||
|
||||
suspend fun getItems(listId: Long): List<ShoppingListItemEntity>
|
||||
|
||||
suspend fun addItem(item: ShoppingListItemEntity): Long
|
||||
|
||||
suspend fun updateItem(item: ShoppingListItemEntity)
|
||||
|
||||
suspend fun deleteItem(item: ShoppingListItemEntity)
|
||||
suspend fun setItemChecked(id: Long, checked: Boolean)
|
||||
|
||||
suspend fun setItemChecked(
|
||||
id: Long,
|
||||
checked: Boolean,
|
||||
)
|
||||
|
||||
suspend fun uncheckAllItems(listId: Long)
|
||||
|
||||
suspend fun deleteAllItems(listId: Long)
|
||||
|
||||
// Stats
|
||||
fun observeItemCount(listId: Long): Flow<Int>
|
||||
|
||||
fun observeCheckedCount(listId: Long): Flow<Int>
|
||||
|
||||
// Members
|
||||
fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>>
|
||||
|
||||
suspend fun addMember(member: ShoppingListMemberEntity): Long
|
||||
|
||||
suspend fun updateMember(member: ShoppingListMemberEntity)
|
||||
|
||||
suspend fun removeMember(member: ShoppingListMemberEntity)
|
||||
|
||||
suspend fun deleteAllMembers(listId: Long)
|
||||
|
||||
// Helpers
|
||||
suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity)
|
||||
suspend fun addItemToList(
|
||||
listId: Long,
|
||||
item: ShoppingListItemEntity,
|
||||
)
|
||||
}
|
||||
|
||||
@ -8,15 +8,17 @@ import javax.inject.Inject
|
||||
* UseCase pour trouver des produits alternatifs dans la même catégorie
|
||||
* sans les allergènes problématiques.
|
||||
*/
|
||||
class GetAlternativesUseCase @Inject constructor(
|
||||
private val productRepository: ProductRepository
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
category: String,
|
||||
excludeAllergenTags: Set<String>,
|
||||
limit: Int = 5
|
||||
): List<Product> {
|
||||
if (category.isBlank()) return emptyList()
|
||||
return productRepository.searchAlternatives(category, excludeAllergenTags, limit)
|
||||
class GetAlternativesUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val productRepository: ProductRepository,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
category: String,
|
||||
excludeAllergenTags: Set<String>,
|
||||
limit: Int = 5,
|
||||
): List<Product> {
|
||||
if (category.isBlank()) return emptyList()
|
||||
return productRepository.searchAlternatives(category, excludeAllergenTags, limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ package com.safebite.app.domain.usecase
|
||||
|
||||
import com.safebite.app.domain.engine.AllergenAnalysisEngine
|
||||
import com.safebite.app.domain.model.DataSource
|
||||
import com.safebite.app.domain.model.DetectionLanguage
|
||||
import com.safebite.app.domain.model.Product
|
||||
import com.safebite.app.domain.model.ScanResult
|
||||
import com.safebite.app.domain.model.UserProfile
|
||||
@ -16,108 +15,163 @@ import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Fetch a product by barcode (remote or cache). */
|
||||
class FetchProductUseCase @Inject constructor(
|
||||
private val productRepository: ProductRepository
|
||||
) {
|
||||
suspend operator fun invoke(barcode: String): ProductFetchResult =
|
||||
productRepository.fetchProduct(barcode)
|
||||
}
|
||||
class FetchProductUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val productRepository: ProductRepository,
|
||||
) {
|
||||
suspend operator fun invoke(barcode: String): ProductFetchResult = productRepository.fetchProduct(barcode)
|
||||
}
|
||||
|
||||
/** Analyze a product against a list of profiles using the engine. */
|
||||
class AnalyzeProductUseCase @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
product: Product,
|
||||
profiles: List<UserProfile>,
|
||||
source: DataSource
|
||||
): ScanResult {
|
||||
val lang = settingsRepository.detectionLanguage.first()
|
||||
val strictness = settingsRepository.healthStrictness.first()
|
||||
return AllergenAnalysisEngine.analyze(product, profiles, source, lang, strictness)
|
||||
class AnalyzeProductUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
product: Product,
|
||||
profiles: List<UserProfile>,
|
||||
source: DataSource,
|
||||
): ScanResult {
|
||||
val lang = settingsRepository.detectionLanguage.first()
|
||||
val strictness = settingsRepository.healthStrictness.first()
|
||||
return AllergenAnalysisEngine.analyze(product, profiles, source, lang, strictness)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Analyze free-form ingredients text (OCR path). */
|
||||
class AnalyzeIngredientsTextUseCase @Inject constructor(
|
||||
private val analyzeProductUseCase: AnalyzeProductUseCase
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
text: String,
|
||||
profiles: List<UserProfile>,
|
||||
barcode: String? = null,
|
||||
productName: String? = null
|
||||
): ScanResult {
|
||||
val product = Product(
|
||||
barcode = barcode ?: "ocr-${System.currentTimeMillis()}",
|
||||
name = productName,
|
||||
brand = null,
|
||||
imageUrl = null,
|
||||
ingredientsText = text,
|
||||
allergensTags = emptyList(),
|
||||
tracesTags = emptyList()
|
||||
)
|
||||
return analyzeProductUseCase(product, profiles, DataSource.OCR)
|
||||
class AnalyzeIngredientsTextUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val analyzeProductUseCase: AnalyzeProductUseCase,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
text: String,
|
||||
profiles: List<UserProfile>,
|
||||
barcode: String? = null,
|
||||
productName: String? = null,
|
||||
): ScanResult {
|
||||
val product =
|
||||
Product(
|
||||
barcode = barcode ?: "ocr-${System.currentTimeMillis()}",
|
||||
name = productName,
|
||||
brand = null,
|
||||
imageUrl = null,
|
||||
ingredientsText = text,
|
||||
allergensTags = emptyList(),
|
||||
tracesTags = emptyList(),
|
||||
)
|
||||
return analyzeProductUseCase(product, profiles, DataSource.OCR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ManageProfileUseCase @Inject constructor(
|
||||
private val repo: UserProfileRepository
|
||||
) {
|
||||
fun observe(): Flow<List<UserProfile>> = repo.observeProfiles()
|
||||
suspend fun get(id: Long) = repo.getProfile(id)
|
||||
suspend fun save(profile: UserProfile): Long = repo.upsert(profile)
|
||||
suspend fun delete(profile: UserProfile) = repo.delete(profile)
|
||||
suspend fun setDefault(id: Long) = repo.setDefault(id)
|
||||
fun observeActiveIds() = repo.observeActiveProfileIds()
|
||||
suspend fun setActive(ids: Set<Long>) = repo.setActiveProfileIds(ids)
|
||||
}
|
||||
class ManageProfileUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val repo: UserProfileRepository,
|
||||
) {
|
||||
fun observe(): Flow<List<UserProfile>> = repo.observeProfiles()
|
||||
|
||||
class GetScanHistoryUseCase @Inject constructor(
|
||||
private val repo: ScanHistoryRepository
|
||||
) {
|
||||
fun observe(): Flow<List<com.safebite.app.domain.model.ScanHistoryItem>> = repo.observeHistory()
|
||||
suspend fun delete(id: Long) = repo.delete(id)
|
||||
suspend fun clear() = repo.clear()
|
||||
suspend fun get(id: Long) = repo.getById(id)
|
||||
}
|
||||
suspend fun get(id: Long) = repo.getProfile(id)
|
||||
|
||||
class SaveScanUseCase @Inject constructor(
|
||||
private val repo: ScanHistoryRepository
|
||||
) {
|
||||
suspend operator fun invoke(result: ScanResult): Long = repo.save(result)
|
||||
}
|
||||
suspend fun save(profile: UserProfile): Long = repo.upsert(profile)
|
||||
|
||||
suspend fun delete(profile: UserProfile) = repo.delete(profile)
|
||||
|
||||
suspend fun setDefault(id: Long) = repo.setDefault(id)
|
||||
|
||||
fun observeActiveIds() = repo.observeActiveProfileIds()
|
||||
|
||||
suspend fun setActive(ids: Set<Long>) = repo.setActiveProfileIds(ids)
|
||||
}
|
||||
|
||||
class GetScanHistoryUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val repo: ScanHistoryRepository,
|
||||
) {
|
||||
fun observe(): Flow<List<com.safebite.app.domain.model.ScanHistoryItem>> = repo.observeHistory()
|
||||
|
||||
suspend fun delete(id: Long) = repo.delete(id)
|
||||
|
||||
suspend fun clear() = repo.clear()
|
||||
|
||||
suspend fun get(id: Long) = repo.getById(id)
|
||||
}
|
||||
|
||||
class SaveScanUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val repo: ScanHistoryRepository,
|
||||
) {
|
||||
suspend operator fun invoke(result: ScanResult): Long = repo.save(result)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Shopping List UseCases (Phase 2)
|
||||
// =============================================================================
|
||||
|
||||
class GetShoppingListsUseCase @Inject constructor(
|
||||
private val repo: com.safebite.app.domain.repository.ShoppingListRepository
|
||||
) {
|
||||
fun observeActive() = repo.observeActiveLists()
|
||||
fun observeAll() = repo.observeAllLists()
|
||||
suspend fun getList(id: Long) = repo.getListById(id)
|
||||
suspend fun createList(name: String, backgroundResName: String? = null) = repo.createList(name, backgroundResName)
|
||||
suspend fun updateList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.updateList(list)
|
||||
suspend fun deleteList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.deleteList(list)
|
||||
fun observeItemCount(listId: Long) = repo.observeItemCount(listId)
|
||||
fun observeCheckedCount(listId: Long) = repo.observeCheckedCount(listId)
|
||||
fun observeMembers(listId: Long) = repo.observeMembers(listId)
|
||||
suspend fun addMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.addMember(member)
|
||||
suspend fun removeMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.removeMember(member)
|
||||
}
|
||||
class GetShoppingListsUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val repo: com.safebite.app.domain.repository.ShoppingListRepository,
|
||||
) {
|
||||
fun observeActive() = repo.observeActiveLists()
|
||||
|
||||
class ManageShoppingListUseCase @Inject constructor(
|
||||
private val repo: com.safebite.app.domain.repository.ShoppingListRepository
|
||||
) {
|
||||
fun observeItems(listId: Long) = repo.observeItems(listId)
|
||||
suspend fun getItems(listId: Long) = repo.getItems(listId)
|
||||
suspend fun addItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.addItem(item)
|
||||
suspend fun updateItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.updateItem(item)
|
||||
suspend fun deleteItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.deleteItem(item)
|
||||
suspend fun setItemChecked(id: Long, checked: Boolean) = repo.setItemChecked(id, checked)
|
||||
suspend fun uncheckAllItems(listId: Long) = repo.uncheckAllItems(listId)
|
||||
suspend fun deleteAllItems(listId: Long) = repo.deleteAllItems(listId)
|
||||
suspend fun addItemToList(listId: Long, item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.addItemToList(listId, item)
|
||||
}
|
||||
fun observeAll() = repo.observeAllLists()
|
||||
|
||||
suspend fun getList(id: Long) = repo.getListById(id)
|
||||
|
||||
suspend fun createList(
|
||||
name: String,
|
||||
backgroundResName: String? = null,
|
||||
) = repo.createList(name, backgroundResName)
|
||||
|
||||
suspend fun updateList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.updateList(list)
|
||||
|
||||
suspend fun deleteList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.deleteList(list)
|
||||
|
||||
fun observeItemCount(listId: Long) = repo.observeItemCount(listId)
|
||||
|
||||
fun observeCheckedCount(listId: Long) = repo.observeCheckedCount(listId)
|
||||
|
||||
fun observeMembers(listId: Long) = repo.observeMembers(listId)
|
||||
|
||||
suspend fun addMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.addMember(member)
|
||||
|
||||
suspend fun removeMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.removeMember(member)
|
||||
}
|
||||
|
||||
class ManageShoppingListUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val repo: com.safebite.app.domain.repository.ShoppingListRepository,
|
||||
) {
|
||||
fun observeItems(listId: Long) = repo.observeItems(listId)
|
||||
|
||||
suspend fun getItems(listId: Long) = repo.getItems(listId)
|
||||
|
||||
suspend fun addItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.addItem(item)
|
||||
|
||||
suspend fun updateItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.updateItem(item)
|
||||
|
||||
suspend fun deleteItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.deleteItem(item)
|
||||
|
||||
suspend fun setItemChecked(
|
||||
id: Long,
|
||||
checked: Boolean,
|
||||
) = repo.setItemChecked(id, checked)
|
||||
|
||||
suspend fun uncheckAllItems(listId: Long) = repo.uncheckAllItems(listId)
|
||||
|
||||
suspend fun deleteAllItems(listId: Long) = repo.deleteAllItems(listId)
|
||||
|
||||
suspend fun addItemToList(
|
||||
listId: Long,
|
||||
item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity,
|
||||
) = repo.addItemToList(
|
||||
listId,
|
||||
item,
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,11 +4,11 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.safebite.app.domain.model.ThemePref
|
||||
import com.safebite.app.domain.repository.SettingsRepository
|
||||
import com.safebite.app.presentation.navigation.SafeBiteNavGraph
|
||||
@ -25,30 +25,32 @@ data class RootUi(
|
||||
val onboardingDone: Boolean = false,
|
||||
val theme: ThemePref = ThemePref.SYSTEM,
|
||||
val showSplash: Boolean = false,
|
||||
val ready: Boolean = false
|
||||
val ready: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class RootViewModel @Inject constructor(
|
||||
settings: SettingsRepository
|
||||
) : ViewModel() {
|
||||
val state: StateFlow<RootUi> = combine(
|
||||
settings.onboardingCompleted,
|
||||
settings.theme,
|
||||
settings.splashScreenEnabled
|
||||
) { done, theme, splashEnabled ->
|
||||
RootUi(
|
||||
onboardingDone = done,
|
||||
theme = theme,
|
||||
showSplash = splashEnabled && done,
|
||||
ready = true
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, RootUi())
|
||||
}
|
||||
class RootViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
settings: SettingsRepository,
|
||||
) : ViewModel() {
|
||||
val state: StateFlow<RootUi> =
|
||||
combine(
|
||||
settings.onboardingCompleted,
|
||||
settings.theme,
|
||||
settings.splashScreenEnabled,
|
||||
) { done, theme, splashEnabled ->
|
||||
RootUi(
|
||||
onboardingDone = done,
|
||||
theme = theme,
|
||||
showSplash = splashEnabled && done,
|
||||
ready = true,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, RootUi())
|
||||
}
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val rootViewModel: RootViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@ -56,16 +58,17 @@ class MainActivity : ComponentActivity() {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||
setContent {
|
||||
val ui by rootViewModel.state.collectAsStateWithLifecycle()
|
||||
val dark = when (ui.theme) {
|
||||
ThemePref.LIGHT -> false
|
||||
ThemePref.DARK -> true
|
||||
ThemePref.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme()
|
||||
}
|
||||
val dark =
|
||||
when (ui.theme) {
|
||||
ThemePref.LIGHT -> false
|
||||
ThemePref.DARK -> true
|
||||
ThemePref.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme()
|
||||
}
|
||||
SafeBiteTheme(darkTheme = dark) {
|
||||
if (ui.ready) {
|
||||
SafeBiteNavGraph(
|
||||
onboardingCompleted = ui.onboardingDone,
|
||||
showSplash = ui.showSplash
|
||||
showSplash = ui.showSplash,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,27 +31,29 @@ import com.safebite.app.presentation.theme.LocalDimens
|
||||
enum class AllergenLevel(val label: String, val emoji: String) {
|
||||
NONE("Aucun", ""),
|
||||
TRACE("Traces", "⚠️"),
|
||||
SEVERE("Sévère", "❌")
|
||||
SEVERE("Sévère", "❌"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Couleurs de fond par état d'allergie (spec UX §4.2).
|
||||
*/
|
||||
fun AllergenLevel.backgroundColor(): Color = when (this) {
|
||||
AllergenLevel.NONE -> Color.Transparent
|
||||
AllergenLevel.TRACE -> Color(0xFFFEF5E7) // Orange clair
|
||||
AllergenLevel.SEVERE -> Color(0xFFFDEDEC) // Rouge clair
|
||||
}
|
||||
fun AllergenLevel.backgroundColor(): Color =
|
||||
when (this) {
|
||||
AllergenLevel.NONE -> Color.Transparent
|
||||
AllergenLevel.TRACE -> Color(0xFFFEF5E7) // Orange clair
|
||||
AllergenLevel.SEVERE -> Color(0xFFFDEDEC) // Rouge clair
|
||||
}
|
||||
|
||||
/**
|
||||
* Couleur de bordure par état.
|
||||
*/
|
||||
@Composable
|
||||
fun AllergenLevel.borderColor(): Color = when (this) {
|
||||
AllergenLevel.NONE -> MaterialTheme.colorScheme.outlineVariant
|
||||
AllergenLevel.TRACE -> Color(0xFFF39C12)
|
||||
AllergenLevel.SEVERE -> Color(0xFFE74C3C)
|
||||
}
|
||||
fun AllergenLevel.borderColor(): Color =
|
||||
when (this) {
|
||||
AllergenLevel.NONE -> MaterialTheme.colorScheme.outlineVariant
|
||||
AllergenLevel.TRACE -> Color(0xFFF39C12)
|
||||
AllergenLevel.SEVERE -> Color(0xFFE74C3C)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grille de sélection d'allergènes avec 3 états par tap.
|
||||
@ -66,7 +68,7 @@ fun AllergenLevel.borderColor(): Color = when (this) {
|
||||
fun AllergenSelectionGrid(
|
||||
selectedAllergens: Map<AllergenType, AllergenLevel>,
|
||||
onLevelChanged: (AllergenType, AllergenLevel) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
val columns = 3
|
||||
@ -74,12 +76,12 @@ fun AllergenSelectionGrid(
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm),
|
||||
) {
|
||||
rows.forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)
|
||||
horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm),
|
||||
) {
|
||||
row.forEach { allergen ->
|
||||
val currentLevel = selectedAllergens[allergen] ?: AllergenLevel.NONE
|
||||
@ -88,13 +90,14 @@ fun AllergenSelectionGrid(
|
||||
allergen = allergen,
|
||||
level = currentLevel,
|
||||
onClick = {
|
||||
val nextLevel = when (currentLevel) {
|
||||
AllergenLevel.NONE -> AllergenLevel.TRACE
|
||||
AllergenLevel.TRACE -> AllergenLevel.SEVERE
|
||||
AllergenLevel.SEVERE -> AllergenLevel.NONE
|
||||
}
|
||||
val nextLevel =
|
||||
when (currentLevel) {
|
||||
AllergenLevel.NONE -> AllergenLevel.TRACE
|
||||
AllergenLevel.TRACE -> AllergenLevel.SEVERE
|
||||
AllergenLevel.SEVERE -> AllergenLevel.NONE
|
||||
}
|
||||
onLevelChanged(allergen, nextLevel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
repeat(columns - row.size) {
|
||||
@ -113,33 +116,37 @@ fun AllergenSelectionChip(
|
||||
allergen: AllergenType,
|
||||
level: AllergenLevel,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClick),
|
||||
modifier =
|
||||
modifier
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(dimens.radiusMd),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = level.backgroundColor()
|
||||
),
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
width = 2.dp,
|
||||
color = level.borderColor()
|
||||
)
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = level.backgroundColor(),
|
||||
),
|
||||
border =
|
||||
androidx.compose.foundation.BorderStroke(
|
||||
width = 2.dp,
|
||||
color = level.borderColor(),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = dimens.spacingSm, horizontal = dimens.spacingXs),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = dimens.spacingSm, horizontal = dimens.spacingXs),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Emoji allergène
|
||||
Text(
|
||||
text = allergen.icon,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
// Nom court
|
||||
@ -148,7 +155,7 @@ fun AllergenSelectionChip(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1
|
||||
maxLines = 1,
|
||||
)
|
||||
|
||||
// Indicateur d'état
|
||||
@ -156,7 +163,7 @@ fun AllergenSelectionChip(
|
||||
Text(
|
||||
text = level.emoji,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -171,7 +178,7 @@ fun AllergenSelectionChip(
|
||||
fun AllergenDisplayGrid(
|
||||
severeAllergens: Set<AllergenType>,
|
||||
moderateIntolerances: Set<AllergenType>,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
@ -180,29 +187,29 @@ fun AllergenDisplayGrid(
|
||||
text = "Aucune allergie détectée ✅",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = modifier.padding(dimens.spacingSm)
|
||||
modifier = modifier.padding(dimens.spacingSm),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm),
|
||||
) {
|
||||
// Allergènes sévères
|
||||
if (severeAllergens.isNotEmpty()) {
|
||||
Text(
|
||||
text = "❌ Allergies sévères :",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFFE74C3C)
|
||||
color = Color(0xFFE74C3C),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
severeAllergens.forEach { allergen ->
|
||||
AllergenBadge(
|
||||
allergen = allergen,
|
||||
level = AllergenLevel.SEVERE
|
||||
level = AllergenLevel.SEVERE,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -213,15 +220,15 @@ fun AllergenDisplayGrid(
|
||||
Text(
|
||||
text = "⚠️ Intolérances :",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFFF39C12)
|
||||
color = Color(0xFFF39C12),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
moderateIntolerances.forEach { allergen ->
|
||||
AllergenBadge(
|
||||
allergen = allergen,
|
||||
level = AllergenLevel.TRACE
|
||||
level = AllergenLevel.TRACE,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -236,30 +243,31 @@ fun AllergenDisplayGrid(
|
||||
fun AllergenBadge(
|
||||
allergen: AllergenType,
|
||||
level: AllergenLevel,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(
|
||||
color = level.backgroundColor(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = level.borderColor(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
modifier =
|
||||
modifier
|
||||
.background(
|
||||
color = level.backgroundColor(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = level.borderColor(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(text = allergen.icon, style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
text = allergen.displayNameFr,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
|
||||
@ -51,19 +51,19 @@ fun DonutChart(
|
||||
progressColor: Color = LocalStatusColors.current.safe,
|
||||
centerText: String = "${(progress * 100).toInt()}%",
|
||||
centerSubText: String? = null,
|
||||
animated: Boolean = true
|
||||
animated: Boolean = true,
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = progress.coerceIn(0f, 1f),
|
||||
animationSpec = tween(durationMillis = 800),
|
||||
label = "donutProgress"
|
||||
label = "donutProgress",
|
||||
)
|
||||
val displayProgress = if (animated) animatedProgress else progress
|
||||
|
||||
Box(
|
||||
modifier = modifier.size(size),
|
||||
contentAlignment = Alignment.Center
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val sweepAngle = 360f * displayProgress
|
||||
@ -75,7 +75,7 @@ fun DonutChart(
|
||||
startAngle = -90f,
|
||||
sweepAngle = 360f,
|
||||
useCenter = false,
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round)
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round),
|
||||
)
|
||||
|
||||
// Arc de progression
|
||||
@ -85,27 +85,27 @@ fun DonutChart(
|
||||
startAngle = -90f,
|
||||
sweepAngle = sweepAngle,
|
||||
useCenter = false,
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round)
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Texte central
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = centerText,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (centerSubText != null) {
|
||||
Text(
|
||||
text = centerSubText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -117,9 +117,10 @@ fun DonutChart(
|
||||
*/
|
||||
data class SparklineData(
|
||||
val values: List<Float>,
|
||||
val labels: List<String> = emptyList()
|
||||
val labels: List<String> = emptyList(),
|
||||
) {
|
||||
fun max(): Float = values.maxOrNull() ?: 0f
|
||||
|
||||
fun min(): Float = values.minOrNull() ?: 0f
|
||||
}
|
||||
|
||||
@ -138,20 +139,21 @@ fun Sparkline(
|
||||
lineColor: Color = LocalStatusColors.current.safe,
|
||||
fillColor: Color = LocalStatusColors.current.safe.copy(alpha = 0.15f),
|
||||
height: Dp = 80.dp,
|
||||
showDots: Boolean = true
|
||||
showDots: Boolean = true,
|
||||
) {
|
||||
if (data.values.isEmpty()) return
|
||||
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = 600),
|
||||
label = "sparklineProgress"
|
||||
label = "sparklineProgress",
|
||||
)
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(height),
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
@ -163,39 +165,42 @@ fun Sparkline(
|
||||
val stepX = (width - 2 * padding) / (data.values.size - 1).coerceAtLeast(1)
|
||||
|
||||
// Calcul des points
|
||||
val points = data.values.mapIndexed { index, value ->
|
||||
val x = padding + index * stepX
|
||||
val normalizedValue = if (range > 0) (value - minVal) / range else 0.5f
|
||||
val y = height - padding - normalizedValue * (height - 2 * padding)
|
||||
Offset(x, y)
|
||||
}
|
||||
val points =
|
||||
data.values.mapIndexed { index, value ->
|
||||
val x = padding + index * stepX
|
||||
val normalizedValue = if (range > 0) (value - minVal) / range else 0.5f
|
||||
val y = height - padding - normalizedValue * (height - 2 * padding)
|
||||
Offset(x, y)
|
||||
}
|
||||
|
||||
// Zone de remplissage
|
||||
if (points.size > 1) {
|
||||
val fillPath = androidx.compose.ui.graphics.Path().apply {
|
||||
moveTo(points.first().x, height)
|
||||
lineTo(points.first().x, points.first().y)
|
||||
points.forEach { point ->
|
||||
lineTo(point.x, point.y)
|
||||
val fillPath =
|
||||
androidx.compose.ui.graphics.Path().apply {
|
||||
moveTo(points.first().x, height)
|
||||
lineTo(points.first().x, points.first().y)
|
||||
points.forEach { point ->
|
||||
lineTo(point.x, point.y)
|
||||
}
|
||||
lineTo(points.last().x, height)
|
||||
close()
|
||||
}
|
||||
lineTo(points.last().x, height)
|
||||
close()
|
||||
}
|
||||
drawPath(fillPath, color = fillColor)
|
||||
}
|
||||
|
||||
// Ligne
|
||||
if (points.size > 1) {
|
||||
val linePath = androidx.compose.ui.graphics.Path().apply {
|
||||
moveTo(points.first().x, points.first().y)
|
||||
points.forEach { point ->
|
||||
lineTo(point.x, point.y)
|
||||
val linePath =
|
||||
androidx.compose.ui.graphics.Path().apply {
|
||||
moveTo(points.first().x, points.first().y)
|
||||
points.forEach { point ->
|
||||
lineTo(point.x, point.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
drawPath(
|
||||
path = linePath,
|
||||
color = lineColor,
|
||||
style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round)
|
||||
style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
|
||||
@ -205,12 +210,12 @@ fun Sparkline(
|
||||
drawCircle(
|
||||
color = lineColor,
|
||||
radius = 4.dp.toPx(),
|
||||
center = point
|
||||
center = point,
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = 2.dp.toPx(),
|
||||
center = point
|
||||
center = point,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -221,13 +226,13 @@ fun Sparkline(
|
||||
* Données pour un graphique à barres.
|
||||
*/
|
||||
data class BarChartData(
|
||||
val items: List<BarChartItem>
|
||||
val items: List<BarChartItem>,
|
||||
)
|
||||
|
||||
data class BarChartItem(
|
||||
val label: String,
|
||||
val value: Int,
|
||||
val color: Color = Color.Unspecified
|
||||
val color: Color = Color.Unspecified,
|
||||
)
|
||||
|
||||
/**
|
||||
@ -244,7 +249,7 @@ fun HorizontalBarChart(
|
||||
modifier: Modifier = Modifier,
|
||||
maxValue: Int? = null,
|
||||
height: Dp = 32.dp,
|
||||
spacing: Dp = LocalDimens.current.spacingSm
|
||||
spacing: Dp = LocalDimens.current.spacingSm,
|
||||
) {
|
||||
if (data.items.isEmpty()) return
|
||||
|
||||
@ -252,13 +257,13 @@ fun HorizontalBarChart(
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing)
|
||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||
) {
|
||||
data.items.forEach { item ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd)
|
||||
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd),
|
||||
) {
|
||||
// Label
|
||||
Text(
|
||||
@ -266,7 +271,7 @@ fun HorizontalBarChart(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(0.4f),
|
||||
maxLines = 1
|
||||
maxLines = 1,
|
||||
)
|
||||
|
||||
// Barre
|
||||
@ -274,13 +279,14 @@ fun HorizontalBarChart(
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = progress,
|
||||
animationSpec = tween(durationMillis = 500),
|
||||
label = "barProgress"
|
||||
label = "barProgress",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(0.4f)
|
||||
.height(height)
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(0.4f)
|
||||
.height(height),
|
||||
) {
|
||||
// Fond
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
@ -288,7 +294,7 @@ fun HorizontalBarChart(
|
||||
drawRoundRect(
|
||||
color = androidx.compose.ui.graphics.Color(0xFFE3E2EC),
|
||||
size = Size(size.width, size.height),
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius)
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius),
|
||||
)
|
||||
|
||||
// Progression
|
||||
@ -296,7 +302,7 @@ fun HorizontalBarChart(
|
||||
drawRoundRect(
|
||||
color = item.color,
|
||||
size = Size(size.width * animatedProgress, size.height),
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius)
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -306,8 +312,9 @@ fun HorizontalBarChart(
|
||||
text = "${item.value}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
.padding(end = 8.dp)
|
||||
modifier =
|
||||
Modifier.align(Alignment.CenterEnd)
|
||||
.padding(end = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -324,30 +331,31 @@ fun StatCard(
|
||||
value: String,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
valueColor: Color = MaterialTheme.colorScheme.onSurface
|
||||
valueColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(dimens.spacingSm),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
modifier =
|
||||
modifier
|
||||
.padding(dimens.spacingSm),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = icon,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = valueColor
|
||||
color = valueColor,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -359,25 +367,25 @@ enum class TimeFilter(val label: String) {
|
||||
WEEK("Semaine"),
|
||||
MONTH("Mois"),
|
||||
YEAR("Année"),
|
||||
ALL("Tout")
|
||||
ALL("Tout"),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimeFilterRow(
|
||||
selected: TimeFilter,
|
||||
onFilterChanged: (TimeFilter) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm)
|
||||
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm),
|
||||
) {
|
||||
TimeFilter.values().forEach { filter ->
|
||||
androidx.compose.material3.FilterChip(
|
||||
selected = selected == filter,
|
||||
onClick = { onFilterChanged(filter) },
|
||||
label = { Text(filter.label) }
|
||||
)
|
||||
}
|
||||
androidx.compose.material3.FilterChip(
|
||||
selected = selected == filter,
|
||||
onClick = { onFilterChanged(filter) },
|
||||
label = { Text(filter.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,10 @@ package com.safebite.app.presentation.common.util
|
||||
|
||||
sealed interface UiState<out T> {
|
||||
data object Idle : UiState<Nothing>
|
||||
|
||||
data object Loading : UiState<Nothing>
|
||||
|
||||
data class Success<T>(val data: T) : UiState<T>
|
||||
|
||||
data class Error(val message: String, val offline: Boolean = false) : UiState<Nothing>
|
||||
}
|
||||
|
||||
@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,95 +27,112 @@ import javax.inject.Inject
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class CatalogViewModel @Inject constructor(
|
||||
private val repository: CatalogRepository,
|
||||
private val manageListUseCase: ManageShoppingListUseCase
|
||||
) : ViewModel() {
|
||||
class CatalogViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val repository: CatalogRepository,
|
||||
private val manageListUseCase: ManageShoppingListUseCase,
|
||||
) : ViewModel() {
|
||||
private val _activeListId = MutableStateFlow<Long?>(null)
|
||||
val activeListId: StateFlow<Long?> = _activeListId.asStateFlow()
|
||||
|
||||
private val _activeListId = MutableStateFlow<Long?>(null)
|
||||
val activeListId: StateFlow<Long?> = _activeListId.asStateFlow()
|
||||
|
||||
val domains: StateFlow<List<DomainWithCategoriesAndItems>> =
|
||||
repository.observeDomainsWithCategoriesAndItems().stateIn(
|
||||
viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()
|
||||
)
|
||||
|
||||
private val _selectedDomainId = MutableStateFlow<String?>(null)
|
||||
val selectedDomainId: StateFlow<String?> = _selectedDomainId.asStateFlow()
|
||||
|
||||
val categoriesForSelectedDomain: StateFlow<List<CategoryEntity>> =
|
||||
_selectedDomainId
|
||||
.flatMapLatest { id ->
|
||||
if (id == null) flowOf(emptyList())
|
||||
else repository.observeCategoriesForDomain(id)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val _selectedCategoryId = MutableStateFlow<String?>(null)
|
||||
val selectedCategoryId: StateFlow<String?> = _selectedCategoryId.asStateFlow()
|
||||
|
||||
val itemsForSelectedCategory: StateFlow<List<CatalogItemEntity>> =
|
||||
_selectedCategoryId
|
||||
.flatMapLatest { id ->
|
||||
if (id == null) flowOf(emptyList())
|
||||
else repository.observeItemsForCategory(id)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
val searchResults: StateFlow<List<CatalogItemEntity>> =
|
||||
_searchQuery
|
||||
.flatMapLatest { q ->
|
||||
if (q.isBlank()) flowOf(emptyList())
|
||||
else repository.search(q, limit = 30)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun setActiveList(listId: Long) {
|
||||
_activeListId.value = listId.takeIf { it > 0 }
|
||||
}
|
||||
|
||||
fun selectDomain(domainId: String) {
|
||||
_selectedDomainId.value = domainId
|
||||
}
|
||||
|
||||
fun selectCategory(categoryId: String) {
|
||||
_selectedCategoryId.value = categoryId
|
||||
}
|
||||
|
||||
fun updateSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun clearSearch() {
|
||||
_searchQuery.value = ""
|
||||
}
|
||||
|
||||
/** Ajoute l'article du catalogue à la liste active courante. */
|
||||
fun addItemToActiveList(item: CatalogItemEntity, categoryNameOverride: String? = null) {
|
||||
val listId = _activeListId.value ?: return
|
||||
viewModelScope.launch {
|
||||
// Évite les doublons par nom (ignore-case).
|
||||
val existing = manageListUseCase.getItems(listId)
|
||||
.firstOrNull { it.productName.equals(item.name, ignoreCase = true) }
|
||||
if (existing != null) {
|
||||
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
||||
return@launch
|
||||
}
|
||||
val categoryName = categoryNameOverride
|
||||
?: item.primaryCategoryId?.let { repository.getCategory(it)?.name }
|
||||
manageListUseCase.addItemToList(
|
||||
listId,
|
||||
ShoppingListItemEntity(
|
||||
listId = listId,
|
||||
productName = item.name,
|
||||
category = categoryName,
|
||||
customEmoji = item.emoji
|
||||
)
|
||||
val domains: StateFlow<List<DomainWithCategoriesAndItems>> =
|
||||
repository.observeDomainsWithCategoriesAndItems().stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
emptyList(),
|
||||
)
|
||||
repository.incrementPopularity(item.itemId)
|
||||
|
||||
private val _selectedDomainId = MutableStateFlow<String?>(null)
|
||||
val selectedDomainId: StateFlow<String?> = _selectedDomainId.asStateFlow()
|
||||
|
||||
val categoriesForSelectedDomain: StateFlow<List<CategoryEntity>> =
|
||||
_selectedDomainId
|
||||
.flatMapLatest { id ->
|
||||
if (id == null) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
repository.observeCategoriesForDomain(id)
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val _selectedCategoryId = MutableStateFlow<String?>(null)
|
||||
val selectedCategoryId: StateFlow<String?> = _selectedCategoryId.asStateFlow()
|
||||
|
||||
val itemsForSelectedCategory: StateFlow<List<CatalogItemEntity>> =
|
||||
_selectedCategoryId
|
||||
.flatMapLatest { id ->
|
||||
if (id == null) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
repository.observeItemsForCategory(id)
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
val searchResults: StateFlow<List<CatalogItemEntity>> =
|
||||
_searchQuery
|
||||
.flatMapLatest { q ->
|
||||
if (q.isBlank()) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
repository.search(q, limit = 30)
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun setActiveList(listId: Long) {
|
||||
_activeListId.value = listId.takeIf { it > 0 }
|
||||
}
|
||||
|
||||
fun selectDomain(domainId: String) {
|
||||
_selectedDomainId.value = domainId
|
||||
}
|
||||
|
||||
fun selectCategory(categoryId: String) {
|
||||
_selectedCategoryId.value = categoryId
|
||||
}
|
||||
|
||||
fun updateSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun clearSearch() {
|
||||
_searchQuery.value = ""
|
||||
}
|
||||
|
||||
/** Ajoute l'article du catalogue à la liste active courante. */
|
||||
fun addItemToActiveList(
|
||||
item: CatalogItemEntity,
|
||||
categoryNameOverride: String? = null,
|
||||
) {
|
||||
val listId = _activeListId.value ?: return
|
||||
viewModelScope.launch {
|
||||
// Évite les doublons par nom (ignore-case).
|
||||
val existing =
|
||||
manageListUseCase.getItems(listId)
|
||||
.firstOrNull { it.productName.equals(item.name, ignoreCase = true) }
|
||||
if (existing != null) {
|
||||
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
||||
return@launch
|
||||
}
|
||||
val categoryName =
|
||||
categoryNameOverride
|
||||
?: item.primaryCategoryId?.let { repository.getCategory(it)?.name }
|
||||
manageListUseCase.addItemToList(
|
||||
listId,
|
||||
ShoppingListItemEntity(
|
||||
listId = listId,
|
||||
productName = item.name,
|
||||
category = categoryName,
|
||||
customEmoji = item.emoji,
|
||||
),
|
||||
)
|
||||
repository.incrementPopularity(item.itemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,8 +2,10 @@ package com.safebite.app.presentation.screen.dashboard
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.safebite.app.data.local.database.entity.ShoppingListEntity
|
||||
import com.safebite.app.domain.model.SafetyStatus
|
||||
import com.safebite.app.domain.model.ScanHistoryItem
|
||||
import com.safebite.app.domain.model.UserProfile
|
||||
import com.safebite.app.domain.usecase.GetScanHistoryUseCase
|
||||
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
|
||||
import com.safebite.app.domain.usecase.ManageProfileUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@ -15,66 +17,158 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.util.Calendar
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Mode contextuel du dashboard. */
|
||||
enum class DashboardContextMode {
|
||||
/** Aucun scan dans l'historique — onboarding implicite. */
|
||||
FIRST_TIME,
|
||||
|
||||
/** Créneau 8h-20h en semaine OU liste active avec restants → mode magasin. */
|
||||
STORE,
|
||||
|
||||
/** Soirée, weekend, ou aucune condition magasin remplie. */
|
||||
HOME,
|
||||
}
|
||||
|
||||
data class DashboardUiState(
|
||||
val greetingName: String = "",
|
||||
val lists: List<ListSummary> = emptyList()
|
||||
val contextMode: DashboardContextMode = DashboardContextMode.FIRST_TIME,
|
||||
val lists: List<ListSummary> = emptyList(),
|
||||
val weeklyStats: WeeklyStats? = null,
|
||||
val recentScans: List<ScanHistoryItem> = emptyList(),
|
||||
)
|
||||
|
||||
data class WeeklyStats(
|
||||
val safePercentage: Int,
|
||||
val warningCount: Int,
|
||||
val dangerCount: Int,
|
||||
val totalScans: Int,
|
||||
)
|
||||
|
||||
data class ListSummary(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val remaining: Int
|
||||
val remaining: Int,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class DashboardViewModel @Inject constructor(
|
||||
private val manageProfile: ManageProfileUseCase,
|
||||
private val getShoppingLists: GetShoppingListsUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val state: StateFlow<DashboardUiState> = combine(
|
||||
manageProfile.observe(),
|
||||
manageProfile.observeActiveIds()
|
||||
) { profiles, activeIds ->
|
||||
profiles to activeIds
|
||||
}.flatMapLatest { (profiles, activeIds) ->
|
||||
val greetingName = resolveGreetingName(profiles, activeIds)
|
||||
observeListsWithStats(greetingName)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = DashboardUiState()
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun observeListsWithStats(greetingName: String): Flow<DashboardUiState> {
|
||||
return getShoppingLists.observeActive().flatMapLatest { lists ->
|
||||
val sortedLists = lists.sortedBy { it.createdAt }.take(4)
|
||||
if (sortedLists.isEmpty()) {
|
||||
flowOf(DashboardUiState(greetingName = greetingName, lists = emptyList()))
|
||||
} else {
|
||||
val listFlows = sortedLists.map { list ->
|
||||
combine(
|
||||
getShoppingLists.observeItemCount(list.id),
|
||||
getShoppingLists.observeCheckedCount(list.id)
|
||||
) { total, checked ->
|
||||
ListSummary(list.id, list.name, total - checked)
|
||||
}
|
||||
class DashboardViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val manageProfile: ManageProfileUseCase,
|
||||
private val getShoppingLists: GetShoppingListsUseCase,
|
||||
private val getScanHistory: GetScanHistoryUseCase,
|
||||
) : ViewModel() {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val state: StateFlow<DashboardUiState> =
|
||||
combine(
|
||||
manageProfile.observe(),
|
||||
manageProfile.observeActiveIds(),
|
||||
) { profiles, activeIds ->
|
||||
profiles to activeIds
|
||||
}.flatMapLatest { (profiles, activeIds) ->
|
||||
val greetingName = resolveGreetingName(profiles, activeIds)
|
||||
combine(
|
||||
observeListsWithStats(greetingName),
|
||||
observeHistory(),
|
||||
) { dashboard, history ->
|
||||
val weeklyStats = computeWeeklyStats(history)
|
||||
val contextMode = detectContextMode(history, dashboard.lists)
|
||||
dashboard.copy(
|
||||
contextMode = contextMode,
|
||||
recentScans = history.take(5),
|
||||
weeklyStats = weeklyStats,
|
||||
)
|
||||
}
|
||||
combine(listFlows) { array ->
|
||||
DashboardUiState(greetingName, array.toList())
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = DashboardUiState(),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun observeListsWithStats(greetingName: String): Flow<DashboardUiState> {
|
||||
return getShoppingLists.observeActive().flatMapLatest { lists ->
|
||||
val sortedLists = lists.sortedBy { it.createdAt }.take(4)
|
||||
if (sortedLists.isEmpty()) {
|
||||
flowOf(DashboardUiState(greetingName = greetingName, lists = emptyList()))
|
||||
} else {
|
||||
val listFlows =
|
||||
sortedLists.map { list ->
|
||||
combine(
|
||||
getShoppingLists.observeItemCount(list.id),
|
||||
getShoppingLists.observeCheckedCount(list.id),
|
||||
) { total, checked ->
|
||||
ListSummary(list.id, list.name, total - checked)
|
||||
}
|
||||
}
|
||||
combine(listFlows) { array ->
|
||||
DashboardUiState(greetingName = greetingName, lists = array.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveGreetingName(profiles: List<UserProfile>, activeIds: Set<Long>): String {
|
||||
return when {
|
||||
activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }.firstOrNull()?.name
|
||||
else -> profiles.filter { it.isDefault }.firstOrNull()?.name ?: profiles.firstOrNull()?.name
|
||||
} ?: ""
|
||||
private fun observeHistory(): Flow<List<ScanHistoryItem>> = getScanHistory.observe()
|
||||
|
||||
/**
|
||||
* Détection du mode contextuel :
|
||||
* - FIRST_TIME : aucun scan dans l'historique
|
||||
* - STORE : heure 8h-20h en semaine, OU liste active avec produits restants
|
||||
* - HOME : par défaut (soirée, weekend)
|
||||
*/
|
||||
private fun detectContextMode(
|
||||
history: List<ScanHistoryItem>,
|
||||
lists: List<ListSummary>,
|
||||
): DashboardContextMode {
|
||||
if (history.isEmpty()) return DashboardContextMode.FIRST_TIME
|
||||
|
||||
val cal = Calendar.getInstance()
|
||||
val hour = cal.get(Calendar.HOUR_OF_DAY)
|
||||
val dayOfWeek = cal.get(Calendar.DAY_OF_WEEK)
|
||||
val isWeekday = dayOfWeek in Calendar.MONDAY..Calendar.FRIDAY
|
||||
val isStoreHours = hour in 8..19
|
||||
val hasActiveList = lists.any { it.remaining > 0 }
|
||||
|
||||
return if ((isWeekday && isStoreHours) || hasActiveList) {
|
||||
DashboardContextMode.STORE
|
||||
} else {
|
||||
DashboardContextMode.HOME
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeWeeklyStats(history: List<ScanHistoryItem>): WeeklyStats? {
|
||||
if (history.isEmpty()) return null
|
||||
val cal = Calendar.getInstance()
|
||||
cal.set(Calendar.DAY_OF_WEEK, cal.firstDayOfWeek)
|
||||
cal.set(Calendar.HOUR_OF_DAY, 0)
|
||||
cal.set(Calendar.MINUTE, 0)
|
||||
cal.set(Calendar.SECOND, 0)
|
||||
cal.set(Calendar.MILLISECOND, 0)
|
||||
val weekStart = cal.timeInMillis
|
||||
val weeklyScans = history.filter { it.scannedAt >= weekStart }
|
||||
if (weeklyScans.isEmpty()) return null
|
||||
val total = weeklyScans.size
|
||||
val safe = weeklyScans.count { it.safetyStatus == SafetyStatus.SAFE }
|
||||
val warnings = weeklyScans.count { it.safetyStatus == SafetyStatus.WARNING }
|
||||
val dangers = weeklyScans.count { it.safetyStatus == SafetyStatus.DANGER }
|
||||
return WeeklyStats(
|
||||
safePercentage = if (total > 0) (safe * 100) / total else 0,
|
||||
warningCount = warnings,
|
||||
dangerCount = dangers,
|
||||
totalScans = total,
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveGreetingName(
|
||||
profiles: List<UserProfile>,
|
||||
activeIds: Set<Long>,
|
||||
): String {
|
||||
return when {
|
||||
activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }.firstOrNull()?.name
|
||||
else -> profiles.filter { it.isDefault }.firstOrNull()?.name ?: profiles.firstOrNull()?.name
|
||||
} ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,45 +19,51 @@ import javax.inject.Inject
|
||||
data class FamilyUiState(
|
||||
val profiles: List<UserProfile> = emptyList(),
|
||||
val activeProfileIds: Set<Long> = emptySet(),
|
||||
val isLoading: Boolean = true
|
||||
val isLoading: Boolean = true,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class FamilyViewModel @Inject constructor(
|
||||
private val manageProfileUseCase: ManageProfileUseCase
|
||||
) : ViewModel() {
|
||||
class FamilyViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val manageProfileUseCase: ManageProfileUseCase,
|
||||
) : ViewModel() {
|
||||
val uiState: StateFlow<FamilyUiState> =
|
||||
manageProfileUseCase.observe()
|
||||
.map { profiles ->
|
||||
FamilyUiState(
|
||||
profiles = profiles,
|
||||
isLoading = false,
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
FamilyUiState(),
|
||||
)
|
||||
|
||||
val uiState: StateFlow<FamilyUiState> = manageProfileUseCase.observe()
|
||||
.map { profiles ->
|
||||
FamilyUiState(
|
||||
profiles = profiles,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
FamilyUiState()
|
||||
)
|
||||
val activeProfileIds: StateFlow<Set<Long>> =
|
||||
manageProfileUseCase.observeActiveIds()
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
val activeProfileIds: StateFlow<Set<Long>> = manageProfileUseCase.observeActiveIds()
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
emptySet()
|
||||
)
|
||||
fun toggleProfileActive(id: Long) =
|
||||
viewModelScope.launch {
|
||||
val current = manageProfileUseCase.observeActiveIds().first()
|
||||
val newIds = if (id in current) current - id else current + id
|
||||
manageProfileUseCase.setActive(newIds)
|
||||
}
|
||||
|
||||
fun toggleProfileActive(id: Long) = viewModelScope.launch {
|
||||
val current = manageProfileUseCase.observeActiveIds().first()
|
||||
val newIds = if (id in current) current - id else current + id
|
||||
manageProfileUseCase.setActive(newIds)
|
||||
fun deleteProfile(profile: UserProfile) =
|
||||
viewModelScope.launch {
|
||||
manageProfileUseCase.delete(profile)
|
||||
}
|
||||
|
||||
fun setDefaultProfile(id: Long) =
|
||||
viewModelScope.launch {
|
||||
manageProfileUseCase.setDefault(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteProfile(profile: UserProfile) = viewModelScope.launch {
|
||||
manageProfileUseCase.delete(profile)
|
||||
}
|
||||
|
||||
fun setDefaultProfile(id: Long) = viewModelScope.launch {
|
||||
manageProfileUseCase.setDefault(id)
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,7 +40,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.safebite.app.R
|
||||
import com.safebite.app.domain.model.SafetyStatus
|
||||
import com.safebite.app.domain.model.UserProfile
|
||||
import com.safebite.app.presentation.common.components.AvatarBubble
|
||||
import com.safebite.app.presentation.common.components.OutlinedActionButton
|
||||
@ -60,7 +59,7 @@ fun HomeScreen(
|
||||
onHistory: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
onOpenHistoryItem: (String) -> Unit,
|
||||
viewModel: HomeViewModel = hiltViewModel()
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
@ -71,29 +70,36 @@ fun HomeScreen(
|
||||
SafeBiteTopAppBar(
|
||||
title = stringResource(R.string.app_name),
|
||||
actions = {
|
||||
IconButton(onClick = onProfiles) { Icon(Icons.Filled.Person, contentDescription = stringResource(R.string.nav_profiles)) }
|
||||
IconButton(onClick = onHistory) { Icon(Icons.Filled.History, contentDescription = stringResource(R.string.nav_history)) }
|
||||
IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.nav_settings)) }
|
||||
IconButton(
|
||||
onClick = onProfiles,
|
||||
) { Icon(Icons.Filled.Person, contentDescription = stringResource(R.string.nav_profiles)) }
|
||||
IconButton(
|
||||
onClick = onHistory,
|
||||
) { Icon(Icons.Filled.History, contentDescription = stringResource(R.string.nav_history)) }
|
||||
IconButton(
|
||||
onClick = onSettings,
|
||||
) { Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.nav_settings)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
if (state.profiles.isEmpty()) {
|
||||
NoProfileBlock(modifier = Modifier.padding(padding), onCreate = onCreateProfile)
|
||||
return@Scaffold
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingLg)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingLg),
|
||||
) {
|
||||
ActiveProfilesRow(
|
||||
profiles = state.profiles,
|
||||
active = state.activeProfiles,
|
||||
onToggle = viewModel::toggleActive,
|
||||
onManage = onProfiles
|
||||
onManage = onProfiles,
|
||||
)
|
||||
|
||||
ScanButton(onClick = onScan)
|
||||
@ -102,35 +108,38 @@ fun HomeScreen(
|
||||
text = stringResource(R.string.home_ocr_button),
|
||||
onClick = onOcr,
|
||||
icon = Icons.Filled.TextFields,
|
||||
modifier = Modifier.fillMaxWidth().height(dimens.buttonHeightLg)
|
||||
modifier = Modifier.fillMaxWidth().height(dimens.buttonHeightLg),
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.home_recent_scans),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
if (state.recent.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.home_no_recent),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
state.recent.forEach { item ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenHistoryItem(item.barcode) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenHistoryItem(item.barcode) },
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(12.dp).background(statusColor(item.safetyStatus), CircleShape)
|
||||
modifier = Modifier.size(12.dp).background(statusColor(item.safetyStatus), CircleShape),
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
ProductCard(
|
||||
title = item.productName ?: item.barcode,
|
||||
subtitle = item.brand,
|
||||
imageUrl = item.imageUrl,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -144,7 +153,7 @@ private fun ActiveProfilesRow(
|
||||
profiles: List<UserProfile>,
|
||||
active: List<UserProfile>,
|
||||
onToggle: (UserProfile) -> Unit,
|
||||
onManage: () -> Unit
|
||||
onManage: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@ -159,7 +168,7 @@ private fun ActiveProfilesRow(
|
||||
onClick = { onToggle(p) },
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
Row(modifier = Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
AvatarBubble(avatar = p.avatar, size = 32.dp)
|
||||
@ -180,37 +189,40 @@ private fun ScanButton(onClick: () -> Unit) {
|
||||
onClick = onClick,
|
||||
icon = Icons.Filled.QrCodeScanner,
|
||||
large = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(dimens.buttonHeightHero)
|
||||
.semantics { contentDescription = "Scan a product" },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(dimens.buttonHeightHero)
|
||||
.semantics { contentDescription = "Scan a product" },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoProfileBlock(modifier: Modifier, onCreate: () -> Unit) {
|
||||
private fun NoProfileBlock(
|
||||
modifier: Modifier,
|
||||
onCreate: () -> Unit,
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().padding(dimens.spacingXl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.home_no_profile_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.size(dimens.spacingSm))
|
||||
Text(
|
||||
stringResource(R.string.home_no_profile_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.size(dimens.spacingLg))
|
||||
PrimaryButton(
|
||||
text = stringResource(R.string.home_create_profile),
|
||||
onClick = onCreate
|
||||
onClick = onCreate,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,34 +17,39 @@ import javax.inject.Inject
|
||||
data class HomeUi(
|
||||
val profiles: List<UserProfile> = emptyList(),
|
||||
val activeProfiles: List<UserProfile> = emptyList(),
|
||||
val recent: List<ScanHistoryItem> = emptyList()
|
||||
val recent: List<ScanHistoryItem> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val manageProfile: ManageProfileUseCase,
|
||||
private val history: GetScanHistoryUseCase
|
||||
) : ViewModel() {
|
||||
class HomeViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val manageProfile: ManageProfileUseCase,
|
||||
private val history: GetScanHistoryUseCase,
|
||||
) : ViewModel() {
|
||||
val state: StateFlow<HomeUi> =
|
||||
combine(
|
||||
manageProfile.observe(),
|
||||
manageProfile.observeActiveIds(),
|
||||
history.observe(),
|
||||
) { profiles, activeIds, scans ->
|
||||
val resolvedActive =
|
||||
when {
|
||||
activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }
|
||||
else -> profiles.filter { it.isDefault }.ifEmpty { profiles.take(1) }
|
||||
}
|
||||
HomeUi(profiles = profiles, activeProfiles = resolvedActive, recent = scans.take(3))
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeUi())
|
||||
|
||||
val state: StateFlow<HomeUi> = combine(
|
||||
manageProfile.observe(),
|
||||
manageProfile.observeActiveIds(),
|
||||
history.observe()
|
||||
) { profiles, activeIds, scans ->
|
||||
val resolvedActive = when {
|
||||
activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }
|
||||
else -> profiles.filter { it.isDefault }.ifEmpty { profiles.take(1) }
|
||||
}
|
||||
HomeUi(profiles = profiles, activeProfiles = resolvedActive, recent = scans.take(3))
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeUi())
|
||||
fun toggleActive(profile: UserProfile) =
|
||||
viewModelScope.launch {
|
||||
val current = state.value.activeProfiles.map { it.id }.toMutableSet()
|
||||
if (profile.id in current) current.remove(profile.id) else current.add(profile.id)
|
||||
manageProfile.setActive(current)
|
||||
}
|
||||
|
||||
fun toggleActive(profile: UserProfile) = viewModelScope.launch {
|
||||
val current = state.value.activeProfiles.map { it.id }.toMutableSet()
|
||||
if (profile.id in current) current.remove(profile.id) else current.add(profile.id)
|
||||
manageProfile.setActive(current)
|
||||
fun setActiveOnly(profile: UserProfile) =
|
||||
viewModelScope.launch {
|
||||
manageProfile.setActive(setOf(profile.id))
|
||||
}
|
||||
}
|
||||
|
||||
fun setActiveOnly(profile: UserProfile) = viewModelScope.launch {
|
||||
manageProfile.setActive(setOf(profile.id))
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,9 +38,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -52,13 +50,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.safebite.app.domain.engine.CatalogProvider
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@ -67,7 +63,7 @@ fun IconPickerSheet(
|
||||
categories: List<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onSelectIcon: (String) -> Unit,
|
||||
catalogProvider: CatalogProvider = hiltViewModel<ListDetailViewModel>().catalog
|
||||
catalogProvider: CatalogProvider = hiltViewModel<ListDetailViewModel>().catalog,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
@ -75,18 +71,19 @@ fun IconPickerSheet(
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Fermer")
|
||||
@ -95,34 +92,36 @@ fun IconPickerSheet(
|
||||
text = "Choisir une icône",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = { onSelectIcon("") }) {
|
||||
Icon(
|
||||
Icons.Filled.Delete,
|
||||
contentDescription = "Supprimer l'icône",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Current icon display
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center
|
||||
modifier =
|
||||
Modifier
|
||||
.size(120.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = currentEmoji,
|
||||
style = MaterialTheme.typography.displayLarge
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -131,9 +130,10 @@ fun IconPickerSheet(
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
placeholder = { Text("Chercher une icône") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Search, contentDescription = null)
|
||||
@ -146,23 +146,26 @@ fun IconPickerSheet(
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(28.dp)
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
)
|
||||
|
||||
// Icon grid by category
|
||||
// Icon grid by category (catalogue food items first, then extra emoji categories)
|
||||
val extraCategories = remember { ExtraEmojiCategories.all }
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// 1. Catégories du catalogue alimentaire
|
||||
categories.forEach { category ->
|
||||
val categoryItems = catalogProvider.itemsForCategory(category)
|
||||
val filteredItems = if (searchQuery.isNotBlank()) {
|
||||
categoryItems.filter {
|
||||
it.name.contains(searchQuery, ignoreCase = true)
|
||||
val filteredItems =
|
||||
if (searchQuery.isNotBlank()) {
|
||||
categoryItems.filter {
|
||||
it.name.contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
} else {
|
||||
categoryItems
|
||||
}
|
||||
} else {
|
||||
categoryItems
|
||||
}
|
||||
|
||||
if (filteredItems.isNotEmpty()) {
|
||||
val expanded = expandedCategories[category] ?: (searchQuery.isNotBlank())
|
||||
@ -174,7 +177,7 @@ fun IconPickerSheet(
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
expandedCategories[category] = !expanded
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -183,7 +186,51 @@ fun IconPickerSheet(
|
||||
IconGrid(
|
||||
items = filteredItems,
|
||||
currentEmoji = currentEmoji,
|
||||
onSelectIcon = onSelectIcon
|
||||
onSelectIcon = onSelectIcon,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Catégories d'émojis supplémentaires (non-alimentaires)
|
||||
extraCategories.forEach { (category, items) ->
|
||||
val filteredItems =
|
||||
if (searchQuery.isNotBlank()) {
|
||||
items.filter {
|
||||
it.name.contains(searchQuery, ignoreCase = true) ||
|
||||
it.emoji.contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
} else {
|
||||
items
|
||||
}
|
||||
|
||||
if (filteredItems.isNotEmpty()) {
|
||||
val expanded = expandedCategories[category] ?: (searchQuery.isNotBlank())
|
||||
|
||||
item(key = "header-extra-$category") {
|
||||
CategoryHeader(
|
||||
title = category,
|
||||
count = filteredItems.size,
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
expandedCategories[category] = !expanded
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
item(key = "grid-extra-$category") {
|
||||
IconGrid(
|
||||
items = filteredItems.map {
|
||||
CatalogProvider.CatalogItem(
|
||||
name = it.name,
|
||||
category = category,
|
||||
emoji = it.emoji,
|
||||
)
|
||||
},
|
||||
currentEmoji = currentEmoji,
|
||||
onSelectIcon = onSelectIcon,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -201,34 +248,35 @@ private fun CategoryHeader(
|
||||
title: String,
|
||||
count: Int,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(vertical = 12.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(vertical = 12.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (expanded) 90f else 0f),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -237,23 +285,24 @@ private fun CategoryHeader(
|
||||
private fun IconGrid(
|
||||
items: List<CatalogProvider.CatalogItem>,
|
||||
currentEmoji: String,
|
||||
onSelectIcon: (String) -> Unit
|
||||
onSelectIcon: (String) -> Unit,
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(((items.size + 2) / 3 * 100).dp.coerceAtMost(400.dp)),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(((items.size + 2) / 3 * 100).dp.coerceAtMost(400.dp)),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items) { item ->
|
||||
IconCard(
|
||||
emoji = item.emoji,
|
||||
label = item.name,
|
||||
isSelected = item.emoji == currentEmoji,
|
||||
onClick = { onSelectIcon(item.emoji) }
|
||||
onClick = { onSelectIcon(item.emoji) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -264,39 +313,42 @@ private fun IconCard(
|
||||
emoji: String,
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val backgroundColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
val contentColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val backgroundColor =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
val contentColor =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clickable(onClick = onClick),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = backgroundColor)
|
||||
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
modifier = Modifier.padding(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = emoji,
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
@ -305,7 +357,7 @@ private fun IconCard(
|
||||
color = contentColor,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
if (isSelected) {
|
||||
@ -313,12 +365,150 @@ private fun IconCard(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = "Sélectionné",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(20.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Émojis supplémentaires non-alimentaires pour l'IconPicker. */
|
||||
private data class ExtraEmoji(val emoji: String, val name: String)
|
||||
|
||||
private object ExtraEmojiCategories {
|
||||
val all: List<Pair<String, List<ExtraEmoji>>> =
|
||||
listOf(
|
||||
"🐾 Animaux" to
|
||||
listOf(
|
||||
ExtraEmoji("🐶", "Chien"),
|
||||
ExtraEmoji("🐱", "Chat"),
|
||||
ExtraEmoji("🐰", "Lapin"),
|
||||
ExtraEmoji("🐻", "Ours"),
|
||||
ExtraEmoji("🐼", "Panda"),
|
||||
ExtraEmoji("🐨", "Koala"),
|
||||
ExtraEmoji("🦁", "Lion"),
|
||||
ExtraEmoji("🐮", "Vache"),
|
||||
ExtraEmoji("🐷", "Cochon"),
|
||||
ExtraEmoji("🐸", "Grenouille"),
|
||||
ExtraEmoji("🐵", "Singe"),
|
||||
ExtraEmoji("🐔", "Poule"),
|
||||
ExtraEmoji("🐧", "Pingouin"),
|
||||
ExtraEmoji("🐦", "Oiseau"),
|
||||
ExtraEmoji("🐤", "Poussin"),
|
||||
ExtraEmoji("🦆", "Canard"),
|
||||
ExtraEmoji("🦉", "Chouette"),
|
||||
ExtraEmoji("🦋", "Papillon"),
|
||||
ExtraEmoji("🐝", "Abeille"),
|
||||
ExtraEmoji("🐞", "Coccinelle"),
|
||||
ExtraEmoji("🐠", "Poisson tropical"),
|
||||
ExtraEmoji("🐬", "Dauphin"),
|
||||
ExtraEmoji("🐳", "Baleine"),
|
||||
ExtraEmoji("🦖", "Dinosaure"),
|
||||
),
|
||||
"⚽ Sports & Loisirs" to
|
||||
listOf(
|
||||
ExtraEmoji("⚽", "Football"),
|
||||
ExtraEmoji("🏀", "Basket"),
|
||||
ExtraEmoji("🎾", "Tennis"),
|
||||
ExtraEmoji("🏈", "Football US"),
|
||||
ExtraEmoji("⚾", "Baseball"),
|
||||
ExtraEmoji("🏐", "Volley"),
|
||||
ExtraEmoji("🏓", "Ping-pong"),
|
||||
ExtraEmoji("🎱", "Billard"),
|
||||
ExtraEmoji("🏊", "Natation"),
|
||||
ExtraEmoji("🚴", "Vélo"),
|
||||
ExtraEmoji("🏃", "Course"),
|
||||
ExtraEmoji("🧘", "Yoga"),
|
||||
ExtraEmoji("🎮", "Jeux vidéo"),
|
||||
ExtraEmoji("🎲", "Jeu de dés"),
|
||||
ExtraEmoji("♟️", "Échecs"),
|
||||
ExtraEmoji("🎸", "Guitare"),
|
||||
ExtraEmoji("🎹", "Piano"),
|
||||
ExtraEmoji("🎧", "Musique"),
|
||||
),
|
||||
"🚗 Transports" to
|
||||
listOf(
|
||||
ExtraEmoji("🚗", "Voiture"),
|
||||
ExtraEmoji("🚙", "SUV"),
|
||||
ExtraEmoji("🏍️", "Moto"),
|
||||
ExtraEmoji("🚌", "Bus"),
|
||||
ExtraEmoji("🚛", "Camion"),
|
||||
ExtraEmoji("✈️", "Avion"),
|
||||
ExtraEmoji("🚀", "Fusée"),
|
||||
ExtraEmoji("⛵", "Bateau"),
|
||||
ExtraEmoji("🚲", "Vélo"),
|
||||
ExtraEmoji("🚂", "Train"),
|
||||
ExtraEmoji("🚁", "Hélicoptère"),
|
||||
ExtraEmoji("🛴", "Trottinette"),
|
||||
),
|
||||
"🏠 Maison & Objets" to
|
||||
listOf(
|
||||
ExtraEmoji("🏠", "Maison"),
|
||||
ExtraEmoji("🛏️", "Lit"),
|
||||
ExtraEmoji("🛋️", "Canapé"),
|
||||
ExtraEmoji("🚿", "Douche"),
|
||||
ExtraEmoji("🪥", "Brosse à dents"),
|
||||
ExtraEmoji("🧴", "Lotion"),
|
||||
ExtraEmoji("🧹", "Balai"),
|
||||
ExtraEmoji("🧺", "Panier à linge"),
|
||||
ExtraEmoji("🪴", "Plante"),
|
||||
ExtraEmoji("💡", "Ampoule"),
|
||||
ExtraEmoji("🔑", "Clé"),
|
||||
ExtraEmoji("📱", "Téléphone"),
|
||||
ExtraEmoji("💻", "Ordinateur"),
|
||||
ExtraEmoji("📺", "Télévision"),
|
||||
ExtraEmoji("📷", "Appareil photo"),
|
||||
ExtraEmoji("🔋", "Batterie"),
|
||||
),
|
||||
"🎉 Fêtes & Événements" to
|
||||
listOf(
|
||||
ExtraEmoji("🎂", "Gâteau d'anniversaire"),
|
||||
ExtraEmoji("🎁", "Cadeau"),
|
||||
ExtraEmoji("🎈", "Ballon"),
|
||||
ExtraEmoji("🎄", "Sapin de Noël"),
|
||||
ExtraEmoji("🎃", "Citrouille Halloween"),
|
||||
ExtraEmoji("🎆", "Feu d'artifice"),
|
||||
ExtraEmoji("💝", "Saint-Valentin"),
|
||||
ExtraEmoji("🥂", "Célébration"),
|
||||
ExtraEmoji("🎊", "Confetti"),
|
||||
ExtraEmoji("🕯️", "Bougie"),
|
||||
),
|
||||
"💊 Santé & Bien-être" to
|
||||
listOf(
|
||||
ExtraEmoji("💊", "Médicament"),
|
||||
ExtraEmoji("🩹", "Pansement"),
|
||||
ExtraEmoji("💉", "Seringue"),
|
||||
ExtraEmoji("🩺", "Stéthoscope"),
|
||||
ExtraEmoji("🧬", "ADN"),
|
||||
ExtraEmoji("🦷", "Dent"),
|
||||
ExtraEmoji("👁️", "Œil"),
|
||||
ExtraEmoji("❤️", "Cœur"),
|
||||
ExtraEmoji("🧠", "Cerveau"),
|
||||
),
|
||||
"⭐ Symboles" to
|
||||
listOf(
|
||||
ExtraEmoji("⭐", "Étoile"),
|
||||
ExtraEmoji("❤️", "Cœur rouge"),
|
||||
ExtraEmoji("💚", "Cœur vert"),
|
||||
ExtraEmoji("💙", "Cœur bleu"),
|
||||
ExtraEmoji("🔥", "Feu"),
|
||||
ExtraEmoji("💧", "Goutte"),
|
||||
ExtraEmoji("✨", "Étincelles"),
|
||||
ExtraEmoji("✅", "Check"),
|
||||
ExtraEmoji("❌", "Croix"),
|
||||
ExtraEmoji("⚠️", "Attention"),
|
||||
ExtraEmoji("🚫", "Interdit"),
|
||||
ExtraEmoji("💯", "100"),
|
||||
ExtraEmoji("🆕", "Nouveau"),
|
||||
ExtraEmoji("🔝", "Top"),
|
||||
ExtraEmoji("💪", "Force"),
|
||||
ExtraEmoji("👑", "Couronne"),
|
||||
ExtraEmoji("💎", "Diamant"),
|
||||
ExtraEmoji("🌈", "Arc-en-ciel"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,5 @@
|
||||
package com.safebite.app.presentation.screen.lists
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@ -25,9 +23,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@ -62,7 +58,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@ -72,7 +67,6 @@ import com.safebite.app.presentation.common.components.EmptyState
|
||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||
import com.safebite.app.presentation.screen.lists.util.backgroundByResName
|
||||
import com.safebite.app.presentation.theme.LocalDimens
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ -82,7 +76,7 @@ fun ListsScreen(
|
||||
onOpenScanner: () -> Unit,
|
||||
onOpenListCreate: () -> Unit,
|
||||
onOpenListSettings: (Long) -> Unit,
|
||||
viewModel: ListsViewModel = hiltViewModel()
|
||||
viewModel: ListsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val isEditMode by viewModel.isEditMode.collectAsStateWithLifecycle()
|
||||
@ -108,7 +102,7 @@ fun ListsScreen(
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
@ -116,16 +110,17 @@ fun ListsScreen(
|
||||
onClick = onOpenListCreate,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
shape = MaterialTheme.shapes.medium
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.lists_new))
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
when (val s = state) {
|
||||
is ListsViewModel.UiState.Loading -> {
|
||||
@ -139,9 +134,9 @@ fun ListsScreen(
|
||||
action = {
|
||||
PrimaryButton(
|
||||
text = stringResource(R.string.lists_new),
|
||||
onClick = onOpenListCreate
|
||||
onClick = onOpenListCreate,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
is ListsViewModel.UiState.Success -> {
|
||||
@ -150,14 +145,14 @@ fun ListsScreen(
|
||||
isEditMode = isEditMode,
|
||||
onItemClick = { item -> onOpenList(item.list.id, item.list.name) },
|
||||
onSettingsClick = { item -> onOpenListSettings(item.list.id) },
|
||||
onReorder = { from, to -> viewModel.reorderLists(from, to) }
|
||||
onReorder = { from, to -> viewModel.reorderLists(from, to) },
|
||||
)
|
||||
}
|
||||
is ListsViewModel.UiState.Error -> {
|
||||
EmptyState(
|
||||
title = "Erreur",
|
||||
message = s.message,
|
||||
emoji = "❌"
|
||||
emoji = "❌",
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -171,7 +166,7 @@ private fun ReorderableList(
|
||||
isEditMode: Boolean,
|
||||
onItemClick: (ListsViewModel.ShoppingListWithStats) -> Unit,
|
||||
onSettingsClick: (ListsViewModel.ShoppingListWithStats) -> Unit,
|
||||
onReorder: (Int, Int) -> Unit
|
||||
onReorder: (Int, Int) -> Unit,
|
||||
) {
|
||||
var draggedIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||
@ -182,11 +177,11 @@ private fun ReorderableList(
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = items,
|
||||
key = { _, item -> item.list.id }
|
||||
key = { _, item -> item.list.id },
|
||||
) { index, item ->
|
||||
val isDragged = draggedIndex == index
|
||||
val zIndex = if (isDragged) 1f else 0f
|
||||
@ -194,45 +189,49 @@ private fun ReorderableList(
|
||||
val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.zIndex(zIndex)
|
||||
.offset { IntOffset(0, offsetY) }
|
||||
.graphicsLayer {
|
||||
scaleX = if (isDragged) 1.02f else 1f
|
||||
scaleY = if (isDragged) 1.02f else 1f
|
||||
}
|
||||
.then(
|
||||
if (isEditMode) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { draggedIndex = index },
|
||||
onDragEnd = {
|
||||
draggedIndex?.let { from ->
|
||||
val to = (from + (dragOffsetY / itemPx).roundToInt())
|
||||
.coerceIn(0, items.size - 1)
|
||||
if (from != to) onReorder(from, to)
|
||||
}
|
||||
draggedIndex = null
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
draggedIndex = null
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffsetY += dragAmount.y
|
||||
}
|
||||
)
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
modifier =
|
||||
Modifier
|
||||
.zIndex(zIndex)
|
||||
.offset { IntOffset(0, offsetY) }
|
||||
.graphicsLayer {
|
||||
scaleX = if (isDragged) 1.02f else 1f
|
||||
scaleY = if (isDragged) 1.02f else 1f
|
||||
}
|
||||
.then(
|
||||
if (isEditMode) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { draggedIndex = index },
|
||||
onDragEnd = {
|
||||
draggedIndex?.let { from ->
|
||||
val to =
|
||||
(from + (dragOffsetY / itemPx).roundToInt())
|
||||
.coerceIn(0, items.size - 1)
|
||||
if (from != to) onReorder(from, to)
|
||||
}
|
||||
draggedIndex = null
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
draggedIndex = null
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffsetY += dragAmount.y
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
) {
|
||||
ShoppingListCard(
|
||||
item = item,
|
||||
isEditMode = isEditMode,
|
||||
onClick = { onItemClick(item) },
|
||||
onSettingsClick = { onSettingsClick(item) }
|
||||
onSettingsClick = { onSettingsClick(item) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -244,18 +243,19 @@ private fun ShoppingListCard(
|
||||
item: ListsViewModel.ShoppingListWithStats,
|
||||
isEditMode: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit
|
||||
onSettingsClick: () -> Unit,
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
val bg = backgroundByResName(item.list.backgroundResName)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(160.dp)
|
||||
.then(if (!isEditMode) Modifier.clickable(onClick = onClick) else Modifier),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(160.dp)
|
||||
.then(if (!isEditMode) Modifier.clickable(onClick = onClick) else Modifier),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Background image
|
||||
@ -264,37 +264,40 @@ private fun ShoppingListCard(
|
||||
painter = painterResource(id = bg.drawableRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.35f))
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.35f)),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(dimens.spacingMd)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(dimens.spacingMd),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
if (isEditMode) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DragHandle,
|
||||
contentDescription = "Reorder",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
@ -302,13 +305,13 @@ private fun ShoppingListCard(
|
||||
|
||||
IconButton(
|
||||
onClick = onSettingsClick,
|
||||
modifier = Modifier.size(32.dp)
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings,
|
||||
contentDescription = stringResource(R.string.lists_settings),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp)
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -316,40 +319,41 @@ private fun ShoppingListCard(
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// List name
|
||||
val regionFlagEmoji = item.list.region?.let { code ->
|
||||
when (code) {
|
||||
"de" -> "🇩🇪"
|
||||
"au" -> "🇦🇺"
|
||||
"at" -> "🇦🇹"
|
||||
"ca" -> "🇨🇦"
|
||||
"es" -> "🇪🇸"
|
||||
"fr" -> "🇫🇷"
|
||||
"hu" -> "🇭🇺"
|
||||
"it" -> "🇮🇹"
|
||||
"no" -> "🇳🇴"
|
||||
"nl" -> "🇳🇱"
|
||||
"pl" -> "🇵🇱"
|
||||
"pt" -> "🇵🇹"
|
||||
"gb" -> "🇬🇧"
|
||||
"ru" -> "🇷🇺"
|
||||
"ch_de", "ch_fr" -> "🇨🇭"
|
||||
else -> ""
|
||||
}
|
||||
} ?: ""
|
||||
val regionFlagEmoji =
|
||||
item.list.region?.let { code ->
|
||||
when (code) {
|
||||
"de" -> "🇩🇪"
|
||||
"au" -> "🇦🇺"
|
||||
"at" -> "🇦🇹"
|
||||
"ca" -> "🇨🇦"
|
||||
"es" -> "🇪🇸"
|
||||
"fr" -> "🇫🇷"
|
||||
"hu" -> "🇭🇺"
|
||||
"it" -> "🇮🇹"
|
||||
"no" -> "🇳🇴"
|
||||
"nl" -> "🇳🇱"
|
||||
"pl" -> "🇵🇱"
|
||||
"pt" -> "🇵🇹"
|
||||
"gb" -> "🇬🇧"
|
||||
"ru" -> "🇷🇺"
|
||||
"ch_de", "ch_fr" -> "🇨🇭"
|
||||
else -> ""
|
||||
}
|
||||
} ?: ""
|
||||
Text(
|
||||
text = "$regionFlagEmoji ${item.list.name}".trim(),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Item count badge
|
||||
val remaining = item.itemCount - item.checkedCount
|
||||
@ -358,16 +362,17 @@ private fun ShoppingListCard(
|
||||
text = "$remaining articles",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.background(badgeColor.copy(alpha = 0.85f), RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.background(badgeColor.copy(alpha = 0.85f), RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Member avatars
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy((-8).dp)
|
||||
horizontalArrangement = Arrangement.spacedBy((-8).dp),
|
||||
) {
|
||||
item.members.take(3).forEach { member ->
|
||||
MemberAvatar(member = member)
|
||||
@ -382,17 +387,18 @@ private fun ShoppingListCard(
|
||||
@Composable
|
||||
private fun MemberAvatar(member: ShoppingListMemberEntity) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
modifier =
|
||||
Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = member.name.take(1).uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@ import com.safebite.app.data.local.database.entity.ShoppingListMemberEntity
|
||||
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@ -21,88 +21,100 @@ import javax.inject.Inject
|
||||
* ViewModel pour l'écran Listes (Phase 2).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ListsViewModel @Inject constructor(
|
||||
private val getShoppingListsUseCase: GetShoppingListsUseCase
|
||||
) : ViewModel() {
|
||||
class ListsViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val getShoppingListsUseCase: GetShoppingListsUseCase,
|
||||
) : ViewModel() {
|
||||
sealed class UiState {
|
||||
object Loading : UiState()
|
||||
|
||||
sealed class UiState {
|
||||
object Loading : UiState()
|
||||
data class Success(
|
||||
val lists: List<ShoppingListWithStats>
|
||||
) : UiState()
|
||||
data class Empty(val message: String = "") : UiState()
|
||||
data class Error(val message: String) : UiState()
|
||||
}
|
||||
data class Success(
|
||||
val lists: List<ShoppingListWithStats>,
|
||||
) : UiState()
|
||||
|
||||
data class ShoppingListWithStats(
|
||||
val list: ShoppingListEntity,
|
||||
val itemCount: Int,
|
||||
val checkedCount: Int,
|
||||
val members: List<ShoppingListMemberEntity> = emptyList()
|
||||
)
|
||||
data class Empty(val message: String = "") : UiState()
|
||||
|
||||
private val _isEditMode = MutableStateFlow(false)
|
||||
val isEditMode: StateFlow<Boolean> = _isEditMode
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val state: StateFlow<UiState> = getShoppingListsUseCase.observeActive()
|
||||
.flatMapLatest { lists ->
|
||||
if (lists.isEmpty()) {
|
||||
flowOf(UiState.Empty("Aucune liste de courses. Créez votre première liste !"))
|
||||
} else {
|
||||
val statsFlows = lists.sortedBy { it.displayOrder }.map { list ->
|
||||
combine(
|
||||
getShoppingListsUseCase.observeItemCount(list.id),
|
||||
getShoppingListsUseCase.observeCheckedCount(list.id),
|
||||
getShoppingListsUseCase.observeMembers(list.id)
|
||||
) { itemCount, checkedCount, members ->
|
||||
ShoppingListWithStats(list, itemCount, checkedCount, members.take(3))
|
||||
}
|
||||
}
|
||||
combine(statsFlows) { array ->
|
||||
UiState.Success(array.toList().sortedBy { it.list.displayOrder })
|
||||
}
|
||||
}
|
||||
data class Error(val message: String) : UiState()
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = UiState.Loading
|
||||
|
||||
data class ShoppingListWithStats(
|
||||
val list: ShoppingListEntity,
|
||||
val itemCount: Int,
|
||||
val checkedCount: Int,
|
||||
val members: List<ShoppingListMemberEntity> = emptyList(),
|
||||
)
|
||||
|
||||
fun createList(name: String, backgroundResName: String? = null) {
|
||||
viewModelScope.launch {
|
||||
getShoppingListsUseCase.createList(name, backgroundResName)
|
||||
}
|
||||
}
|
||||
private val _isEditMode = MutableStateFlow(false)
|
||||
val isEditMode: StateFlow<Boolean> = _isEditMode
|
||||
|
||||
fun deleteList(list: ShoppingListEntity) {
|
||||
viewModelScope.launch {
|
||||
getShoppingListsUseCase.deleteList(list)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleEditMode() {
|
||||
_isEditMode.value = !_isEditMode.value
|
||||
}
|
||||
|
||||
fun updateList(list: ShoppingListEntity) {
|
||||
viewModelScope.launch {
|
||||
getShoppingListsUseCase.updateList(list)
|
||||
}
|
||||
}
|
||||
|
||||
fun reorderLists(fromIndex: Int, toIndex: Int) {
|
||||
viewModelScope.launch {
|
||||
val current = state.value as? UiState.Success ?: return@launch
|
||||
val mutable = current.lists.toMutableList()
|
||||
val moved = mutable.removeAt(fromIndex)
|
||||
mutable.add(toIndex.coerceIn(0, mutable.size), moved)
|
||||
mutable.forEachIndexed { index, item ->
|
||||
getShoppingListsUseCase.updateList(
|
||||
item.list.copy(displayOrder = index)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val state: StateFlow<UiState> =
|
||||
getShoppingListsUseCase.observeActive()
|
||||
.flatMapLatest { lists ->
|
||||
if (lists.isEmpty()) {
|
||||
flowOf(UiState.Empty("Aucune liste de courses. Créez votre première liste !"))
|
||||
} else {
|
||||
val statsFlows =
|
||||
lists.sortedBy { it.displayOrder }.map { list ->
|
||||
combine(
|
||||
getShoppingListsUseCase.observeItemCount(list.id),
|
||||
getShoppingListsUseCase.observeCheckedCount(list.id),
|
||||
getShoppingListsUseCase.observeMembers(list.id),
|
||||
) { itemCount, checkedCount, members ->
|
||||
ShoppingListWithStats(list, itemCount, checkedCount, members.take(3))
|
||||
}
|
||||
}
|
||||
combine(statsFlows) { array ->
|
||||
UiState.Success(array.toList().sortedBy { it.list.displayOrder })
|
||||
}
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = UiState.Loading,
|
||||
)
|
||||
|
||||
fun createList(
|
||||
name: String,
|
||||
backgroundResName: String? = null,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
getShoppingListsUseCase.createList(name, backgroundResName)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteList(list: ShoppingListEntity) {
|
||||
viewModelScope.launch {
|
||||
getShoppingListsUseCase.deleteList(list)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleEditMode() {
|
||||
_isEditMode.value = !_isEditMode.value
|
||||
}
|
||||
|
||||
fun updateList(list: ShoppingListEntity) {
|
||||
viewModelScope.launch {
|
||||
getShoppingListsUseCase.updateList(list)
|
||||
}
|
||||
}
|
||||
|
||||
fun reorderLists(
|
||||
fromIndex: Int,
|
||||
toIndex: Int,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val current = state.value as? UiState.Success ?: return@launch
|
||||
val mutable = current.lists.toMutableList()
|
||||
val moved = mutable.removeAt(fromIndex)
|
||||
mutable.add(toIndex.coerceIn(0, mutable.size), moved)
|
||||
mutable.forEachIndexed { index, item ->
|
||||
getShoppingListsUseCase.updateList(
|
||||
item.list.copy(displayOrder = index),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@ -21,7 +19,6 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Sort
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Brush
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material3.Button
|
||||
@ -62,7 +59,7 @@ fun ListSettingsScreen(
|
||||
onOpenRegion: () -> Unit,
|
||||
onOpenNameImage: () -> Unit,
|
||||
onOpenMembers: () -> Unit,
|
||||
viewModel: ListsViewModel = hiltViewModel()
|
||||
viewModel: ListsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
|
||||
@ -75,15 +72,16 @@ fun ListSettingsScreen(
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
if (listData == null) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
@ -92,10 +90,11 @@ fun ListSettingsScreen(
|
||||
// Header card with list preview
|
||||
val bg = backgroundByResName(listData.list.backgroundResName)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(140.dp),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(140.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (bg != null) {
|
||||
@ -103,18 +102,20 @@ fun ListSettingsScreen(
|
||||
painter = painterResource(id = bg.drawableRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.35f))
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.35f)),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
@ -122,9 +123,10 @@ fun ListSettingsScreen(
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(16.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -135,7 +137,7 @@ fun ListSettingsScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.list_personalize),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
@ -144,40 +146,43 @@ fun ListSettingsScreen(
|
||||
columns = GridCells.Fixed(2),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
item {
|
||||
SettingsTile(
|
||||
icon = Icons.AutoMirrored.Filled.Sort,
|
||||
label = stringResource(R.string.list_sort),
|
||||
onClick = onOpenSort
|
||||
onClick = onOpenSort,
|
||||
)
|
||||
}
|
||||
item {
|
||||
val regionCode = listData.list.region
|
||||
val regionSubtitle = if (regionCode != null) {
|
||||
val (flag, name) = regionFlagAndName(regionCode)
|
||||
"$flag $name"
|
||||
} else null
|
||||
val regionSubtitle =
|
||||
if (regionCode != null) {
|
||||
val (flag, name) = regionFlagAndName(regionCode)
|
||||
"$flag $name"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
SettingsTile(
|
||||
icon = Icons.Filled.Language,
|
||||
label = stringResource(R.string.list_region_language),
|
||||
subtitle = regionSubtitle,
|
||||
onClick = onOpenRegion
|
||||
onClick = onOpenRegion,
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsTile(
|
||||
icon = Icons.Filled.People,
|
||||
label = stringResource(R.string.list_members),
|
||||
onClick = onOpenMembers
|
||||
onClick = onOpenMembers,
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsTile(
|
||||
icon = Icons.Filled.Brush,
|
||||
label = stringResource(R.string.list_name_image),
|
||||
onClick = onOpenNameImage
|
||||
onClick = onOpenNameImage,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -192,10 +197,11 @@ fun ListSettingsScreen(
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.list_leave))
|
||||
}
|
||||
@ -207,55 +213,59 @@ fun ListSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
private fun regionFlagAndName(code: String): Pair<String, String> = when (code) {
|
||||
"de" -> "🇩🇪" to "Allemagne"
|
||||
"au" -> "🇦🇺" to "Australie"
|
||||
"at" -> "🇦🇹" to "Autriche"
|
||||
"ca" -> "🇨🇦" to "Canada"
|
||||
"es" -> "🇪🇸" to "Espagne"
|
||||
"fr" -> "🇫🇷" to "France"
|
||||
"hu" -> "🇭🇺" to "Hongrie"
|
||||
"it" -> "🇮🇹" to "Italie"
|
||||
"no" -> "🇳🇴" to "Norvège"
|
||||
"nl" -> "🇳🇱" to "Pays-Bas"
|
||||
"pl" -> "🇵🇱" to "Pologne"
|
||||
"pt" -> "🇵🇹" to "Portugal"
|
||||
"gb" -> "🇬🇧" to "Royaume-Uni"
|
||||
"ru" -> "🇷🇺" to "Russie"
|
||||
"ch_de" -> "🇨🇭" to "Suisse (Allemand)"
|
||||
"ch_fr" -> "🇨🇭" to "Suisse (français)"
|
||||
else -> "" to code
|
||||
}
|
||||
private fun regionFlagAndName(code: String): Pair<String, String> =
|
||||
when (code) {
|
||||
"de" -> "🇩🇪" to "Allemagne"
|
||||
"au" -> "🇦🇺" to "Australie"
|
||||
"at" -> "🇦🇹" to "Autriche"
|
||||
"ca" -> "🇨🇦" to "Canada"
|
||||
"es" -> "🇪🇸" to "Espagne"
|
||||
"fr" -> "🇫🇷" to "France"
|
||||
"hu" -> "🇭🇺" to "Hongrie"
|
||||
"it" -> "🇮🇹" to "Italie"
|
||||
"no" -> "🇳🇴" to "Norvège"
|
||||
"nl" -> "🇳🇱" to "Pays-Bas"
|
||||
"pl" -> "🇵🇱" to "Pologne"
|
||||
"pt" -> "🇵🇹" to "Portugal"
|
||||
"gb" -> "🇬🇧" to "Royaume-Uni"
|
||||
"ru" -> "🇷🇺" to "Russie"
|
||||
"ch_de" -> "🇨🇭" to "Suisse (Allemand)"
|
||||
"ch_fr" -> "🇨🇭" to "Suisse (français)"
|
||||
else -> "" to code
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsTile(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
subtitle: String? = null,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1.2f),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1.2f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
@ -263,7 +273,7 @@ private fun SettingsTile(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
@ -272,7 +282,7 @@ private fun SettingsTile(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,18 +70,19 @@ import kotlin.math.roundToInt
|
||||
fun ListSortScreen(
|
||||
listId: Long,
|
||||
onBack: () -> Unit,
|
||||
viewModel: ListsViewModel = hiltViewModel()
|
||||
viewModel: ListsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
|
||||
val catalog = remember { CatalogProvider() }
|
||||
|
||||
val savedOrder = listData?.list?.categoryOrder?.split(",")?.filter { it.isNotBlank() }
|
||||
val orderedCategories = remember(listData?.list?.categoryOrder) {
|
||||
mutableStateListOf<String>().apply {
|
||||
addAll(savedOrder ?: catalog.categories)
|
||||
val orderedCategories =
|
||||
remember(listData?.list?.categoryOrder) {
|
||||
mutableStateListOf<String>().apply {
|
||||
addAll(savedOrder ?: catalog.categories)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val savedVisible = listData?.list?.visibleCategories?.split(",")?.filter { it.isNotBlank() }?.toSet()
|
||||
var visibleCategories by remember(listData?.list?.visibleCategories) {
|
||||
@ -109,99 +110,105 @@ fun ListSortScreen(
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.clickable {
|
||||
listData?.let {
|
||||
viewModel.updateList(
|
||||
it.list.copy(
|
||||
visibleCategories = visibleCategories.joinToString(","),
|
||||
categoryOrder = orderedCategories.joinToString(",")
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(end = 16.dp)
|
||||
.clickable {
|
||||
listData?.let {
|
||||
viewModel.updateList(
|
||||
it.list.copy(
|
||||
visibleCategories = visibleCategories.joinToString(","),
|
||||
categoryOrder = orderedCategories.joinToString(","),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
onBack()
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.list_sort_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
|
||||
// Preview card (collapsible)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.clickable { previewExpanded = !previewExpanded },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.clickable { previewExpanded = !previewExpanded },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Sort,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.list_sort_preview),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (previewExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (previewExpanded) "Réduire" else "Développer"
|
||||
contentDescription = if (previewExpanded) "Réduire" else "Développer",
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = previewExpanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
orderedCategories.forEach { category ->
|
||||
val isVisible = category in visibleCategories
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = if (isVisible) "●" else "○",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = category,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (isVisible) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = if (isVisible) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -216,92 +223,100 @@ fun ListSortScreen(
|
||||
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = orderedCategories,
|
||||
key = { _, item -> item }
|
||||
key = { _, item -> item },
|
||||
) { index, category ->
|
||||
val isDragged = draggedIndex == index
|
||||
val zIndex = if (isDragged) 1f else 0f
|
||||
val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.zIndex(zIndex)
|
||||
.offset { IntOffset(0, offsetY) }
|
||||
.graphicsLayer {
|
||||
scaleX = if (isDragged) 1.02f else 1f
|
||||
scaleY = if (isDragged) 1.02f else 1f
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { draggedIndex = index },
|
||||
onDragEnd = {
|
||||
draggedIndex?.let { from ->
|
||||
val to = (from + (dragOffsetY / itemPx).roundToInt())
|
||||
.coerceIn(0, orderedCategories.size - 1)
|
||||
if (from != to) {
|
||||
val moved = orderedCategories.removeAt(from)
|
||||
orderedCategories.add(to, moved)
|
||||
modifier =
|
||||
Modifier
|
||||
.zIndex(zIndex)
|
||||
.offset { IntOffset(0, offsetY) }
|
||||
.graphicsLayer {
|
||||
scaleX = if (isDragged) 1.02f else 1f
|
||||
scaleY = if (isDragged) 1.02f else 1f
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { draggedIndex = index },
|
||||
onDragEnd = {
|
||||
draggedIndex?.let { from ->
|
||||
val to =
|
||||
(from + (dragOffsetY / itemPx).roundToInt())
|
||||
.coerceIn(0, orderedCategories.size - 1)
|
||||
if (from != to) {
|
||||
val moved = orderedCategories.removeAt(from)
|
||||
orderedCategories.add(to, moved)
|
||||
}
|
||||
}
|
||||
}
|
||||
draggedIndex = null
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
draggedIndex = null
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffsetY += dragAmount.y
|
||||
}
|
||||
)
|
||||
}
|
||||
draggedIndex = null
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
draggedIndex = null
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffsetY += dragAmount.y
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
val isVisible = category in visibleCategories
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (isDragged) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
.clickable {
|
||||
visibleCategories = if (isVisible) {
|
||||
visibleCategories - category
|
||||
} else {
|
||||
visibleCategories + category
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (isDragged) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
)
|
||||
.clickable {
|
||||
visibleCategories =
|
||||
if (isVisible) {
|
||||
visibleCategories - category
|
||||
} else {
|
||||
visibleCategories + category
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = category,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = {
|
||||
visibleCategories = if (isVisible) {
|
||||
visibleCategories - category
|
||||
} else {
|
||||
visibleCategories + category
|
||||
}
|
||||
visibleCategories =
|
||||
if (isVisible) {
|
||||
visibleCategories - category
|
||||
} else {
|
||||
visibleCategories + category
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = if (isVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||
contentDescription = if (isVisible) "Masquer" else "Afficher",
|
||||
tint = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
tint = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DragHandle,
|
||||
contentDescription = "Réordonner",
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,24 +5,23 @@ import com.safebite.app.R
|
||||
data class ListBackground(
|
||||
val resName: String,
|
||||
val label: String,
|
||||
val drawableRes: Int
|
||||
val drawableRes: Int,
|
||||
)
|
||||
|
||||
val allListBackgrounds: List<ListBackground> = listOf(
|
||||
ListBackground("bg_animaux", "Animaux", R.drawable.bg_animaux),
|
||||
ListBackground("bg_baby", "Bébé", R.drawable.bg_baby),
|
||||
ListBackground("bg_epicerie", "Épicerie", R.drawable.bg_epicerie),
|
||||
ListBackground("bg_epicerie2", "Épicerie 2", R.drawable.bg_epicerie2),
|
||||
ListBackground("bg_jardinage", "Maison & Jardin", R.drawable.bg_jardinage),
|
||||
ListBackground("bg_office", "Bureau", R.drawable.bg_office),
|
||||
ListBackground("bg_party", "Fête", R.drawable.bg_party),
|
||||
ListBackground("bg_pharmacie", "Pharmacie", R.drawable.bg_pharmacie),
|
||||
ListBackground("bg_plage", "Plage", R.drawable.bg_plage),
|
||||
ListBackground("bg_renovation", "Rénovation", R.drawable.bg_renovation)
|
||||
)
|
||||
val allListBackgrounds: List<ListBackground> =
|
||||
listOf(
|
||||
ListBackground("bg_animaux", "Animaux", R.drawable.bg_animaux),
|
||||
ListBackground("bg_baby", "Bébé", R.drawable.bg_baby),
|
||||
ListBackground("bg_epicerie", "Épicerie", R.drawable.bg_epicerie),
|
||||
ListBackground("bg_epicerie2", "Épicerie 2", R.drawable.bg_epicerie2),
|
||||
ListBackground("bg_jardinage", "Maison & Jardin", R.drawable.bg_jardinage),
|
||||
ListBackground("bg_office", "Bureau", R.drawable.bg_office),
|
||||
ListBackground("bg_party", "Fête", R.drawable.bg_party),
|
||||
ListBackground("bg_pharmacie", "Pharmacie", R.drawable.bg_pharmacie),
|
||||
ListBackground("bg_plage", "Plage", R.drawable.bg_plage),
|
||||
ListBackground("bg_renovation", "Rénovation", R.drawable.bg_renovation),
|
||||
)
|
||||
|
||||
fun backgroundByResName(name: String?): ListBackground? =
|
||||
allListBackgrounds.firstOrNull { it.resName == name }
|
||||
fun backgroundByResName(name: String?): ListBackground? = allListBackgrounds.firstOrNull { it.resName == name }
|
||||
|
||||
fun backgroundLabel(name: String?): String =
|
||||
backgroundByResName(name)?.label ?: ""
|
||||
fun backgroundLabel(name: String?): String = backgroundByResName(name)?.label ?: ""
|
||||
|
||||
@ -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<BottomNavItem>
|
||||
items: List<BottomNavItem>,
|
||||
) {
|
||||
NavigationBar(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
tonalElevation = 2.dp
|
||||
tonalElevation = 2.dp,
|
||||
) {
|
||||
items.forEach { item ->
|
||||
val selected = currentDestination?.hierarchy?.any { it.route == item.screen.route } == true
|
||||
@ -211,23 +220,24 @@ private fun SafeBiteBottomNavigation(
|
||||
val icon = if (selected) item.iconSelected else item.iconUnselected
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
},
|
||||
alwaysShowLabel = true,
|
||||
colors = androidx.compose.material3.NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = Color.White,
|
||||
selectedTextColor = Color.White,
|
||||
unselectedIconColor = Color.White.copy(alpha = 0.7f),
|
||||
unselectedTextColor = Color.White.copy(alpha = 0.7f),
|
||||
indicatorColor = Color.White.copy(alpha = 0.2f)
|
||||
)
|
||||
colors =
|
||||
androidx.compose.material3.NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = Color.White,
|
||||
selectedTextColor = Color.White,
|
||||
unselectedIconColor = Color.White.copy(alpha = 0.7f),
|
||||
unselectedTextColor = Color.White.copy(alpha = 0.7f),
|
||||
indicatorColor = Color.White.copy(alpha = 0.2f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,9 +8,13 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class OcrViewModel @Inject constructor() : ViewModel() {
|
||||
private val _capturedText = MutableStateFlow("")
|
||||
val capturedText: StateFlow<String> = _capturedText.asStateFlow()
|
||||
class OcrViewModel
|
||||
@Inject
|
||||
constructor() : ViewModel() {
|
||||
private val _capturedText = MutableStateFlow("")
|
||||
val capturedText: StateFlow<String> = _capturedText.asStateFlow()
|
||||
|
||||
fun setText(text: String) { _capturedText.value = text }
|
||||
}
|
||||
fun setText(text: String) {
|
||||
_capturedText.value = text
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,12 +5,14 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@ -20,7 +22,9 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -30,38 +34,33 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.google.accompanist.permissions.shouldShowRationale
|
||||
import com.safebite.app.R
|
||||
import com.safebite.app.domain.model.AllergenType
|
||||
import com.safebite.app.domain.model.CustomDietItem
|
||||
import com.safebite.app.domain.model.CustomItemTag
|
||||
import com.safebite.app.domain.model.DietaryRestriction
|
||||
import com.safebite.app.presentation.common.components.AllergenLevel
|
||||
import com.safebite.app.presentation.common.components.AllergenSelectionGrid
|
||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||
import com.safebite.app.presentation.common.components.StandardTextField
|
||||
import com.safebite.app.presentation.common.components.TertiaryButton
|
||||
import com.safebite.app.presentation.common.components.AllergenLevel
|
||||
import com.safebite.app.presentation.common.components.AllergenSelectionGrid
|
||||
import com.safebite.app.presentation.screen.profile.CustomItemAdder
|
||||
import com.safebite.app.presentation.screen.profile.CustomItemsList
|
||||
import com.google.accompanist.permissions.shouldShowRationale
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Switch
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
onFinished: () -> Unit,
|
||||
viewModel: OnboardingViewModel = hiltViewModel()
|
||||
viewModel: OnboardingViewModel = hiltViewModel(),
|
||||
) {
|
||||
var step by rememberSaveable { mutableStateOf(0) }
|
||||
var name by rememberSaveable { mutableStateOf("") }
|
||||
@ -77,49 +76,53 @@ fun OnboardingScreen(
|
||||
when (step) {
|
||||
0 -> WelcomeStep(onNext = { step = 1 })
|
||||
1 -> HowStep(onNext = { step = 2 })
|
||||
2 -> CreateProfileStep(
|
||||
name = name,
|
||||
onNameChange = { name = it },
|
||||
avatar = avatar,
|
||||
onAvatarChange = { avatar = it },
|
||||
isDefault = isDefault,
|
||||
onSetDefault = { isDefault = it },
|
||||
allergenLevels = allergenLevels.value,
|
||||
onSetAllergenLevel = { a, level ->
|
||||
allergenLevels.value = if (level == AllergenLevel.NONE) {
|
||||
allergenLevels.value - a
|
||||
} else {
|
||||
allergenLevels.value + (a to level)
|
||||
}
|
||||
},
|
||||
restrictions = restrictions.value,
|
||||
onToggleRestriction = { r ->
|
||||
restrictions.value = if (r in restrictions.value) restrictions.value - r else restrictions.value + r
|
||||
},
|
||||
customItems = customItems.value,
|
||||
onAddCustomItem = { n, t ->
|
||||
customItems.value = customItems.value + CustomDietItem(name = n, tag = t)
|
||||
},
|
||||
onRemoveCustomItem = { item ->
|
||||
customItems.value = customItems.value - item
|
||||
},
|
||||
onNext = {
|
||||
val severe = allergenLevels.value.filterValues { it == AllergenLevel.SEVERE }.keys
|
||||
val moderate = allergenLevels.value.filterValues { it == AllergenLevel.TRACE }.keys
|
||||
viewModel.createProfile(name, avatar, severe, moderate, restrictions.value, customItems.value)
|
||||
step = 3
|
||||
}
|
||||
)
|
||||
3 -> PermissionStep(
|
||||
granted = cameraPermission.status.isGranted,
|
||||
rationale = cameraPermission.status.shouldShowRationale,
|
||||
onRequest = { cameraPermission.launchPermissionRequest() },
|
||||
onNext = { step = 4 }
|
||||
)
|
||||
4 -> ReadyStep(onFinish = {
|
||||
viewModel.complete()
|
||||
onFinished()
|
||||
})
|
||||
2 ->
|
||||
CreateProfileStep(
|
||||
name = name,
|
||||
onNameChange = { name = it },
|
||||
avatar = avatar,
|
||||
onAvatarChange = { avatar = it },
|
||||
isDefault = isDefault,
|
||||
onSetDefault = { isDefault = it },
|
||||
allergenLevels = allergenLevels.value,
|
||||
onSetAllergenLevel = { a, level ->
|
||||
allergenLevels.value =
|
||||
if (level == AllergenLevel.NONE) {
|
||||
allergenLevels.value - a
|
||||
} else {
|
||||
allergenLevels.value + (a to level)
|
||||
}
|
||||
},
|
||||
restrictions = restrictions.value,
|
||||
onToggleRestriction = { r ->
|
||||
restrictions.value = if (r in restrictions.value) restrictions.value - r else restrictions.value + r
|
||||
},
|
||||
customItems = customItems.value,
|
||||
onAddCustomItem = { n, t ->
|
||||
customItems.value = customItems.value + CustomDietItem(name = n, tag = t)
|
||||
},
|
||||
onRemoveCustomItem = { item ->
|
||||
customItems.value = customItems.value - item
|
||||
},
|
||||
onNext = {
|
||||
val severe = allergenLevels.value.filterValues { it == AllergenLevel.SEVERE }.keys
|
||||
val moderate = allergenLevels.value.filterValues { it == AllergenLevel.TRACE }.keys
|
||||
viewModel.createProfile(name, avatar, severe, moderate, restrictions.value, customItems.value)
|
||||
step = 3
|
||||
},
|
||||
)
|
||||
3 ->
|
||||
PermissionStep(
|
||||
granted = cameraPermission.status.isGranted,
|
||||
rationale = cameraPermission.status.shouldShowRationale,
|
||||
onRequest = { cameraPermission.launchPermissionRequest() },
|
||||
onNext = { step = 4 },
|
||||
)
|
||||
4 ->
|
||||
ReadyStep(onFinish = {
|
||||
viewModel.complete()
|
||||
onFinished()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -129,31 +132,31 @@ private fun WelcomeStep(onNext: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.safebite_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(120.dp)
|
||||
modifier = Modifier.size(120.dp),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(R.string.onboarding_welcome_title),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.onboarding_welcome_subtitle),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
PrimaryButton(
|
||||
text = stringResource(R.string.action_continue),
|
||||
onClick = onNext,
|
||||
large = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -162,66 +165,70 @@ private fun WelcomeStep(onNext: () -> Unit) {
|
||||
private fun HowStep(onNext: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.onboarding_how_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.onboarding_how_subtitle),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
val steps = listOf(
|
||||
Triple("1", "👤", stringResource(R.string.onboarding_how_step1)),
|
||||
Triple("2", "📷", stringResource(R.string.onboarding_how_step2)),
|
||||
Triple("3", "✅", stringResource(R.string.onboarding_how_step3))
|
||||
)
|
||||
val steps =
|
||||
listOf(
|
||||
Triple("1", "👤", stringResource(R.string.onboarding_how_step1)),
|
||||
Triple("2", "📷", stringResource(R.string.onboarding_how_step2)),
|
||||
Triple("3", "✅", stringResource(R.string.onboarding_how_step3)),
|
||||
)
|
||||
|
||||
for ((number, emoji, label) in steps) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
modifier =
|
||||
Modifier
|
||||
.size(48.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
CircleShape,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = number,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = emoji,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -233,7 +240,7 @@ private fun HowStep(onNext: () -> Unit) {
|
||||
text = stringResource(R.string.action_continue),
|
||||
onClick = onNext,
|
||||
large = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -254,20 +261,20 @@ private fun CreateProfileStep(
|
||||
customItems: List<CustomDietItem>,
|
||||
onAddCustomItem: (String, CustomItemTag) -> Unit,
|
||||
onRemoveCustomItem: (CustomDietItem) -> Unit,
|
||||
onNext: () -> Unit
|
||||
onNext: () -> Unit,
|
||||
) {
|
||||
val avatars = listOf("🙂", "😀", "👧", "👦", "👨", "👩", "👵", "👴", "🧑", "👶", "🧒", "🧓", "🍽️", "🛒", "🥗", "🍎")
|
||||
val dimens = com.safebite.app.presentation.theme.LocalDimens.current
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.onboarding_profile_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
item {
|
||||
@ -288,7 +295,7 @@ private fun CreateProfileStep(
|
||||
shape = CircleShape,
|
||||
color = bg,
|
||||
border = if (selected) androidx.compose.foundation.BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
modifier = Modifier.size(72.dp)
|
||||
modifier = Modifier.size(72.dp),
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(a, fontSize = MaterialTheme.typography.displaySmall.fontSize)
|
||||
@ -312,7 +319,7 @@ private fun CreateProfileStep(
|
||||
item {
|
||||
AllergenSelectionGrid(
|
||||
selectedAllergens = allergenLevels,
|
||||
onLevelChanged = onSetAllergenLevel
|
||||
onLevelChanged = onSetAllergenLevel,
|
||||
)
|
||||
}
|
||||
|
||||
@ -324,7 +331,7 @@ private fun CreateProfileStep(
|
||||
selected = r in restrictions,
|
||||
onClick = { onToggleRestriction(r) },
|
||||
label = { Text(r.displayFr) },
|
||||
modifier = Modifier.padding(4.dp)
|
||||
modifier = Modifier.padding(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -347,27 +354,32 @@ private fun CreateProfileStep(
|
||||
onClick = onNext,
|
||||
enabled = name.isNotBlank(),
|
||||
large = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionStep(granted: Boolean, rationale: Boolean, onRequest: () -> Unit, onNext: () -> Unit) {
|
||||
private fun PermissionStep(
|
||||
granted: Boolean,
|
||||
rationale: Boolean,
|
||||
onRequest: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.onboarding_permission_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.onboarding_permission_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (granted) {
|
||||
@ -375,14 +387,14 @@ private fun PermissionStep(granted: Boolean, rationale: Boolean, onRequest: () -
|
||||
text = stringResource(R.string.action_continue),
|
||||
onClick = onNext,
|
||||
large = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
PrimaryButton(
|
||||
text = stringResource(R.string.onboarding_permission_grant),
|
||||
onClick = onRequest,
|
||||
large = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
TertiaryButton(
|
||||
text = stringResource(R.string.action_continue),
|
||||
@ -397,27 +409,27 @@ private fun ReadyStep(onFinish: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("🎉", style = MaterialTheme.typography.displayLarge)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
stringResource(R.string.onboarding_ready_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.onboarding_ready_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
PrimaryButton(
|
||||
text = stringResource(R.string.onboarding_start),
|
||||
onClick = onFinish,
|
||||
large = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,31 +13,34 @@ import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class OnboardingViewModel @Inject constructor(
|
||||
private val manageProfile: ManageProfileUseCase,
|
||||
private val settings: SettingsRepository
|
||||
) : ViewModel() {
|
||||
fun createProfile(
|
||||
name: String,
|
||||
avatar: String,
|
||||
severe: Set<AllergenType>,
|
||||
moderate: Set<AllergenType>,
|
||||
restrictions: Set<DietaryRestriction> = emptySet(),
|
||||
customItems: List<CustomDietItem> = emptyList()
|
||||
) = viewModelScope.launch {
|
||||
val id = manageProfile.save(
|
||||
UserProfile(
|
||||
name = name.ifBlank { "Moi" },
|
||||
avatar = avatar,
|
||||
severeAllergens = severe,
|
||||
moderateIntolerances = moderate,
|
||||
dietaryRestrictions = restrictions,
|
||||
customItems = customItems,
|
||||
isDefault = true
|
||||
)
|
||||
)
|
||||
manageProfile.setActive(setOf(id))
|
||||
}
|
||||
class OnboardingViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val manageProfile: ManageProfileUseCase,
|
||||
private val settings: SettingsRepository,
|
||||
) : ViewModel() {
|
||||
fun createProfile(
|
||||
name: String,
|
||||
avatar: String,
|
||||
severe: Set<AllergenType>,
|
||||
moderate: Set<AllergenType>,
|
||||
restrictions: Set<DietaryRestriction> = emptySet(),
|
||||
customItems: List<CustomDietItem> = emptyList(),
|
||||
) = viewModelScope.launch {
|
||||
val id =
|
||||
manageProfile.save(
|
||||
UserProfile(
|
||||
name = name.ifBlank { "Moi" },
|
||||
avatar = avatar,
|
||||
severeAllergens = severe,
|
||||
moderateIntolerances = moderate,
|
||||
dietaryRestrictions = restrictions,
|
||||
customItems = customItems,
|
||||
isDefault = true,
|
||||
),
|
||||
)
|
||||
manageProfile.setActive(setOf(id))
|
||||
}
|
||||
|
||||
fun complete() = viewModelScope.launch { settings.setOnboardingCompleted(true) }
|
||||
}
|
||||
fun complete() = viewModelScope.launch { settings.setOnboardingCompleted(true) }
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,49 +21,57 @@ import javax.inject.Inject
|
||||
*/
|
||||
sealed class ProductDetailUiState {
|
||||
data object Loading : ProductDetailUiState()
|
||||
|
||||
data class Success(
|
||||
val product: Product,
|
||||
val scanResult: ScanResult?
|
||||
val scanResult: ScanResult?,
|
||||
) : ProductDetailUiState()
|
||||
|
||||
data class Error(val message: String) : ProductDetailUiState()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class ProductDetailViewModel @Inject constructor(
|
||||
private val fetchProduct: FetchProductUseCase,
|
||||
private val analyzeProduct: AnalyzeProductUseCase,
|
||||
private val manageProfile: ManageProfileUseCase
|
||||
) : ViewModel() {
|
||||
class ProductDetailViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val fetchProduct: FetchProductUseCase,
|
||||
private val analyzeProduct: AnalyzeProductUseCase,
|
||||
private val manageProfile: ManageProfileUseCase,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow<ProductDetailUiState>(ProductDetailUiState.Loading)
|
||||
val uiState: StateFlow<ProductDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _uiState = MutableStateFlow<ProductDetailUiState>(ProductDetailUiState.Loading)
|
||||
val uiState: StateFlow<ProductDetailUiState> = _uiState.asStateFlow()
|
||||
fun loadProduct(barcode: String) =
|
||||
viewModelScope.launch {
|
||||
_uiState.value = ProductDetailUiState.Loading
|
||||
|
||||
fun loadProduct(barcode: String) = viewModelScope.launch {
|
||||
_uiState.value = ProductDetailUiState.Loading
|
||||
when (val result = fetchProduct(barcode)) {
|
||||
is ProductFetchResult.Found -> {
|
||||
val profiles = resolveProfiles()
|
||||
val scanResult =
|
||||
if (profiles.isNotEmpty()) {
|
||||
analyzeProduct(result.product, profiles, com.safebite.app.domain.model.DataSource.API)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
_uiState.value = ProductDetailUiState.Success(result.product, scanResult)
|
||||
}
|
||||
is ProductFetchResult.NotFound -> {
|
||||
_uiState.value = ProductDetailUiState.Error("Produit non trouvé")
|
||||
}
|
||||
is ProductFetchResult.Error -> {
|
||||
_uiState.value = ProductDetailUiState.Error(result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val result = fetchProduct(barcode)) {
|
||||
is ProductFetchResult.Found -> {
|
||||
val profiles = resolveProfiles()
|
||||
val scanResult = if (profiles.isNotEmpty()) {
|
||||
analyzeProduct(result.product, profiles, com.safebite.app.domain.model.DataSource.API)
|
||||
} else null
|
||||
_uiState.value = ProductDetailUiState.Success(result.product, scanResult)
|
||||
private suspend fun resolveProfiles() =
|
||||
run {
|
||||
val all = manageProfile.observe().first()
|
||||
val activeIds = manageProfile.observeActiveIds().first()
|
||||
when {
|
||||
activeIds.isNotEmpty() -> all.filter { it.id in activeIds }
|
||||
else -> all.filter { it.isDefault }.ifEmpty { all.take(1) }
|
||||
}
|
||||
}
|
||||
is ProductFetchResult.NotFound -> {
|
||||
_uiState.value = ProductDetailUiState.Error("Produit non trouvé")
|
||||
}
|
||||
is ProductFetchResult.Error -> {
|
||||
_uiState.value = ProductDetailUiState.Error(result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveProfiles() = run {
|
||||
val all = manageProfile.observe().first()
|
||||
val activeIds = manageProfile.observeActiveIds().first()
|
||||
when {
|
||||
activeIds.isNotEmpty() -> all.filter { it.id in activeIds }
|
||||
else -> all.filter { it.isDefault }.ifEmpty { all.take(1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@ -40,7 +39,10 @@ import com.safebite.app.domain.model.CustomItemTag
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun AllergenGrid(selected: Set<AllergenType>, onToggle: (AllergenType) -> Unit) {
|
||||
fun AllergenGrid(
|
||||
selected: Set<AllergenType>,
|
||||
onToggle: (AllergenType) -> Unit,
|
||||
) {
|
||||
FlowRow {
|
||||
AllergenType.entries.forEach { a ->
|
||||
FilterChip(
|
||||
@ -48,7 +50,7 @@ fun AllergenGrid(selected: Set<AllergenType>, onToggle: (AllergenType) -> Unit)
|
||||
onClick = { onToggle(a) },
|
||||
leadingIcon = { Text(a.icon) },
|
||||
label = { Text(a.displayNameFr) },
|
||||
modifier = Modifier.padding(4.dp)
|
||||
modifier = Modifier.padding(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -66,7 +68,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(R.string.profile_custom_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
singleLine = true,
|
||||
)
|
||||
Text(stringResource(R.string.profile_custom_tag), style = MaterialTheme.typography.labelLarge)
|
||||
FlowRow {
|
||||
@ -75,7 +77,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
|
||||
selected = tag == t,
|
||||
onClick = { tag = t },
|
||||
label = { Text(tagLabel(t)) },
|
||||
modifier = Modifier.padding(4.dp)
|
||||
modifier = Modifier.padding(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -85,7 +87,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
|
||||
name = ""
|
||||
},
|
||||
enabled = name.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Add, null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
@ -97,7 +99,10 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CustomItemsList(items: List<CustomDietItem>, onRemove: (CustomDietItem) -> Unit) {
|
||||
fun CustomItemsList(
|
||||
items: List<CustomDietItem>,
|
||||
onRemove: (CustomDietItem) -> Unit,
|
||||
) {
|
||||
if (items.isEmpty()) {
|
||||
Text(stringResource(R.string.profile_custom_empty), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
return
|
||||
@ -109,38 +114,42 @@ fun CustomItemsList(items: List<CustomDietItem>, onRemove: (CustomDietItem) -> U
|
||||
label = {
|
||||
Text(
|
||||
"${tagIcon(item.tag)} ${item.name}",
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
},
|
||||
trailingIcon = { Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = tagColor(item.tag).copy(alpha = 0.18f)
|
||||
),
|
||||
modifier = Modifier.padding(4.dp)
|
||||
colors =
|
||||
AssistChipDefaults.assistChipColors(
|
||||
containerColor = tagColor(item.tag).copy(alpha = 0.18f),
|
||||
),
|
||||
modifier = Modifier.padding(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun tagLabel(tag: CustomItemTag): String = when (tag) {
|
||||
CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy)
|
||||
CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance)
|
||||
CustomItemTag.DIET -> stringResource(R.string.profile_custom_tag_diet)
|
||||
CustomItemTag.UNHEALTHY -> stringResource(R.string.profile_custom_tag_unhealthy)
|
||||
}
|
||||
fun tagLabel(tag: CustomItemTag): String =
|
||||
when (tag) {
|
||||
CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy)
|
||||
CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance)
|
||||
CustomItemTag.DIET -> stringResource(R.string.profile_custom_tag_diet)
|
||||
CustomItemTag.UNHEALTHY -> stringResource(R.string.profile_custom_tag_unhealthy)
|
||||
}
|
||||
|
||||
fun tagIcon(tag: CustomItemTag): String = when (tag) {
|
||||
CustomItemTag.ALLERGY -> "⛔"
|
||||
CustomItemTag.INTOLERANCE -> "⚠️"
|
||||
CustomItemTag.DIET -> "🥗"
|
||||
CustomItemTag.UNHEALTHY -> "🍩"
|
||||
}
|
||||
fun tagIcon(tag: CustomItemTag): String =
|
||||
when (tag) {
|
||||
CustomItemTag.ALLERGY -> "⛔"
|
||||
CustomItemTag.INTOLERANCE -> "⚠️"
|
||||
CustomItemTag.DIET -> "🥗"
|
||||
CustomItemTag.UNHEALTHY -> "🍩"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun tagColor(tag: CustomItemTag): Color = when (tag) {
|
||||
CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.error
|
||||
CustomItemTag.INTOLERANCE -> Color(0xFFFFA000)
|
||||
CustomItemTag.DIET -> MaterialTheme.colorScheme.tertiary
|
||||
CustomItemTag.UNHEALTHY -> Color(0xFF9575CD)
|
||||
}
|
||||
fun tagColor(tag: CustomItemTag): Color =
|
||||
when (tag) {
|
||||
CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.error
|
||||
CustomItemTag.INTOLERANCE -> Color(0xFFFFA000)
|
||||
CustomItemTag.DIET -> MaterialTheme.colorScheme.tertiary
|
||||
CustomItemTag.UNHEALTHY -> Color(0xFF9575CD)
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ data class ProfileEditUi(
|
||||
val restrictions: Set<DietaryRestriction> = emptySet(),
|
||||
val customItems: List<CustomDietItem> = emptyList(),
|
||||
val isDefault: Boolean = false,
|
||||
val loaded: Boolean = false
|
||||
val loaded: Boolean = false,
|
||||
) {
|
||||
// Propriétés calculées pour la compatibilité
|
||||
val severe: Set<AllergenType>
|
||||
@ -38,110 +38,134 @@ data class ProfileEditUi(
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class ProfileViewModel @Inject constructor(
|
||||
private val manage: ManageProfileUseCase
|
||||
) : ViewModel() {
|
||||
class ProfileViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val manage: ManageProfileUseCase,
|
||||
) : ViewModel() {
|
||||
val profiles: StateFlow<List<UserProfile>> =
|
||||
manage.observe()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val profiles: StateFlow<List<UserProfile>> = manage.observe()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
private val _edit = MutableStateFlow(ProfileEditUi())
|
||||
val edit: StateFlow<ProfileEditUi> = _edit.asStateFlow()
|
||||
|
||||
private val _edit = MutableStateFlow(ProfileEditUi())
|
||||
val edit: StateFlow<ProfileEditUi> = _edit.asStateFlow()
|
||||
fun load(id: Long) =
|
||||
viewModelScope.launch {
|
||||
if (id == 0L) {
|
||||
_edit.value = ProfileEditUi(loaded = true)
|
||||
} else {
|
||||
val p = manage.get(id)
|
||||
if (p != null) {
|
||||
// Construire la map des niveaux d'allergènes
|
||||
val allergenLevels = mutableMapOf<AllergenType, AllergenLevel>()
|
||||
p.severeAllergens.forEach { allergenLevels[it] = AllergenLevel.SEVERE }
|
||||
p.moderateIntolerances.forEach { allergenLevels[it] = AllergenLevel.TRACE }
|
||||
|
||||
fun load(id: Long) = viewModelScope.launch {
|
||||
if (id == 0L) {
|
||||
_edit.value = ProfileEditUi(loaded = true)
|
||||
} else {
|
||||
val p = manage.get(id)
|
||||
if (p != null) {
|
||||
// Construire la map des niveaux d'allergènes
|
||||
val allergenLevels = mutableMapOf<AllergenType, AllergenLevel>()
|
||||
p.severeAllergens.forEach { allergenLevels[it] = AllergenLevel.SEVERE }
|
||||
p.moderateIntolerances.forEach { allergenLevels[it] = AllergenLevel.TRACE }
|
||||
_edit.value =
|
||||
ProfileEditUi(
|
||||
id = p.id,
|
||||
name = p.name,
|
||||
avatar = p.avatar,
|
||||
allergenLevels = allergenLevels,
|
||||
restrictions = p.dietaryRestrictions,
|
||||
customItems = p.customItems,
|
||||
isDefault = p.isDefault,
|
||||
loaded = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_edit.value = ProfileEditUi(
|
||||
id = p.id,
|
||||
name = p.name,
|
||||
avatar = p.avatar,
|
||||
allergenLevels = allergenLevels,
|
||||
restrictions = p.dietaryRestrictions,
|
||||
customItems = p.customItems,
|
||||
isDefault = p.isDefault,
|
||||
loaded = true
|
||||
)
|
||||
fun setName(v: String) = _edit.update { it.copy(name = v) }
|
||||
|
||||
fun setAvatar(v: String) = _edit.update { it.copy(avatar = v) }
|
||||
|
||||
/** Met à jour le niveau d'un allergène (cycle : NONE → TRACE → SEVERE → NONE) */
|
||||
fun setAllergenLevel(
|
||||
allergen: AllergenType,
|
||||
level: AllergenLevel,
|
||||
) = _edit.update { s ->
|
||||
val newLevels =
|
||||
if (level == AllergenLevel.NONE) {
|
||||
s.allergenLevels - allergen
|
||||
} else {
|
||||
s.allergenLevels + (allergen to level)
|
||||
}
|
||||
s.copy(allergenLevels = newLevels)
|
||||
}
|
||||
|
||||
// Méthodes de compatibilité pour l'ancien AllergenGrid
|
||||
fun toggleSevere(a: AllergenType) =
|
||||
_edit.update { s ->
|
||||
val newLevel = if (a in s.severe) AllergenLevel.NONE else AllergenLevel.SEVERE
|
||||
val newLevels =
|
||||
if (newLevel == AllergenLevel.NONE) {
|
||||
s.allergenLevels - a
|
||||
} else {
|
||||
s.allergenLevels + (a to newLevel)
|
||||
}
|
||||
s.copy(allergenLevels = newLevels)
|
||||
}
|
||||
|
||||
fun toggleModerate(a: AllergenType) =
|
||||
_edit.update { s ->
|
||||
val newLevel = if (a in s.moderate) AllergenLevel.NONE else AllergenLevel.TRACE
|
||||
val newLevels =
|
||||
if (newLevel == AllergenLevel.NONE) {
|
||||
s.allergenLevels - a
|
||||
} else {
|
||||
s.allergenLevels + (a to newLevel)
|
||||
}
|
||||
s.copy(allergenLevels = newLevels)
|
||||
}
|
||||
|
||||
fun toggleRestriction(r: DietaryRestriction) =
|
||||
_edit.update { s ->
|
||||
s.copy(restrictions = if (r in s.restrictions) s.restrictions - r else s.restrictions + r)
|
||||
}
|
||||
|
||||
fun setDefault(v: Boolean) = _edit.update { it.copy(isDefault = v) }
|
||||
|
||||
fun addCustomItem(
|
||||
name: String,
|
||||
tag: CustomItemTag,
|
||||
) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isBlank()) return
|
||||
_edit.update { s ->
|
||||
if (s.customItems.any { it.name.equals(trimmed, ignoreCase = true) && it.tag == tag }) {
|
||||
s
|
||||
} else {
|
||||
s.copy(customItems = s.customItems + CustomDietItem(trimmed, tag))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCustomItem(item: CustomDietItem) =
|
||||
_edit.update { s ->
|
||||
s.copy(customItems = s.customItems.filterNot { it.name == item.name && it.tag == item.tag })
|
||||
}
|
||||
|
||||
fun save(onDone: () -> Unit) =
|
||||
viewModelScope.launch {
|
||||
val ui = _edit.value
|
||||
val id =
|
||||
manage.save(
|
||||
UserProfile(
|
||||
id = ui.id,
|
||||
name = ui.name.ifBlank { "Profil" },
|
||||
avatar = ui.avatar,
|
||||
severeAllergens = ui.severe,
|
||||
moderateIntolerances = ui.moderate,
|
||||
dietaryRestrictions = ui.restrictions,
|
||||
customItems = ui.customItems,
|
||||
isDefault = ui.isDefault,
|
||||
),
|
||||
)
|
||||
if (ui.isDefault) manage.setDefault(id)
|
||||
onDone()
|
||||
}
|
||||
|
||||
fun delete(profile: UserProfile) = viewModelScope.launch { manage.delete(profile) }
|
||||
}
|
||||
|
||||
fun setName(v: String) = _edit.update { it.copy(name = v) }
|
||||
fun setAvatar(v: String) = _edit.update { it.copy(avatar = v) }
|
||||
|
||||
/** Met à jour le niveau d'un allergène (cycle : NONE → TRACE → SEVERE → NONE) */
|
||||
fun setAllergenLevel(allergen: AllergenType, level: AllergenLevel) = _edit.update { s ->
|
||||
val newLevels = if (level == AllergenLevel.NONE) {
|
||||
s.allergenLevels - allergen
|
||||
} else {
|
||||
s.allergenLevels + (allergen to level)
|
||||
}
|
||||
s.copy(allergenLevels = newLevels)
|
||||
}
|
||||
|
||||
// Méthodes de compatibilité pour l'ancien AllergenGrid
|
||||
fun toggleSevere(a: AllergenType) = _edit.update { s ->
|
||||
val newLevel = if (a in s.severe) AllergenLevel.NONE else AllergenLevel.SEVERE
|
||||
val newLevels = if (newLevel == AllergenLevel.NONE) {
|
||||
s.allergenLevels - a
|
||||
} else {
|
||||
s.allergenLevels + (a to newLevel)
|
||||
}
|
||||
s.copy(allergenLevels = newLevels)
|
||||
}
|
||||
|
||||
fun toggleModerate(a: AllergenType) = _edit.update { s ->
|
||||
val newLevel = if (a in s.moderate) AllergenLevel.NONE else AllergenLevel.TRACE
|
||||
val newLevels = if (newLevel == AllergenLevel.NONE) {
|
||||
s.allergenLevels - a
|
||||
} else {
|
||||
s.allergenLevels + (a to newLevel)
|
||||
}
|
||||
s.copy(allergenLevels = newLevels)
|
||||
}
|
||||
|
||||
fun toggleRestriction(r: DietaryRestriction) = _edit.update { s ->
|
||||
s.copy(restrictions = if (r in s.restrictions) s.restrictions - r else s.restrictions + r)
|
||||
}
|
||||
fun setDefault(v: Boolean) = _edit.update { it.copy(isDefault = v) }
|
||||
|
||||
fun addCustomItem(name: String, tag: CustomItemTag) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isBlank()) return
|
||||
_edit.update { s ->
|
||||
if (s.customItems.any { it.name.equals(trimmed, ignoreCase = true) && it.tag == tag }) s
|
||||
else s.copy(customItems = s.customItems + CustomDietItem(trimmed, tag))
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCustomItem(item: CustomDietItem) = _edit.update { s ->
|
||||
s.copy(customItems = s.customItems.filterNot { it.name == item.name && it.tag == item.tag })
|
||||
}
|
||||
|
||||
fun save(onDone: () -> Unit) = viewModelScope.launch {
|
||||
val ui = _edit.value
|
||||
val id = manage.save(
|
||||
UserProfile(
|
||||
id = ui.id,
|
||||
name = ui.name.ifBlank { "Profil" },
|
||||
avatar = ui.avatar,
|
||||
severeAllergens = ui.severe,
|
||||
moderateIntolerances = ui.moderate,
|
||||
dietaryRestrictions = ui.restrictions,
|
||||
customItems = ui.customItems,
|
||||
isDefault = ui.isDefault
|
||||
)
|
||||
)
|
||||
if (ui.isDefault) manage.setDefault(id)
|
||||
onDone()
|
||||
}
|
||||
|
||||
fun delete(profile: UserProfile) = viewModelScope.launch { manage.delete(profile) }
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<com.safebite.app.data.local.database.entity.ShoppingListEntity>,
|
||||
onSelect: (Long) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.result_choose_list),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (lists.isEmpty()) {
|
||||
Text(
|
||||
text = "Aucune liste disponible",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
lists.forEach { list ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onSelect(list.id) }
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onSelect(list.id) }
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = list.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -699,3 +794,26 @@ private fun ListPickerBottomSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animation stagger pour les actions du verdict.
|
||||
* Chaque bouton apparaît avec un délai progressif (+50ms).
|
||||
*/
|
||||
@Composable
|
||||
private fun StaggeredAction(
|
||||
visible: Boolean,
|
||||
delayMs: Int,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter =
|
||||
fadeIn(animationSpec = tween(durationMillis = 300, delayMillis = delayMs)) +
|
||||
slideInVertically(
|
||||
initialOffsetY = { it / 4 },
|
||||
animationSpec = tween(durationMillis = 300, delayMillis = delayMs),
|
||||
),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,13 +2,13 @@ package com.safebite.app.presentation.screen.result
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
|
||||
import com.safebite.app.domain.model.DataSource
|
||||
import com.safebite.app.domain.model.ScanResult
|
||||
import com.safebite.app.domain.model.UserProfile
|
||||
import com.safebite.app.domain.repository.ProductFetchResult
|
||||
import com.safebite.app.domain.usecase.AnalyzeIngredientsTextUseCase
|
||||
import com.safebite.app.domain.usecase.AnalyzeProductUseCase
|
||||
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
|
||||
import com.safebite.app.domain.usecase.FetchProductUseCase
|
||||
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
|
||||
import com.safebite.app.domain.usecase.ManageProfileUseCase
|
||||
@ -26,76 +26,82 @@ import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ResultViewModel @Inject constructor(
|
||||
private val fetchProduct: FetchProductUseCase,
|
||||
private val analyzeProduct: AnalyzeProductUseCase,
|
||||
private val analyzeText: AnalyzeIngredientsTextUseCase,
|
||||
private val manageProfile: ManageProfileUseCase,
|
||||
private val saveScan: SaveScanUseCase,
|
||||
private val getLists: GetShoppingListsUseCase,
|
||||
private val manageList: ManageShoppingListUseCase
|
||||
) : ViewModel() {
|
||||
class ResultViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val fetchProduct: FetchProductUseCase,
|
||||
private val analyzeProduct: AnalyzeProductUseCase,
|
||||
private val analyzeText: AnalyzeIngredientsTextUseCase,
|
||||
private val manageProfile: ManageProfileUseCase,
|
||||
private val saveScan: SaveScanUseCase,
|
||||
private val getLists: GetShoppingListsUseCase,
|
||||
private val manageList: ManageShoppingListUseCase,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow<UiState<ScanResult>>(UiState.Idle)
|
||||
val state: StateFlow<UiState<ScanResult>> = _state.asStateFlow()
|
||||
|
||||
private val _state = MutableStateFlow<UiState<ScanResult>>(UiState.Idle)
|
||||
val state: StateFlow<UiState<ScanResult>> = _state.asStateFlow()
|
||||
val lists =
|
||||
getLists.observeActive()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val lists = getLists.observeActive()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
fun analyzeBarcode(barcode: String) =
|
||||
viewModelScope.launch {
|
||||
_state.value = UiState.Loading
|
||||
val profiles = resolveProfiles()
|
||||
if (profiles.isEmpty()) {
|
||||
_state.value = UiState.Error("No profile configured")
|
||||
return@launch
|
||||
}
|
||||
when (val fetched = fetchProduct(barcode)) {
|
||||
is ProductFetchResult.Found -> {
|
||||
val source = if (fetched.fromCache) DataSource.CACHE else DataSource.API
|
||||
val result = analyzeProduct(fetched.product, profiles, source)
|
||||
_state.value = UiState.Success(result)
|
||||
saveScan(result)
|
||||
}
|
||||
ProductFetchResult.NotFound -> _state.value = UiState.Error("not_found")
|
||||
is ProductFetchResult.Error -> _state.value = UiState.Error(fetched.message, offline = fetched.offline)
|
||||
}
|
||||
}
|
||||
|
||||
fun analyzeBarcode(barcode: String) = viewModelScope.launch {
|
||||
_state.value = UiState.Loading
|
||||
val profiles = resolveProfiles()
|
||||
if (profiles.isEmpty()) {
|
||||
_state.value = UiState.Error("No profile configured")
|
||||
return@launch
|
||||
}
|
||||
when (val fetched = fetchProduct(barcode)) {
|
||||
is ProductFetchResult.Found -> {
|
||||
val source = if (fetched.fromCache) DataSource.CACHE else DataSource.API
|
||||
val result = analyzeProduct(fetched.product, profiles, source)
|
||||
fun analyzeOcrText(text: String) =
|
||||
viewModelScope.launch {
|
||||
_state.value = UiState.Loading
|
||||
val profiles = resolveProfiles()
|
||||
if (profiles.isEmpty()) {
|
||||
_state.value = UiState.Error("No profile configured")
|
||||
return@launch
|
||||
}
|
||||
val result = analyzeText(text, profiles)
|
||||
_state.value = UiState.Success(result)
|
||||
saveScan(result)
|
||||
}
|
||||
ProductFetchResult.NotFound -> _state.value = UiState.Error("not_found")
|
||||
is ProductFetchResult.Error -> _state.value = UiState.Error(fetched.message, offline = fetched.offline)
|
||||
}
|
||||
}
|
||||
|
||||
fun analyzeOcrText(text: String) = viewModelScope.launch {
|
||||
_state.value = UiState.Loading
|
||||
val profiles = resolveProfiles()
|
||||
if (profiles.isEmpty()) {
|
||||
_state.value = UiState.Error("No profile configured")
|
||||
return@launch
|
||||
private suspend fun resolveProfiles(): List<UserProfile> {
|
||||
val all = manageProfile.observe().first()
|
||||
val activeIds = manageProfile.observeActiveIds().first()
|
||||
return when {
|
||||
activeIds.isNotEmpty() -> all.filter { it.id in activeIds }
|
||||
else -> all.filter { it.isDefault }.ifEmpty { all.take(1) }
|
||||
}
|
||||
}
|
||||
val result = analyzeText(text, profiles)
|
||||
_state.value = UiState.Success(result)
|
||||
saveScan(result)
|
||||
}
|
||||
|
||||
private suspend fun resolveProfiles(): List<UserProfile> {
|
||||
val all = manageProfile.observe().first()
|
||||
val activeIds = manageProfile.observeActiveIds().first()
|
||||
return when {
|
||||
activeIds.isNotEmpty() -> all.filter { it.id in activeIds }
|
||||
else -> all.filter { it.isDefault }.ifEmpty { all.take(1) }
|
||||
}
|
||||
fun addToList(listId: Long) =
|
||||
viewModelScope.launch {
|
||||
val currentState = _state.value
|
||||
if (currentState !is UiState.Success) return@launch
|
||||
val result = currentState.data
|
||||
val entity =
|
||||
ShoppingListItemEntity(
|
||||
listId = listId,
|
||||
barcode = result.product.barcode,
|
||||
productName = result.product.name ?: result.product.barcode,
|
||||
brand = result.product.brand,
|
||||
imageUrl = result.product.imageUrl,
|
||||
isChecked = false,
|
||||
safetyStatus = result.safetyStatus.name,
|
||||
allergenWarning = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr,
|
||||
)
|
||||
manageList.addItemToList(listId, entity)
|
||||
}
|
||||
}
|
||||
|
||||
fun addToList(listId: Long) = viewModelScope.launch {
|
||||
val currentState = _state.value
|
||||
if (currentState !is UiState.Success) return@launch
|
||||
val result = currentState.data
|
||||
val entity = ShoppingListItemEntity(
|
||||
listId = listId,
|
||||
barcode = result.product.barcode,
|
||||
productName = result.product.name ?: result.product.barcode,
|
||||
brand = result.product.brand,
|
||||
imageUrl = result.product.imageUrl,
|
||||
isChecked = false,
|
||||
safetyStatus = result.safetyStatus.name,
|
||||
allergenWarning = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr
|
||||
)
|
||||
manageList.addItemToList(listId, entity)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ->
|
||||
|
||||
@ -20,21 +20,27 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.FlashOff
|
||||
import androidx.compose.material.icons.filled.FlashOn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@ -49,13 +55,14 @@ import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
@ -68,11 +75,15 @@ import java.util.concurrent.Executors
|
||||
@Composable
|
||||
fun ScannerScreen(
|
||||
onBack: () -> Unit,
|
||||
onBarcode: (String) -> Unit
|
||||
onBarcode: (String) -> Unit,
|
||||
) {
|
||||
val permission = rememberPermissionState(android.Manifest.permission.CAMERA)
|
||||
LaunchedEffect(Unit) { if (!permission.status.isGranted) permission.launchPermissionRequest() }
|
||||
|
||||
var showManualDialog by remember { mutableStateOf(false) }
|
||||
var manualCode by remember { mutableStateOf("") }
|
||||
var manualError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
@ -81,7 +92,7 @@ fun ScannerScreen(
|
||||
onBack = onBack,
|
||||
backContentDescription = stringResource(R.string.a11y_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
val scanAreaDesc = stringResource(R.string.a11y_scan_area)
|
||||
Box(
|
||||
@ -90,22 +101,98 @@ fun ScannerScreen(
|
||||
.padding(padding)
|
||||
.semantics {
|
||||
contentDescription = scanAreaDesc
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (!permission.status.isGranted) {
|
||||
ErrorView(
|
||||
message = stringResource(R.string.scanner_camera_denied),
|
||||
onRetry = { permission.launchPermissionRequest() }
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
ErrorView(
|
||||
message = stringResource(R.string.scanner_camera_denied),
|
||||
onRetry = { permission.launchPermissionRequest() },
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
TextButton(onClick = { showManualDialog = true }) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = null)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.scanner_manual_entry_button))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
CameraView(onBarcode = onBarcode)
|
||||
CameraView(
|
||||
onBarcode = onBarcode,
|
||||
onManualEntry = { showManualDialog = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialog de saisie manuelle
|
||||
if (showManualDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showManualDialog = false
|
||||
manualCode = ""
|
||||
manualError = null
|
||||
},
|
||||
title = { Text(stringResource(R.string.scanner_manual_entry_title)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = manualCode,
|
||||
onValueChange = {
|
||||
manualCode = it.filter { c -> c.isDigit() }.take(13)
|
||||
manualError = null
|
||||
},
|
||||
label = { Text(stringResource(R.string.scanner_manual_entry_hint)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
isError = manualError != null,
|
||||
supportingText =
|
||||
if (manualError != null) {
|
||||
{ Text(manualError!!) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
val invalidMsg = stringResource(R.string.scanner_manual_entry_invalid)
|
||||
TextButton(onClick = {
|
||||
val code = manualCode.trim()
|
||||
if (isValidBarcodeFormat(code)) {
|
||||
showManualDialog = false
|
||||
manualCode = ""
|
||||
manualError = null
|
||||
onBarcode(code)
|
||||
} else {
|
||||
manualError = invalidMsg
|
||||
}
|
||||
}) {
|
||||
Text(stringResource(R.string.scanner_manual_entry_search))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
showManualDialog = false
|
||||
manualCode = ""
|
||||
manualError = null
|
||||
}) {
|
||||
Text(stringResource(R.string.a11y_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraView(onBarcode: (String) -> Unit) {
|
||||
private fun CameraView(
|
||||
onBarcode: (String) -> Unit,
|
||||
onManualEntry: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var torch by remember { mutableStateOf(false) }
|
||||
@ -119,66 +206,99 @@ private fun CameraView(onBarcode: (String) -> Unit) {
|
||||
androidx.compose.ui.viewinterop.AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
val previewView = PreviewView(ctx).apply {
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
}
|
||||
val previewView =
|
||||
PreviewView(ctx).apply {
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
}
|
||||
val providerFuture = ProcessCameraProvider.getInstance(ctx)
|
||||
providerFuture.addListener({
|
||||
val provider = providerFuture.get()
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also { it.setAnalyzer(executor, BarcodeAnalyzer { code ->
|
||||
if (!detected) {
|
||||
detected = true
|
||||
triggerHaptic(ctx)
|
||||
onBarcode(code)
|
||||
val preview =
|
||||
Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
val analysis =
|
||||
ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also {
|
||||
it.setAnalyzer(
|
||||
executor,
|
||||
BarcodeAnalyzer { code ->
|
||||
if (!detected) {
|
||||
detected = true
|
||||
triggerHaptic(ctx)
|
||||
onBarcode(code)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}) }
|
||||
try {
|
||||
provider.unbindAll()
|
||||
val camera = provider.bindToLifecycle(
|
||||
lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
|
||||
)
|
||||
val camera =
|
||||
provider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
analysis,
|
||||
)
|
||||
cameraControl = camera.cameraControl
|
||||
} catch (t: Throwable) { /* ignore */ }
|
||||
} catch (t: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(ctx))
|
||||
previewView
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
ScanOverlay(modifier = Modifier.fillMaxSize())
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scanner_hint),
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.background(Color(0x99000000), RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.background(Color(0x99000000), RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
IconButton(
|
||||
onClick = {
|
||||
torch = !torch
|
||||
cameraControl?.enableTorch(torch)
|
||||
},
|
||||
modifier = Modifier.size(48.dp)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
if (torch) Icons.Filled.FlashOn else Icons.Filled.FlashOff,
|
||||
contentDescription = stringResource(R.string.a11y_torch),
|
||||
tint = Color.White
|
||||
)
|
||||
TextButton(onClick = onManualEntry) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.scanner_manual_entry_button),
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
torch = !torch
|
||||
cameraControl?.enableTorch(torch)
|
||||
},
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (torch) Icons.Filled.FlashOn else Icons.Filled.FlashOff,
|
||||
contentDescription = stringResource(R.string.a11y_torch),
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -190,11 +310,12 @@ private fun ScanOverlay(modifier: Modifier = Modifier) {
|
||||
val y by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1800, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "scanY"
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(1800, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "scanY",
|
||||
)
|
||||
Canvas(modifier = modifier) {
|
||||
val w = size.width
|
||||
@ -203,35 +324,35 @@ private fun ScanOverlay(modifier: Modifier = Modifier) {
|
||||
val topLeft = Offset((w - boxSize.width) / 2f, (h - boxSize.height) / 2f)
|
||||
drawRect(
|
||||
color = Color(0xB3000000),
|
||||
size = Size(w, topLeft.y)
|
||||
size = Size(w, topLeft.y),
|
||||
)
|
||||
drawRect(
|
||||
color = Color(0xB3000000),
|
||||
topLeft = Offset(0f, topLeft.y + boxSize.height),
|
||||
size = Size(w, h - topLeft.y - boxSize.height)
|
||||
size = Size(w, h - topLeft.y - boxSize.height),
|
||||
)
|
||||
drawRect(
|
||||
color = Color(0xB3000000),
|
||||
topLeft = Offset(0f, topLeft.y),
|
||||
size = Size(topLeft.x, boxSize.height)
|
||||
size = Size(topLeft.x, boxSize.height),
|
||||
)
|
||||
drawRect(
|
||||
color = Color(0xB3000000),
|
||||
topLeft = Offset(topLeft.x + boxSize.width, topLeft.y),
|
||||
size = Size(w - topLeft.x - boxSize.width, boxSize.height)
|
||||
size = Size(w - topLeft.x - boxSize.width, boxSize.height),
|
||||
)
|
||||
drawRect(
|
||||
color = Color.White,
|
||||
topLeft = topLeft,
|
||||
size = boxSize,
|
||||
style = Stroke(width = 4f)
|
||||
style = Stroke(width = 4f),
|
||||
)
|
||||
val lineY = topLeft.y + boxSize.height * y
|
||||
drawLine(
|
||||
color = Color(0xFF00E676),
|
||||
start = Offset(topLeft.x, lineY),
|
||||
end = Offset(topLeft.x + boxSize.width, lineY),
|
||||
strokeWidth = 4f
|
||||
strokeWidth = 4f,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -246,5 +367,15 @@ private fun triggerHaptic(context: android.content.Context) {
|
||||
val v = context.getSystemService(android.content.Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
v?.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
}
|
||||
} catch (_: Throwable) { /* ignore */ }
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Valide le format d'un code-barres (EAN-13, EAN-8, UPC-A, UPC-E). */
|
||||
private fun isValidBarcodeFormat(code: String): Boolean {
|
||||
if (code.isEmpty()) return false
|
||||
val digits = code.filter { it.isDigit() }
|
||||
if (digits.length != code.length) return false
|
||||
return digits.length in 8..13
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -24,41 +24,52 @@ data class SettingsUi(
|
||||
val sound: Boolean = true,
|
||||
val theme: ThemePref = ThemePref.SYSTEM,
|
||||
val healthStrictness: HealthStrictness = HealthStrictness.NORMAL,
|
||||
val splashScreenEnabled: Boolean = true
|
||||
val splashScreenEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val settings: SettingsRepository,
|
||||
private val productRepo: ProductRepository,
|
||||
private val historyRepo: ScanHistoryRepository
|
||||
) : ViewModel() {
|
||||
class SettingsViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val settings: SettingsRepository,
|
||||
private val productRepo: ProductRepository,
|
||||
private val historyRepo: ScanHistoryRepository,
|
||||
) : ViewModel() {
|
||||
private val coreFlow =
|
||||
combine(
|
||||
settings.appLanguage,
|
||||
settings.detectionLanguage,
|
||||
settings.hapticsEnabled,
|
||||
settings.soundEnabled,
|
||||
settings.theme,
|
||||
) { lang, detection, haptics, sound, theme ->
|
||||
SettingsUi(lang, detection, haptics, sound, theme)
|
||||
}
|
||||
|
||||
private val coreFlow = combine(
|
||||
settings.appLanguage,
|
||||
settings.detectionLanguage,
|
||||
settings.hapticsEnabled,
|
||||
settings.soundEnabled,
|
||||
settings.theme
|
||||
) { lang, detection, haptics, sound, theme ->
|
||||
SettingsUi(lang, detection, haptics, sound, theme)
|
||||
val state: StateFlow<SettingsUi> =
|
||||
combine(
|
||||
coreFlow,
|
||||
settings.healthStrictness,
|
||||
settings.splashScreenEnabled,
|
||||
) { core, strict, splash ->
|
||||
core.copy(healthStrictness = strict, splashScreenEnabled = splash)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUi())
|
||||
|
||||
fun setAppLanguage(v: AppLanguage) = viewModelScope.launch { settings.setAppLanguage(v) }
|
||||
|
||||
fun setDetectionLanguage(v: DetectionLanguage) = viewModelScope.launch { settings.setDetectionLanguage(v) }
|
||||
|
||||
fun setHaptics(v: Boolean) = viewModelScope.launch { settings.setHaptics(v) }
|
||||
|
||||
fun setSound(v: Boolean) = viewModelScope.launch { settings.setSound(v) }
|
||||
|
||||
fun setTheme(v: ThemePref) = viewModelScope.launch { settings.setTheme(v) }
|
||||
|
||||
fun setHealthStrictness(v: HealthStrictness) = viewModelScope.launch { settings.setHealthStrictness(v) }
|
||||
|
||||
fun setSplashScreenEnabled(v: Boolean) = viewModelScope.launch { settings.setSplashScreenEnabled(v) }
|
||||
|
||||
fun clearCache() = viewModelScope.launch { productRepo.clearCache() }
|
||||
|
||||
fun clearHistory() = viewModelScope.launch { historyRepo.clear() }
|
||||
}
|
||||
|
||||
val state: StateFlow<SettingsUi> = combine(
|
||||
coreFlow,
|
||||
settings.healthStrictness,
|
||||
settings.splashScreenEnabled
|
||||
) { core, strict, splash ->
|
||||
core.copy(healthStrictness = strict, splashScreenEnabled = splash)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUi())
|
||||
|
||||
fun setAppLanguage(v: AppLanguage) = viewModelScope.launch { settings.setAppLanguage(v) }
|
||||
fun setDetectionLanguage(v: DetectionLanguage) = viewModelScope.launch { settings.setDetectionLanguage(v) }
|
||||
fun setHaptics(v: Boolean) = viewModelScope.launch { settings.setHaptics(v) }
|
||||
fun setSound(v: Boolean) = viewModelScope.launch { settings.setSound(v) }
|
||||
fun setTheme(v: ThemePref) = viewModelScope.launch { settings.setTheme(v) }
|
||||
fun setHealthStrictness(v: HealthStrictness) = viewModelScope.launch { settings.setHealthStrictness(v) }
|
||||
fun setSplashScreenEnabled(v: Boolean) = viewModelScope.launch { settings.setSplashScreenEnabled(v) }
|
||||
fun clearCache() = viewModelScope.launch { productRepo.clearCache() }
|
||||
fun clearHistory() = viewModelScope.launch { historyRepo.clear() }
|
||||
}
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ import java.util.Date
|
||||
fun TrackingScreen(
|
||||
onOpenHistoryItem: (String) -> Unit,
|
||||
onOpenScanner: () -> Unit,
|
||||
viewModel: TrackingViewModel = hiltViewModel()
|
||||
viewModel: TrackingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val timeFilter by viewModel.timeFilter.collectAsStateWithLifecycle()
|
||||
@ -80,18 +80,19 @@ fun TrackingScreen(
|
||||
val clearAllDesc = stringResource(R.string.a11y_clear_all)
|
||||
IconButton(
|
||||
onClick = viewModel::clearAll,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = clearAllDesc
|
||||
}
|
||||
modifier =
|
||||
Modifier.semantics {
|
||||
contentDescription = clearAllDesc
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.DeleteSweep,
|
||||
contentDescription = null
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
when (uiState) {
|
||||
is TrackingUiState.Loading -> {
|
||||
@ -99,32 +100,34 @@ fun TrackingScreen(
|
||||
}
|
||||
is TrackingUiState.Empty -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.tracking_empty_title),
|
||||
message = stringResource(R.string.tracking_empty_body),
|
||||
emoji = "📊"
|
||||
emoji = "📊",
|
||||
)
|
||||
}
|
||||
}
|
||||
is TrackingUiState.Success -> {
|
||||
val success = uiState as TrackingUiState.Success
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = dimens.spacingLg),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = dimens.spacingLg),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
|
||||
) {
|
||||
// Filtres temporels
|
||||
item {
|
||||
TimeFilterRow(
|
||||
selected = success.timeFilter,
|
||||
onFilterChanged = viewModel::setTimeFilter
|
||||
onFilterChanged = viewModel::setTimeFilter,
|
||||
)
|
||||
}
|
||||
|
||||
@ -132,7 +135,7 @@ fun TrackingScreen(
|
||||
item {
|
||||
StatsSection(
|
||||
stats = success.stats,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -140,7 +143,7 @@ fun TrackingScreen(
|
||||
item {
|
||||
EvolutionChart(
|
||||
data = success.stats.sparklineData,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -148,7 +151,7 @@ fun TrackingScreen(
|
||||
item {
|
||||
VerdictDistribution(
|
||||
data = success.stats.barChartData,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -157,7 +160,7 @@ fun TrackingScreen(
|
||||
item {
|
||||
TopAllergensSection(
|
||||
allergens = success.stats.topAllergens,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -166,7 +169,7 @@ fun TrackingScreen(
|
||||
item {
|
||||
StatusFilterRow(
|
||||
selected = success.statusFilter,
|
||||
onFilterChanged = viewModel::setStatusFilter
|
||||
onFilterChanged = viewModel::setStatusFilter,
|
||||
)
|
||||
}
|
||||
|
||||
@ -176,7 +179,7 @@ fun TrackingScreen(
|
||||
value = searchQuery,
|
||||
onValueChange = viewModel::setSearchQuery,
|
||||
placeholder = "Rechercher un produit...",
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) }
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) },
|
||||
)
|
||||
}
|
||||
|
||||
@ -186,7 +189,7 @@ fun TrackingScreen(
|
||||
text = stringResource(R.string.tracking_recent_scans),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
@ -197,9 +200,10 @@ fun TrackingScreen(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = dimens.spacingXl)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = dimens.spacingXl),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@ -207,7 +211,7 @@ fun TrackingScreen(
|
||||
HistoryItemCard(
|
||||
item = item,
|
||||
onClick = { onOpenHistoryItem(item.barcode) },
|
||||
onDelete = { viewModel.deleteItem(item.id) }
|
||||
onDelete = { viewModel.deleteItem(item.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -223,28 +227,30 @@ fun TrackingScreen(
|
||||
@Composable
|
||||
fun StatsSection(
|
||||
stats: TrackingStats,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.tracking_stats_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(dimens.spacingMd))
|
||||
@ -252,7 +258,7 @@ fun StatsSection(
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
// Donut chart
|
||||
DonutChart(
|
||||
@ -260,32 +266,32 @@ fun StatsSection(
|
||||
size = 120.dp,
|
||||
strokeWidth = 12.dp,
|
||||
centerText = "${(stats.safePercentage * 100).toInt()}%",
|
||||
centerSubText = stringResource(R.string.tracking_safe_rate)
|
||||
centerSubText = stringResource(R.string.tracking_safe_rate),
|
||||
)
|
||||
|
||||
// Stats cards
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm),
|
||||
) {
|
||||
StatCardMini(
|
||||
icon = "📊",
|
||||
value = "${stats.totalScans}",
|
||||
label = stringResource(R.string.tracking_total_scans)
|
||||
label = stringResource(R.string.tracking_total_scans),
|
||||
)
|
||||
StatCardMini(
|
||||
icon = "✅",
|
||||
value = "${stats.safeCount}",
|
||||
label = "Sûrs"
|
||||
label = "Sûrs",
|
||||
)
|
||||
StatCardMini(
|
||||
icon = "⚠️",
|
||||
value = "${stats.warningCount}",
|
||||
label = "Attention"
|
||||
label = "Attention",
|
||||
)
|
||||
StatCardMini(
|
||||
icon = "❌",
|
||||
value = "${stats.dangerCount}",
|
||||
label = "Danger"
|
||||
label = "Danger",
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -297,11 +303,11 @@ fun StatsSection(
|
||||
fun StatCardMini(
|
||||
icon: String,
|
||||
value: String,
|
||||
label: String
|
||||
label: String,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = icon, style = MaterialTheme.typography.bodyLarge)
|
||||
Column {
|
||||
@ -309,12 +315,12 @@ fun StatCardMini(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -326,7 +332,7 @@ fun StatCardMini(
|
||||
@Composable
|
||||
fun EvolutionChart(
|
||||
data: com.safebite.app.presentation.common.components.SparklineData,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
@ -334,28 +340,30 @@ fun EvolutionChart(
|
||||
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.tracking_evolution),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
||||
Sparkline(
|
||||
data = data,
|
||||
height = 100.dp,
|
||||
lineColor = MaterialTheme.colorScheme.primary,
|
||||
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -367,27 +375,29 @@ fun EvolutionChart(
|
||||
@Composable
|
||||
fun VerdictDistribution(
|
||||
data: com.safebite.app.presentation.common.components.BarChartData,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.tracking_distribution),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
||||
HorizontalBarChart(data = data)
|
||||
@ -401,47 +411,50 @@ fun VerdictDistribution(
|
||||
@Composable
|
||||
fun TopAllergensSection(
|
||||
allergens: List<AllergenCount>,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.tracking_top_allergens),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
||||
allergens.forEach { allergen ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "${allergen.emoji} ${allergen.name}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "${allergen.count}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -455,35 +468,35 @@ fun TopAllergensSection(
|
||||
@Composable
|
||||
fun StatusFilterRow(
|
||||
selected: SafetyStatus?,
|
||||
onFilterChanged: (SafetyStatus?) -> Unit
|
||||
onFilterChanged: (SafetyStatus?) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm)
|
||||
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selected == null,
|
||||
onClick = { onFilterChanged(null) },
|
||||
label = { Text("Tous") },
|
||||
modifier = Modifier.semantics { contentDescription = "Filtrer par tous les produits" }
|
||||
modifier = Modifier.semantics { contentDescription = "Filtrer par tous les produits" },
|
||||
)
|
||||
FilterChip(
|
||||
selected = selected == SafetyStatus.DANGER,
|
||||
onClick = { onFilterChanged(SafetyStatus.DANGER) },
|
||||
label = { Text("❌ Danger") },
|
||||
modifier = Modifier.semantics { contentDescription = "Filtrer par produits dangereux" }
|
||||
modifier = Modifier.semantics { contentDescription = "Filtrer par produits dangereux" },
|
||||
)
|
||||
FilterChip(
|
||||
selected = selected == SafetyStatus.WARNING,
|
||||
onClick = { onFilterChanged(SafetyStatus.WARNING) },
|
||||
label = { Text("⚠️ Attention") },
|
||||
modifier = Modifier.semantics { contentDescription = "Filtrer par produits avec attention" }
|
||||
modifier = Modifier.semantics { contentDescription = "Filtrer par produits avec attention" },
|
||||
)
|
||||
FilterChip(
|
||||
selected = selected == SafetyStatus.SAFE,
|
||||
onClick = { onFilterChanged(SafetyStatus.SAFE) },
|
||||
label = { Text("✅ Sûr") },
|
||||
modifier = Modifier.semantics { contentDescription = "Filtrer par produits sûrs" }
|
||||
modifier = Modifier.semantics { contentDescription = "Filtrer par produits sûrs" },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -495,31 +508,35 @@ fun StatusFilterRow(
|
||||
fun HistoryItemCard(
|
||||
item: com.safebite.app.domain.model.ScanHistoryItem,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
||||
horizontalArrangement = Arrangement.spacedBy(dimens.spacingMd),
|
||||
) {
|
||||
// Indicateur de couleur
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.background(statusColor(item.safetyStatus), CircleShape)
|
||||
modifier =
|
||||
Modifier
|
||||
.size(12.dp)
|
||||
.background(statusColor(item.safetyStatus), CircleShape),
|
||||
)
|
||||
|
||||
// Infos produit
|
||||
@ -528,19 +545,20 @@ fun HistoryItemCard(
|
||||
text = item.productName ?: item.barcode,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
|
||||
.format(Date(item.scannedAt)),
|
||||
text =
|
||||
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
|
||||
.format(Date(item.scannedAt)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (item.profileNames.isNotEmpty()) {
|
||||
Text(
|
||||
text = item.profileNames.joinToString(", "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -549,14 +567,15 @@ fun HistoryItemCard(
|
||||
val deleteDesc = stringResource(R.string.a11y_delete, item.productName ?: item.barcode)
|
||||
IconButton(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = deleteDesc
|
||||
}
|
||||
modifier =
|
||||
Modifier.semantics {
|
||||
contentDescription = deleteDesc
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -572,34 +591,38 @@ fun TrackingLoadingSkeleton(modifier: Modifier = Modifier) {
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.padding(horizontal = dimens.spacingLg),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
|
||||
) {
|
||||
item {
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ data class TrackingStats(
|
||||
val weeklyScans: Int = 0,
|
||||
val weeklySafePercentage: Float = 0f,
|
||||
val sparklineData: SparklineData = SparklineData(emptyList()),
|
||||
val barChartData: BarChartData = BarChartData(emptyList())
|
||||
val barChartData: BarChartData = BarChartData(emptyList()),
|
||||
)
|
||||
|
||||
/**
|
||||
@ -43,7 +43,7 @@ data class TrackingStats(
|
||||
data class AllergenCount(
|
||||
val name: String,
|
||||
val count: Int,
|
||||
val emoji: String = "⚠️"
|
||||
val emoji: String = "⚠️",
|
||||
)
|
||||
|
||||
/**
|
||||
@ -51,204 +51,224 @@ data class AllergenCount(
|
||||
*/
|
||||
sealed class TrackingUiState {
|
||||
data object Loading : TrackingUiState()
|
||||
|
||||
data class Success(
|
||||
val stats: TrackingStats,
|
||||
val historyItems: List<ScanHistoryItem>,
|
||||
val timeFilter: TimeFilter,
|
||||
val statusFilter: SafetyStatus? = null,
|
||||
val searchQuery: String = ""
|
||||
val searchQuery: String = "",
|
||||
) : TrackingUiState()
|
||||
|
||||
data object Empty : TrackingUiState()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class TrackingViewModel @Inject constructor(
|
||||
private val getScanHistoryUseCase: GetScanHistoryUseCase
|
||||
) : ViewModel() {
|
||||
class TrackingViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val getScanHistoryUseCase: GetScanHistoryUseCase,
|
||||
) : ViewModel() {
|
||||
private val _timeFilter = MutableStateFlow(TimeFilter.WEEK)
|
||||
val timeFilter: StateFlow<TimeFilter> = _timeFilter.asStateFlow()
|
||||
|
||||
private val _timeFilter = MutableStateFlow(TimeFilter.WEEK)
|
||||
val timeFilter: StateFlow<TimeFilter> = _timeFilter.asStateFlow()
|
||||
private val _statusFilter = MutableStateFlow<SafetyStatus?>(null)
|
||||
val statusFilter: StateFlow<SafetyStatus?> = _statusFilter.asStateFlow()
|
||||
|
||||
private val _statusFilter = MutableStateFlow<SafetyStatus?>(null)
|
||||
val statusFilter: StateFlow<SafetyStatus?> = _statusFilter.asStateFlow()
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
val uiState: StateFlow<TrackingUiState> =
|
||||
combine(
|
||||
getScanHistoryUseCase.observe(),
|
||||
_timeFilter,
|
||||
_statusFilter,
|
||||
_searchQuery,
|
||||
) { items, timeFilter, statusFilter, query ->
|
||||
val filteredItems =
|
||||
items
|
||||
.filterByTime(timeFilter)
|
||||
.filter { statusFilter == null || it.safetyStatus == statusFilter }
|
||||
.filter { query.isBlank() || matchesSearch(it, query) }
|
||||
|
||||
val uiState: StateFlow<TrackingUiState> = combine(
|
||||
getScanHistoryUseCase.observe(),
|
||||
_timeFilter,
|
||||
_statusFilter,
|
||||
_searchQuery
|
||||
) { items, timeFilter, statusFilter, query ->
|
||||
val filteredItems = items
|
||||
.filterByTime(timeFilter)
|
||||
.filter { statusFilter == null || it.safetyStatus == statusFilter }
|
||||
.filter { query.isBlank() || matchesSearch(it, query) }
|
||||
|
||||
if (items.isEmpty()) {
|
||||
TrackingUiState.Empty
|
||||
} else {
|
||||
val stats = computeStats(items, timeFilter)
|
||||
TrackingUiState.Success(
|
||||
stats = stats,
|
||||
historyItems = filteredItems,
|
||||
timeFilter = timeFilter,
|
||||
statusFilter = statusFilter,
|
||||
searchQuery = query
|
||||
if (items.isEmpty()) {
|
||||
TrackingUiState.Empty
|
||||
} else {
|
||||
val stats = computeStats(items, timeFilter)
|
||||
TrackingUiState.Success(
|
||||
stats = stats,
|
||||
historyItems = filteredItems,
|
||||
timeFilter = timeFilter,
|
||||
statusFilter = statusFilter,
|
||||
searchQuery = query,
|
||||
)
|
||||
}
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
TrackingUiState.Loading,
|
||||
)
|
||||
|
||||
fun setTimeFilter(filter: TimeFilter) {
|
||||
_timeFilter.value = filter
|
||||
}
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
TrackingUiState.Loading
|
||||
)
|
||||
|
||||
fun setTimeFilter(filter: TimeFilter) {
|
||||
_timeFilter.value = filter
|
||||
}
|
||||
|
||||
fun setStatusFilter(status: SafetyStatus?) {
|
||||
_statusFilter.value = status
|
||||
}
|
||||
|
||||
fun setSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun deleteItem(id: Long) = viewModelScope.launch {
|
||||
getScanHistoryUseCase.delete(id)
|
||||
}
|
||||
|
||||
fun clearAll() = viewModelScope.launch {
|
||||
getScanHistoryUseCase.clear()
|
||||
}
|
||||
|
||||
private fun List<ScanHistoryItem>.filterByTime(filter: TimeFilter): List<ScanHistoryItem> {
|
||||
val calendar = Calendar.getInstance()
|
||||
val cutoffTime = when (filter) {
|
||||
TimeFilter.WEEK -> {
|
||||
calendar.add(Calendar.DAY_OF_YEAR, -7)
|
||||
calendar.timeInMillis
|
||||
}
|
||||
TimeFilter.MONTH -> {
|
||||
calendar.add(Calendar.MONTH, -1)
|
||||
calendar.timeInMillis
|
||||
}
|
||||
TimeFilter.YEAR -> {
|
||||
calendar.add(Calendar.YEAR, -1)
|
||||
calendar.timeInMillis
|
||||
}
|
||||
TimeFilter.ALL -> 0L
|
||||
fun setStatusFilter(status: SafetyStatus?) {
|
||||
_statusFilter.value = status
|
||||
}
|
||||
return this.filter { it.scannedAt >= cutoffTime }
|
||||
}
|
||||
|
||||
private fun matchesSearch(item: ScanHistoryItem, query: String): Boolean {
|
||||
return item.productName?.contains(query, ignoreCase = true) == true ||
|
||||
fun setSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun deleteItem(id: Long) =
|
||||
viewModelScope.launch {
|
||||
getScanHistoryUseCase.delete(id)
|
||||
}
|
||||
|
||||
fun clearAll() =
|
||||
viewModelScope.launch {
|
||||
getScanHistoryUseCase.clear()
|
||||
}
|
||||
|
||||
private fun List<ScanHistoryItem>.filterByTime(filter: TimeFilter): List<ScanHistoryItem> {
|
||||
val calendar = Calendar.getInstance()
|
||||
val cutoffTime =
|
||||
when (filter) {
|
||||
TimeFilter.WEEK -> {
|
||||
calendar.add(Calendar.DAY_OF_YEAR, -7)
|
||||
calendar.timeInMillis
|
||||
}
|
||||
TimeFilter.MONTH -> {
|
||||
calendar.add(Calendar.MONTH, -1)
|
||||
calendar.timeInMillis
|
||||
}
|
||||
TimeFilter.YEAR -> {
|
||||
calendar.add(Calendar.YEAR, -1)
|
||||
calendar.timeInMillis
|
||||
}
|
||||
TimeFilter.ALL -> 0L
|
||||
}
|
||||
return this.filter { it.scannedAt >= cutoffTime }
|
||||
}
|
||||
|
||||
private fun matchesSearch(
|
||||
item: ScanHistoryItem,
|
||||
query: String,
|
||||
): Boolean {
|
||||
return item.productName?.contains(query, ignoreCase = true) == true ||
|
||||
item.brand?.contains(query, ignoreCase = true) == true ||
|
||||
item.barcode.contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeStats(allItems: List<ScanHistoryItem>, timeFilter: TimeFilter): TrackingStats {
|
||||
val items = allItems.filterByTime(timeFilter)
|
||||
val total = items.size
|
||||
val safeCount = items.count { it.safetyStatus == SafetyStatus.SAFE }
|
||||
val warningCount = items.count { it.safetyStatus == SafetyStatus.WARNING }
|
||||
val dangerCount = items.count { it.safetyStatus == SafetyStatus.DANGER }
|
||||
val safePercentage = if (total > 0) safeCount.toFloat() / total else 0f
|
||||
private fun computeStats(
|
||||
allItems: List<ScanHistoryItem>,
|
||||
timeFilter: TimeFilter,
|
||||
): TrackingStats {
|
||||
val items = allItems.filterByTime(timeFilter)
|
||||
val total = items.size
|
||||
val safeCount = items.count { it.safetyStatus == SafetyStatus.SAFE }
|
||||
val warningCount = items.count { it.safetyStatus == SafetyStatus.WARNING }
|
||||
val dangerCount = items.count { it.safetyStatus == SafetyStatus.DANGER }
|
||||
val safePercentage = if (total > 0) safeCount.toFloat() / total else 0f
|
||||
|
||||
// Calcul des allergènes top (simulé à partir des noms de produits)
|
||||
val topAllergens = computeTopAllergens(items)
|
||||
// Calcul des allergènes top (simulé à partir des noms de produits)
|
||||
val topAllergens = computeTopAllergens(items)
|
||||
|
||||
// Données sparkline (scans par jour sur la période)
|
||||
val sparklineData = computeSparklineData(items, timeFilter)
|
||||
// Données sparkline (scans par jour sur la période)
|
||||
val sparklineData = computeSparklineData(items, timeFilter)
|
||||
|
||||
// Données bar chart (répartition par statut)
|
||||
val barChartData = BarChartData(
|
||||
items = listOf(
|
||||
BarChartItem("Sûr", safeCount, SemanticColors.Safe),
|
||||
BarChartItem("Attention", warningCount, SemanticColors.Warning),
|
||||
BarChartItem("Danger", dangerCount, SemanticColors.Danger)
|
||||
// Données bar chart (répartition par statut)
|
||||
val barChartData =
|
||||
BarChartData(
|
||||
items =
|
||||
listOf(
|
||||
BarChartItem("Sûr", safeCount, SemanticColors.Safe),
|
||||
BarChartItem("Attention", warningCount, SemanticColors.Warning),
|
||||
BarChartItem("Danger", dangerCount, SemanticColors.Danger),
|
||||
),
|
||||
)
|
||||
|
||||
return TrackingStats(
|
||||
totalScans = total,
|
||||
safeCount = safeCount,
|
||||
warningCount = warningCount,
|
||||
dangerCount = dangerCount,
|
||||
safePercentage = safePercentage,
|
||||
topAllergens = topAllergens,
|
||||
weeklyScans = items.size,
|
||||
weeklySafePercentage = safePercentage,
|
||||
sparklineData = sparklineData,
|
||||
barChartData = barChartData,
|
||||
)
|
||||
)
|
||||
|
||||
return TrackingStats(
|
||||
totalScans = total,
|
||||
safeCount = safeCount,
|
||||
warningCount = warningCount,
|
||||
dangerCount = dangerCount,
|
||||
safePercentage = safePercentage,
|
||||
topAllergens = topAllergens,
|
||||
weeklyScans = items.size,
|
||||
weeklySafePercentage = safePercentage,
|
||||
sparklineData = sparklineData,
|
||||
barChartData = barChartData
|
||||
)
|
||||
}
|
||||
|
||||
private fun computeTopAllergens(items: List<ScanHistoryItem>): List<AllergenCount> {
|
||||
// Simulation : on compte les profils associés aux scans danger/warning
|
||||
val allergenCounts = mutableMapOf<String, Int>()
|
||||
items.filter { it.safetyStatus != SafetyStatus.SAFE }.forEach { item ->
|
||||
item.profileNames.forEach { profileName ->
|
||||
allergenCounts[profileName] = allergenCounts.getOrDefault(profileName, 0) + 1
|
||||
}
|
||||
}
|
||||
return allergenCounts.entries
|
||||
.sortedByDescending { it.value }
|
||||
.take(5)
|
||||
.map { AllergenCount(it.key, it.value) }
|
||||
}
|
||||
|
||||
private fun computeSparklineData(items: List<ScanHistoryItem>, timeFilter: TimeFilter): SparklineData {
|
||||
val calendar = Calendar.getInstance()
|
||||
val days = when (timeFilter) {
|
||||
TimeFilter.WEEK -> 7
|
||||
TimeFilter.MONTH -> 30
|
||||
TimeFilter.YEAR -> 12
|
||||
TimeFilter.ALL -> {
|
||||
val oldest = items.minOfOrNull { it.scannedAt } ?: 0L
|
||||
val daysDiff = ((System.currentTimeMillis() - oldest) / (1000 * 60 * 60 * 24)).toInt()
|
||||
daysDiff.coerceAtMost(365)
|
||||
}
|
||||
}
|
||||
|
||||
val labels = mutableListOf<String>()
|
||||
val values = mutableListOf<Float>()
|
||||
|
||||
if (timeFilter == TimeFilter.YEAR) {
|
||||
// Par mois
|
||||
for (i in 11 downTo 0) {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MONTH, -i)
|
||||
val monthStart = cal.timeInMillis
|
||||
cal.add(Calendar.MONTH, 1)
|
||||
val monthEnd = cal.timeInMillis
|
||||
val count = items.count { it.scannedAt in monthStart..<monthEnd }
|
||||
values.add(count.toFloat())
|
||||
labels.add(cal.getDisplayName(Calendar.MONTH, Calendar.SHORT, java.util.Locale.getDefault()))
|
||||
}
|
||||
} else {
|
||||
// Par jour
|
||||
for (i in days - 1 downTo 0) {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.DAY_OF_YEAR, -i)
|
||||
val dayStart = cal.timeInMillis
|
||||
cal.set(Calendar.HOUR_OF_DAY, 23)
|
||||
cal.set(Calendar.MINUTE, 59)
|
||||
cal.set(Calendar.SECOND, 59)
|
||||
val dayEnd = cal.timeInMillis
|
||||
val count = items.count { it.scannedAt in dayStart..dayEnd }
|
||||
values.add(count.toFloat())
|
||||
if (timeFilter == TimeFilter.WEEK || i % 7 == 0) {
|
||||
labels.add(cal.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT, java.util.Locale.getDefault()))
|
||||
} else {
|
||||
labels.add("")
|
||||
private fun computeTopAllergens(items: List<ScanHistoryItem>): List<AllergenCount> {
|
||||
// Simulation : on compte les profils associés aux scans danger/warning
|
||||
val allergenCounts = mutableMapOf<String, Int>()
|
||||
items.filter { it.safetyStatus != SafetyStatus.SAFE }.forEach { item ->
|
||||
item.profileNames.forEach { profileName ->
|
||||
allergenCounts[profileName] = allergenCounts.getOrDefault(profileName, 0) + 1
|
||||
}
|
||||
}
|
||||
return allergenCounts.entries
|
||||
.sortedByDescending { it.value }
|
||||
.take(5)
|
||||
.map { AllergenCount(it.key, it.value) }
|
||||
}
|
||||
|
||||
return SparklineData(values, labels)
|
||||
private fun computeSparklineData(
|
||||
items: List<ScanHistoryItem>,
|
||||
timeFilter: TimeFilter,
|
||||
): SparklineData {
|
||||
val calendar = Calendar.getInstance()
|
||||
val days =
|
||||
when (timeFilter) {
|
||||
TimeFilter.WEEK -> 7
|
||||
TimeFilter.MONTH -> 30
|
||||
TimeFilter.YEAR -> 12
|
||||
TimeFilter.ALL -> {
|
||||
val oldest = items.minOfOrNull { it.scannedAt } ?: 0L
|
||||
val daysDiff = ((System.currentTimeMillis() - oldest) / (1000 * 60 * 60 * 24)).toInt()
|
||||
daysDiff.coerceAtMost(365)
|
||||
}
|
||||
}
|
||||
|
||||
val labels = mutableListOf<String>()
|
||||
val values = mutableListOf<Float>()
|
||||
|
||||
if (timeFilter == TimeFilter.YEAR) {
|
||||
// Par mois
|
||||
for (i in 11 downTo 0) {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MONTH, -i)
|
||||
val monthStart = cal.timeInMillis
|
||||
cal.add(Calendar.MONTH, 1)
|
||||
val monthEnd = cal.timeInMillis
|
||||
val count = items.count { it.scannedAt in monthStart..<monthEnd }
|
||||
values.add(count.toFloat())
|
||||
labels.add(cal.getDisplayName(Calendar.MONTH, Calendar.SHORT, java.util.Locale.getDefault()))
|
||||
}
|
||||
} else {
|
||||
// Par jour
|
||||
for (i in days - 1 downTo 0) {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.DAY_OF_YEAR, -i)
|
||||
val dayStart = cal.timeInMillis
|
||||
cal.set(Calendar.HOUR_OF_DAY, 23)
|
||||
cal.set(Calendar.MINUTE, 59)
|
||||
cal.set(Calendar.SECOND, 59)
|
||||
val dayEnd = cal.timeInMillis
|
||||
val count = items.count { it.scannedAt in dayStart..dayEnd }
|
||||
values.add(count.toFloat())
|
||||
if (timeFilter == TimeFilter.WEEK || i % 7 == 0) {
|
||||
labels.add(cal.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT, java.util.Locale.getDefault()))
|
||||
} else {
|
||||
labels.add("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SparklineData(values, labels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,17 +11,17 @@ import androidx.compose.ui.graphics.Color
|
||||
// Ces couleurs sont indépendantes du thème M3 pour cohérence marque.
|
||||
object SemanticColors {
|
||||
// Light mode
|
||||
val Safe = Color(0xFF43A047) // Vert sécurité
|
||||
val Safe = Color(0xFF43A047) // Vert sécurité
|
||||
val SafeContainer = Color(0xFFE8F5E9) // Fond très clair
|
||||
val OnSafe = Color(0xFFFFFFFF)
|
||||
val OnSafeContainer = Color(0xFF1A3A2A)
|
||||
|
||||
val Warning = Color(0xFFFFA000) // Orange attention
|
||||
val Warning = Color(0xFFFFA000) // Orange attention
|
||||
val WarningContainer = Color(0xFFFFF3E0)
|
||||
val OnWarning = Color(0xFFFFFFFF)
|
||||
val OnWarningContainer = Color(0xFF4A2800)
|
||||
|
||||
val Danger = Color(0xFFD32F2F) // Rouge danger
|
||||
val Danger = Color(0xFFD32F2F) // Rouge danger
|
||||
val DangerContainer = Color(0xFFFFEBEE)
|
||||
val OnDanger = Color(0xFFFFFFFF)
|
||||
val OnDangerContainer = Color(0xFF5C0B0B)
|
||||
@ -37,137 +37,150 @@ object SemanticColors {
|
||||
|
||||
// ---- NEUTRES (spec UX §2.1) ------------------------------------------------
|
||||
object NeutralColors {
|
||||
val Background = Color(0xFFF1F8E9) // Fond principal light
|
||||
val Surface = Color(0xFFFFFFFF) // Blanc pur pour cartes
|
||||
val TextPrimary = Color(0xFF212121) // Texte principal
|
||||
val Background = Color(0xFFF1F8E9) // Fond principal light
|
||||
val Surface = Color(0xFFFFFFFF) // Blanc pur pour cartes
|
||||
val TextPrimary = Color(0xFF212121) // Texte principal
|
||||
val TextSecondary = Color(0xFF757575) // Texte secondaire
|
||||
val Separator = Color(0xFFBDBDBD) // Séparateurs
|
||||
val Separator = Color(0xFFBDBDBD) // Séparateurs
|
||||
}
|
||||
|
||||
// ---- Brand anchors (Material 3) --------------------------------------------
|
||||
val BrandPrimary = Color(0xFF1B7A2B)
|
||||
val BrandPrimaryDark = Color(0xFF0D5E1A)
|
||||
val BrandPrimaryLight = Color(0xFF4CAF50)
|
||||
val BrandSecondary = Color(0xFF2E7D32)
|
||||
val BrandPrimary = Color(0xFF1B7A2B)
|
||||
val BrandPrimaryDark = Color(0xFF0D5E1A)
|
||||
val BrandPrimaryLight = Color(0xFF4CAF50)
|
||||
val BrandSecondary = Color(0xFF2E7D32)
|
||||
|
||||
// ---- Light scheme ---------------------------------------------------------
|
||||
val LightPrimary = Color(0xFF1B7A2B)
|
||||
val LightOnPrimary = Color(0xFFFFFFFF)
|
||||
val LightPrimaryContainer = Color(0xFFA5D6A7)
|
||||
val LightOnPrimaryContainer = Color(0xFF0D3B12)
|
||||
val LightPrimary = Color(0xFF1B7A2B)
|
||||
val LightOnPrimary = Color(0xFFFFFFFF)
|
||||
val LightPrimaryContainer = Color(0xFFA5D6A7)
|
||||
val LightOnPrimaryContainer = Color(0xFF0D3B12)
|
||||
|
||||
val LightSecondary = Color(0xFF2E7D32)
|
||||
val LightOnSecondary = Color(0xFFFFFFFF)
|
||||
val LightSecondaryContainer = Color(0xFFC8E6C9)
|
||||
val LightOnSecondaryContainer = Color(0xFF1B5E20)
|
||||
val LightSecondary = Color(0xFF2E7D32)
|
||||
val LightOnSecondary = Color(0xFFFFFFFF)
|
||||
val LightSecondaryContainer = Color(0xFFC8E6C9)
|
||||
val LightOnSecondaryContainer = Color(0xFF1B5E20)
|
||||
|
||||
val LightTertiary = Color(0xFF00796B)
|
||||
val LightOnTertiary = Color(0xFFFFFFFF)
|
||||
val LightTertiaryContainer = Color(0xFFB2DFDB)
|
||||
val LightOnTertiaryContainer = Color(0xFF004D40)
|
||||
val LightTertiary = Color(0xFF00796B)
|
||||
val LightOnTertiary = Color(0xFFFFFFFF)
|
||||
val LightTertiaryContainer = Color(0xFFB2DFDB)
|
||||
val LightOnTertiaryContainer = Color(0xFF004D40)
|
||||
|
||||
val LightError = Color(0xFFD32F2F)
|
||||
val LightOnError = Color(0xFFFFFFFF)
|
||||
val LightErrorContainer = Color(0xFFFFCDD2)
|
||||
val LightOnErrorContainer = Color(0xFF5C0B0B)
|
||||
val LightError = Color(0xFFD32F2F)
|
||||
val LightOnError = Color(0xFFFFFFFF)
|
||||
val LightErrorContainer = Color(0xFFFFCDD2)
|
||||
val LightOnErrorContainer = Color(0xFF5C0B0B)
|
||||
|
||||
val LightBackground = NeutralColors.Background // #F1F8E9
|
||||
val LightOnBackground = NeutralColors.TextPrimary // #212121
|
||||
val LightSurface = NeutralColors.Surface // #FFFFFF
|
||||
val LightOnSurface = NeutralColors.TextPrimary // #212121
|
||||
val LightSurfaceVariant = Color(0xFFE8F5E9)
|
||||
val LightOnSurfaceVariant = NeutralColors.TextSecondary
|
||||
val LightSurfaceTint = LightPrimary
|
||||
val LightBackground = NeutralColors.Background // #F1F8E9
|
||||
val LightOnBackground = NeutralColors.TextPrimary // #212121
|
||||
val LightSurface = NeutralColors.Surface // #FFFFFF
|
||||
val LightOnSurface = NeutralColors.TextPrimary // #212121
|
||||
val LightSurfaceVariant = Color(0xFFE8F5E9)
|
||||
val LightOnSurfaceVariant = NeutralColors.TextSecondary
|
||||
val LightSurfaceTint = LightPrimary
|
||||
|
||||
val LightOutline = NeutralColors.Separator
|
||||
val LightOutlineVariant = Color(0xFFE0E0E0)
|
||||
val LightOutline = NeutralColors.Separator
|
||||
val LightOutlineVariant = Color(0xFFE0E0E0)
|
||||
|
||||
val LightInverseSurface = Color(0xFF2F3033)
|
||||
val LightInverseOnSurface = Color(0xFFF1F0F4)
|
||||
val LightInversePrimary = Color(0xFF81C784)
|
||||
val LightInverseSurface = Color(0xFF2F3033)
|
||||
val LightInverseOnSurface = Color(0xFFF1F0F4)
|
||||
val LightInversePrimary = Color(0xFF81C784)
|
||||
|
||||
val LightScrim = Color(0xFF000000)
|
||||
val LightScrim = Color(0xFF000000)
|
||||
|
||||
// ---- Dark scheme (surfaces élevées M3) ------------------------------------
|
||||
val DarkPrimary = Color(0xFF81C784)
|
||||
val DarkOnPrimary = Color(0xFF0D3B12)
|
||||
val DarkPrimaryContainer = Color(0xFF1B5E20)
|
||||
val DarkOnPrimaryContainer = Color(0xFFA5D6A7)
|
||||
val DarkPrimary = Color(0xFF81C784)
|
||||
val DarkOnPrimary = Color(0xFF0D3B12)
|
||||
val DarkPrimaryContainer = Color(0xFF1B5E20)
|
||||
val DarkOnPrimaryContainer = Color(0xFFA5D6A7)
|
||||
|
||||
val DarkSecondary = Color(0xFFA5D6A7)
|
||||
val DarkOnSecondary = Color(0xFF1B5E20)
|
||||
val DarkSecondaryContainer = Color(0xFF2E7D32)
|
||||
val DarkOnSecondaryContainer = Color(0xFFC8E6C9)
|
||||
val DarkSecondary = Color(0xFFA5D6A7)
|
||||
val DarkOnSecondary = Color(0xFF1B5E20)
|
||||
val DarkSecondaryContainer = Color(0xFF2E7D32)
|
||||
val DarkOnSecondaryContainer = Color(0xFFC8E6C9)
|
||||
|
||||
val DarkTertiary = Color(0xFF4DB6AC)
|
||||
val DarkOnTertiary = Color(0xFF00332C)
|
||||
val DarkTertiaryContainer = Color(0xFF00695C)
|
||||
val DarkOnTertiaryContainer = Color(0xFFB2DFDB)
|
||||
val DarkTertiary = Color(0xFF4DB6AC)
|
||||
val DarkOnTertiary = Color(0xFF00332C)
|
||||
val DarkTertiaryContainer = Color(0xFF00695C)
|
||||
val DarkOnTertiaryContainer = Color(0xFFB2DFDB)
|
||||
|
||||
val DarkError = Color(0xFFEF9A9A)
|
||||
val DarkOnError = Color(0xFF690005)
|
||||
val DarkErrorContainer = Color(0xFF93000A)
|
||||
val DarkOnErrorContainer = Color(0xFFFFCDD2)
|
||||
val DarkError = Color(0xFFEF9A9A)
|
||||
val DarkOnError = Color(0xFF690005)
|
||||
val DarkErrorContainer = Color(0xFF93000A)
|
||||
val DarkOnErrorContainer = Color(0xFFFFCDD2)
|
||||
|
||||
val DarkBackground = Color(0xFF1A1C1A)
|
||||
val DarkOnBackground = Color(0xFFE0E0E0)
|
||||
val DarkSurface = Color(0xFF2D2F2D)
|
||||
val DarkOnSurface = Color(0xFFE0E0E0)
|
||||
val DarkSurfaceVariant = Color(0xFF3A3F3A)
|
||||
val DarkOnSurfaceVariant = Color(0xFFBDBDBD)
|
||||
val DarkSurfaceTint = DarkPrimary
|
||||
val DarkBackground = Color(0xFF1A1C1A)
|
||||
val DarkOnBackground = Color(0xFFE0E0E0)
|
||||
val DarkSurface = Color(0xFF2D2F2D)
|
||||
val DarkOnSurface = Color(0xFFE0E0E0)
|
||||
val DarkSurfaceVariant = Color(0xFF3A3F3A)
|
||||
val DarkOnSurfaceVariant = Color(0xFFBDBDBD)
|
||||
val DarkSurfaceTint = DarkPrimary
|
||||
|
||||
val DarkOutline = Color(0xFF90909A)
|
||||
val DarkOutlineVariant = Color(0xFF46464F)
|
||||
val DarkOutline = Color(0xFF90909A)
|
||||
val DarkOutlineVariant = Color(0xFF46464F)
|
||||
|
||||
val DarkInverseSurface = Color(0xFFE6E1E5)
|
||||
val DarkInverseOnSurface = Color(0xFF2F3033)
|
||||
val DarkInversePrimary = Color(0xFF1B7A2B)
|
||||
val DarkInverseSurface = Color(0xFFE6E1E5)
|
||||
val DarkInverseOnSurface = Color(0xFF2F3033)
|
||||
val DarkInversePrimary = Color(0xFF1B7A2B)
|
||||
|
||||
val DarkScrim = Color(0xFF000000)
|
||||
val DarkScrim = Color(0xFF000000)
|
||||
|
||||
// ---- Dégradé signature (rappel du fond du logo bouclier) --------------------
|
||||
val ShieldGradient = androidx.compose.ui.graphics.Brush.linearGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF4CAF50), // Vert clair (haut-gauche)
|
||||
Color(0xFF1B7A2B), // Vert moyen
|
||||
Color(0xFF0D5E1A) // Vert foncé (bas-droite)
|
||||
),
|
||||
start = androidx.compose.ui.geometry.Offset(0f, 0f),
|
||||
end = androidx.compose.ui.geometry.Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY)
|
||||
)
|
||||
val ShieldGradient =
|
||||
androidx.compose.ui.graphics.Brush.linearGradient(
|
||||
colors =
|
||||
listOf(
|
||||
Color(0xFF4CAF50), // Vert clair (haut-gauche)
|
||||
Color(0xFF1B7A2B), // Vert moyen
|
||||
Color(0xFF0D5E1A), // Vert foncé (bas-droite)
|
||||
),
|
||||
start = androidx.compose.ui.geometry.Offset(0f, 0f),
|
||||
end = androidx.compose.ui.geometry.Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY),
|
||||
)
|
||||
|
||||
// ---- Legacy aliases (backward compat pour code existant) -------------------
|
||||
@Deprecated("Use SemanticColors.Safe", ReplaceWith("SemanticColors.Safe"))
|
||||
val StatusSafe get() = SemanticColors.Safe
|
||||
|
||||
@Deprecated("Use SemanticColors.SafeContainer", ReplaceWith("SemanticColors.SafeContainer"))
|
||||
val StatusSafeContainer get() = SemanticColors.SafeContainer
|
||||
|
||||
@Deprecated("Use SemanticColors.OnSafe", ReplaceWith("SemanticColors.OnSafe"))
|
||||
val OnStatusSafe get() = SemanticColors.OnSafe
|
||||
|
||||
@Deprecated("Use SemanticColors.Warning", ReplaceWith("SemanticColors.Warning"))
|
||||
val StatusWarning get() = SemanticColors.Warning
|
||||
|
||||
@Deprecated("Use SemanticColors.WarningContainer", ReplaceWith("SemanticColors.WarningContainer"))
|
||||
val StatusWarningContainer get() = SemanticColors.WarningContainer
|
||||
|
||||
@Deprecated("Use SemanticColors.OnWarning", ReplaceWith("SemanticColors.OnWarning"))
|
||||
val OnStatusWarning get() = SemanticColors.OnWarning
|
||||
|
||||
@Deprecated("Use SemanticColors.Danger", ReplaceWith("SemanticColors.Danger"))
|
||||
val StatusDanger get() = SemanticColors.Danger
|
||||
|
||||
@Deprecated("Use SemanticColors.DangerContainer", ReplaceWith("SemanticColors.DangerContainer"))
|
||||
val StatusDangerContainer get() = SemanticColors.DangerContainer
|
||||
|
||||
@Deprecated("Use SemanticColors.OnDanger", ReplaceWith("SemanticColors.OnDanger"))
|
||||
val OnStatusDanger get() = SemanticColors.OnDanger
|
||||
|
||||
@Deprecated("Use SemanticColors.SafeDark", ReplaceWith("SemanticColors.SafeDark"))
|
||||
val StatusSafeDark get() = SemanticColors.SafeDark
|
||||
|
||||
@Deprecated("Use SemanticColors.SafeContainerDark", ReplaceWith("SemanticColors.SafeContainerDark"))
|
||||
val StatusSafeContainerDark get() = SemanticColors.SafeContainerDark
|
||||
|
||||
@Deprecated("Use SemanticColors.WarningDark", ReplaceWith("SemanticColors.WarningDark"))
|
||||
val StatusWarningDark get() = SemanticColors.WarningDark
|
||||
|
||||
@Deprecated("Use SemanticColors.WarningContainerDark", ReplaceWith("SemanticColors.WarningContainerDark"))
|
||||
val StatusWarningContainerDark get() = SemanticColors.WarningContainerDark
|
||||
|
||||
@Deprecated("Use SemanticColors.DangerDark", ReplaceWith("SemanticColors.DangerDark"))
|
||||
val StatusDangerDark get() = SemanticColors.DangerDark
|
||||
|
||||
@Deprecated("Use SemanticColors.DangerContainerDark", ReplaceWith("SemanticColors.DangerContainerDark"))
|
||||
val StatusDangerContainerDark get() = SemanticColors.DangerContainerDark
|
||||
|
||||
@ -22,7 +22,6 @@ data class Dimens(
|
||||
val spacingXl: Dp = 24.dp,
|
||||
val spacingXxl: Dp = 32.dp,
|
||||
val spacingXxxl: Dp = 48.dp,
|
||||
|
||||
// Corner radius
|
||||
val radiusSm: Dp = 4.dp,
|
||||
val radiusMd: Dp = 8.dp,
|
||||
@ -30,14 +29,12 @@ data class Dimens(
|
||||
val radiusXl: Dp = 16.dp,
|
||||
val radiusXxl: Dp = 24.dp,
|
||||
val radiusPill: Dp = 999.dp,
|
||||
|
||||
// Elevations
|
||||
val elevationNone: Dp = 0.dp,
|
||||
val elevationSm: Dp = 1.dp,
|
||||
val elevationMd: Dp = 3.dp,
|
||||
val elevationLg: Dp = 6.dp,
|
||||
val elevationXl: Dp = 8.dp,
|
||||
|
||||
// Component heights
|
||||
val buttonHeightSm: Dp = 40.dp,
|
||||
val buttonHeight: Dp = 48.dp,
|
||||
|
||||
@ -4,10 +4,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val SafeBiteShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(4.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp),
|
||||
extraLarge = RoundedCornerShape(24.dp)
|
||||
)
|
||||
val SafeBiteShapes =
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(4.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp),
|
||||
extraLarge = RoundedCornerShape(24.dp),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user