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/),
|
Le format est basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||||
et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.28.0] — 2026-05-11
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **Haptic feedback sur le FAB Scanner** (15ms, distinct du scan 60ms)
|
||||||
|
- **Saisie manuelle du code-barres** dans le Scanner (`AlertDialog` avec `OutlinedTextField`, validation 8-13 chiffres)
|
||||||
|
- **Bouton "Voir les alternatives"** dans ResultScreen (visible si verdict != SAFE, navigation vers ProductDetail)
|
||||||
|
- **Dashboard données réelles** :
|
||||||
|
- Stats hebdomadaires (✅ % safe / ⚠️ warnings / ❌ dangers)
|
||||||
|
- 5 derniers scans avec verdict, marque, temps relatif
|
||||||
|
- 3 modes contextuels auto-détectés (FIRST_TIME / STORE / HOME)
|
||||||
|
- **Animations stagger** sur les actions ResultScreen (fadeIn + slideInVertically, délais 0/50/100/150ms)
|
||||||
|
- **Animation slide-up** sur le contenu ResultScreen (250ms, ease-out)
|
||||||
|
- **Validation format code-barres** dans le Scanner (manuel) et BarcodeAnalyzer (ML Kit)
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- `DashboardViewModel` : injecte `GetScanHistoryUseCase`, calcule `WeeklyStats`
|
||||||
|
- `DashboardScreen` : 3 layouts contextuels distincts (FirstTimeContent, StoreContent, HomeContent)
|
||||||
|
- `ResultScreen` : nouveau callback `onOpenAlternatives`, composant `StaggeredAction`
|
||||||
|
- `ScannerScreen` : état `manualCode` + `AlertDialog` saisie manuelle
|
||||||
|
- `MainScreen` : `SafeBiteFab` avec retour haptique 15ms
|
||||||
|
- `NavGraph` : navigation `ResultScreen.onOpenAlternatives` → `ProductDetail`
|
||||||
|
|
||||||
|
### Vérifié
|
||||||
|
- Mode sombre : `StatusColors` light/dark correctement câblés dans `Theme.kt`
|
||||||
|
- Contraste Material 3 conforme WCAG 2.1 AA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.27.0] — 2026-05-10
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **Catalogue** : écrans Catalog, DomainCategories, CategoryItems, CatalogSearch
|
||||||
|
- **Gestion avancée des listes** : création, tri, région, nom/image, membres
|
||||||
|
- **Splash screen** configurable
|
||||||
|
- **Paramètres liste** : ListSettingsScreen, ListSortScreen, ListRegionScreen
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- Navigation enrichie : routes Catalog*, ListCreate, ListSettings*
|
||||||
|
- `MainScreen` : bottom bar et FAB avec animations scale/fade/slide
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.2.0] — 2026-04-26
|
## [1.2.0] — 2026-04-26
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# SafeBite
|
# SafeBite - Scan d'allergènes en épicerie
|
||||||
|
|
||||||
SafeBite est une application Android native (Kotlin + Jetpack Compose) qui permet aux personnes souffrant d'allergies ou d'intolérances alimentaires de scanner un code-barres en épicerie et d'obtenir instantanément un verdict visuel (**SAFE / ATTENTION / DANGER**) selon leurs profils d'allergies configurés.
|
SafeBite est une application Android native (Kotlin + Jetpack Compose) qui permet aux personnes souffrant d'allergies ou d'intolérances alimentaires de scanner un code-barres en épicerie et d'obtenir instantanément un verdict visuel (**SAFE / ATTENTION / DANGER**) selon leurs profils d'allergies configurés.
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,8 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.parcelize)
|
alias(libs.plugins.kotlin.parcelize)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
|
alias(libs.plugins.ktlint)
|
||||||
|
alias(libs.plugins.detekt)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@ -154,3 +156,24 @@ dependencies {
|
|||||||
// LeakCanary pour détection de fuites mémoire (debug uniquement)
|
// LeakCanary pour détection de fuites mémoire (debug uniquement)
|
||||||
debugImplementation(libs.leakcanary.android)
|
debugImplementation(libs.leakcanary.android)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ktlint — formatage Kotlin
|
||||||
|
ktlint {
|
||||||
|
android = true
|
||||||
|
ignoreFailures = true
|
||||||
|
reporters {
|
||||||
|
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN)
|
||||||
|
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detekt — analyse statique
|
||||||
|
detekt {
|
||||||
|
buildUponDefaultConfig = true
|
||||||
|
allRules = false
|
||||||
|
autoCorrect = false
|
||||||
|
parallel = true
|
||||||
|
ignoreFailures = true
|
||||||
|
config.setFrom(file("$rootDir/config/detekt/detekt.yml"))
|
||||||
|
baseline = file("$rootDir/config/detekt/baseline.xml")
|
||||||
|
}
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import org.junit.runner.RunWith
|
|||||||
*/
|
*/
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class ExampleComposeTest {
|
class ExampleComposeTest {
|
||||||
|
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeTestRule = createComposeRule()
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class SafeBiteApplication : Application() {
|
class SafeBiteApplication : Application() {
|
||||||
|
|
||||||
@Inject lateinit var catalogSeedManager: CatalogSeedManager
|
@Inject lateinit var catalogSeedManager: CatalogSeedManager
|
||||||
|
|
||||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|||||||
@ -10,16 +10,14 @@ import com.safebite.app.domain.model.SafetyStatus
|
|||||||
|
|
||||||
class Converters {
|
class Converters {
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun allergenSetToString(set: Set<AllergenType>?): String =
|
fun allergenSetToString(set: Set<AllergenType>?): String = set.orEmpty().joinToString(",") { it.name }
|
||||||
set.orEmpty().joinToString(",") { it.name }
|
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun stringToAllergenSet(raw: String?): Set<AllergenType> =
|
fun stringToAllergenSet(raw: String?): Set<AllergenType> =
|
||||||
raw.orEmpty().split(',').mapNotNull { AllergenType.fromName(it.trim()) }.toSet()
|
raw.orEmpty().split(',').mapNotNull { AllergenType.fromName(it.trim()) }.toSet()
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun restrictionSetToString(set: Set<DietaryRestriction>?): String =
|
fun restrictionSetToString(set: Set<DietaryRestriction>?): String = set.orEmpty().joinToString(",") { it.name }
|
||||||
set.orEmpty().joinToString(",") { it.name }
|
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun stringToRestrictionSet(raw: String?): Set<DietaryRestriction> =
|
fun stringToRestrictionSet(raw: String?): Set<DietaryRestriction> =
|
||||||
@ -29,12 +27,10 @@ class Converters {
|
|||||||
.toSet()
|
.toSet()
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun stringListToString(list: List<String>?): String =
|
fun stringListToString(list: List<String>?): String = list.orEmpty().joinToString("\u0001")
|
||||||
list.orEmpty().joinToString("\u0001")
|
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun stringToStringList(raw: String?): List<String> =
|
fun stringToStringList(raw: String?): List<String> = if (raw.isNullOrEmpty()) emptyList() else raw.split('\u0001')
|
||||||
if (raw.isNullOrEmpty()) emptyList() else raw.split('\u0001')
|
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun safetyStatusToString(status: SafetyStatus): String = status.name
|
fun safetyStatusToString(status: SafetyStatus): String = status.name
|
||||||
@ -55,7 +51,7 @@ class Converters {
|
|||||||
listOf(
|
listOf(
|
||||||
item.name.replace('|', '/').replace('\u0002', ' '),
|
item.name.replace('|', '/').replace('\u0002', ' '),
|
||||||
item.tag.name,
|
item.tag.name,
|
||||||
item.keywords.joinToString(";") { it.replace(';', ',').replace('|', '/') }
|
item.keywords.joinToString(";") { it.replace(';', ',').replace('|', '/') },
|
||||||
).joinToString("|")
|
).joinToString("|")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,9 +63,12 @@ class Converters {
|
|||||||
if (parts.size < 2) return@mapNotNull null
|
if (parts.size < 2) return@mapNotNull null
|
||||||
val name = parts[0].trim()
|
val name = parts[0].trim()
|
||||||
val tag = runCatching { CustomItemTag.valueOf(parts[1].trim()) }.getOrNull() ?: return@mapNotNull null
|
val tag = runCatching { CustomItemTag.valueOf(parts[1].trim()) }.getOrNull() ?: return@mapNotNull null
|
||||||
val keywords = if (parts.size >= 3 && parts[2].isNotBlank())
|
val keywords =
|
||||||
|
if (parts.size >= 3 && parts[2].isNotBlank()) {
|
||||||
parts[2].split(';').map { it.trim() }.filter { it.isNotBlank() }
|
parts[2].split(';').map { it.trim() }.filter { it.isNotBlank() }
|
||||||
else emptyList()
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
CustomDietItem(name = name, tag = tag, keywords = keywords)
|
CustomDietItem(name = name, tag = tag, keywords = keywords)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,17 +30,21 @@ import com.safebite.app.data.local.database.entity.UserProfileEntity
|
|||||||
ShoppingDomainEntity::class,
|
ShoppingDomainEntity::class,
|
||||||
CategoryEntity::class,
|
CategoryEntity::class,
|
||||||
CatalogItemEntity::class,
|
CatalogItemEntity::class,
|
||||||
ItemCategoryCrossRef::class
|
ItemCategoryCrossRef::class,
|
||||||
],
|
],
|
||||||
version = 9,
|
version = 9,
|
||||||
exportSchema = false
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
abstract class SafeBiteDatabase : RoomDatabase() {
|
abstract class SafeBiteDatabase : RoomDatabase() {
|
||||||
abstract fun userProfileDao(): UserProfileDao
|
abstract fun userProfileDao(): UserProfileDao
|
||||||
|
|
||||||
abstract fun productCacheDao(): ProductCacheDao
|
abstract fun productCacheDao(): ProductCacheDao
|
||||||
|
|
||||||
abstract fun scanHistoryDao(): ScanHistoryDao
|
abstract fun scanHistoryDao(): ScanHistoryDao
|
||||||
|
|
||||||
abstract fun shoppingListDao(): ShoppingListDao
|
abstract fun shoppingListDao(): ShoppingListDao
|
||||||
|
|
||||||
abstract fun catalogDao(): CatalogDao
|
abstract fun catalogDao(): CatalogDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface CatalogDao {
|
interface CatalogDao {
|
||||||
|
|
||||||
// ── Domaines ──────────────────────────────────────────────────────────────
|
// ── Domaines ──────────────────────────────────────────────────────────────
|
||||||
@Query("SELECT * FROM shopping_domains WHERE isActive = 1 ORDER BY sortOrder")
|
@Query("SELECT * FROM shopping_domains WHERE isActive = 1 ORDER BY sortOrder")
|
||||||
fun getAllDomains(): Flow<List<ShoppingDomainEntity>>
|
fun getAllDomains(): Flow<List<ShoppingDomainEntity>>
|
||||||
@ -67,9 +66,12 @@ interface CatalogDao {
|
|||||||
popularity DESC,
|
popularity DESC,
|
||||||
name ASC
|
name ASC
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""
|
""",
|
||||||
)
|
)
|
||||||
fun searchItems(query: String, limit: Int = 20): Flow<List<CatalogItemEntity>>
|
fun searchItems(
|
||||||
|
query: String,
|
||||||
|
limit: Int = 20,
|
||||||
|
): Flow<List<CatalogItemEntity>>
|
||||||
|
|
||||||
@Query("SELECT * FROM catalog_items WHERE primaryCategoryId = :categoryId ORDER BY sortOrder, name")
|
@Query("SELECT * FROM catalog_items WHERE primaryCategoryId = :categoryId ORDER BY sortOrder, name")
|
||||||
fun getItemsForCategory(categoryId: String): Flow<List<CatalogItemEntity>>
|
fun getItemsForCategory(categoryId: String): Flow<List<CatalogItemEntity>>
|
||||||
|
|||||||
@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
*/
|
*/
|
||||||
@Dao
|
@Dao
|
||||||
interface ShoppingListDao {
|
interface ShoppingListDao {
|
||||||
|
|
||||||
// ── Shopping Lists ──────────────────────────────────────────────────────
|
// ── Shopping Lists ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Query("SELECT * FROM shopping_lists WHERE isArchived = 0 ORDER BY displayOrder ASC, updatedAt DESC")
|
@Query("SELECT * FROM shopping_lists WHERE isArchived = 0 ORDER BY displayOrder ASC, updatedAt DESC")
|
||||||
@ -65,7 +64,10 @@ interface ShoppingListDao {
|
|||||||
suspend fun deleteItem(item: ShoppingListItemEntity)
|
suspend fun deleteItem(item: ShoppingListItemEntity)
|
||||||
|
|
||||||
@Query("UPDATE shopping_list_items SET isChecked = :checked WHERE id = :id")
|
@Query("UPDATE shopping_list_items SET isChecked = :checked WHERE id = :id")
|
||||||
suspend fun setItemChecked(id: Long, checked: Boolean)
|
suspend fun setItemChecked(
|
||||||
|
id: Long,
|
||||||
|
checked: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
@Query("UPDATE shopping_list_items SET isChecked = 0 WHERE listId = :listId")
|
@Query("UPDATE shopping_list_items SET isChecked = 0 WHERE listId = :listId")
|
||||||
suspend fun uncheckAllItems(listId: Long)
|
suspend fun uncheckAllItems(listId: Long)
|
||||||
@ -101,7 +103,10 @@ interface ShoppingListDao {
|
|||||||
// ── Transaction: ajouter un produit à une liste ─────────────────────────
|
// ── Transaction: ajouter un produit à une liste ─────────────────────────
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) {
|
suspend fun addItemToList(
|
||||||
|
listId: Long,
|
||||||
|
item: ShoppingListItemEntity,
|
||||||
|
) {
|
||||||
// S'assurer que le listId est correct
|
// S'assurer que le listId est correct
|
||||||
val itemWithCorrectList = item.copy(listId = listId)
|
val itemWithCorrectList = item.copy(listId = listId)
|
||||||
insertItem(itemWithCorrectList)
|
insertItem(itemWithCorrectList)
|
||||||
|
|||||||
@ -22,18 +22,20 @@ data class ShoppingDomainEntity(
|
|||||||
val iconResName: String? = null,
|
val iconResName: String? = null,
|
||||||
val color: String? = null,
|
val color: String? = null,
|
||||||
val sortOrder: Int,
|
val sortOrder: Int,
|
||||||
val isActive: Boolean = true
|
val isActive: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "categories",
|
tableName = "categories",
|
||||||
foreignKeys = [ForeignKey(
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
entity = ShoppingDomainEntity::class,
|
entity = ShoppingDomainEntity::class,
|
||||||
parentColumns = ["domainId"],
|
parentColumns = ["domainId"],
|
||||||
childColumns = ["domainId"],
|
childColumns = ["domainId"],
|
||||||
onDelete = ForeignKey.CASCADE
|
onDelete = ForeignKey.CASCADE,
|
||||||
)],
|
),
|
||||||
indices = [Index("domainId"), Index("name")]
|
],
|
||||||
|
indices = [Index("domainId"), Index("name")],
|
||||||
)
|
)
|
||||||
data class CategoryEntity(
|
data class CategoryEntity(
|
||||||
@PrimaryKey val categoryId: String,
|
@PrimaryKey val categoryId: String,
|
||||||
@ -43,22 +45,24 @@ data class CategoryEntity(
|
|||||||
val iconResName: String? = null,
|
val iconResName: String? = null,
|
||||||
val color: String? = null,
|
val color: String? = null,
|
||||||
val sortOrder: Int,
|
val sortOrder: Int,
|
||||||
val isActive: Boolean = true
|
val isActive: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "catalog_items",
|
tableName = "catalog_items",
|
||||||
foreignKeys = [ForeignKey(
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
entity = CategoryEntity::class,
|
entity = CategoryEntity::class,
|
||||||
parentColumns = ["categoryId"],
|
parentColumns = ["categoryId"],
|
||||||
childColumns = ["primaryCategoryId"],
|
childColumns = ["primaryCategoryId"],
|
||||||
onDelete = ForeignKey.SET_NULL
|
onDelete = ForeignKey.SET_NULL,
|
||||||
)],
|
),
|
||||||
|
],
|
||||||
indices = [
|
indices = [
|
||||||
Index("primaryCategoryId"),
|
Index("primaryCategoryId"),
|
||||||
Index("name"),
|
Index("name"),
|
||||||
Index("barcode")
|
Index("barcode"),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
data class CatalogItemEntity(
|
data class CatalogItemEntity(
|
||||||
@PrimaryKey val itemId: String,
|
@PrimaryKey val itemId: String,
|
||||||
@ -72,7 +76,7 @@ data class CatalogItemEntity(
|
|||||||
val variants: String = "",
|
val variants: String = "",
|
||||||
val isUserCreated: Boolean = false,
|
val isUserCreated: Boolean = false,
|
||||||
val popularity: Int = 0,
|
val popularity: Int = 0,
|
||||||
val sortOrder: Int = 0
|
val sortOrder: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@ -83,18 +87,18 @@ data class CatalogItemEntity(
|
|||||||
entity = CatalogItemEntity::class,
|
entity = CatalogItemEntity::class,
|
||||||
parentColumns = ["itemId"],
|
parentColumns = ["itemId"],
|
||||||
childColumns = ["itemId"],
|
childColumns = ["itemId"],
|
||||||
onDelete = ForeignKey.CASCADE
|
onDelete = ForeignKey.CASCADE,
|
||||||
),
|
),
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = CategoryEntity::class,
|
entity = CategoryEntity::class,
|
||||||
parentColumns = ["categoryId"],
|
parentColumns = ["categoryId"],
|
||||||
childColumns = ["categoryId"],
|
childColumns = ["categoryId"],
|
||||||
onDelete = ForeignKey.CASCADE
|
onDelete = ForeignKey.CASCADE,
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
indices = [Index("categoryId")]
|
indices = [Index("categoryId")],
|
||||||
)
|
)
|
||||||
data class ItemCategoryCrossRef(
|
data class ItemCategoryCrossRef(
|
||||||
val itemId: String,
|
val itemId: String,
|
||||||
val categoryId: String
|
val categoryId: String,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -17,7 +17,7 @@ data class UserProfileEntity(
|
|||||||
val moderateIntolerances: Set<AllergenType>,
|
val moderateIntolerances: Set<AllergenType>,
|
||||||
val dietaryRestrictions: Set<DietaryRestriction>,
|
val dietaryRestrictions: Set<DietaryRestriction>,
|
||||||
val customItems: List<CustomDietItem> = emptyList(),
|
val customItems: List<CustomDietItem> = emptyList(),
|
||||||
val isDefault: Boolean
|
val isDefault: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(tableName = "product_cache")
|
@Entity(tableName = "product_cache")
|
||||||
@ -45,7 +45,7 @@ data class ProductCacheEntity(
|
|||||||
val fiber100g: Double? = null,
|
val fiber100g: Double? = null,
|
||||||
val proteins100g: Double? = null,
|
val proteins100g: Double? = null,
|
||||||
val carbohydrates100g: Double? = null,
|
val carbohydrates100g: Double? = null,
|
||||||
val cachedAt: Long
|
val cachedAt: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(tableName = "scan_history")
|
@Entity(tableName = "scan_history")
|
||||||
@ -58,7 +58,7 @@ data class ScanHistoryEntity(
|
|||||||
val safetyStatus: SafetyStatus,
|
val safetyStatus: SafetyStatus,
|
||||||
val profileNames: List<String>,
|
val profileNames: List<String>,
|
||||||
val scannedAt: Long,
|
val scannedAt: Long,
|
||||||
val source: DataSource
|
val source: DataSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@ -77,7 +77,7 @@ data class ShoppingListEntity(
|
|||||||
val sortType: String = "category",
|
val sortType: String = "category",
|
||||||
val displayOrder: Int = 0,
|
val displayOrder: Int = 0,
|
||||||
val visibleCategories: String? = null,
|
val visibleCategories: String? = null,
|
||||||
val categoryOrder: String? = null
|
val categoryOrder: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@ -87,10 +87,10 @@ data class ShoppingListEntity(
|
|||||||
entity = ShoppingListEntity::class,
|
entity = ShoppingListEntity::class,
|
||||||
parentColumns = ["id"],
|
parentColumns = ["id"],
|
||||||
childColumns = ["listId"],
|
childColumns = ["listId"],
|
||||||
onDelete = androidx.room.ForeignKey.CASCADE
|
onDelete = androidx.room.ForeignKey.CASCADE,
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
indices = [androidx.room.Index("listId")]
|
indices = [androidx.room.Index("listId")],
|
||||||
)
|
)
|
||||||
data class ShoppingListItemEntity(
|
data class ShoppingListItemEntity(
|
||||||
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
|
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
|
||||||
@ -106,7 +106,7 @@ data class ShoppingListItemEntity(
|
|||||||
val note: String? = null, // Quantité / description libre (ex: "2 kg")
|
val note: String? = null, // Quantité / description libre (ex: "2 kg")
|
||||||
val customEmoji: String? = null, // Emoji personnalisé choisi par l'utilisateur
|
val customEmoji: String? = null, // Emoji personnalisé choisi par l'utilisateur
|
||||||
val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever"
|
val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever"
|
||||||
val addedAt: Long = System.currentTimeMillis()
|
val addedAt: Long = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@ -116,10 +116,10 @@ data class ShoppingListItemEntity(
|
|||||||
entity = ShoppingListEntity::class,
|
entity = ShoppingListEntity::class,
|
||||||
parentColumns = ["id"],
|
parentColumns = ["id"],
|
||||||
childColumns = ["listId"],
|
childColumns = ["listId"],
|
||||||
onDelete = androidx.room.ForeignKey.CASCADE
|
onDelete = androidx.room.ForeignKey.CASCADE,
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
indices = [androidx.room.Index("listId")]
|
indices = [androidx.room.Index("listId")],
|
||||||
)
|
)
|
||||||
data class ShoppingListMemberEntity(
|
data class ShoppingListMemberEntity(
|
||||||
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
|
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
|
||||||
@ -128,5 +128,5 @@ data class ShoppingListMemberEntity(
|
|||||||
val email: String,
|
val email: String,
|
||||||
val avatarUrl: String? = null,
|
val avatarUrl: String? = null,
|
||||||
val role: String = "member", // "owner" | "member"
|
val role: String = "member", // "owner" | "member"
|
||||||
val joinedAt: Long = System.currentTimeMillis()
|
val joinedAt: Long = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -8,7 +8,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
* (domaines, catégories, articles, cross-ref) + leurs index. Aucune
|
* (domaines, catégories, articles, cross-ref) + leurs index. Aucune
|
||||||
* donnée existante n'est touchée. Le seed JSON est appliqué après ouverture.
|
* donnée existante n'est touchée. Le seed JSON est appliqué après ouverture.
|
||||||
*/
|
*/
|
||||||
val MIGRATION_7_8: Migration = object : Migration(7, 8) {
|
val MIGRATION_7_8: Migration =
|
||||||
|
object : Migration(7, 8) {
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"""
|
"""
|
||||||
@ -21,7 +22,7 @@ val MIGRATION_7_8: Migration = object : Migration(7, 8) {
|
|||||||
sortOrder INTEGER NOT NULL,
|
sortOrder INTEGER NOT NULL,
|
||||||
isActive INTEGER NOT NULL
|
isActive INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
""".trimIndent()
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
|
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
@ -37,7 +38,7 @@ val MIGRATION_7_8: Migration = object : Migration(7, 8) {
|
|||||||
isActive INTEGER NOT NULL,
|
isActive INTEGER NOT NULL,
|
||||||
FOREIGN KEY(domainId) REFERENCES shopping_domains(domainId) ON DELETE CASCADE
|
FOREIGN KEY(domainId) REFERENCES shopping_domains(domainId) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
""".trimIndent()
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_categories_domainId ON categories(domainId)")
|
db.execSQL("CREATE INDEX IF NOT EXISTS index_categories_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_name ON categories(name)")
|
||||||
@ -58,7 +59,7 @@ val MIGRATION_7_8: Migration = object : Migration(7, 8) {
|
|||||||
sortOrder INTEGER NOT NULL,
|
sortOrder INTEGER NOT NULL,
|
||||||
FOREIGN KEY(primaryCategoryId) REFERENCES categories(categoryId) ON DELETE SET NULL
|
FOREIGN KEY(primaryCategoryId) REFERENCES categories(categoryId) ON DELETE SET NULL
|
||||||
)
|
)
|
||||||
""".trimIndent()
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_primaryCategoryId ON catalog_items(primaryCategoryId)")
|
db.execSQL("CREATE INDEX IF NOT EXISTS index_catalog_items_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_name ON catalog_items(name)")
|
||||||
@ -73,7 +74,7 @@ val MIGRATION_7_8: Migration = object : Migration(7, 8) {
|
|||||||
FOREIGN KEY(itemId) REFERENCES catalog_items(itemId) ON DELETE CASCADE,
|
FOREIGN KEY(itemId) REFERENCES catalog_items(itemId) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(categoryId) REFERENCES categories(categoryId) ON DELETE CASCADE
|
FOREIGN KEY(categoryId) REFERENCES categories(categoryId) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
""".trimIndent()
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
db.execSQL("CREATE INDEX IF NOT EXISTS index_item_category_cross_ref_categoryId ON item_category_cross_ref(categoryId)")
|
db.execSQL("CREATE INDEX IF NOT EXISTS index_item_category_cross_ref_categoryId ON item_category_cross_ref(categoryId)")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,10 +7,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
* Migration additive : ajoute la colonne `variants` à la table `catalog_items`.
|
* Migration additive : ajoute la colonne `variants` à la table `catalog_items`.
|
||||||
* Les données existantes conservent la valeur par défaut ('').
|
* Les données existantes conservent la valeur par défaut ('').
|
||||||
*/
|
*/
|
||||||
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
|
val MIGRATION_8_9: Migration =
|
||||||
|
object : Migration(8, 9) {
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"ALTER TABLE catalog_items ADD COLUMN variants TEXT NOT NULL DEFAULT ''"
|
"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(
|
data class DomainWithCategories(
|
||||||
@Embedded val domain: ShoppingDomainEntity,
|
@Embedded val domain: ShoppingDomainEntity,
|
||||||
@Relation(parentColumn = "domainId", entityColumn = "domainId")
|
@Relation(parentColumn = "domainId", entityColumn = "domainId")
|
||||||
val categories: List<CategoryEntity>
|
val categories: List<CategoryEntity>,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class CategoryWithItems(
|
data class CategoryWithItems(
|
||||||
@ -19,13 +19,14 @@ data class CategoryWithItems(
|
|||||||
@Relation(
|
@Relation(
|
||||||
parentColumn = "categoryId",
|
parentColumn = "categoryId",
|
||||||
entityColumn = "itemId",
|
entityColumn = "itemId",
|
||||||
associateBy = Junction(
|
associateBy =
|
||||||
|
Junction(
|
||||||
value = ItemCategoryCrossRef::class,
|
value = ItemCategoryCrossRef::class,
|
||||||
parentColumn = "categoryId",
|
parentColumn = "categoryId",
|
||||||
entityColumn = "itemId"
|
entityColumn = "itemId",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
val items: List<CatalogItemEntity>,
|
||||||
val items: List<CatalogItemEntity>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DomainWithCategoriesAndItems(
|
data class DomainWithCategoriesAndItems(
|
||||||
@ -33,7 +34,7 @@ data class DomainWithCategoriesAndItems(
|
|||||||
@Relation(
|
@Relation(
|
||||||
entity = CategoryEntity::class,
|
entity = CategoryEntity::class,
|
||||||
parentColumn = "domainId",
|
parentColumn = "domainId",
|
||||||
entityColumn = "domainId"
|
entityColumn = "domainId",
|
||||||
)
|
)
|
||||||
val categoriesWithItems: List<CategoryWithItems>
|
val categoriesWithItems: List<CategoryWithItems>,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -30,13 +30,14 @@ object UserPreferencesKeys {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class UserPreferences(private val dataStore: DataStore<Preferences>) {
|
class UserPreferences(private val dataStore: DataStore<Preferences>) {
|
||||||
|
val appLanguage: Flow<AppLanguage> =
|
||||||
val appLanguage: Flow<AppLanguage> = dataStore.data.map {
|
dataStore.data.map {
|
||||||
runCatching { AppLanguage.valueOf(it[UserPreferencesKeys.APP_LANGUAGE] ?: AppLanguage.FR.name) }
|
runCatching { AppLanguage.valueOf(it[UserPreferencesKeys.APP_LANGUAGE] ?: AppLanguage.FR.name) }
|
||||||
.getOrDefault(AppLanguage.FR)
|
.getOrDefault(AppLanguage.FR)
|
||||||
}
|
}
|
||||||
|
|
||||||
val detectionLanguage: Flow<DetectionLanguage> = dataStore.data.map {
|
val detectionLanguage: Flow<DetectionLanguage> =
|
||||||
|
dataStore.data.map {
|
||||||
runCatching { DetectionLanguage.valueOf(it[UserPreferencesKeys.DETECTION_LANGUAGE] ?: DetectionLanguage.BOTH.name) }
|
runCatching { DetectionLanguage.valueOf(it[UserPreferencesKeys.DETECTION_LANGUAGE] ?: DetectionLanguage.BOTH.name) }
|
||||||
.getOrDefault(DetectionLanguage.BOTH)
|
.getOrDefault(DetectionLanguage.BOTH)
|
||||||
}
|
}
|
||||||
@ -44,25 +45,29 @@ class UserPreferences(private val dataStore: DataStore<Preferences>) {
|
|||||||
val haptics: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.HAPTICS] ?: true }
|
val haptics: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.HAPTICS] ?: true }
|
||||||
val sound: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.SOUND] ?: true }
|
val sound: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.SOUND] ?: true }
|
||||||
|
|
||||||
val theme: Flow<ThemePref> = dataStore.data.map {
|
val theme: Flow<ThemePref> =
|
||||||
|
dataStore.data.map {
|
||||||
runCatching { ThemePref.valueOf(it[UserPreferencesKeys.THEME] ?: ThemePref.SYSTEM.name) }
|
runCatching { ThemePref.valueOf(it[UserPreferencesKeys.THEME] ?: ThemePref.SYSTEM.name) }
|
||||||
.getOrDefault(ThemePref.SYSTEM)
|
.getOrDefault(ThemePref.SYSTEM)
|
||||||
}
|
}
|
||||||
|
|
||||||
val onboardingCompleted: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.ONBOARDING_DONE] ?: false }
|
val onboardingCompleted: Flow<Boolean> = dataStore.data.map { it[UserPreferencesKeys.ONBOARDING_DONE] ?: false }
|
||||||
|
|
||||||
val activeProfileIds: Flow<Set<Long>> = dataStore.data.map { prefs ->
|
val activeProfileIds: Flow<Set<Long>> =
|
||||||
|
dataStore.data.map { prefs ->
|
||||||
prefs[UserPreferencesKeys.ACTIVE_PROFILE_IDS].orEmpty()
|
prefs[UserPreferencesKeys.ACTIVE_PROFILE_IDS].orEmpty()
|
||||||
.mapNotNull { it.toLongOrNull() }
|
.mapNotNull { it.toLongOrNull() }
|
||||||
.toSet()
|
.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
val healthStrictness: Flow<HealthStrictness> = dataStore.data.map {
|
val healthStrictness: Flow<HealthStrictness> =
|
||||||
|
dataStore.data.map {
|
||||||
runCatching { HealthStrictness.valueOf(it[UserPreferencesKeys.HEALTH_STRICTNESS] ?: HealthStrictness.NORMAL.name) }
|
runCatching { HealthStrictness.valueOf(it[UserPreferencesKeys.HEALTH_STRICTNESS] ?: HealthStrictness.NORMAL.name) }
|
||||||
.getOrDefault(HealthStrictness.NORMAL)
|
.getOrDefault(HealthStrictness.NORMAL)
|
||||||
}
|
}
|
||||||
|
|
||||||
val splashScreenEnabled: Flow<Boolean> = dataStore.data.map {
|
val splashScreenEnabled: Flow<Boolean> =
|
||||||
|
dataStore.data.map {
|
||||||
it[UserPreferencesKeys.SPLASH_SCREEN_ENABLED] ?: true
|
it[UserPreferencesKeys.SPLASH_SCREEN_ENABLED] ?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.safebite.app.data.local.seed
|
package com.safebite.app.data.local.seed
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.room.withTransaction
|
import androidx.room.withTransaction
|
||||||
import com.safebite.app.data.local.database.SafeBiteDatabase
|
import com.safebite.app.data.local.database.SafeBiteDatabase
|
||||||
import com.safebite.app.data.local.database.dao.CatalogDao
|
import com.safebite.app.data.local.database.dao.CatalogDao
|
||||||
@ -8,7 +9,6 @@ import com.safebite.app.data.local.database.entity.CatalogItemEntity
|
|||||||
import com.safebite.app.data.local.database.entity.CategoryEntity
|
import com.safebite.app.data.local.database.entity.CategoryEntity
|
||||||
import com.safebite.app.data.local.database.entity.ItemCategoryCrossRef
|
import com.safebite.app.data.local.database.entity.ItemCategoryCrossRef
|
||||||
import com.safebite.app.data.local.database.entity.ShoppingDomainEntity
|
import com.safebite.app.data.local.database.entity.ShoppingDomainEntity
|
||||||
import android.util.Log
|
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -22,16 +22,20 @@ import javax.inject.Singleton
|
|||||||
* lancement (ou après une migration qui a créé les tables vides).
|
* lancement (ou après une migration qui a créé les tables vides).
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class CatalogSeedManager @Inject constructor(
|
class CatalogSeedManager
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val database: SafeBiteDatabase,
|
private val database: SafeBiteDatabase,
|
||||||
private val catalogDao: CatalogDao,
|
private val catalogDao: CatalogDao,
|
||||||
private val moshi: Moshi
|
private val moshi: Moshi,
|
||||||
) {
|
) {
|
||||||
suspend fun seedIfNeeded() = withContext(Dispatchers.IO) {
|
suspend fun seedIfNeeded() =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
val storedVersion = prefs.getInt(PREF_SEED_VERSION, 0)
|
val storedVersion = prefs.getInt(PREF_SEED_VERSION, 0)
|
||||||
val jsonVersion = runCatching {
|
val jsonVersion =
|
||||||
|
runCatching {
|
||||||
val json = context.assets.open(SEED_ASSET).bufferedReader().use { it.readText() }
|
val json = context.assets.open(SEED_ASSET).bufferedReader().use { it.readText() }
|
||||||
moshi.adapter(CatalogSeed::class.java).fromJson(json)?.version ?: 0
|
moshi.adapter(CatalogSeed::class.java).fromJson(json)?.version ?: 0
|
||||||
}.getOrElse { 0 }
|
}.getOrElse { 0 }
|
||||||
@ -63,9 +67,9 @@ class CatalogSeedManager @Inject constructor(
|
|||||||
emoji = domainSeed.emoji,
|
emoji = domainSeed.emoji,
|
||||||
color = domainSeed.color,
|
color = domainSeed.color,
|
||||||
sortOrder = domainSeed.sortOrder,
|
sortOrder = domainSeed.sortOrder,
|
||||||
isActive = true
|
isActive = true,
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
domainSeed.categories.forEach { catSeed ->
|
domainSeed.categories.forEach { catSeed ->
|
||||||
@ -78,12 +82,13 @@ class CatalogSeedManager @Inject constructor(
|
|||||||
emoji = catSeed.emoji,
|
emoji = catSeed.emoji,
|
||||||
color = catSeed.color,
|
color = catSeed.color,
|
||||||
sortOrder = catSeed.sortOrder,
|
sortOrder = catSeed.sortOrder,
|
||||||
isActive = true
|
isActive = true,
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val items = catSeed.items.mapIndexed { index, itemSeed ->
|
val items =
|
||||||
|
catSeed.items.mapIndexed { index, itemSeed ->
|
||||||
CatalogItemEntity(
|
CatalogItemEntity(
|
||||||
itemId = itemSeed.itemId,
|
itemId = itemSeed.itemId,
|
||||||
name = itemSeed.name,
|
name = itemSeed.name,
|
||||||
@ -93,13 +98,13 @@ class CatalogSeedManager @Inject constructor(
|
|||||||
tags = itemSeed.tags.orEmpty(),
|
tags = itemSeed.tags.orEmpty(),
|
||||||
variants = itemSeed.variants.orEmpty(),
|
variants = itemSeed.variants.orEmpty(),
|
||||||
barcode = itemSeed.barcode,
|
barcode = itemSeed.barcode,
|
||||||
sortOrder = index
|
sortOrder = index,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (items.isNotEmpty()) {
|
if (items.isNotEmpty()) {
|
||||||
catalogDao.insertItems(items)
|
catalogDao.insertItems(items)
|
||||||
catalogDao.insertCrossRefs(
|
catalogDao.insertCrossRefs(
|
||||||
items.map { ItemCategoryCrossRef(it.itemId, catSeed.categoryId) }
|
items.map { ItemCategoryCrossRef(it.itemId, catSeed.categoryId) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import com.squareup.moshi.JsonClass
|
|||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
data class CatalogSeed(
|
data class CatalogSeed(
|
||||||
val version: Int,
|
val version: Int,
|
||||||
val domains: List<DomainSeed>
|
val domains: List<DomainSeed>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
@ -20,7 +20,7 @@ data class DomainSeed(
|
|||||||
val emoji: String,
|
val emoji: String,
|
||||||
val color: String? = null,
|
val color: String? = null,
|
||||||
val sortOrder: Int,
|
val sortOrder: Int,
|
||||||
val categories: List<CategorySeed>
|
val categories: List<CategorySeed>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
@ -30,7 +30,7 @@ data class CategorySeed(
|
|||||||
val emoji: String,
|
val emoji: String,
|
||||||
val color: String? = null,
|
val color: String? = null,
|
||||||
val sortOrder: Int,
|
val sortOrder: Int,
|
||||||
val items: List<ItemSeed>
|
val items: List<ItemSeed>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
@ -41,5 +41,5 @@ data class ItemSeed(
|
|||||||
val aliases: String? = null,
|
val aliases: String? = null,
|
||||||
val tags: String? = null,
|
val tags: String? = null,
|
||||||
val variants: String? = null,
|
val variants: String? = null,
|
||||||
val barcode: String? = null
|
val barcode: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,7 +7,9 @@ import retrofit2.http.Path
|
|||||||
|
|
||||||
interface OpenFoodFactsApi {
|
interface OpenFoodFactsApi {
|
||||||
@GET("api/v2/product/{barcode}.json")
|
@GET("api/v2/product/{barcode}.json")
|
||||||
suspend fun getProduct(@Path("barcode") barcode: String): Response<ProductResponse>
|
suspend fun getProduct(
|
||||||
|
@Path("barcode") barcode: String,
|
||||||
|
): Response<ProductResponse>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val BASE_URL = "https://world.openfoodfacts.org/"
|
const val BASE_URL = "https://world.openfoodfacts.org/"
|
||||||
|
|||||||
@ -8,7 +8,7 @@ data class ProductResponse(
|
|||||||
@Json(name = "code") val code: String? = null,
|
@Json(name = "code") val code: String? = null,
|
||||||
@Json(name = "status") val status: Int? = null,
|
@Json(name = "status") val status: Int? = null,
|
||||||
@Json(name = "status_verbose") val statusVerbose: String? = null,
|
@Json(name = "status_verbose") val statusVerbose: String? = null,
|
||||||
@Json(name = "product") val product: ProductDto? = null
|
@Json(name = "product") val product: ProductDto? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
@ -30,7 +30,7 @@ data class ProductDto(
|
|||||||
@Json(name = "serving_size") val servingSize: String? = null,
|
@Json(name = "serving_size") val servingSize: String? = null,
|
||||||
@Json(name = "labels_tags") val labelsTags: List<String>? = null,
|
@Json(name = "labels_tags") val labelsTags: List<String>? = null,
|
||||||
@Json(name = "categories_tags") val categoriesTags: List<String>? = null,
|
@Json(name = "categories_tags") val categoriesTags: List<String>? = null,
|
||||||
@Json(name = "nutriments") val nutriments: NutrimentsDto? = null
|
@Json(name = "nutriments") val nutriments: NutrimentsDto? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = true)
|
@JsonClass(generateAdapter = true)
|
||||||
@ -44,5 +44,5 @@ data class NutrimentsDto(
|
|||||||
@Json(name = "sodium_100g") val sodium100g: Double? = null,
|
@Json(name = "sodium_100g") val sodium100g: Double? = null,
|
||||||
@Json(name = "fiber_100g") val fiber100g: Double? = null,
|
@Json(name = "fiber_100g") val fiber100g: Double? = null,
|
||||||
@Json(name = "proteins_100g") val proteins100g: Double? = null,
|
@Json(name = "proteins_100g") val proteins100g: Double? = null,
|
||||||
@Json(name = "carbohydrates_100g") val carbohydrates100g: Double? = null
|
@Json(name = "carbohydrates_100g") val carbohydrates100g: Double? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -6,14 +6,17 @@ import com.safebite.app.data.remote.dto.ProductDto
|
|||||||
import com.safebite.app.domain.model.Nutriments
|
import com.safebite.app.domain.model.Nutriments
|
||||||
import com.safebite.app.domain.model.Product
|
import com.safebite.app.domain.model.Product
|
||||||
|
|
||||||
fun ProductDto.toDomain(barcode: String): Product = Product(
|
fun ProductDto.toDomain(barcode: String): Product =
|
||||||
|
Product(
|
||||||
barcode = barcode,
|
barcode = barcode,
|
||||||
name = productNameFr?.takeIf { it.isNotBlank() }
|
name =
|
||||||
|
productNameFr?.takeIf { it.isNotBlank() }
|
||||||
?: productNameEn?.takeIf { it.isNotBlank() }
|
?: productNameEn?.takeIf { it.isNotBlank() }
|
||||||
?: productName?.takeIf { it.isNotBlank() },
|
?: productName?.takeIf { it.isNotBlank() },
|
||||||
brand = brands?.takeIf { it.isNotBlank() },
|
brand = brands?.takeIf { it.isNotBlank() },
|
||||||
imageUrl = imageFrontUrl ?: imageUrl,
|
imageUrl = imageFrontUrl ?: imageUrl,
|
||||||
ingredientsText = ingredientsTextFr?.takeIf { it.isNotBlank() }
|
ingredientsText =
|
||||||
|
ingredientsTextFr?.takeIf { it.isNotBlank() }
|
||||||
?: ingredientsTextEn?.takeIf { it.isNotBlank() }
|
?: ingredientsTextEn?.takeIf { it.isNotBlank() }
|
||||||
?: ingredientsText,
|
?: ingredientsText,
|
||||||
allergensTags = allergensTags.orEmpty(),
|
allergensTags = allergensTags.orEmpty(),
|
||||||
@ -24,10 +27,11 @@ fun ProductDto.toDomain(barcode: String): Product = Product(
|
|||||||
servingSize = servingSize,
|
servingSize = servingSize,
|
||||||
nutriments = nutriments?.toDomain() ?: Nutriments(),
|
nutriments = nutriments?.toDomain() ?: Nutriments(),
|
||||||
labels = labelsTags.orEmpty(),
|
labels = labelsTags.orEmpty(),
|
||||||
categories = categoriesTags.orEmpty()
|
categories = categoriesTags.orEmpty(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun NutrimentsDto.toDomain(): Nutriments = Nutriments(
|
fun NutrimentsDto.toDomain(): Nutriments =
|
||||||
|
Nutriments(
|
||||||
energyKcal100g = energyKcal100g,
|
energyKcal100g = energyKcal100g,
|
||||||
energyKcalServing = energyKcalServing,
|
energyKcalServing = energyKcalServing,
|
||||||
fat100g = fat100g,
|
fat100g = fat100g,
|
||||||
@ -37,10 +41,11 @@ fun NutrimentsDto.toDomain(): Nutriments = Nutriments(
|
|||||||
sodium100g = sodium100g,
|
sodium100g = sodium100g,
|
||||||
fiber100g = fiber100g,
|
fiber100g = fiber100g,
|
||||||
proteins100g = proteins100g,
|
proteins100g = proteins100g,
|
||||||
carbohydrates100g = carbohydrates100g
|
carbohydrates100g = carbohydrates100g,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun Product.toCacheEntity(): ProductCacheEntity = ProductCacheEntity(
|
fun Product.toCacheEntity(): ProductCacheEntity =
|
||||||
|
ProductCacheEntity(
|
||||||
barcode = barcode,
|
barcode = barcode,
|
||||||
name = name,
|
name = name,
|
||||||
brand = brand,
|
brand = brand,
|
||||||
@ -64,10 +69,11 @@ fun Product.toCacheEntity(): ProductCacheEntity = ProductCacheEntity(
|
|||||||
fiber100g = nutriments.fiber100g,
|
fiber100g = nutriments.fiber100g,
|
||||||
proteins100g = nutriments.proteins100g,
|
proteins100g = nutriments.proteins100g,
|
||||||
carbohydrates100g = nutriments.carbohydrates100g,
|
carbohydrates100g = nutriments.carbohydrates100g,
|
||||||
cachedAt = System.currentTimeMillis()
|
cachedAt = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ProductCacheEntity.toDomain(): Product = Product(
|
fun ProductCacheEntity.toDomain(): Product =
|
||||||
|
Product(
|
||||||
barcode = barcode,
|
barcode = barcode,
|
||||||
name = name,
|
name = name,
|
||||||
brand = brand,
|
brand = brand,
|
||||||
@ -81,7 +87,8 @@ fun ProductCacheEntity.toDomain(): Product = Product(
|
|||||||
servingSize = servingSize,
|
servingSize = servingSize,
|
||||||
labels = labels,
|
labels = labels,
|
||||||
categories = categories,
|
categories = categories,
|
||||||
nutriments = Nutriments(
|
nutriments =
|
||||||
|
Nutriments(
|
||||||
energyKcal100g = energyKcal100g,
|
energyKcal100g = energyKcal100g,
|
||||||
energyKcalServing = energyKcalServing,
|
energyKcalServing = energyKcalServing,
|
||||||
fat100g = fat100g,
|
fat100g = fat100g,
|
||||||
@ -91,6 +98,6 @@ fun ProductCacheEntity.toDomain(): Product = Product(
|
|||||||
sodium100g = sodium100g,
|
sodium100g = sodium100g,
|
||||||
fiber100g = fiber100g,
|
fiber100g = fiber100g,
|
||||||
proteins100g = proteins100g,
|
proteins100g = proteins100g,
|
||||||
carbohydrates100g = carbohydrates100g
|
carbohydrates100g = carbohydrates100g,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -18,37 +18,38 @@ import javax.inject.Singleton
|
|||||||
* aux écrans Catalogue, Catégories, Articles et Recherche.
|
* aux écrans Catalogue, Catégories, Articles et Recherche.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class CatalogRepository @Inject constructor(
|
class CatalogRepository
|
||||||
private val dao: CatalogDao
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val dao: CatalogDao,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun observeDomains(): Flow<List<ShoppingDomainEntity>> = dao.getAllDomains()
|
fun observeDomains(): Flow<List<ShoppingDomainEntity>> = dao.getAllDomains()
|
||||||
|
|
||||||
fun observeDomainsWithCategories(): Flow<List<DomainWithCategories>> =
|
fun observeDomainsWithCategories(): Flow<List<DomainWithCategories>> = dao.getDomainsWithCategories()
|
||||||
dao.getDomainsWithCategories()
|
|
||||||
|
|
||||||
fun observeDomainsWithCategoriesAndItems(): Flow<List<DomainWithCategoriesAndItems>> =
|
fun observeDomainsWithCategoriesAndItems(): Flow<List<DomainWithCategoriesAndItems>> = dao.getDomainsWithCategoriesAndItems()
|
||||||
dao.getDomainsWithCategoriesAndItems()
|
|
||||||
|
|
||||||
fun observeCategoriesForDomain(domainId: String): Flow<List<CategoryEntity>> =
|
fun observeCategoriesForDomain(domainId: String): Flow<List<CategoryEntity>> = dao.getCategoriesForDomain(domainId)
|
||||||
dao.getCategoriesForDomain(domainId)
|
|
||||||
|
|
||||||
fun observeCategoryWithItems(categoryId: String): Flow<CategoryWithItems?> =
|
fun observeCategoryWithItems(categoryId: String): Flow<CategoryWithItems?> = dao.getCategoryWithItems(categoryId)
|
||||||
dao.getCategoryWithItems(categoryId)
|
|
||||||
|
|
||||||
fun observeItemsForCategory(categoryId: String): Flow<List<CatalogItemEntity>> =
|
fun observeItemsForCategory(categoryId: String): Flow<List<CatalogItemEntity>> = dao.getItemsForCategory(categoryId)
|
||||||
dao.getItemsForCategory(categoryId)
|
|
||||||
|
|
||||||
fun observePopularItems(limit: Int = 15): Flow<List<CatalogItemEntity>> =
|
fun observePopularItems(limit: Int = 15): Flow<List<CatalogItemEntity>> = dao.getPopularItems(limit)
|
||||||
dao.getPopularItems(limit)
|
|
||||||
|
|
||||||
fun search(query: String, limit: Int = 20): Flow<List<CatalogItemEntity>> =
|
fun search(
|
||||||
dao.searchItems(query.trim(), limit)
|
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 getCategory(categoryId: String): CategoryEntity? = dao.getCategoryById(categoryId)
|
||||||
|
|
||||||
suspend fun getItem(itemId: String): CatalogItemEntity? = dao.getItemById(itemId)
|
suspend fun getItem(itemId: String): CatalogItemEntity? = dao.getItemById(itemId)
|
||||||
|
|
||||||
suspend fun getItemByBarcode(barcode: String): CatalogItemEntity? = dao.getItemByBarcode(barcode)
|
suspend fun getItemByBarcode(barcode: String): CatalogItemEntity? = dao.getItemByBarcode(barcode)
|
||||||
|
|
||||||
suspend fun incrementPopularity(itemId: String) = dao.incrementPopularity(itemId)
|
suspend fun incrementPopularity(itemId: String) = dao.incrementPopularity(itemId)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -60,9 +61,10 @@ class CatalogRepository @Inject constructor(
|
|||||||
emoji: String,
|
emoji: String,
|
||||||
primaryCategoryId: String?,
|
primaryCategoryId: String?,
|
||||||
aliases: String = "",
|
aliases: String = "",
|
||||||
tags: String = ""
|
tags: String = "",
|
||||||
): CatalogItemEntity {
|
): CatalogItemEntity {
|
||||||
val item = CatalogItemEntity(
|
val item =
|
||||||
|
CatalogItemEntity(
|
||||||
itemId = "user_${UUID.randomUUID()}",
|
itemId = "user_${UUID.randomUUID()}",
|
||||||
name = name,
|
name = name,
|
||||||
primaryCategoryId = primaryCategoryId,
|
primaryCategoryId = primaryCategoryId,
|
||||||
@ -71,7 +73,7 @@ class CatalogRepository @Inject constructor(
|
|||||||
tags = tags,
|
tags = tags,
|
||||||
isUserCreated = true,
|
isUserCreated = true,
|
||||||
popularity = 0,
|
popularity = 0,
|
||||||
sortOrder = 0
|
sortOrder = 0,
|
||||||
)
|
)
|
||||||
dao.insertItem(item)
|
dao.insertItem(item)
|
||||||
if (primaryCategoryId != null) {
|
if (primaryCategoryId != null) {
|
||||||
|
|||||||
@ -16,13 +16,15 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class ProductRepositoryImpl @Inject constructor(
|
class ProductRepositoryImpl
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val api: OpenFoodFactsApi,
|
private val api: OpenFoodFactsApi,
|
||||||
private val cacheDao: ProductCacheDao,
|
private val cacheDao: ProductCacheDao,
|
||||||
private val connectivity: ConnectivityObserver
|
private val connectivity: ConnectivityObserver,
|
||||||
) : ProductRepository {
|
) : ProductRepository {
|
||||||
|
override suspend fun fetchProduct(barcode: String): ProductFetchResult =
|
||||||
override suspend fun fetchProduct(barcode: String): ProductFetchResult = withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val cached = cacheDao.getByBarcode(barcode)?.toDomain()
|
val cached = cacheDao.getByBarcode(barcode)?.toDomain()
|
||||||
val online = connectivity.isOnline()
|
val online = connectivity.isOnline()
|
||||||
|
|
||||||
@ -61,11 +63,13 @@ class ProductRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun cacheProduct(product: Product) = withContext(Dispatchers.IO) {
|
override suspend fun cacheProduct(product: Product) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
cacheDao.upsert(product.toCacheEntity())
|
cacheDao.upsert(product.toCacheEntity())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getCachedProduct(barcode: String): Product? = withContext(Dispatchers.IO) {
|
override suspend fun getCachedProduct(barcode: String): Product? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
cacheDao.getByBarcode(barcode)?.toDomain()
|
cacheDao.getByBarcode(barcode)?.toDomain()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,8 +78,9 @@ class ProductRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun searchAlternatives(
|
override suspend fun searchAlternatives(
|
||||||
category: String,
|
category: String,
|
||||||
excludeAllergens: Set<String>,
|
excludeAllergens: Set<String>,
|
||||||
limit: Int
|
limit: Int,
|
||||||
): List<Product> = withContext(Dispatchers.IO) {
|
): List<Product> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
// TODO: Implémenter la recherche d'alternatives via l'API OFF
|
// TODO: Implémenter la recherche d'alternatives via l'API OFF
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,14 +13,15 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class ScanHistoryRepositoryImpl @Inject constructor(
|
class ScanHistoryRepositoryImpl
|
||||||
private val dao: ScanHistoryDao
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val dao: ScanHistoryDao,
|
||||||
) : ScanHistoryRepository {
|
) : ScanHistoryRepository {
|
||||||
|
override fun observeHistory(): Flow<List<ScanHistoryItem>> = dao.observeAll().map { list -> list.map { it.toDomain() } }
|
||||||
|
|
||||||
override fun observeHistory(): Flow<List<ScanHistoryItem>> =
|
override suspend fun save(result: ScanResult): Long =
|
||||||
dao.observeAll().map { list -> list.map { it.toDomain() } }
|
withContext(Dispatchers.IO) {
|
||||||
|
|
||||||
override suspend fun save(result: ScanResult): Long = withContext(Dispatchers.IO) {
|
|
||||||
dao.insert(
|
dao.insert(
|
||||||
ScanHistoryEntity(
|
ScanHistoryEntity(
|
||||||
barcode = result.product.barcode,
|
barcode = result.product.barcode,
|
||||||
@ -30,8 +31,8 @@ class ScanHistoryRepositoryImpl @Inject constructor(
|
|||||||
safetyStatus = result.safetyStatus,
|
safetyStatus = result.safetyStatus,
|
||||||
profileNames = result.analyzedProfiles.map { it.name },
|
profileNames = result.analyzedProfiles.map { it.name },
|
||||||
scannedAt = System.currentTimeMillis(),
|
scannedAt = System.currentTimeMillis(),
|
||||||
source = result.source
|
source = result.source,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,12 +40,14 @@ class ScanHistoryRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun clear() = withContext(Dispatchers.IO) { dao.clear() }
|
override suspend fun clear() = withContext(Dispatchers.IO) { dao.clear() }
|
||||||
|
|
||||||
override suspend fun getById(id: Long): ScanHistoryItem? = withContext(Dispatchers.IO) {
|
override suspend fun getById(id: Long): ScanHistoryItem? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
dao.getById(id)?.toDomain()
|
dao.getById(id)?.toDomain()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ScanHistoryEntity.toDomain() = ScanHistoryItem(
|
private fun ScanHistoryEntity.toDomain() =
|
||||||
|
ScanHistoryItem(
|
||||||
id = id,
|
id = id,
|
||||||
barcode = barcode,
|
barcode = barcode,
|
||||||
productName = productName,
|
productName = productName,
|
||||||
@ -53,5 +56,5 @@ private fun ScanHistoryEntity.toDomain() = ScanHistoryItem(
|
|||||||
safetyStatus = safetyStatus,
|
safetyStatus = safetyStatus,
|
||||||
profileNames = profileNames,
|
profileNames = profileNames,
|
||||||
scannedAt = scannedAt,
|
scannedAt = scannedAt,
|
||||||
source = source
|
source = source,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -10,8 +10,10 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class SettingsRepositoryImpl @Inject constructor(
|
class SettingsRepositoryImpl
|
||||||
private val prefs: UserPreferences
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val prefs: UserPreferences,
|
||||||
) : SettingsRepository {
|
) : SettingsRepository {
|
||||||
override val appLanguage = prefs.appLanguage
|
override val appLanguage = prefs.appLanguage
|
||||||
override val detectionLanguage = prefs.detectionLanguage
|
override val detectionLanguage = prefs.detectionLanguage
|
||||||
@ -23,11 +25,18 @@ class SettingsRepositoryImpl @Inject constructor(
|
|||||||
override val splashScreenEnabled = prefs.splashScreenEnabled
|
override val splashScreenEnabled = prefs.splashScreenEnabled
|
||||||
|
|
||||||
override suspend fun setAppLanguage(value: AppLanguage) = prefs.setAppLanguage(value)
|
override suspend fun setAppLanguage(value: AppLanguage) = prefs.setAppLanguage(value)
|
||||||
|
|
||||||
override suspend fun setDetectionLanguage(value: DetectionLanguage) = prefs.setDetectionLanguage(value)
|
override suspend fun setDetectionLanguage(value: DetectionLanguage) = prefs.setDetectionLanguage(value)
|
||||||
|
|
||||||
override suspend fun setHaptics(enabled: Boolean) = prefs.setHaptics(enabled)
|
override suspend fun setHaptics(enabled: Boolean) = prefs.setHaptics(enabled)
|
||||||
|
|
||||||
override suspend fun setSound(enabled: Boolean) = prefs.setSound(enabled)
|
override suspend fun setSound(enabled: Boolean) = prefs.setSound(enabled)
|
||||||
|
|
||||||
override suspend fun setTheme(value: ThemePref) = prefs.setTheme(value)
|
override suspend fun setTheme(value: ThemePref) = prefs.setTheme(value)
|
||||||
|
|
||||||
override suspend fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
|
override suspend fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
|
||||||
|
|
||||||
override suspend fun setHealthStrictness(value: HealthStrictness) = prefs.setHealthStrictness(value)
|
override suspend fun setHealthStrictness(value: HealthStrictness) = prefs.setHealthStrictness(value)
|
||||||
|
|
||||||
override suspend fun setSplashScreenEnabled(enabled: Boolean) = prefs.setSplashScreenEnabled(enabled)
|
override suspend fun setSplashScreenEnabled(enabled: Boolean) = prefs.setSplashScreenEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,25 +10,27 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class ShoppingListRepositoryImpl @Inject constructor(
|
class ShoppingListRepositoryImpl
|
||||||
private val dao: ShoppingListDao
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val dao: ShoppingListDao,
|
||||||
) : ShoppingListRepository {
|
) : ShoppingListRepository {
|
||||||
|
override fun observeActiveLists(): Flow<List<ShoppingListEntity>> = dao.observeActiveLists()
|
||||||
|
|
||||||
override fun observeActiveLists(): Flow<List<ShoppingListEntity>> =
|
override fun observeAllLists(): Flow<List<ShoppingListEntity>> = dao.observeAllLists()
|
||||||
dao.observeActiveLists()
|
|
||||||
|
|
||||||
override fun observeAllLists(): Flow<List<ShoppingListEntity>> =
|
override suspend fun getListById(id: Long): ShoppingListEntity? = dao.getListById(id)
|
||||||
dao.observeAllLists()
|
|
||||||
|
|
||||||
override suspend fun getListById(id: Long): ShoppingListEntity? =
|
override suspend fun createList(
|
||||||
dao.getListById(id)
|
name: String,
|
||||||
|
backgroundResName: String?,
|
||||||
override suspend fun createList(name: String, backgroundResName: String?): Long {
|
): Long {
|
||||||
val list = ShoppingListEntity(
|
val list =
|
||||||
|
ShoppingListEntity(
|
||||||
name = name,
|
name = name,
|
||||||
createdAt = System.currentTimeMillis(),
|
createdAt = System.currentTimeMillis(),
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = System.currentTimeMillis(),
|
||||||
backgroundResName = backgroundResName
|
backgroundResName = backgroundResName,
|
||||||
)
|
)
|
||||||
return dao.insertList(list)
|
return dao.insertList(list)
|
||||||
}
|
}
|
||||||
@ -45,14 +47,11 @@ class ShoppingListRepositoryImpl @Inject constructor(
|
|||||||
dao.archiveList(id)
|
dao.archiveList(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>> =
|
override fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>> = dao.observeItems(listId)
|
||||||
dao.observeItems(listId)
|
|
||||||
|
|
||||||
override suspend fun getItems(listId: Long): List<ShoppingListItemEntity> =
|
override suspend fun getItems(listId: Long): List<ShoppingListItemEntity> = dao.getItems(listId)
|
||||||
dao.getItems(listId)
|
|
||||||
|
|
||||||
override suspend fun addItem(item: ShoppingListItemEntity): Long =
|
override suspend fun addItem(item: ShoppingListItemEntity): Long = dao.insertItem(item)
|
||||||
dao.insertItem(item)
|
|
||||||
|
|
||||||
override suspend fun updateItem(item: ShoppingListItemEntity) {
|
override suspend fun updateItem(item: ShoppingListItemEntity) {
|
||||||
dao.updateItem(item)
|
dao.updateItem(item)
|
||||||
@ -62,7 +61,10 @@ class ShoppingListRepositoryImpl @Inject constructor(
|
|||||||
dao.deleteItem(item)
|
dao.deleteItem(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setItemChecked(id: Long, checked: Boolean) {
|
override suspend fun setItemChecked(
|
||||||
|
id: Long,
|
||||||
|
checked: Boolean,
|
||||||
|
) {
|
||||||
dao.setItemChecked(id, checked)
|
dao.setItemChecked(id, checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,21 +76,20 @@ class ShoppingListRepositoryImpl @Inject constructor(
|
|||||||
dao.deleteAllItems(listId)
|
dao.deleteAllItems(listId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun observeItemCount(listId: Long): Flow<Int> =
|
override fun observeItemCount(listId: Long): Flow<Int> = dao.observeItemCount(listId)
|
||||||
dao.observeItemCount(listId)
|
|
||||||
|
|
||||||
override fun observeCheckedCount(listId: Long): Flow<Int> =
|
override fun observeCheckedCount(listId: Long): Flow<Int> = dao.observeCheckedCount(listId)
|
||||||
dao.observeCheckedCount(listId)
|
|
||||||
|
|
||||||
override suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) {
|
override suspend fun addItemToList(
|
||||||
|
listId: Long,
|
||||||
|
item: ShoppingListItemEntity,
|
||||||
|
) {
|
||||||
dao.addItemToList(listId, item)
|
dao.addItemToList(listId, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>> =
|
override fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>> = dao.observeMembers(listId)
|
||||||
dao.observeMembers(listId)
|
|
||||||
|
|
||||||
override suspend fun addMember(member: ShoppingListMemberEntity): Long =
|
override suspend fun addMember(member: ShoppingListMemberEntity): Long = dao.insertMember(member)
|
||||||
dao.insertMember(member)
|
|
||||||
|
|
||||||
override suspend fun updateMember(member: ShoppingListMemberEntity) {
|
override suspend fun updateMember(member: ShoppingListMemberEntity) {
|
||||||
dao.updateMember(member)
|
dao.updateMember(member)
|
||||||
|
|||||||
@ -13,41 +13,50 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class UserProfileRepositoryImpl @Inject constructor(
|
class UserProfileRepositoryImpl
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val dao: UserProfileDao,
|
private val dao: UserProfileDao,
|
||||||
private val prefs: UserPreferences
|
private val prefs: UserPreferences,
|
||||||
) : UserProfileRepository {
|
) : UserProfileRepository {
|
||||||
|
override fun observeProfiles(): Flow<List<UserProfile>> = dao.observeAll().map { list -> list.map { it.toDomain() } }
|
||||||
|
|
||||||
override fun observeProfiles(): Flow<List<UserProfile>> =
|
override suspend fun getProfile(id: Long): UserProfile? =
|
||||||
dao.observeAll().map { list -> list.map { it.toDomain() } }
|
withContext(Dispatchers.IO) {
|
||||||
|
|
||||||
override suspend fun getProfile(id: Long): UserProfile? = withContext(Dispatchers.IO) {
|
|
||||||
dao.getById(id)?.toDomain()
|
dao.getById(id)?.toDomain()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun upsert(profile: UserProfile): Long = withContext(Dispatchers.IO) {
|
override suspend fun upsert(profile: UserProfile): Long =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
val entity = profile.toEntity()
|
val entity = profile.toEntity()
|
||||||
if (profile.id == 0L) dao.insert(entity) else {
|
if (profile.id == 0L) {
|
||||||
|
dao.insert(entity)
|
||||||
|
} else {
|
||||||
dao.update(entity)
|
dao.update(entity)
|
||||||
profile.id
|
profile.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun delete(profile: UserProfile) = withContext(Dispatchers.IO) {
|
override suspend fun delete(profile: UserProfile) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
dao.delete(profile.toEntity())
|
dao.delete(profile.toEntity())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setDefault(id: Long) = withContext(Dispatchers.IO) {
|
override suspend fun setDefault(id: Long) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
dao.clearDefault()
|
dao.clearDefault()
|
||||||
dao.markDefault(id)
|
dao.markDefault(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun observeActiveProfileIds(): Flow<Set<Long>> = prefs.activeProfileIds
|
override fun observeActiveProfileIds(): Flow<Set<Long>> = prefs.activeProfileIds
|
||||||
|
|
||||||
override suspend fun setActiveProfileIds(ids: Set<Long>) { prefs.setActiveProfileIds(ids) }
|
override suspend fun setActiveProfileIds(ids: Set<Long>) {
|
||||||
|
prefs.setActiveProfileIds(ids)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun UserProfileEntity.toDomain(): UserProfile = UserProfile(
|
private fun UserProfileEntity.toDomain(): UserProfile =
|
||||||
|
UserProfile(
|
||||||
id = id,
|
id = id,
|
||||||
name = name,
|
name = name,
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
@ -55,10 +64,11 @@ private fun UserProfileEntity.toDomain(): UserProfile = UserProfile(
|
|||||||
moderateIntolerances = moderateIntolerances,
|
moderateIntolerances = moderateIntolerances,
|
||||||
dietaryRestrictions = dietaryRestrictions,
|
dietaryRestrictions = dietaryRestrictions,
|
||||||
customItems = customItems,
|
customItems = customItems,
|
||||||
isDefault = isDefault
|
isDefault = isDefault,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun UserProfile.toEntity(): UserProfileEntity = UserProfileEntity(
|
private fun UserProfile.toEntity(): UserProfileEntity =
|
||||||
|
UserProfileEntity(
|
||||||
id = id,
|
id = id,
|
||||||
name = name,
|
name = name,
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
@ -66,5 +76,5 @@ private fun UserProfile.toEntity(): UserProfileEntity = UserProfileEntity(
|
|||||||
moderateIntolerances = moderateIntolerances,
|
moderateIntolerances = moderateIntolerances,
|
||||||
dietaryRestrictions = dietaryRestrictions,
|
dietaryRestrictions = dietaryRestrictions,
|
||||||
customItems = customItems,
|
customItems = customItems,
|
||||||
isDefault = isDefault
|
isDefault = isDefault,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import kotlinx.coroutines.flow.callbackFlow
|
|||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
class ConnectivityObserver(private val context: Context) {
|
class ConnectivityObserver(private val context: Context) {
|
||||||
|
|
||||||
fun isOnline(): Boolean {
|
fun isOnline(): Boolean {
|
||||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false
|
||||||
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
|
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
|
||||||
@ -19,14 +18,25 @@ class ConnectivityObserver(private val context: Context) {
|
|||||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observe(): Flow<Boolean> = callbackFlow {
|
fun observe(): Flow<Boolean> =
|
||||||
|
callbackFlow {
|
||||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
val callback =
|
||||||
override fun onAvailable(network: Network) { trySend(true) }
|
object : ConnectivityManager.NetworkCallback() {
|
||||||
override fun onLost(network: Network) { trySend(false) }
|
override fun onAvailable(network: Network) {
|
||||||
override fun onUnavailable() { trySend(false) }
|
trySend(true)
|
||||||
}
|
}
|
||||||
val request = NetworkRequest.Builder()
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
trySend(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnavailable() {
|
||||||
|
trySend(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val request =
|
||||||
|
NetworkRequest.Builder()
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
.build()
|
.build()
|
||||||
cm.registerNetworkCallback(request, callback)
|
cm.registerNetworkCallback(request, callback)
|
||||||
|
|||||||
@ -16,18 +16,19 @@ import javax.inject.Singleton
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object AppModule {
|
object AppModule {
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideMoshi(): Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
|
fun provideMoshi(): Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideUserPreferences(@ApplicationContext context: Context): UserPreferences =
|
fun provideUserPreferences(
|
||||||
UserPreferences(context.safeBiteDataStore)
|
@ApplicationContext context: Context,
|
||||||
|
): UserPreferences = UserPreferences(context.safeBiteDataStore)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideConnectivity(@ApplicationContext context: Context): ConnectivityObserver =
|
fun provideConnectivity(
|
||||||
ConnectivityObserver(context)
|
@ApplicationContext context: Context,
|
||||||
|
): ConnectivityObserver = ConnectivityObserver(context)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,18 +20,23 @@ import javax.inject.Singleton
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object DatabaseModule {
|
object DatabaseModule {
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideDatabase(@ApplicationContext context: Context): SafeBiteDatabase =
|
fun provideDatabase(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): SafeBiteDatabase =
|
||||||
Room.databaseBuilder(context, SafeBiteDatabase::class.java, SafeBiteDatabase.NAME)
|
Room.databaseBuilder(context, SafeBiteDatabase::class.java, SafeBiteDatabase.NAME)
|
||||||
.addMigrations(MIGRATION_7_8, MIGRATION_8_9)
|
.addMigrations(MIGRATION_7_8, MIGRATION_8_9)
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@Provides fun provideUserProfileDao(db: SafeBiteDatabase): UserProfileDao = db.userProfileDao()
|
@Provides fun provideUserProfileDao(db: SafeBiteDatabase): UserProfileDao = db.userProfileDao()
|
||||||
|
|
||||||
@Provides fun provideProductCacheDao(db: SafeBiteDatabase): ProductCacheDao = db.productCacheDao()
|
@Provides fun provideProductCacheDao(db: SafeBiteDatabase): ProductCacheDao = db.productCacheDao()
|
||||||
|
|
||||||
@Provides fun provideScanHistoryDao(db: SafeBiteDatabase): ScanHistoryDao = db.scanHistoryDao()
|
@Provides fun provideScanHistoryDao(db: SafeBiteDatabase): ScanHistoryDao = db.scanHistoryDao()
|
||||||
|
|
||||||
@Provides fun provideShoppingListDao(db: SafeBiteDatabase): ShoppingListDao = db.shoppingListDao()
|
@Provides fun provideShoppingListDao(db: SafeBiteDatabase): ShoppingListDao = db.shoppingListDao()
|
||||||
|
|
||||||
@Provides fun provideCatalogDao(db: SafeBiteDatabase): CatalogDao = db.catalogDao()
|
@Provides fun provideCatalogDao(db: SafeBiteDatabase): CatalogDao = db.catalogDao()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import javax.inject.Singleton
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object EngineModule {
|
object EngineModule {
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideCategoryEngine(): CategoryEngine = CategoryEngine()
|
fun provideCategoryEngine(): CategoryEngine = CategoryEngine()
|
||||||
|
|||||||
@ -17,14 +17,14 @@ import javax.inject.Singleton
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object NetworkModule {
|
object NetworkModule {
|
||||||
|
|
||||||
private const val USER_AGENT = "SafeBite/1.0 (Android; contact@safebite.app)"
|
private const val USER_AGENT = "SafeBite/1.0 (Android; contact@safebite.app)"
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideOkHttp(): OkHttpClient {
|
fun provideOkHttp(): OkHttpClient {
|
||||||
val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
|
val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
|
||||||
val uaInterceptor = Interceptor { chain ->
|
val uaInterceptor =
|
||||||
|
Interceptor { chain ->
|
||||||
val req = chain.request().newBuilder().header("User-Agent", USER_AGENT).build()
|
val req = chain.request().newBuilder().header("User-Agent", USER_AGENT).build()
|
||||||
chain.proceed(req)
|
chain.proceed(req)
|
||||||
}
|
}
|
||||||
@ -39,7 +39,10 @@ object NetworkModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit =
|
fun provideRetrofit(
|
||||||
|
client: OkHttpClient,
|
||||||
|
moshi: Moshi,
|
||||||
|
): Retrofit =
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl(OpenFoodFactsApi.BASE_URL)
|
.baseUrl(OpenFoodFactsApi.BASE_URL)
|
||||||
.client(client)
|
.client(client)
|
||||||
@ -48,6 +51,5 @@ object NetworkModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideOpenFoodFactsApi(retrofit: Retrofit): OpenFoodFactsApi =
|
fun provideOpenFoodFactsApi(retrofit: Retrofit): OpenFoodFactsApi = retrofit.create(OpenFoodFactsApi::class.java)
|
||||||
retrofit.create(OpenFoodFactsApi::class.java)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,6 @@ import javax.inject.Singleton
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
abstract class RepositoryModule {
|
abstract class RepositoryModule {
|
||||||
|
|
||||||
@Binds @Singleton
|
@Binds @Singleton
|
||||||
abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository
|
abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository
|
||||||
|
|
||||||
|
|||||||
@ -9,8 +9,6 @@ import com.safebite.app.domain.model.DetectedAllergen
|
|||||||
import com.safebite.app.domain.model.DetectedCustomItem
|
import com.safebite.app.domain.model.DetectedCustomItem
|
||||||
import com.safebite.app.domain.model.DetectionLanguage
|
import com.safebite.app.domain.model.DetectionLanguage
|
||||||
import com.safebite.app.domain.model.DetectionLevel
|
import com.safebite.app.domain.model.DetectionLevel
|
||||||
import com.safebite.app.domain.model.HealthAssessment
|
|
||||||
import com.safebite.app.domain.model.HealthRating
|
|
||||||
import com.safebite.app.domain.model.HealthStrictness
|
import com.safebite.app.domain.model.HealthStrictness
|
||||||
import com.safebite.app.domain.model.Product
|
import com.safebite.app.domain.model.Product
|
||||||
import com.safebite.app.domain.model.SafetyStatus
|
import com.safebite.app.domain.model.SafetyStatus
|
||||||
@ -27,15 +25,21 @@ import java.text.Normalizer
|
|||||||
* 3. "May contain / traces" pattern extraction from the ingredients text.
|
* 3. "May contain / traces" pattern extraction from the ingredients text.
|
||||||
*/
|
*/
|
||||||
object AllergenAnalysisEngine {
|
object AllergenAnalysisEngine {
|
||||||
|
|
||||||
/** Regexes for "may contain" disclosures, in French and English. */
|
/** Regexes for "may contain" disclosures, in French and English. */
|
||||||
private val MAY_CONTAIN_PATTERNS = listOf(
|
private val MAY_CONTAIN_PATTERNS =
|
||||||
|
listOf(
|
||||||
Regex("peut contenir(?:\\s+des\\s+traces\\s+de)?\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE),
|
Regex("peut contenir(?:\\s+des\\s+traces\\s+de)?\\s*[:\\-]?\\s*([^.]{1,200})", RegexOption.IGNORE_CASE),
|
||||||
Regex("traces?\\s+possibles?\\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(
|
||||||
|
"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("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(
|
||||||
Regex("produced\\s+in\\s+a\\s+plant\\s+that\\s+also\\s+handles[^.]{1,200}", RegexOption.IGNORE_CASE)
|
"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),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -48,7 +52,7 @@ object AllergenAnalysisEngine {
|
|||||||
profiles: List<UserProfile>,
|
profiles: List<UserProfile>,
|
||||||
source: DataSource,
|
source: DataSource,
|
||||||
language: DetectionLanguage = DetectionLanguage.BOTH,
|
language: DetectionLanguage = DetectionLanguage.BOTH,
|
||||||
healthStrictness: HealthStrictness = HealthStrictness.NORMAL
|
healthStrictness: HealthStrictness = HealthStrictness.NORMAL,
|
||||||
): ScanResult {
|
): ScanResult {
|
||||||
if (profiles.isEmpty()) {
|
if (profiles.isEmpty()) {
|
||||||
return ScanResult(
|
return ScanResult(
|
||||||
@ -59,7 +63,7 @@ object AllergenAnalysisEngine {
|
|||||||
health = HealthClassifier.classify(product, emptyList(), healthStrictness),
|
health = HealthClassifier.classify(product, emptyList(), healthStrictness),
|
||||||
analyzedProfiles = emptyList(),
|
analyzedProfiles = emptyList(),
|
||||||
confidence = AnalysisConfidence.LOW,
|
confidence = AnalysisConfidence.LOW,
|
||||||
source = source
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,25 +77,27 @@ object AllergenAnalysisEngine {
|
|||||||
for (allergen in watched) {
|
for (allergen in watched) {
|
||||||
val tagHits = matchTags(product.allergensTags, allergen.openFoodFactsTags)
|
val tagHits = matchTags(product.allergensTags, allergen.openFoodFactsTags)
|
||||||
if (tagHits.isNotEmpty()) {
|
if (tagHits.isNotEmpty()) {
|
||||||
detections[allergen] = DetectedAllergen(
|
detections[allergen] =
|
||||||
|
DetectedAllergen(
|
||||||
allergenType = allergen,
|
allergenType = allergen,
|
||||||
detectionLevel = DetectionLevel.CONFIRMED,
|
detectionLevel = DetectionLevel.CONFIRMED,
|
||||||
matchedKeywords = tagHits,
|
matchedKeywords = tagHits,
|
||||||
source = "API allergens_tags",
|
source = "API allergens_tags",
|
||||||
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
||||||
severe = allergen in severeSet
|
severe = allergen in severeSet,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val traceTagHits = matchTags(product.tracesTags, allergen.openFoodFactsTags)
|
val traceTagHits = matchTags(product.tracesTags, allergen.openFoodFactsTags)
|
||||||
if (traceTagHits.isNotEmpty()) {
|
if (traceTagHits.isNotEmpty()) {
|
||||||
detections.compute(allergen) { _, existing ->
|
detections.compute(allergen) { _, existing ->
|
||||||
val hit = DetectedAllergen(
|
val hit =
|
||||||
|
DetectedAllergen(
|
||||||
allergenType = allergen,
|
allergenType = allergen,
|
||||||
detectionLevel = DetectionLevel.TRACE,
|
detectionLevel = DetectionLevel.TRACE,
|
||||||
matchedKeywords = traceTagHits,
|
matchedKeywords = traceTagHits,
|
||||||
source = "API traces_tags",
|
source = "API traces_tags",
|
||||||
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
||||||
severe = allergen in severeSet
|
severe = allergen in severeSet,
|
||||||
)
|
)
|
||||||
merge(existing, hit)
|
merge(existing, hit)
|
||||||
}
|
}
|
||||||
@ -110,31 +116,34 @@ object AllergenAnalysisEngine {
|
|||||||
|
|
||||||
val ingMatches = findKeywordMatches(ingredientsOnly, keywords)
|
val ingMatches = findKeywordMatches(ingredientsOnly, keywords)
|
||||||
if (ingMatches.isNotEmpty()) {
|
if (ingMatches.isNotEmpty()) {
|
||||||
val level = if (detections[allergen]?.detectionLevel == DetectionLevel.CONFIRMED) {
|
val level =
|
||||||
|
if (detections[allergen]?.detectionLevel == DetectionLevel.CONFIRMED) {
|
||||||
DetectionLevel.CONFIRMED
|
DetectionLevel.CONFIRMED
|
||||||
} else {
|
} else {
|
||||||
DetectionLevel.SUSPECTED
|
DetectionLevel.SUSPECTED
|
||||||
}
|
}
|
||||||
val hit = DetectedAllergen(
|
val hit =
|
||||||
|
DetectedAllergen(
|
||||||
allergenType = allergen,
|
allergenType = allergen,
|
||||||
detectionLevel = level,
|
detectionLevel = level,
|
||||||
matchedKeywords = ingMatches,
|
matchedKeywords = ingMatches,
|
||||||
source = "Ingredients text",
|
source = "Ingredients text",
|
||||||
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
||||||
severe = allergen in severeSet
|
severe = allergen in severeSet,
|
||||||
)
|
)
|
||||||
detections.compute(allergen) { _, existing -> merge(existing, hit) }
|
detections.compute(allergen) { _, existing -> merge(existing, hit) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val traceMatches = traceRegions.flatMap { region -> findKeywordMatches(region, keywords) }
|
val traceMatches = traceRegions.flatMap { region -> findKeywordMatches(region, keywords) }
|
||||||
if (traceMatches.isNotEmpty()) {
|
if (traceMatches.isNotEmpty()) {
|
||||||
val hit = DetectedAllergen(
|
val hit =
|
||||||
|
DetectedAllergen(
|
||||||
allergenType = allergen,
|
allergenType = allergen,
|
||||||
detectionLevel = DetectionLevel.TRACE,
|
detectionLevel = DetectionLevel.TRACE,
|
||||||
matchedKeywords = traceMatches.distinct(),
|
matchedKeywords = traceMatches.distinct(),
|
||||||
source = "May-contain mention",
|
source = "May-contain mention",
|
||||||
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
profileIds = profiles.filter { allergen in it.allAllergens() }.map { it.id },
|
||||||
severe = allergen in severeSet
|
severe = allergen in severeSet,
|
||||||
)
|
)
|
||||||
detections.compute(allergen) { _, existing -> merge(existing, hit) }
|
detections.compute(allergen) { _, existing -> merge(existing, hit) }
|
||||||
}
|
}
|
||||||
@ -144,7 +153,8 @@ object AllergenAnalysisEngine {
|
|||||||
val detected = detections.values.toList()
|
val detected = detections.values.toList()
|
||||||
|
|
||||||
// Custom items: per profile, scan against ingredients text + OFF tags / labels / categories.
|
// Custom items: per profile, scan against ingredients text + OFF tags / labels / categories.
|
||||||
val searchable = buildString {
|
val searchable =
|
||||||
|
buildString {
|
||||||
append(normalizedIngredients)
|
append(normalizedIngredients)
|
||||||
append(' ')
|
append(' ')
|
||||||
append(product.allergensTags.joinToString(" ") { normalize(it) })
|
append(product.allergensTags.joinToString(" ") { normalize(it) })
|
||||||
@ -162,7 +172,8 @@ object AllergenAnalysisEngine {
|
|||||||
val status = computeStatus(detected, severeSet, customDetections)
|
val status = computeStatus(detected, severeSet, customDetections)
|
||||||
val confidence = computeConfidence(product, source, hasAnyData = detected.isNotEmpty() || normalizedIngredients.isNotBlank())
|
val confidence = computeConfidence(product, source, hasAnyData = detected.isNotEmpty() || normalizedIngredients.isNotBlank())
|
||||||
|
|
||||||
val unhealthyCustomNames = customDetections
|
val unhealthyCustomNames =
|
||||||
|
customDetections
|
||||||
.filter { it.item.tag == CustomItemTag.UNHEALTHY }
|
.filter { it.item.tag == CustomItemTag.UNHEALTHY }
|
||||||
.map { it.item.name }
|
.map { it.item.name }
|
||||||
val health = HealthClassifier.classify(product, unhealthyCustomNames, healthStrictness)
|
val health = HealthClassifier.classify(product, unhealthyCustomNames, healthStrictness)
|
||||||
@ -170,15 +181,16 @@ object AllergenAnalysisEngine {
|
|||||||
return ScanResult(
|
return ScanResult(
|
||||||
product = product,
|
product = product,
|
||||||
safetyStatus = status,
|
safetyStatus = status,
|
||||||
detectedAllergens = detected.sortedWith(
|
detectedAllergens =
|
||||||
|
detected.sortedWith(
|
||||||
compareByDescending<DetectedAllergen> { it.detectionLevel.ordinal == DetectionLevel.CONFIRMED.ordinal }
|
compareByDescending<DetectedAllergen> { it.detectionLevel.ordinal == DetectionLevel.CONFIRMED.ordinal }
|
||||||
.thenByDescending { it.severe }
|
.thenByDescending { it.severe },
|
||||||
),
|
),
|
||||||
detectedCustomItems = customDetections,
|
detectedCustomItems = customDetections,
|
||||||
health = health,
|
health = health,
|
||||||
analyzedProfiles = profiles,
|
analyzedProfiles = profiles,
|
||||||
confidence = confidence,
|
confidence = confidence,
|
||||||
source = source
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,7 +198,10 @@ object AllergenAnalysisEngine {
|
|||||||
* Match each profile's custom items against the pre-normalized [searchable] text
|
* Match each profile's custom items against the pre-normalized [searchable] text
|
||||||
* (ingredients + tags + labels + categories + product name).
|
* (ingredients + tags + labels + categories + product name).
|
||||||
*/
|
*/
|
||||||
private fun detectCustomItems(searchable: String, profiles: List<UserProfile>): List<DetectedCustomItem> {
|
private fun detectCustomItems(
|
||||||
|
searchable: String,
|
||||||
|
profiles: List<UserProfile>,
|
||||||
|
): List<DetectedCustomItem> {
|
||||||
if (searchable.isBlank()) return emptyList()
|
if (searchable.isBlank()) return emptyList()
|
||||||
// Group by (name, tag) so the same item across two profiles becomes a single detection
|
// Group by (name, tag) so the same item across two profiles becomes a single detection
|
||||||
// with the union of profile IDs.
|
// with the union of profile IDs.
|
||||||
@ -207,8 +222,8 @@ object AllergenAnalysisEngine {
|
|||||||
DetectedCustomItem(
|
DetectedCustomItem(
|
||||||
item = first,
|
item = first,
|
||||||
matchedKeywords = hits,
|
matchedKeywords = hits,
|
||||||
profileIds = pairs.map { it.first.id }.distinct()
|
profileIds = pairs.map { it.first.id }.distinct(),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -220,7 +235,8 @@ object AllergenAnalysisEngine {
|
|||||||
/** Lower-case, strip accents, expand common ligatures, collapse punctuation. */
|
/** Lower-case, strip accents, expand common ligatures, collapse punctuation. */
|
||||||
fun normalize(raw: String): String {
|
fun normalize(raw: String): String {
|
||||||
if (raw.isBlank()) return ""
|
if (raw.isBlank()) return ""
|
||||||
val lowered = raw.lowercase()
|
val lowered =
|
||||||
|
raw.lowercase()
|
||||||
.replace("œ", "oe")
|
.replace("œ", "oe")
|
||||||
.replace("æ", "ae")
|
.replace("æ", "ae")
|
||||||
val decomposed = Normalizer.normalize(lowered, Normalizer.Form.NFD)
|
val decomposed = Normalizer.normalize(lowered, Normalizer.Form.NFD)
|
||||||
@ -233,14 +249,20 @@ object AllergenAnalysisEngine {
|
|||||||
.trim()
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun keywordsFor(allergen: AllergenType, language: DetectionLanguage): List<String> =
|
private fun keywordsFor(
|
||||||
|
allergen: AllergenType,
|
||||||
|
language: DetectionLanguage,
|
||||||
|
): List<String> =
|
||||||
when (language) {
|
when (language) {
|
||||||
DetectionLanguage.FR -> allergen.keywordsFr
|
DetectionLanguage.FR -> allergen.keywordsFr
|
||||||
DetectionLanguage.EN -> allergen.keywordsEn
|
DetectionLanguage.EN -> allergen.keywordsEn
|
||||||
DetectionLanguage.BOTH -> allergen.keywordsFr + allergen.keywordsEn
|
DetectionLanguage.BOTH -> allergen.keywordsFr + allergen.keywordsEn
|
||||||
}.map { normalize(it) }.filter { it.isNotBlank() }.distinct()
|
}.map { normalize(it) }.filter { it.isNotBlank() }.distinct()
|
||||||
|
|
||||||
private fun matchTags(productTags: List<String>, allergenTags: List<String>): List<String> {
|
private fun matchTags(
|
||||||
|
productTags: List<String>,
|
||||||
|
allergenTags: List<String>,
|
||||||
|
): List<String> {
|
||||||
if (productTags.isEmpty()) return emptyList()
|
if (productTags.isEmpty()) return emptyList()
|
||||||
val lowered = productTags.map { it.lowercase() }
|
val lowered = productTags.map { it.lowercase() }
|
||||||
return allergenTags.filter { tag -> lowered.any { it == tag.lowercase() } }
|
return allergenTags.filter { tag -> lowered.any { it == tag.lowercase() } }
|
||||||
@ -250,7 +272,10 @@ object AllergenAnalysisEngine {
|
|||||||
* Word-boundary aware keyword matcher. Handles plurals by also matching the keyword
|
* Word-boundary aware keyword matcher. Handles plurals by also matching the keyword
|
||||||
* followed by an "s". Supports multi-word keywords.
|
* followed by an "s". Supports multi-word keywords.
|
||||||
*/
|
*/
|
||||||
private fun findKeywordMatches(normalized: String, keywords: List<String>): List<String> {
|
private fun findKeywordMatches(
|
||||||
|
normalized: String,
|
||||||
|
keywords: List<String>,
|
||||||
|
): List<String> {
|
||||||
if (normalized.isBlank()) return emptyList()
|
if (normalized.isBlank()) return emptyList()
|
||||||
val hits = mutableListOf<String>()
|
val hits = mutableListOf<String>()
|
||||||
for (kw in keywords) {
|
for (kw in keywords) {
|
||||||
@ -273,7 +298,10 @@ object AllergenAnalysisEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stripRegions(text: String, regions: List<String>): String {
|
private fun stripRegions(
|
||||||
|
text: String,
|
||||||
|
regions: List<String>,
|
||||||
|
): String {
|
||||||
var result = text
|
var result = text
|
||||||
for (region in regions) {
|
for (region in regions) {
|
||||||
result = result.replace(region, " ")
|
result = result.replace(region, " ")
|
||||||
@ -283,7 +311,10 @@ object AllergenAnalysisEngine {
|
|||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
private fun merge(existing: DetectedAllergen?, incoming: DetectedAllergen): DetectedAllergen {
|
private fun merge(
|
||||||
|
existing: DetectedAllergen?,
|
||||||
|
incoming: DetectedAllergen,
|
||||||
|
): DetectedAllergen {
|
||||||
if (existing == null) return incoming
|
if (existing == null) return incoming
|
||||||
// Keep the most severe detection level: CONFIRMED > TRACE > SUSPECTED.
|
// Keep the most severe detection level: CONFIRMED > TRACE > SUSPECTED.
|
||||||
val priority: (DetectionLevel) -> Int = {
|
val priority: (DetectionLevel) -> Int = {
|
||||||
@ -297,28 +328,34 @@ object AllergenAnalysisEngine {
|
|||||||
return best.copy(
|
return best.copy(
|
||||||
matchedKeywords = (existing.matchedKeywords + incoming.matchedKeywords).distinct(),
|
matchedKeywords = (existing.matchedKeywords + incoming.matchedKeywords).distinct(),
|
||||||
source = if (best == existing) existing.source else incoming.source,
|
source = if (best == existing) existing.source else incoming.source,
|
||||||
profileIds = (existing.profileIds + incoming.profileIds).distinct()
|
profileIds = (existing.profileIds + incoming.profileIds).distinct(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computeStatus(
|
private fun computeStatus(
|
||||||
detected: List<DetectedAllergen>,
|
detected: List<DetectedAllergen>,
|
||||||
severeSet: Set<AllergenType>,
|
severeSet: Set<AllergenType>,
|
||||||
customDetections: List<DetectedCustomItem>
|
customDetections: List<DetectedCustomItem>,
|
||||||
): SafetyStatus {
|
): SafetyStatus {
|
||||||
val hasSevereConfirmed = detected.any {
|
val hasSevereConfirmed =
|
||||||
|
detected.any {
|
||||||
it.detectionLevel != DetectionLevel.TRACE && it.allergenType in severeSet
|
it.detectionLevel != DetectionLevel.TRACE && it.allergenType in severeSet
|
||||||
}
|
}
|
||||||
val customHasAllergy = customDetections.any { it.item.tag == CustomItemTag.ALLERGY }
|
val customHasAllergy = customDetections.any { it.item.tag == CustomItemTag.ALLERGY }
|
||||||
if (hasSevereConfirmed || customHasAllergy) return SafetyStatus.DANGER
|
if (hasSevereConfirmed || customHasAllergy) return SafetyStatus.DANGER
|
||||||
val customTriggersWarning = customDetections.any {
|
val customTriggersWarning =
|
||||||
|
customDetections.any {
|
||||||
it.item.tag == CustomItemTag.INTOLERANCE || it.item.tag == CustomItemTag.DIET
|
it.item.tag == CustomItemTag.INTOLERANCE || it.item.tag == CustomItemTag.DIET
|
||||||
}
|
}
|
||||||
if (detected.isEmpty() && !customTriggersWarning) return SafetyStatus.SAFE
|
if (detected.isEmpty() && !customTriggersWarning) return SafetyStatus.SAFE
|
||||||
return SafetyStatus.WARNING
|
return SafetyStatus.WARNING
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computeConfidence(product: Product, source: DataSource, hasAnyData: Boolean): AnalysisConfidence {
|
private fun computeConfidence(
|
||||||
|
product: Product,
|
||||||
|
source: DataSource,
|
||||||
|
hasAnyData: Boolean,
|
||||||
|
): AnalysisConfidence {
|
||||||
return when (source) {
|
return when (source) {
|
||||||
DataSource.OCR -> AnalysisConfidence.LOW
|
DataSource.OCR -> AnalysisConfidence.LOW
|
||||||
DataSource.API, DataSource.CACHE -> {
|
DataSource.API, DataSource.CACHE -> {
|
||||||
|
|||||||
@ -13,14 +13,15 @@ import javax.inject.Singleton
|
|||||||
* cohérence de la classification automatique.
|
* cohérence de la classification automatique.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class CatalogProvider @Inject constructor() {
|
class CatalogProvider
|
||||||
|
@Inject
|
||||||
|
constructor() {
|
||||||
data class CatalogItem(
|
data class CatalogItem(
|
||||||
val name: String,
|
val name: String,
|
||||||
val category: String,
|
val category: String,
|
||||||
val emoji: String,
|
val emoji: String,
|
||||||
val aliases: List<String> = emptyList(),
|
val aliases: List<String> = emptyList(),
|
||||||
val variants: List<String> = emptyList()
|
val variants: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
fun matches(query: String): Boolean {
|
fun matches(query: String): Boolean {
|
||||||
val q = query.trim().lowercase()
|
val q = query.trim().lowercase()
|
||||||
@ -31,7 +32,8 @@ class CatalogProvider @Inject constructor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Toutes les sections catalogue, dans l'ordre d'affichage. */
|
/** Toutes les sections catalogue, dans l'ordre d'affichage. */
|
||||||
val categories: List<String> = listOf(
|
val categories: List<String> =
|
||||||
|
listOf(
|
||||||
"Fruits & Légumes",
|
"Fruits & Légumes",
|
||||||
"Boulangerie",
|
"Boulangerie",
|
||||||
"Produits laitiers",
|
"Produits laitiers",
|
||||||
@ -45,30 +47,79 @@ class CatalogProvider @Inject constructor() {
|
|||||||
"Entretien",
|
"Entretien",
|
||||||
"Bébé",
|
"Bébé",
|
||||||
"Animaux",
|
"Animaux",
|
||||||
"Maison & Jardin"
|
"Maison & Jardin",
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Liste plate du catalogue. */
|
/** Liste plate du catalogue. */
|
||||||
val items: List<CatalogItem> = buildList {
|
val items: List<CatalogItem> =
|
||||||
|
buildList {
|
||||||
// Fruits & Légumes
|
// Fruits & Légumes
|
||||||
add(CatalogItem("Pomme", "Fruits & Légumes", "🍎", listOf("apple", "apples", "pommes"), listOf("Gala", "Cortland", "Honeycrisp", "Granny Smith", "Fuji", "McIntosh")))
|
add(
|
||||||
|
CatalogItem(
|
||||||
|
"Pomme",
|
||||||
|
"Fruits & Légumes",
|
||||||
|
"🍎",
|
||||||
|
listOf("apple", "apples", "pommes"),
|
||||||
|
listOf("Gala", "Cortland", "Honeycrisp", "Granny Smith", "Fuji", "McIntosh"),
|
||||||
|
),
|
||||||
|
)
|
||||||
add(CatalogItem("Banane", "Fruits & Légumes", "🍌", listOf("banana", "bananas")))
|
add(CatalogItem("Banane", "Fruits & Légumes", "🍌", listOf("banana", "bananas")))
|
||||||
add(CatalogItem("Orange", "Fruits & Légumes", "🍊", listOf("oranges")))
|
add(CatalogItem("Orange", "Fruits & Légumes", "🍊", listOf("oranges")))
|
||||||
add(CatalogItem("Citron", "Fruits & Légumes", "🍋", listOf("lemon")))
|
add(CatalogItem("Citron", "Fruits & Légumes", "🍋", listOf("lemon")))
|
||||||
add(CatalogItem("Fraise", "Fruits & Légumes", "🍓", listOf("strawberry", "strawberries")))
|
add(CatalogItem("Fraise", "Fruits & Légumes", "🍓", listOf("strawberry", "strawberries")))
|
||||||
add(CatalogItem("Raisin", "Fruits & Légumes", "🍇", listOf("grapes"), listOf("Vert", "Rouge", "Sans pépins")))
|
add(CatalogItem("Raisin", "Fruits & Légumes", "🍇", listOf("grapes"), listOf("Vert", "Rouge", "Sans pépins")))
|
||||||
add(CatalogItem("Poire", "Fruits & Légumes", "🍐", listOf("pear", "pears", "poires")))
|
add(CatalogItem("Poire", "Fruits & Légumes", "🍐", listOf("pear", "pears", "poires")))
|
||||||
add(CatalogItem("Tomate", "Fruits & Légumes", "🍅", listOf("tomatoes"), listOf("Baby", "Cherry", "Diced", "Roma", "Sundried")))
|
add(
|
||||||
|
CatalogItem(
|
||||||
|
"Tomate",
|
||||||
|
"Fruits & Légumes",
|
||||||
|
"🍅",
|
||||||
|
listOf("tomatoes"),
|
||||||
|
listOf("Baby", "Cherry", "Diced", "Roma", "Sundried"),
|
||||||
|
),
|
||||||
|
)
|
||||||
add(CatalogItem("Salade", "Fruits & Légumes", "🥬", listOf("lettuce", "salad")))
|
add(CatalogItem("Salade", "Fruits & Légumes", "🥬", listOf("lettuce", "salad")))
|
||||||
add(CatalogItem("Carotte", "Fruits & Légumes", "🥕", listOf("carrots", "carrotte"), listOf("Baby", "Râpée", "Bio")))
|
add(CatalogItem("Carotte", "Fruits & Légumes", "🥕", listOf("carrots", "carrotte"), listOf("Baby", "Râpée", "Bio")))
|
||||||
add(CatalogItem("Brocoli", "Fruits & Légumes", "🥦", listOf("broccoli")))
|
add(CatalogItem("Brocoli", "Fruits & Légumes", "🥦", listOf("broccoli")))
|
||||||
add(CatalogItem("Concombre", "Fruits & Légumes", "🥒", listOf("cucumber")))
|
add(CatalogItem("Concombre", "Fruits & Légumes", "🥒", listOf("cucumber")))
|
||||||
add(CatalogItem("Poivron", "Fruits & Légumes", "🫑", listOf("bell pepper", "bell peppers"), listOf("Rouge", "Vert", "Jaune", "Orange")))
|
add(
|
||||||
|
CatalogItem(
|
||||||
|
"Poivron",
|
||||||
|
"Fruits & Légumes",
|
||||||
|
"🫑",
|
||||||
|
listOf("bell pepper", "bell peppers"),
|
||||||
|
listOf("Rouge", "Vert", "Jaune", "Orange"),
|
||||||
|
),
|
||||||
|
)
|
||||||
add(CatalogItem("Avocat", "Fruits & Légumes", "🥑", listOf("avocado")))
|
add(CatalogItem("Avocat", "Fruits & Légumes", "🥑", listOf("avocado")))
|
||||||
add(CatalogItem("Oignon", "Fruits & Légumes", "🧅", listOf("onions"), listOf("Blanc", "Rouge", "Vert", "Espagnol", "Vidalia")))
|
add(
|
||||||
|
CatalogItem(
|
||||||
|
"Oignon",
|
||||||
|
"Fruits & Légumes",
|
||||||
|
"🧅",
|
||||||
|
listOf("onions"),
|
||||||
|
listOf("Blanc", "Rouge", "Vert", "Espagnol", "Vidalia"),
|
||||||
|
),
|
||||||
|
)
|
||||||
add(CatalogItem("Ail", "Fruits & Légumes", "🧄", listOf("garlic")))
|
add(CatalogItem("Ail", "Fruits & Légumes", "🧄", listOf("garlic")))
|
||||||
add(CatalogItem("Pomme de terre", "Fruits & Légumes", "🥔", listOf("patate", "patates", "potatoes"), listOf("Régulière", "Grelot", "Douce", "Russet", "Yukon Gold")))
|
add(
|
||||||
add(CatalogItem("Champignon", "Fruits & Légumes", "🍄", listOf("mushrooms", "champignons"), listOf("Blanc", "Portobello", "Shiitake", "Crimini")))
|
CatalogItem(
|
||||||
|
"Pomme de terre",
|
||||||
|
"Fruits & Légumes",
|
||||||
|
"🥔",
|
||||||
|
listOf("patate", "patates", "potatoes"),
|
||||||
|
listOf("Régulière", "Grelot", "Douce", "Russet", "Yukon Gold"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
CatalogItem(
|
||||||
|
"Champignon",
|
||||||
|
"Fruits & Légumes",
|
||||||
|
"🍄",
|
||||||
|
listOf("mushrooms", "champignons"),
|
||||||
|
listOf("Blanc", "Portobello", "Shiitake", "Crimini"),
|
||||||
|
),
|
||||||
|
)
|
||||||
add(CatalogItem("Épinard", "Fruits & Légumes", "🥬", listOf("spinach")))
|
add(CatalogItem("Épinard", "Fruits & Légumes", "🥬", listOf("spinach")))
|
||||||
add(CatalogItem("Ananas", "Fruits & Légumes", "🍍", listOf("pineapple")))
|
add(CatalogItem("Ananas", "Fruits & Légumes", "🍍", listOf("pineapple")))
|
||||||
add(CatalogItem("Pêche", "Fruits & Légumes", "🍑", listOf("peach")))
|
add(CatalogItem("Pêche", "Fruits & Légumes", "🍑", listOf("peach")))
|
||||||
@ -80,7 +131,15 @@ class CatalogProvider @Inject constructor() {
|
|||||||
add(CatalogItem("Noix de coco", "Fruits & Légumes", "🥥", listOf("coconut")))
|
add(CatalogItem("Noix de coco", "Fruits & Légumes", "🥥", listOf("coconut")))
|
||||||
add(CatalogItem("Aubergine", "Fruits & Légumes", "🍆", listOf("eggplant")))
|
add(CatalogItem("Aubergine", "Fruits & Légumes", "🍆", listOf("eggplant")))
|
||||||
add(CatalogItem("Maïs", "Fruits & Légumes", "🌽", listOf("sweet corn", "corncobs", "corn cobs")))
|
add(CatalogItem("Maïs", "Fruits & Légumes", "🌽", listOf("sweet corn", "corncobs", "corn cobs")))
|
||||||
add(CatalogItem("Piment", "Fruits & Légumes", "🌶️", listOf("chillies", "chili", "piment jaune"), listOf("Jalapeño", "Serrano", "Habanero", "Chipotle")))
|
add(
|
||||||
|
CatalogItem(
|
||||||
|
"Piment",
|
||||||
|
"Fruits & Légumes",
|
||||||
|
"🌶️",
|
||||||
|
listOf("chillies", "chili", "piment jaune"),
|
||||||
|
listOf("Jalapeño", "Serrano", "Habanero", "Chipotle"),
|
||||||
|
),
|
||||||
|
)
|
||||||
add(CatalogItem("Courgette", "Fruits & Légumes", "🥒", listOf("zucchini")))
|
add(CatalogItem("Courgette", "Fruits & Légumes", "🥒", listOf("zucchini")))
|
||||||
add(CatalogItem("Chou-fleur", "Fruits & Légumes", "🥦", listOf("cauliflower")))
|
add(CatalogItem("Chou-fleur", "Fruits & Légumes", "🥦", listOf("cauliflower")))
|
||||||
add(CatalogItem("Chou", "Fruits & Légumes", "🥬", listOf("cabbage")))
|
add(CatalogItem("Chou", "Fruits & Légumes", "🥬", listOf("cabbage")))
|
||||||
@ -177,7 +236,15 @@ class CatalogProvider @Inject constructor() {
|
|||||||
add(CatalogItem("Baies", "Fruits & Légumes", "🫐", listOf("berries")))
|
add(CatalogItem("Baies", "Fruits & Légumes", "🫐", listOf("berries")))
|
||||||
|
|
||||||
// Boulangerie
|
// Boulangerie
|
||||||
add(CatalogItem("Pain", "Boulangerie", "🍞", listOf("baguette"), listOf("Blanc", "Brun", "Complet", "Sans Gluten", "Multigrains")))
|
add(
|
||||||
|
CatalogItem(
|
||||||
|
"Pain",
|
||||||
|
"Boulangerie",
|
||||||
|
"🍞",
|
||||||
|
listOf("baguette"),
|
||||||
|
listOf("Blanc", "Brun", "Complet", "Sans Gluten", "Multigrains"),
|
||||||
|
),
|
||||||
|
)
|
||||||
add(CatalogItem("Baguette", "Boulangerie", "🥖"))
|
add(CatalogItem("Baguette", "Boulangerie", "🥖"))
|
||||||
add(CatalogItem("Croissant", "Boulangerie", "🥐"))
|
add(CatalogItem("Croissant", "Boulangerie", "🥐"))
|
||||||
add(CatalogItem("Brioche", "Boulangerie", "🥯"))
|
add(CatalogItem("Brioche", "Boulangerie", "🥯"))
|
||||||
@ -245,12 +312,44 @@ class CatalogProvider @Inject constructor() {
|
|||||||
add(CatalogItem("Waffles", "Boulangerie", "🧇"))
|
add(CatalogItem("Waffles", "Boulangerie", "🧇"))
|
||||||
|
|
||||||
// Produits laitiers
|
// Produits laitiers
|
||||||
add(CatalogItem("Lait", "Produits laitiers", "🥛", listOf("milk"), listOf("Entier", "2%", "1%", "Écrémé", "Sans Lactose", "Avoine", "Amande", "Soya")))
|
add(
|
||||||
add(CatalogItem("Yaourt", "Produits laitiers", "🥣", listOf("yogurt"), listOf("Grec", "Nature", "Vaniille", "Fraise", "Sans Lactose")))
|
CatalogItem(
|
||||||
|
"Lait",
|
||||||
|
"Produits laitiers",
|
||||||
|
"🥛",
|
||||||
|
listOf("milk"),
|
||||||
|
listOf("Entier", "2%", "1%", "Écrémé", "Sans Lactose", "Avoine", "Amande", "Soya"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
CatalogItem(
|
||||||
|
"Yaourt",
|
||||||
|
"Produits laitiers",
|
||||||
|
"🥣",
|
||||||
|
listOf("yogurt"),
|
||||||
|
listOf("Grec", "Nature", "Vaniille", "Fraise", "Sans Lactose"),
|
||||||
|
),
|
||||||
|
)
|
||||||
add(CatalogItem("Beurre", "Produits laitiers", "🧈"))
|
add(CatalogItem("Beurre", "Produits laitiers", "🧈"))
|
||||||
add(CatalogItem("Fromage", "Produits laitiers", "🧀", listOf("cheese"), listOf("Cheddar", "Mozzarella", "Parmésan", "Feta", "Brie", "Emmental", "Chèvre")))
|
add(
|
||||||
|
CatalogItem(
|
||||||
|
"Fromage",
|
||||||
|
"Produits laitiers",
|
||||||
|
"🧀",
|
||||||
|
listOf("cheese"),
|
||||||
|
listOf("Cheddar", "Mozzarella", "Parmésan", "Feta", "Brie", "Emmental", "Chèvre"),
|
||||||
|
),
|
||||||
|
)
|
||||||
add(CatalogItem("Crème fraîche", "Produits laitiers", "🥛"))
|
add(CatalogItem("Crème fraîche", "Produits laitiers", "🥛"))
|
||||||
add(CatalogItem("Œufs", "Produits laitiers", "🥚", listOf("oeufs", "eggs"), listOf("Gros", "Très Gros", "Moyen", "Bio", "Libre Parcours")))
|
add(
|
||||||
|
CatalogItem(
|
||||||
|
"Œufs",
|
||||||
|
"Produits laitiers",
|
||||||
|
"🥚",
|
||||||
|
listOf("oeufs", "eggs"),
|
||||||
|
listOf("Gros", "Très Gros", "Moyen", "Bio", "Libre Parcours"),
|
||||||
|
),
|
||||||
|
)
|
||||||
add(CatalogItem("Mozzarella", "Produits laitiers", "🧀"))
|
add(CatalogItem("Mozzarella", "Produits laitiers", "🧀"))
|
||||||
add(CatalogItem("Parmesan", "Produits laitiers", "🧀"))
|
add(CatalogItem("Parmesan", "Produits laitiers", "🧀"))
|
||||||
add(CatalogItem("Cheddar", "Produits laitiers", "🧀"))
|
add(CatalogItem("Cheddar", "Produits laitiers", "🧀"))
|
||||||
@ -332,7 +431,15 @@ class CatalogProvider @Inject constructor() {
|
|||||||
add(CatalogItem("Jambon", "Boucherie", "🥓"))
|
add(CatalogItem("Jambon", "Boucherie", "🥓"))
|
||||||
add(CatalogItem("Saucisse", "Boucherie", "🌭", emptyList(), listOf("Porc", "Veau", "Dinde", "Italienne", "Cocktail")))
|
add(CatalogItem("Saucisse", "Boucherie", "🌭", emptyList(), listOf("Porc", "Veau", "Dinde", "Italienne", "Cocktail")))
|
||||||
add(CatalogItem("Bacon", "Boucherie", "🥓"))
|
add(CatalogItem("Bacon", "Boucherie", "🥓"))
|
||||||
add(CatalogItem("Saumon", "Boucherie", "🐟", emptyList(), listOf("Atlantique", "Pacifique", "Sockeye", "Fumé", "En Conserve")))
|
add(
|
||||||
|
CatalogItem(
|
||||||
|
"Saumon",
|
||||||
|
"Boucherie",
|
||||||
|
"🐟",
|
||||||
|
emptyList(),
|
||||||
|
listOf("Atlantique", "Pacifique", "Sockeye", "Fumé", "En Conserve"),
|
||||||
|
),
|
||||||
|
)
|
||||||
add(CatalogItem("Thon", "Boucherie", "🐟"))
|
add(CatalogItem("Thon", "Boucherie", "🐟"))
|
||||||
add(CatalogItem("Dinde", "Boucherie", "🦃"))
|
add(CatalogItem("Dinde", "Boucherie", "🦃"))
|
||||||
add(CatalogItem("Canard", "Boucherie", "🦆"))
|
add(CatalogItem("Canard", "Boucherie", "🦆"))
|
||||||
@ -1583,22 +1690,24 @@ class CatalogProvider @Inject constructor() {
|
|||||||
add(CatalogItem("Viande de bœuf", "Épicerie", "🌾"))
|
add(CatalogItem("Viande de bœuf", "Épicerie", "🌾"))
|
||||||
|
|
||||||
add(CatalogItem("Vim", "Épicerie", "🌾"))
|
add(CatalogItem("Vim", "Épicerie", "🌾"))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Items pour une catégorie donnée (ordre catalogue). */
|
/** Items pour une catégorie donnée (ordre catalogue). */
|
||||||
fun itemsForCategory(category: String): List<CatalogItem> =
|
fun itemsForCategory(category: String): List<CatalogItem> = items.filter { it.category == category }
|
||||||
items.filter { it.category == category }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recherche dans le catalogue. Retourne au maximum [limit] résultats triés par
|
* Recherche dans le catalogue. Retourne au maximum [limit] résultats triés par
|
||||||
* pertinence : préfixe d'abord, puis sous-chaîne.
|
* pertinence : préfixe d'abord, puis sous-chaîne.
|
||||||
*/
|
*/
|
||||||
fun search(query: String, limit: Int = 8): List<CatalogItem> {
|
fun search(
|
||||||
|
query: String,
|
||||||
|
limit: Int = 8,
|
||||||
|
): List<CatalogItem> {
|
||||||
val q = query.trim().lowercase()
|
val q = query.trim().lowercase()
|
||||||
if (q.isEmpty()) return emptyList()
|
if (q.isEmpty()) return emptyList()
|
||||||
val prefix = items.filter { it.name.lowercase().startsWith(q) }
|
val prefix = items.filter { it.name.lowercase().startsWith(q) }
|
||||||
val contains = items.filter {
|
val contains =
|
||||||
|
items.filter {
|
||||||
!it.name.lowercase().startsWith(q) && it.matches(q)
|
!it.name.lowercase().startsWith(q) && it.matches(q)
|
||||||
}
|
}
|
||||||
return (prefix + contains).take(limit)
|
return (prefix + contains).take(limit)
|
||||||
@ -1608,7 +1717,8 @@ class CatalogProvider @Inject constructor() {
|
|||||||
* Suggestions populaires (utilisées dans la barre de saisie quand vide,
|
* Suggestions populaires (utilisées dans la barre de saisie quand vide,
|
||||||
* équivalent du panneau "Vous avez sûrement besoin").
|
* équivalent du panneau "Vous avez sûrement besoin").
|
||||||
*/
|
*/
|
||||||
val popularSuggestions: List<CatalogItem> = listOf(
|
val popularSuggestions: List<CatalogItem> =
|
||||||
|
listOf(
|
||||||
items.first { it.name == "Lait" },
|
items.first { it.name == "Lait" },
|
||||||
items.first { it.name == "Pain" },
|
items.first { it.name == "Pain" },
|
||||||
items.first { it.name == "Œufs" },
|
items.first { it.name == "Œufs" },
|
||||||
@ -1617,14 +1727,17 @@ class CatalogProvider @Inject constructor() {
|
|||||||
items.first { it.name == "Pâtes" },
|
items.first { it.name == "Pâtes" },
|
||||||
items.first { it.name == "Tomate" },
|
items.first { it.name == "Tomate" },
|
||||||
items.first { it.name == "Yaourt" },
|
items.first { it.name == "Yaourt" },
|
||||||
items.first { it.name == "Papier toilette" }
|
items.first { it.name == "Papier toilette" },
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retourne un emoji représentatif pour un nom d'article libre. Utilise d'abord
|
* Retourne un emoji représentatif pour un nom d'article libre. Utilise d'abord
|
||||||
* une correspondance exacte puis un repli par catégorie.
|
* une correspondance exacte puis un repli par catégorie.
|
||||||
*/
|
*/
|
||||||
fun emojiFor(name: String, category: String?): String {
|
fun emojiFor(
|
||||||
|
name: String,
|
||||||
|
category: String?,
|
||||||
|
): String {
|
||||||
val direct = items.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
val direct = items.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||||
if (direct != null) return direct.emoji
|
if (direct != null) return direct.emoji
|
||||||
return when (category) {
|
return when (category) {
|
||||||
|
|||||||
@ -9,12 +9,16 @@ import javax.inject.Singleton
|
|||||||
* Associe des mots-clés à des catégories de magasin pour organiser les listes de courses.
|
* Associe des mots-clés à des catégories de magasin pour organiser les listes de courses.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class CategoryEngine @Inject constructor() {
|
class CategoryEngine
|
||||||
|
@Inject
|
||||||
|
constructor() {
|
||||||
/**
|
/**
|
||||||
* Détecte le rayon d'un produit basé sur son nom et ses catégories.
|
* Détecte le rayon d'un produit basé sur son nom et ses catégories.
|
||||||
*/
|
*/
|
||||||
fun detectCategory(productName: String, categories: List<String> = emptyList()): String {
|
fun detectCategory(
|
||||||
|
productName: String,
|
||||||
|
categories: List<String> = emptyList(),
|
||||||
|
): String {
|
||||||
val text = (listOf(productName) + categories).joinToString(" ").lowercase()
|
val text = (listOf(productName) + categories).joinToString(" ").lowercase()
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
@ -45,35 +49,42 @@ class CategoryEngine @Inject constructor() {
|
|||||||
|
|
||||||
data class ProductInfo(
|
data class ProductInfo(
|
||||||
val name: String,
|
val name: String,
|
||||||
val categories: List<String> = emptyList()
|
val categories: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun String.containsAny(keywords: List<String>): Boolean =
|
private fun String.containsAny(keywords: List<String>): Boolean = keywords.any { this.contains(it) }
|
||||||
keywords.any { this.contains(it) }
|
|
||||||
|
|
||||||
// ── Mots-clés par catégorie ─────────────────────────────────────────────
|
// ── Mots-clés par catégorie ─────────────────────────────────────────────
|
||||||
|
|
||||||
private val freshKeywords = listOf("frais", "fraise", "framboise", "myrtille", "salade", "tomate", "concombre", "crudite")
|
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 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 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 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 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 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 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 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 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 babyKeywords = listOf("bebe", "couche", "biberon", "lait bebe", "compote bebe", "petit suisse")
|
||||||
|
|
||||||
private val petKeywords = listOf("chien", "chat", "croquette", "patee", "oiseau", "poisson", "animal")
|
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")
|
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.
|
* - STRICT: only A + Nova ≤ 2 → HEALTHY; B → MODERATE; C/D/E or Nova ≥ 3 → UNHEALTHY.
|
||||||
*/
|
*/
|
||||||
object HealthClassifier {
|
object HealthClassifier {
|
||||||
|
|
||||||
fun classify(
|
fun classify(
|
||||||
product: Product,
|
product: Product,
|
||||||
unhealthyCustomHits: List<String>,
|
unhealthyCustomHits: List<String>,
|
||||||
strictness: HealthStrictness
|
strictness: HealthStrictness,
|
||||||
): HealthAssessment {
|
): HealthAssessment {
|
||||||
val nutri = product.nutriScore?.lowercase()?.takeIf { it in listOf("a", "b", "c", "d", "e") }
|
val nutri = product.nutriScore?.lowercase()?.takeIf { it in listOf("a", "b", "c", "d", "e") }
|
||||||
val nova = product.novaGroup?.takeIf { it in 1..4 }
|
val nova = product.novaGroup?.takeIf { it in 1..4 }
|
||||||
@ -30,8 +29,10 @@ object HealthClassifier {
|
|||||||
|
|
||||||
if (unhealthyCustomHits.isNotEmpty()) {
|
if (unhealthyCustomHits.isNotEmpty()) {
|
||||||
reasons += "Contient: ${unhealthyCustomHits.joinToString()}"
|
reasons += "Contient: ${unhealthyCustomHits.joinToString()}"
|
||||||
rating = when (strictness) {
|
rating =
|
||||||
HealthStrictness.LENIENT -> when (rating) {
|
when (strictness) {
|
||||||
|
HealthStrictness.LENIENT ->
|
||||||
|
when (rating) {
|
||||||
HealthRating.HEALTHY, HealthRating.UNKNOWN -> HealthRating.MODERATE
|
HealthRating.HEALTHY, HealthRating.UNKNOWN -> HealthRating.MODERATE
|
||||||
else -> rating
|
else -> rating
|
||||||
}
|
}
|
||||||
@ -49,7 +50,7 @@ object HealthClassifier {
|
|||||||
reasons = reasons,
|
reasons = reasons,
|
||||||
nutriScore = nutri,
|
nutriScore = nutri,
|
||||||
novaGroup = nova,
|
novaGroup = nova,
|
||||||
ecoScore = eco
|
ecoScore = eco,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,7 +58,7 @@ object HealthClassifier {
|
|||||||
nutri: String?,
|
nutri: String?,
|
||||||
nova: Int?,
|
nova: Int?,
|
||||||
strictness: HealthStrictness,
|
strictness: HealthStrictness,
|
||||||
reasons: MutableList<String>
|
reasons: MutableList<String>,
|
||||||
): HealthRating {
|
): HealthRating {
|
||||||
// Score nutri (a=0 best, e=4 worst)
|
// Score nutri (a=0 best, e=4 worst)
|
||||||
val nutriScoreValue = nutri?.let { it[0].code - 'a'.code }
|
val nutriScoreValue = nutri?.let { it[0].code - 'a'.code }
|
||||||
@ -67,7 +68,8 @@ object HealthClassifier {
|
|||||||
if (nova != null) reasons += "NOVA $nova"
|
if (nova != null) reasons += "NOVA $nova"
|
||||||
|
|
||||||
return when (strictness) {
|
return when (strictness) {
|
||||||
HealthStrictness.LENIENT -> when {
|
HealthStrictness.LENIENT ->
|
||||||
|
when {
|
||||||
nutriScoreValue != null && nutriScoreValue >= 3 && novaValue == 4 -> HealthRating.UNHEALTHY
|
nutriScoreValue != null && nutriScoreValue >= 3 && novaValue == 4 -> HealthRating.UNHEALTHY
|
||||||
nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.MODERATE
|
nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.MODERATE
|
||||||
novaValue == 4 -> HealthRating.MODERATE
|
novaValue == 4 -> HealthRating.MODERATE
|
||||||
@ -76,7 +78,8 @@ object HealthClassifier {
|
|||||||
novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY
|
novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY
|
||||||
else -> HealthRating.UNKNOWN
|
else -> HealthRating.UNKNOWN
|
||||||
}
|
}
|
||||||
HealthStrictness.NORMAL -> when {
|
HealthStrictness.NORMAL ->
|
||||||
|
when {
|
||||||
nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.UNHEALTHY
|
nutriScoreValue != null && nutriScoreValue >= 3 -> HealthRating.UNHEALTHY
|
||||||
novaValue != null && novaValue >= 4 -> HealthRating.UNHEALTHY
|
novaValue != null && novaValue >= 4 -> HealthRating.UNHEALTHY
|
||||||
nutriScoreValue == 2 && (novaValue ?: 1) >= 3 -> HealthRating.UNHEALTHY
|
nutriScoreValue == 2 && (novaValue ?: 1) >= 3 -> HealthRating.UNHEALTHY
|
||||||
@ -87,7 +90,8 @@ object HealthClassifier {
|
|||||||
novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY
|
novaValue != null && novaValue <= 2 -> HealthRating.HEALTHY
|
||||||
else -> HealthRating.UNKNOWN
|
else -> HealthRating.UNKNOWN
|
||||||
}
|
}
|
||||||
HealthStrictness.STRICT -> when {
|
HealthStrictness.STRICT ->
|
||||||
|
when {
|
||||||
nutriScoreValue != null && nutriScoreValue >= 2 -> HealthRating.UNHEALTHY
|
nutriScoreValue != null && nutriScoreValue >= 2 -> HealthRating.UNHEALTHY
|
||||||
novaValue != null && novaValue >= 3 -> HealthRating.UNHEALTHY
|
novaValue != null && novaValue >= 3 -> HealthRating.UNHEALTHY
|
||||||
nutriScoreValue == 1 -> HealthRating.MODERATE
|
nutriScoreValue == 1 -> HealthRating.MODERATE
|
||||||
|
|||||||
@ -11,192 +11,247 @@ enum class AllergenType(
|
|||||||
val icon: String,
|
val icon: String,
|
||||||
val openFoodFactsTags: List<String>,
|
val openFoodFactsTags: List<String>,
|
||||||
val keywordsFr: List<String>,
|
val keywordsFr: List<String>,
|
||||||
val keywordsEn: List<String>
|
val keywordsEn: List<String>,
|
||||||
) {
|
) {
|
||||||
GLUTEN(
|
GLUTEN(
|
||||||
"Gluten", "Gluten", "🌾",
|
"Gluten",
|
||||||
|
"Gluten",
|
||||||
|
"🌾",
|
||||||
listOf("en:gluten"),
|
listOf("en:gluten"),
|
||||||
listOf(
|
listOf(
|
||||||
"gluten", "blé", "froment", "seigle", "orge", "avoine",
|
"gluten", "blé", "froment", "seigle", "orge", "avoine",
|
||||||
"épeautre", "kamut", "triticale", "malt", "amidon de blé",
|
"épeautre", "kamut", "triticale", "malt", "amidon de blé",
|
||||||
"farine de blé", "farine d'orge", "farine de seigle",
|
"farine de blé", "farine d'orge", "farine de seigle",
|
||||||
"protéine de blé", "seitan"
|
"protéine de blé", "seitan",
|
||||||
),
|
),
|
||||||
listOf(
|
listOf(
|
||||||
"gluten", "wheat", "rye", "barley", "oats", "spelt",
|
"gluten", "wheat", "rye", "barley", "oats", "spelt",
|
||||||
"kamut", "triticale", "malt", "wheat starch", "wheat flour"
|
"kamut", "triticale", "malt", "wheat starch", "wheat flour",
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
PEANUTS(
|
PEANUTS(
|
||||||
"Arachides", "Peanuts", "🥜",
|
"Arachides",
|
||||||
|
"Peanuts",
|
||||||
|
"🥜",
|
||||||
listOf("en:peanuts"),
|
listOf("en:peanuts"),
|
||||||
listOf(
|
listOf(
|
||||||
"arachide", "arachides", "cacahuète", "cacahuètes",
|
"arachide",
|
||||||
"beurre d'arachide", "huile d'arachide"
|
"arachides",
|
||||||
|
"cacahuète",
|
||||||
|
"cacahuètes",
|
||||||
|
"beurre d'arachide",
|
||||||
|
"huile d'arachide",
|
||||||
),
|
),
|
||||||
listOf(
|
listOf(
|
||||||
"peanut", "peanuts", "peanut butter", "peanut oil",
|
"peanut",
|
||||||
"groundnut", "groundnuts"
|
"peanuts",
|
||||||
)
|
"peanut butter",
|
||||||
|
"peanut oil",
|
||||||
|
"groundnut",
|
||||||
|
"groundnuts",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TREE_NUTS(
|
TREE_NUTS(
|
||||||
"Noix", "Tree Nuts", "🌰",
|
"Noix",
|
||||||
|
"Tree Nuts",
|
||||||
|
"🌰",
|
||||||
listOf("en:nuts", "en:tree-nuts"),
|
listOf("en:nuts", "en:tree-nuts"),
|
||||||
listOf(
|
listOf(
|
||||||
"noix", "amande", "amandes", "noisette", "noisettes",
|
"noix", "amande", "amandes", "noisette", "noisettes",
|
||||||
"cajou", "noix de cajou", "pistache", "pistaches",
|
"cajou", "noix de cajou", "pistache", "pistaches",
|
||||||
"noix de pécan", "pécan", "noix du brésil", "macadamia",
|
"noix de pécan", "pécan", "noix du brésil", "macadamia",
|
||||||
"noix de macadamia", "pralin", "praliné", "massepain",
|
"noix de macadamia", "pralin", "praliné", "massepain",
|
||||||
"pâte d'amande", "poudre d'amande"
|
"pâte d'amande", "poudre d'amande",
|
||||||
),
|
),
|
||||||
listOf(
|
listOf(
|
||||||
"nut", "nuts", "almond", "almonds", "hazelnut", "hazelnuts",
|
"nut", "nuts", "almond", "almonds", "hazelnut", "hazelnuts",
|
||||||
"cashew", "cashews", "pistachio", "pecan", "pecans",
|
"cashew", "cashews", "pistachio", "pecan", "pecans",
|
||||||
"brazil nut", "macadamia", "walnut", "walnuts", "praline",
|
"brazil nut", "macadamia", "walnut", "walnuts", "praline",
|
||||||
"marzipan", "almond paste"
|
"marzipan", "almond paste",
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
MILK(
|
MILK(
|
||||||
"Lait", "Milk", "🥛",
|
"Lait",
|
||||||
|
"Milk",
|
||||||
|
"🥛",
|
||||||
listOf("en:milk"),
|
listOf("en:milk"),
|
||||||
listOf(
|
listOf(
|
||||||
"lait", "lactose", "caséine", "caséinate", "lactosérum",
|
"lait", "lactose", "caséine", "caséinate", "lactosérum",
|
||||||
"petit-lait", "beurre", "crème", "fromage", "yogourt",
|
"petit-lait", "beurre", "crème", "fromage", "yogourt",
|
||||||
"babeurre", "ghee", "lactalbumine", "lactoglobuline",
|
"babeurre", "ghee", "lactalbumine", "lactoglobuline",
|
||||||
"protéine de lait", "poudre de lait", "lait écrémé",
|
"protéine de lait", "poudre de lait", "lait écrémé",
|
||||||
"lait entier", "concentré de protéines de lait"
|
"lait entier", "concentré de protéines de lait",
|
||||||
),
|
),
|
||||||
listOf(
|
listOf(
|
||||||
"milk", "lactose", "casein", "caseinate", "whey",
|
"milk", "lactose", "casein", "caseinate", "whey",
|
||||||
"butter", "cream", "cheese", "yogurt", "buttermilk",
|
"butter", "cream", "cheese", "yogurt", "buttermilk",
|
||||||
"ghee", "lactalbumin", "lactoglobulin", "milk protein",
|
"ghee", "lactalbumin", "lactoglobulin", "milk protein",
|
||||||
"milk powder", "skim milk", "whole milk"
|
"milk powder", "skim milk", "whole milk",
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
EGGS(
|
EGGS(
|
||||||
"Œufs", "Eggs", "🥚",
|
"Œufs",
|
||||||
|
"Eggs",
|
||||||
|
"🥚",
|
||||||
listOf("en:eggs"),
|
listOf("en:eggs"),
|
||||||
listOf(
|
listOf(
|
||||||
"œuf", "oeuf", "œufs", "oeufs", "albumine", "ovomucine",
|
"œuf", "oeuf", "œufs", "oeufs", "albumine", "ovomucine",
|
||||||
"ovomucoïde", "ovalbumine", "lécithine d'œuf",
|
"ovomucoïde", "ovalbumine", "lécithine d'œuf",
|
||||||
"lysozyme", "jaune d'œuf", "blanc d'œuf", "poudre d'œuf",
|
"lysozyme", "jaune d'œuf", "blanc d'œuf", "poudre d'œuf",
|
||||||
"œuf entier"
|
"œuf entier",
|
||||||
),
|
),
|
||||||
listOf(
|
listOf(
|
||||||
"egg", "eggs", "albumin", "ovomucin", "ovomucoid",
|
"egg", "eggs", "albumin", "ovomucin", "ovomucoid",
|
||||||
"ovalbumin", "egg lecithin", "lysozyme", "egg yolk",
|
"ovalbumin", "egg lecithin", "lysozyme", "egg yolk",
|
||||||
"egg white", "egg powder", "whole egg"
|
"egg white", "egg powder", "whole egg",
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
SOY(
|
SOY(
|
||||||
"Soja", "Soy", "🫘",
|
"Soja",
|
||||||
|
"Soy",
|
||||||
|
"🫘",
|
||||||
listOf("en:soybeans"),
|
listOf("en:soybeans"),
|
||||||
listOf(
|
listOf(
|
||||||
"soja", "soya", "lécithine de soja", "protéine de soja",
|
"soja", "soya", "lécithine de soja", "protéine de soja",
|
||||||
"tofu", "tempeh", "edamame", "fève de soja",
|
"tofu", "tempeh", "edamame", "fève de soja",
|
||||||
"huile de soja", "sauce soja", "miso"
|
"huile de soja", "sauce soja", "miso",
|
||||||
),
|
),
|
||||||
listOf(
|
listOf(
|
||||||
"soy", "soya", "soybean", "soybeans", "soy lecithin",
|
"soy", "soya", "soybean", "soybeans", "soy lecithin",
|
||||||
"soy protein", "tofu", "tempeh", "edamame",
|
"soy protein", "tofu", "tempeh", "edamame",
|
||||||
"soybean oil", "soy sauce", "miso"
|
"soybean oil", "soy sauce", "miso",
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
FISH(
|
FISH(
|
||||||
"Poisson", "Fish", "🐟",
|
"Poisson",
|
||||||
|
"Fish",
|
||||||
|
"🐟",
|
||||||
listOf("en:fish"),
|
listOf("en:fish"),
|
||||||
listOf(
|
listOf(
|
||||||
"poisson", "anchois", "bar", "cabillaud", "colin",
|
"poisson", "anchois", "bar", "cabillaud", "colin",
|
||||||
"dorade", "flétan", "hareng", "maquereau", "merlu",
|
"dorade", "flétan", "hareng", "maquereau", "merlu",
|
||||||
"morue", "perche", "sardine", "saumon", "sole",
|
"morue", "perche", "sardine", "saumon", "sole",
|
||||||
"thon", "truite", "huile de poisson", "sauce de poisson",
|
"thon", "truite", "huile de poisson", "sauce de poisson",
|
||||||
"surimi", "gélatine de poisson"
|
"surimi", "gélatine de poisson",
|
||||||
),
|
),
|
||||||
listOf(
|
listOf(
|
||||||
"fish", "anchovy", "anchovies", "bass", "cod", "haddock",
|
"fish", "anchovy", "anchovies", "bass", "cod", "haddock",
|
||||||
"halibut", "herring", "mackerel", "perch", "salmon",
|
"halibut", "herring", "mackerel", "perch", "salmon",
|
||||||
"sardine", "sole", "trout", "tuna", "fish oil",
|
"sardine", "sole", "trout", "tuna", "fish oil",
|
||||||
"fish sauce", "surimi", "fish gelatin"
|
"fish sauce", "surimi", "fish gelatin",
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
CRUSTACEANS(
|
CRUSTACEANS(
|
||||||
"Crustacés", "Crustaceans", "🦐",
|
"Crustacés",
|
||||||
|
"Crustaceans",
|
||||||
|
"🦐",
|
||||||
listOf("en:crustaceans"),
|
listOf("en:crustaceans"),
|
||||||
listOf(
|
listOf(
|
||||||
"crustacé", "crustacés", "crevette", "crevettes",
|
"crustacé", "crustacés", "crevette", "crevettes",
|
||||||
"homard", "crabe", "langouste", "langoustine",
|
"homard", "crabe", "langouste", "langoustine",
|
||||||
"écrevisse", "fruits de mer"
|
"écrevisse", "fruits de mer",
|
||||||
),
|
),
|
||||||
listOf(
|
listOf(
|
||||||
"crustacean", "crustaceans", "shrimp", "lobster",
|
"crustacean", "crustaceans", "shrimp", "lobster",
|
||||||
"crab", "crayfish", "prawn", "langoustine", "seafood"
|
"crab", "crayfish", "prawn", "langoustine", "seafood",
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
SESAME(
|
SESAME(
|
||||||
"Sésame", "Sesame", "⚪",
|
"Sésame",
|
||||||
|
"Sesame",
|
||||||
|
"⚪",
|
||||||
listOf("en:sesame-seeds"),
|
listOf("en:sesame-seeds"),
|
||||||
listOf(
|
listOf(
|
||||||
"sésame", "graines de sésame", "huile de sésame",
|
"sésame",
|
||||||
"tahini", "tahina", "halva"
|
"graines de sésame",
|
||||||
|
"huile de sésame",
|
||||||
|
"tahini",
|
||||||
|
"tahina",
|
||||||
|
"halva",
|
||||||
),
|
),
|
||||||
listOf(
|
listOf(
|
||||||
"sesame", "sesame seeds", "sesame oil", "tahini",
|
"sesame",
|
||||||
"tahina", "halva"
|
"sesame seeds",
|
||||||
)
|
"sesame oil",
|
||||||
|
"tahini",
|
||||||
|
"tahina",
|
||||||
|
"halva",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
MUSTARD(
|
MUSTARD(
|
||||||
"Moutarde", "Mustard", "🟡",
|
"Moutarde",
|
||||||
|
"Mustard",
|
||||||
|
"🟡",
|
||||||
listOf("en:mustard"),
|
listOf("en:mustard"),
|
||||||
listOf(
|
listOf(
|
||||||
"moutarde", "graines de moutarde", "huile de moutarde",
|
"moutarde",
|
||||||
"farine de moutarde"
|
"graines de moutarde",
|
||||||
|
"huile de moutarde",
|
||||||
|
"farine de moutarde",
|
||||||
),
|
),
|
||||||
listOf("mustard", "mustard seeds", "mustard oil", "mustard flour")
|
listOf("mustard", "mustard seeds", "mustard oil", "mustard flour"),
|
||||||
),
|
),
|
||||||
SULPHITES(
|
SULPHITES(
|
||||||
"Sulfites", "Sulphites", "🟣",
|
"Sulfites",
|
||||||
|
"Sulphites",
|
||||||
|
"🟣",
|
||||||
listOf("en:sulphur-dioxide-and-sulphites"),
|
listOf("en:sulphur-dioxide-and-sulphites"),
|
||||||
listOf(
|
listOf(
|
||||||
"sulfite", "sulfites", "dioxyde de soufre", "bisulfite",
|
"sulfite", "sulfites", "dioxyde de soufre", "bisulfite",
|
||||||
"métabisulfite", "anhydride sulfureux",
|
"métabisulfite", "anhydride sulfureux",
|
||||||
"e220", "e221", "e222", "e223", "e224", "e225", "e226", "e228"
|
"e220", "e221", "e222", "e223", "e224", "e225", "e226", "e228",
|
||||||
),
|
),
|
||||||
listOf(
|
listOf(
|
||||||
"sulphite", "sulphites", "sulfite", "sulfites",
|
"sulphite",
|
||||||
"sulphur dioxide", "bisulphite", "metabisulphite"
|
"sulphites",
|
||||||
)
|
"sulfite",
|
||||||
|
"sulfites",
|
||||||
|
"sulphur dioxide",
|
||||||
|
"bisulphite",
|
||||||
|
"metabisulphite",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
LUPIN(
|
LUPIN(
|
||||||
"Lupin", "Lupin", "💐",
|
"Lupin",
|
||||||
|
"Lupin",
|
||||||
|
"💐",
|
||||||
listOf("en:lupin"),
|
listOf("en:lupin"),
|
||||||
listOf("lupin", "lupins", "farine de lupin"),
|
listOf("lupin", "lupins", "farine de lupin"),
|
||||||
listOf("lupin", "lupine", "lupin flour")
|
listOf("lupin", "lupine", "lupin flour"),
|
||||||
),
|
),
|
||||||
MOLLUSCS(
|
MOLLUSCS(
|
||||||
"Mollusques", "Molluscs", "🐚",
|
"Mollusques",
|
||||||
|
"Molluscs",
|
||||||
|
"🐚",
|
||||||
listOf("en:molluscs"),
|
listOf("en:molluscs"),
|
||||||
listOf(
|
listOf(
|
||||||
"mollusque", "mollusques", "huître", "moule", "moules",
|
"mollusque", "mollusques", "huître", "moule", "moules",
|
||||||
"palourde", "pétoncle", "calmar", "calamar", "pieuvre",
|
"palourde", "pétoncle", "calmar", "calamar", "pieuvre",
|
||||||
"poulpe", "escargot", "coquille saint-jacques"
|
"poulpe", "escargot", "coquille saint-jacques",
|
||||||
),
|
),
|
||||||
listOf(
|
listOf(
|
||||||
"mollusc", "molluscs", "mollusk", "oyster", "mussel",
|
"mollusc", "molluscs", "mollusk", "oyster", "mussel",
|
||||||
"clam", "scallop", "squid", "octopus", "snail"
|
"clam", "scallop", "squid", "octopus", "snail",
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
CELERY(
|
CELERY(
|
||||||
"Céleri", "Celery", "🥬",
|
"Céleri",
|
||||||
|
"Celery",
|
||||||
|
"🥬",
|
||||||
listOf("en:celery"),
|
listOf("en:celery"),
|
||||||
listOf(
|
listOf(
|
||||||
"céleri", "celeri", "sel de céleri", "graines de céleri",
|
"céleri",
|
||||||
"celeriac", "céleri-rave"
|
"celeri",
|
||||||
|
"sel de céleri",
|
||||||
|
"graines de céleri",
|
||||||
|
"celeriac",
|
||||||
|
"céleri-rave",
|
||||||
),
|
),
|
||||||
listOf("celery", "celeriac", "celery salt", "celery seed")
|
listOf("celery", "celeriac", "celery salt", "celery seed"),
|
||||||
);
|
),
|
||||||
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromName(name: String): AllergenType? =
|
fun fromName(name: String): AllergenType? = values().firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||||
values().firstOrNull { it.name.equals(name, ignoreCase = true) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ enum class DietaryRestriction(val displayFr: String, val displayEn: String) {
|
|||||||
VEGETARIAN("Végétarien", "Vegetarian"),
|
VEGETARIAN("Végétarien", "Vegetarian"),
|
||||||
HALAL("Halal", "Halal"),
|
HALAL("Halal", "Halal"),
|
||||||
KOSHER("Casher", "Kosher"),
|
KOSHER("Casher", "Kosher"),
|
||||||
NO_PORK("Sans porc", "No pork")
|
NO_PORK("Sans porc", "No pork"),
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class DetectionLanguage { FR, EN, BOTH }
|
enum class DetectionLanguage { FR, EN, BOTH }
|
||||||
@ -31,7 +31,7 @@ enum class CustomItemTag(val displayFr: String, val displayEn: String) {
|
|||||||
ALLERGY("Allergie", "Allergy"),
|
ALLERGY("Allergie", "Allergy"),
|
||||||
INTOLERANCE("Intolérance", "Intolerance"),
|
INTOLERANCE("Intolérance", "Intolerance"),
|
||||||
DIET("Diète", "Diet"),
|
DIET("Diète", "Diet"),
|
||||||
UNHEALTHY("Non-santé", "Unhealthy")
|
UNHEALTHY("Non-santé", "Unhealthy"),
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A user-defined ingredient/substance to watch for (e.g. "huile de palme"). */
|
/** A user-defined ingredient/substance to watch for (e.g. "huile de palme"). */
|
||||||
@ -39,10 +39,9 @@ data class CustomDietItem(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val tag: CustomItemTag,
|
val tag: CustomItemTag,
|
||||||
/** Optional additional keywords; if empty, [name] is used. */
|
/** Optional additional keywords; if empty, [name] is used. */
|
||||||
val keywords: List<String> = emptyList()
|
val keywords: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
fun allKeywords(): List<String> =
|
fun allKeywords(): List<String> = (listOf(name) + keywords).filter { it.isNotBlank() }.distinct()
|
||||||
(listOf(name) + keywords).filter { it.isNotBlank() }.distinct()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A user's allergy profile. */
|
/** A user's allergy profile. */
|
||||||
@ -54,7 +53,7 @@ data class UserProfile(
|
|||||||
val moderateIntolerances: Set<AllergenType> = emptySet(),
|
val moderateIntolerances: Set<AllergenType> = emptySet(),
|
||||||
val dietaryRestrictions: Set<DietaryRestriction> = emptySet(),
|
val dietaryRestrictions: Set<DietaryRestriction> = emptySet(),
|
||||||
val customItems: List<CustomDietItem> = emptyList(),
|
val customItems: List<CustomDietItem> = emptyList(),
|
||||||
val isDefault: Boolean = false
|
val isDefault: Boolean = false,
|
||||||
) {
|
) {
|
||||||
/** Returns every allergen (severe + moderate) referenced by this profile. */
|
/** Returns every allergen (severe + moderate) referenced by this profile. */
|
||||||
fun allAllergens(): Set<AllergenType> = severeAllergens + moderateIntolerances
|
fun allAllergens(): Set<AllergenType> = severeAllergens + moderateIntolerances
|
||||||
@ -71,11 +70,12 @@ data class Nutriments(
|
|||||||
val sodium100g: Double? = null,
|
val sodium100g: Double? = null,
|
||||||
val fiber100g: Double? = null,
|
val fiber100g: Double? = null,
|
||||||
val proteins100g: Double? = null,
|
val proteins100g: Double? = null,
|
||||||
val carbohydrates100g: Double? = null
|
val carbohydrates100g: Double? = null,
|
||||||
) {
|
) {
|
||||||
fun isEmpty(): Boolean = listOf(
|
fun isEmpty(): Boolean =
|
||||||
|
listOf(
|
||||||
energyKcal100g, energyKcalServing, fat100g, saturatedFat100g,
|
energyKcal100g, energyKcalServing, fat100g, saturatedFat100g,
|
||||||
sugars100g, salt100g, sodium100g, fiber100g, proteins100g, carbohydrates100g
|
sugars100g, salt100g, sodium100g, fiber100g, proteins100g, carbohydrates100g,
|
||||||
).all { it == null }
|
).all { it == null }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,7 +94,7 @@ data class Product(
|
|||||||
val servingSize: String? = null,
|
val servingSize: String? = null,
|
||||||
val nutriments: Nutriments = Nutriments(),
|
val nutriments: Nutriments = Nutriments(),
|
||||||
val labels: List<String> = emptyList(),
|
val labels: List<String> = emptyList(),
|
||||||
val categories: List<String> = emptyList()
|
val categories: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
/** Public Open Food Facts product page URL. */
|
/** Public Open Food Facts product page URL. */
|
||||||
fun openFoodFactsUrl(): String = "https://world.openfoodfacts.org/product/$barcode"
|
fun openFoodFactsUrl(): String = "https://world.openfoodfacts.org/product/$barcode"
|
||||||
@ -104,7 +104,7 @@ data class Product(
|
|||||||
data class DetectedCustomItem(
|
data class DetectedCustomItem(
|
||||||
val item: CustomDietItem,
|
val item: CustomDietItem,
|
||||||
val matchedKeywords: List<String>,
|
val matchedKeywords: List<String>,
|
||||||
val profileIds: List<Long> = emptyList()
|
val profileIds: List<Long> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** High-level health verdict computed from Nutri-Score, Nova, Eco-Score + custom rules. */
|
/** High-level health verdict computed from Nutri-Score, Nova, Eco-Score + custom rules. */
|
||||||
@ -113,7 +113,7 @@ data class HealthAssessment(
|
|||||||
val reasons: List<String> = emptyList(),
|
val reasons: List<String> = emptyList(),
|
||||||
val nutriScore: String? = null,
|
val nutriScore: String? = null,
|
||||||
val novaGroup: Int? = null,
|
val novaGroup: Int? = null,
|
||||||
val ecoScore: String? = null
|
val ecoScore: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Describes a single allergen that was detected during analysis. */
|
/** Describes a single allergen that was detected during analysis. */
|
||||||
@ -125,7 +125,7 @@ data class DetectedAllergen(
|
|||||||
/** Which profiles this detection concerns (useful for multi-profile scans). */
|
/** Which profiles this detection concerns (useful for multi-profile scans). */
|
||||||
val profileIds: List<Long> = emptyList(),
|
val profileIds: List<Long> = emptyList(),
|
||||||
/** True when at least one profile lists this as a *severe* allergy. */
|
/** True when at least one profile lists this as a *severe* allergy. */
|
||||||
val severe: Boolean = true
|
val severe: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ScanResult(
|
data class ScanResult(
|
||||||
@ -136,7 +136,7 @@ data class ScanResult(
|
|||||||
val health: HealthAssessment = HealthAssessment(),
|
val health: HealthAssessment = HealthAssessment(),
|
||||||
val analyzedProfiles: List<UserProfile>,
|
val analyzedProfiles: List<UserProfile>,
|
||||||
val confidence: AnalysisConfidence,
|
val confidence: AnalysisConfidence,
|
||||||
val source: DataSource
|
val source: DataSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ScanHistoryItem(
|
data class ScanHistoryItem(
|
||||||
@ -148,5 +148,5 @@ data class ScanHistoryItem(
|
|||||||
val safetyStatus: SafetyStatus,
|
val safetyStatus: SafetyStatus,
|
||||||
val profileNames: List<String>,
|
val profileNames: List<String>,
|
||||||
val scannedAt: Long,
|
val scannedAt: Long,
|
||||||
val source: DataSource
|
val source: DataSource,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -15,38 +15,54 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
|
|
||||||
sealed class ProductFetchResult {
|
sealed class ProductFetchResult {
|
||||||
data class Found(val product: Product, val fromCache: Boolean) : ProductFetchResult()
|
data class Found(val product: Product, val fromCache: Boolean) : ProductFetchResult()
|
||||||
|
|
||||||
data object NotFound : ProductFetchResult()
|
data object NotFound : ProductFetchResult()
|
||||||
|
|
||||||
data class Error(val message: String, val offline: Boolean = false) : ProductFetchResult()
|
data class Error(val message: String, val offline: Boolean = false) : ProductFetchResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductRepository {
|
interface ProductRepository {
|
||||||
suspend fun fetchProduct(barcode: String): ProductFetchResult
|
suspend fun fetchProduct(barcode: String): ProductFetchResult
|
||||||
|
|
||||||
suspend fun cacheProduct(product: Product)
|
suspend fun cacheProduct(product: Product)
|
||||||
|
|
||||||
suspend fun getCachedProduct(barcode: String): Product?
|
suspend fun getCachedProduct(barcode: String): Product?
|
||||||
|
|
||||||
suspend fun clearCache()
|
suspend fun clearCache()
|
||||||
|
|
||||||
/** Search for products in the same category without specific allergens. */
|
/** Search for products in the same category without specific allergens. */
|
||||||
suspend fun searchAlternatives(
|
suspend fun searchAlternatives(
|
||||||
category: String,
|
category: String,
|
||||||
excludeAllergens: Set<String>,
|
excludeAllergens: Set<String>,
|
||||||
limit: Int = 5
|
limit: Int = 5,
|
||||||
): List<Product>
|
): List<Product>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserProfileRepository {
|
interface UserProfileRepository {
|
||||||
fun observeProfiles(): Flow<List<UserProfile>>
|
fun observeProfiles(): Flow<List<UserProfile>>
|
||||||
|
|
||||||
suspend fun getProfile(id: Long): UserProfile?
|
suspend fun getProfile(id: Long): UserProfile?
|
||||||
|
|
||||||
suspend fun upsert(profile: UserProfile): Long
|
suspend fun upsert(profile: UserProfile): Long
|
||||||
|
|
||||||
suspend fun delete(profile: UserProfile)
|
suspend fun delete(profile: UserProfile)
|
||||||
|
|
||||||
suspend fun setDefault(id: Long)
|
suspend fun setDefault(id: Long)
|
||||||
|
|
||||||
fun observeActiveProfileIds(): Flow<Set<Long>>
|
fun observeActiveProfileIds(): Flow<Set<Long>>
|
||||||
|
|
||||||
suspend fun setActiveProfileIds(ids: Set<Long>)
|
suspend fun setActiveProfileIds(ids: Set<Long>)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScanHistoryRepository {
|
interface ScanHistoryRepository {
|
||||||
fun observeHistory(): Flow<List<ScanHistoryItem>>
|
fun observeHistory(): Flow<List<ScanHistoryItem>>
|
||||||
|
|
||||||
suspend fun save(result: ScanResult): Long
|
suspend fun save(result: ScanResult): Long
|
||||||
|
|
||||||
suspend fun delete(id: Long)
|
suspend fun delete(id: Long)
|
||||||
|
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
|
|
||||||
suspend fun getById(id: Long): ScanHistoryItem?
|
suspend fun getById(id: Long): ScanHistoryItem?
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,12 +77,19 @@ interface SettingsRepository {
|
|||||||
val splashScreenEnabled: Flow<Boolean>
|
val splashScreenEnabled: Flow<Boolean>
|
||||||
|
|
||||||
suspend fun setAppLanguage(value: AppLanguage)
|
suspend fun setAppLanguage(value: AppLanguage)
|
||||||
|
|
||||||
suspend fun setDetectionLanguage(value: DetectionLanguage)
|
suspend fun setDetectionLanguage(value: DetectionLanguage)
|
||||||
|
|
||||||
suspend fun setHaptics(enabled: Boolean)
|
suspend fun setHaptics(enabled: Boolean)
|
||||||
|
|
||||||
suspend fun setSound(enabled: Boolean)
|
suspend fun setSound(enabled: Boolean)
|
||||||
|
|
||||||
suspend fun setTheme(value: ThemePref)
|
suspend fun setTheme(value: ThemePref)
|
||||||
|
|
||||||
suspend fun setOnboardingCompleted(value: Boolean)
|
suspend fun setOnboardingCompleted(value: Boolean)
|
||||||
|
|
||||||
suspend fun setHealthStrictness(value: HealthStrictness)
|
suspend fun setHealthStrictness(value: HealthStrictness)
|
||||||
|
|
||||||
suspend fun setSplashScreenEnabled(enabled: Boolean)
|
suspend fun setSplashScreenEnabled(enabled: Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,34 +100,61 @@ interface SettingsRepository {
|
|||||||
interface ShoppingListRepository {
|
interface ShoppingListRepository {
|
||||||
// Lists
|
// Lists
|
||||||
fun observeActiveLists(): Flow<List<ShoppingListEntity>>
|
fun observeActiveLists(): Flow<List<ShoppingListEntity>>
|
||||||
|
|
||||||
fun observeAllLists(): Flow<List<ShoppingListEntity>>
|
fun observeAllLists(): Flow<List<ShoppingListEntity>>
|
||||||
|
|
||||||
suspend fun getListById(id: Long): ShoppingListEntity?
|
suspend fun getListById(id: Long): ShoppingListEntity?
|
||||||
suspend fun createList(name: String, backgroundResName: String? = null): Long
|
|
||||||
|
suspend fun createList(
|
||||||
|
name: String,
|
||||||
|
backgroundResName: String? = null,
|
||||||
|
): Long
|
||||||
|
|
||||||
suspend fun updateList(list: ShoppingListEntity)
|
suspend fun updateList(list: ShoppingListEntity)
|
||||||
|
|
||||||
suspend fun deleteList(list: ShoppingListEntity)
|
suspend fun deleteList(list: ShoppingListEntity)
|
||||||
|
|
||||||
suspend fun archiveList(id: Long)
|
suspend fun archiveList(id: Long)
|
||||||
|
|
||||||
// Items
|
// Items
|
||||||
fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>>
|
fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>>
|
||||||
|
|
||||||
suspend fun getItems(listId: Long): List<ShoppingListItemEntity>
|
suspend fun getItems(listId: Long): List<ShoppingListItemEntity>
|
||||||
|
|
||||||
suspend fun addItem(item: ShoppingListItemEntity): Long
|
suspend fun addItem(item: ShoppingListItemEntity): Long
|
||||||
|
|
||||||
suspend fun updateItem(item: ShoppingListItemEntity)
|
suspend fun updateItem(item: ShoppingListItemEntity)
|
||||||
|
|
||||||
suspend fun deleteItem(item: ShoppingListItemEntity)
|
suspend fun deleteItem(item: ShoppingListItemEntity)
|
||||||
suspend fun setItemChecked(id: Long, checked: Boolean)
|
|
||||||
|
suspend fun setItemChecked(
|
||||||
|
id: Long,
|
||||||
|
checked: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun uncheckAllItems(listId: Long)
|
suspend fun uncheckAllItems(listId: Long)
|
||||||
|
|
||||||
suspend fun deleteAllItems(listId: Long)
|
suspend fun deleteAllItems(listId: Long)
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
fun observeItemCount(listId: Long): Flow<Int>
|
fun observeItemCount(listId: Long): Flow<Int>
|
||||||
|
|
||||||
fun observeCheckedCount(listId: Long): Flow<Int>
|
fun observeCheckedCount(listId: Long): Flow<Int>
|
||||||
|
|
||||||
// Members
|
// Members
|
||||||
fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>>
|
fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>>
|
||||||
|
|
||||||
suspend fun addMember(member: ShoppingListMemberEntity): Long
|
suspend fun addMember(member: ShoppingListMemberEntity): Long
|
||||||
|
|
||||||
suspend fun updateMember(member: ShoppingListMemberEntity)
|
suspend fun updateMember(member: ShoppingListMemberEntity)
|
||||||
|
|
||||||
suspend fun removeMember(member: ShoppingListMemberEntity)
|
suspend fun removeMember(member: ShoppingListMemberEntity)
|
||||||
|
|
||||||
suspend fun deleteAllMembers(listId: Long)
|
suspend fun deleteAllMembers(listId: Long)
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity)
|
suspend fun addItemToList(
|
||||||
|
listId: Long,
|
||||||
|
item: ShoppingListItemEntity,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,13 +8,15 @@ import javax.inject.Inject
|
|||||||
* UseCase pour trouver des produits alternatifs dans la même catégorie
|
* UseCase pour trouver des produits alternatifs dans la même catégorie
|
||||||
* sans les allergènes problématiques.
|
* sans les allergènes problématiques.
|
||||||
*/
|
*/
|
||||||
class GetAlternativesUseCase @Inject constructor(
|
class GetAlternativesUseCase
|
||||||
private val productRepository: ProductRepository
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val productRepository: ProductRepository,
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(
|
suspend operator fun invoke(
|
||||||
category: String,
|
category: String,
|
||||||
excludeAllergenTags: Set<String>,
|
excludeAllergenTags: Set<String>,
|
||||||
limit: Int = 5
|
limit: Int = 5,
|
||||||
): List<Product> {
|
): List<Product> {
|
||||||
if (category.isBlank()) return emptyList()
|
if (category.isBlank()) return emptyList()
|
||||||
return productRepository.searchAlternatives(category, excludeAllergenTags, limit)
|
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.engine.AllergenAnalysisEngine
|
||||||
import com.safebite.app.domain.model.DataSource
|
import com.safebite.app.domain.model.DataSource
|
||||||
import com.safebite.app.domain.model.DetectionLanguage
|
|
||||||
import com.safebite.app.domain.model.Product
|
import com.safebite.app.domain.model.Product
|
||||||
import com.safebite.app.domain.model.ScanResult
|
import com.safebite.app.domain.model.ScanResult
|
||||||
import com.safebite.app.domain.model.UserProfile
|
import com.safebite.app.domain.model.UserProfile
|
||||||
@ -16,21 +15,24 @@ import kotlinx.coroutines.flow.first
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** Fetch a product by barcode (remote or cache). */
|
/** Fetch a product by barcode (remote or cache). */
|
||||||
class FetchProductUseCase @Inject constructor(
|
class FetchProductUseCase
|
||||||
private val productRepository: ProductRepository
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val productRepository: ProductRepository,
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(barcode: String): ProductFetchResult =
|
suspend operator fun invoke(barcode: String): ProductFetchResult = productRepository.fetchProduct(barcode)
|
||||||
productRepository.fetchProduct(barcode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Analyze a product against a list of profiles using the engine. */
|
/** Analyze a product against a list of profiles using the engine. */
|
||||||
class AnalyzeProductUseCase @Inject constructor(
|
class AnalyzeProductUseCase
|
||||||
private val settingsRepository: SettingsRepository
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(
|
suspend operator fun invoke(
|
||||||
product: Product,
|
product: Product,
|
||||||
profiles: List<UserProfile>,
|
profiles: List<UserProfile>,
|
||||||
source: DataSource
|
source: DataSource,
|
||||||
): ScanResult {
|
): ScanResult {
|
||||||
val lang = settingsRepository.detectionLanguage.first()
|
val lang = settingsRepository.detectionLanguage.first()
|
||||||
val strictness = settingsRepository.healthStrictness.first()
|
val strictness = settingsRepository.healthStrictness.first()
|
||||||
@ -39,51 +41,69 @@ class AnalyzeProductUseCase @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Analyze free-form ingredients text (OCR path). */
|
/** Analyze free-form ingredients text (OCR path). */
|
||||||
class AnalyzeIngredientsTextUseCase @Inject constructor(
|
class AnalyzeIngredientsTextUseCase
|
||||||
private val analyzeProductUseCase: AnalyzeProductUseCase
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val analyzeProductUseCase: AnalyzeProductUseCase,
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(
|
suspend operator fun invoke(
|
||||||
text: String,
|
text: String,
|
||||||
profiles: List<UserProfile>,
|
profiles: List<UserProfile>,
|
||||||
barcode: String? = null,
|
barcode: String? = null,
|
||||||
productName: String? = null
|
productName: String? = null,
|
||||||
): ScanResult {
|
): ScanResult {
|
||||||
val product = Product(
|
val product =
|
||||||
|
Product(
|
||||||
barcode = barcode ?: "ocr-${System.currentTimeMillis()}",
|
barcode = barcode ?: "ocr-${System.currentTimeMillis()}",
|
||||||
name = productName,
|
name = productName,
|
||||||
brand = null,
|
brand = null,
|
||||||
imageUrl = null,
|
imageUrl = null,
|
||||||
ingredientsText = text,
|
ingredientsText = text,
|
||||||
allergensTags = emptyList(),
|
allergensTags = emptyList(),
|
||||||
tracesTags = emptyList()
|
tracesTags = emptyList(),
|
||||||
)
|
)
|
||||||
return analyzeProductUseCase(product, profiles, DataSource.OCR)
|
return analyzeProductUseCase(product, profiles, DataSource.OCR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ManageProfileUseCase @Inject constructor(
|
class ManageProfileUseCase
|
||||||
private val repo: UserProfileRepository
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val repo: UserProfileRepository,
|
||||||
) {
|
) {
|
||||||
fun observe(): Flow<List<UserProfile>> = repo.observeProfiles()
|
fun observe(): Flow<List<UserProfile>> = repo.observeProfiles()
|
||||||
|
|
||||||
suspend fun get(id: Long) = repo.getProfile(id)
|
suspend fun get(id: Long) = repo.getProfile(id)
|
||||||
|
|
||||||
suspend fun save(profile: UserProfile): Long = repo.upsert(profile)
|
suspend fun save(profile: UserProfile): Long = repo.upsert(profile)
|
||||||
|
|
||||||
suspend fun delete(profile: UserProfile) = repo.delete(profile)
|
suspend fun delete(profile: UserProfile) = repo.delete(profile)
|
||||||
|
|
||||||
suspend fun setDefault(id: Long) = repo.setDefault(id)
|
suspend fun setDefault(id: Long) = repo.setDefault(id)
|
||||||
|
|
||||||
fun observeActiveIds() = repo.observeActiveProfileIds()
|
fun observeActiveIds() = repo.observeActiveProfileIds()
|
||||||
|
|
||||||
suspend fun setActive(ids: Set<Long>) = repo.setActiveProfileIds(ids)
|
suspend fun setActive(ids: Set<Long>) = repo.setActiveProfileIds(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
class GetScanHistoryUseCase @Inject constructor(
|
class GetScanHistoryUseCase
|
||||||
private val repo: ScanHistoryRepository
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val repo: ScanHistoryRepository,
|
||||||
) {
|
) {
|
||||||
fun observe(): Flow<List<com.safebite.app.domain.model.ScanHistoryItem>> = repo.observeHistory()
|
fun observe(): Flow<List<com.safebite.app.domain.model.ScanHistoryItem>> = repo.observeHistory()
|
||||||
|
|
||||||
suspend fun delete(id: Long) = repo.delete(id)
|
suspend fun delete(id: Long) = repo.delete(id)
|
||||||
|
|
||||||
suspend fun clear() = repo.clear()
|
suspend fun clear() = repo.clear()
|
||||||
|
|
||||||
suspend fun get(id: Long) = repo.getById(id)
|
suspend fun get(id: Long) = repo.getById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
class SaveScanUseCase @Inject constructor(
|
class SaveScanUseCase
|
||||||
private val repo: ScanHistoryRepository
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val repo: ScanHistoryRepository,
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(result: ScanResult): Long = repo.save(result)
|
suspend operator fun invoke(result: ScanResult): Long = repo.save(result)
|
||||||
}
|
}
|
||||||
@ -92,32 +112,66 @@ class SaveScanUseCase @Inject constructor(
|
|||||||
// Shopping List UseCases (Phase 2)
|
// Shopping List UseCases (Phase 2)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
class GetShoppingListsUseCase @Inject constructor(
|
class GetShoppingListsUseCase
|
||||||
private val repo: com.safebite.app.domain.repository.ShoppingListRepository
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val repo: com.safebite.app.domain.repository.ShoppingListRepository,
|
||||||
) {
|
) {
|
||||||
fun observeActive() = repo.observeActiveLists()
|
fun observeActive() = repo.observeActiveLists()
|
||||||
|
|
||||||
fun observeAll() = repo.observeAllLists()
|
fun observeAll() = repo.observeAllLists()
|
||||||
|
|
||||||
suspend fun getList(id: Long) = repo.getListById(id)
|
suspend fun getList(id: Long) = repo.getListById(id)
|
||||||
suspend fun createList(name: String, backgroundResName: String? = null) = repo.createList(name, backgroundResName)
|
|
||||||
|
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 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)
|
suspend fun deleteList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.deleteList(list)
|
||||||
|
|
||||||
fun observeItemCount(listId: Long) = repo.observeItemCount(listId)
|
fun observeItemCount(listId: Long) = repo.observeItemCount(listId)
|
||||||
|
|
||||||
fun observeCheckedCount(listId: Long) = repo.observeCheckedCount(listId)
|
fun observeCheckedCount(listId: Long) = repo.observeCheckedCount(listId)
|
||||||
|
|
||||||
fun observeMembers(listId: Long) = repo.observeMembers(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 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)
|
suspend fun removeMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.removeMember(member)
|
||||||
}
|
}
|
||||||
|
|
||||||
class ManageShoppingListUseCase @Inject constructor(
|
class ManageShoppingListUseCase
|
||||||
private val repo: com.safebite.app.domain.repository.ShoppingListRepository
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val repo: com.safebite.app.domain.repository.ShoppingListRepository,
|
||||||
) {
|
) {
|
||||||
fun observeItems(listId: Long) = repo.observeItems(listId)
|
fun observeItems(listId: Long) = repo.observeItems(listId)
|
||||||
|
|
||||||
suspend fun getItems(listId: Long) = repo.getItems(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 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 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 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 setItemChecked(
|
||||||
|
id: Long,
|
||||||
|
checked: Boolean,
|
||||||
|
) = repo.setItemChecked(id, checked)
|
||||||
|
|
||||||
suspend fun uncheckAllItems(listId: Long) = repo.uncheckAllItems(listId)
|
suspend fun uncheckAllItems(listId: Long) = repo.uncheckAllItems(listId)
|
||||||
|
|
||||||
suspend fun deleteAllItems(listId: Long) = repo.deleteAllItems(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)
|
|
||||||
|
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.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.safebite.app.domain.model.ThemePref
|
import com.safebite.app.domain.model.ThemePref
|
||||||
import com.safebite.app.domain.repository.SettingsRepository
|
import com.safebite.app.domain.repository.SettingsRepository
|
||||||
import com.safebite.app.presentation.navigation.SafeBiteNavGraph
|
import com.safebite.app.presentation.navigation.SafeBiteNavGraph
|
||||||
@ -25,30 +25,32 @@ data class RootUi(
|
|||||||
val onboardingDone: Boolean = false,
|
val onboardingDone: Boolean = false,
|
||||||
val theme: ThemePref = ThemePref.SYSTEM,
|
val theme: ThemePref = ThemePref.SYSTEM,
|
||||||
val showSplash: Boolean = false,
|
val showSplash: Boolean = false,
|
||||||
val ready: Boolean = false
|
val ready: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class RootViewModel @Inject constructor(
|
class RootViewModel
|
||||||
settings: SettingsRepository
|
@Inject
|
||||||
|
constructor(
|
||||||
|
settings: SettingsRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val state: StateFlow<RootUi> = combine(
|
val state: StateFlow<RootUi> =
|
||||||
|
combine(
|
||||||
settings.onboardingCompleted,
|
settings.onboardingCompleted,
|
||||||
settings.theme,
|
settings.theme,
|
||||||
settings.splashScreenEnabled
|
settings.splashScreenEnabled,
|
||||||
) { done, theme, splashEnabled ->
|
) { done, theme, splashEnabled ->
|
||||||
RootUi(
|
RootUi(
|
||||||
onboardingDone = done,
|
onboardingDone = done,
|
||||||
theme = theme,
|
theme = theme,
|
||||||
showSplash = splashEnabled && done,
|
showSplash = splashEnabled && done,
|
||||||
ready = true
|
ready = true,
|
||||||
)
|
)
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, RootUi())
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, RootUi())
|
||||||
}
|
}
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
private val rootViewModel: RootViewModel by viewModels()
|
private val rootViewModel: RootViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@ -56,7 +58,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||||
setContent {
|
setContent {
|
||||||
val ui by rootViewModel.state.collectAsStateWithLifecycle()
|
val ui by rootViewModel.state.collectAsStateWithLifecycle()
|
||||||
val dark = when (ui.theme) {
|
val dark =
|
||||||
|
when (ui.theme) {
|
||||||
ThemePref.LIGHT -> false
|
ThemePref.LIGHT -> false
|
||||||
ThemePref.DARK -> true
|
ThemePref.DARK -> true
|
||||||
ThemePref.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme()
|
ThemePref.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme()
|
||||||
@ -65,7 +68,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (ui.ready) {
|
if (ui.ready) {
|
||||||
SafeBiteNavGraph(
|
SafeBiteNavGraph(
|
||||||
onboardingCompleted = ui.onboardingDone,
|
onboardingCompleted = ui.onboardingDone,
|
||||||
showSplash = ui.showSplash
|
showSplash = ui.showSplash,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,13 +31,14 @@ import com.safebite.app.presentation.theme.LocalDimens
|
|||||||
enum class AllergenLevel(val label: String, val emoji: String) {
|
enum class AllergenLevel(val label: String, val emoji: String) {
|
||||||
NONE("Aucun", ""),
|
NONE("Aucun", ""),
|
||||||
TRACE("Traces", "⚠️"),
|
TRACE("Traces", "⚠️"),
|
||||||
SEVERE("Sévère", "❌")
|
SEVERE("Sévère", "❌"),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Couleurs de fond par état d'allergie (spec UX §4.2).
|
* Couleurs de fond par état d'allergie (spec UX §4.2).
|
||||||
*/
|
*/
|
||||||
fun AllergenLevel.backgroundColor(): Color = when (this) {
|
fun AllergenLevel.backgroundColor(): Color =
|
||||||
|
when (this) {
|
||||||
AllergenLevel.NONE -> Color.Transparent
|
AllergenLevel.NONE -> Color.Transparent
|
||||||
AllergenLevel.TRACE -> Color(0xFFFEF5E7) // Orange clair
|
AllergenLevel.TRACE -> Color(0xFFFEF5E7) // Orange clair
|
||||||
AllergenLevel.SEVERE -> Color(0xFFFDEDEC) // Rouge clair
|
AllergenLevel.SEVERE -> Color(0xFFFDEDEC) // Rouge clair
|
||||||
@ -47,7 +48,8 @@ fun AllergenLevel.backgroundColor(): Color = when (this) {
|
|||||||
* Couleur de bordure par état.
|
* Couleur de bordure par état.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AllergenLevel.borderColor(): Color = when (this) {
|
fun AllergenLevel.borderColor(): Color =
|
||||||
|
when (this) {
|
||||||
AllergenLevel.NONE -> MaterialTheme.colorScheme.outlineVariant
|
AllergenLevel.NONE -> MaterialTheme.colorScheme.outlineVariant
|
||||||
AllergenLevel.TRACE -> Color(0xFFF39C12)
|
AllergenLevel.TRACE -> Color(0xFFF39C12)
|
||||||
AllergenLevel.SEVERE -> Color(0xFFE74C3C)
|
AllergenLevel.SEVERE -> Color(0xFFE74C3C)
|
||||||
@ -66,7 +68,7 @@ fun AllergenLevel.borderColor(): Color = when (this) {
|
|||||||
fun AllergenSelectionGrid(
|
fun AllergenSelectionGrid(
|
||||||
selectedAllergens: Map<AllergenType, AllergenLevel>,
|
selectedAllergens: Map<AllergenType, AllergenLevel>,
|
||||||
onLevelChanged: (AllergenType, AllergenLevel) -> Unit,
|
onLevelChanged: (AllergenType, AllergenLevel) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
val columns = 3
|
val columns = 3
|
||||||
@ -74,12 +76,12 @@ fun AllergenSelectionGrid(
|
|||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
|
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm),
|
||||||
) {
|
) {
|
||||||
rows.forEach { row ->
|
rows.forEach { row ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)
|
horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm),
|
||||||
) {
|
) {
|
||||||
row.forEach { allergen ->
|
row.forEach { allergen ->
|
||||||
val currentLevel = selectedAllergens[allergen] ?: AllergenLevel.NONE
|
val currentLevel = selectedAllergens[allergen] ?: AllergenLevel.NONE
|
||||||
@ -88,13 +90,14 @@ fun AllergenSelectionGrid(
|
|||||||
allergen = allergen,
|
allergen = allergen,
|
||||||
level = currentLevel,
|
level = currentLevel,
|
||||||
onClick = {
|
onClick = {
|
||||||
val nextLevel = when (currentLevel) {
|
val nextLevel =
|
||||||
|
when (currentLevel) {
|
||||||
AllergenLevel.NONE -> AllergenLevel.TRACE
|
AllergenLevel.NONE -> AllergenLevel.TRACE
|
||||||
AllergenLevel.TRACE -> AllergenLevel.SEVERE
|
AllergenLevel.TRACE -> AllergenLevel.SEVERE
|
||||||
AllergenLevel.SEVERE -> AllergenLevel.NONE
|
AllergenLevel.SEVERE -> AllergenLevel.NONE
|
||||||
}
|
}
|
||||||
onLevelChanged(allergen, nextLevel)
|
onLevelChanged(allergen, nextLevel)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
repeat(columns - row.size) {
|
repeat(columns - row.size) {
|
||||||
@ -113,33 +116,37 @@ fun AllergenSelectionChip(
|
|||||||
allergen: AllergenType,
|
allergen: AllergenType,
|
||||||
level: AllergenLevel,
|
level: AllergenLevel,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
shape = RoundedCornerShape(dimens.radiusMd),
|
shape = RoundedCornerShape(dimens.radiusMd),
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
containerColor = level.backgroundColor()
|
CardDefaults.cardColors(
|
||||||
|
containerColor = level.backgroundColor(),
|
||||||
),
|
),
|
||||||
border = androidx.compose.foundation.BorderStroke(
|
border =
|
||||||
|
androidx.compose.foundation.BorderStroke(
|
||||||
width = 2.dp,
|
width = 2.dp,
|
||||||
color = level.borderColor()
|
color = level.borderColor(),
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = dimens.spacingSm, horizontal = dimens.spacingXs),
|
.padding(vertical = dimens.spacingSm, horizontal = dimens.spacingXs),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
// Emoji allergène
|
// Emoji allergène
|
||||||
Text(
|
Text(
|
||||||
text = allergen.icon,
|
text = allergen.icon,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Nom court
|
// Nom court
|
||||||
@ -148,7 +155,7 @@ fun AllergenSelectionChip(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
maxLines = 1
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Indicateur d'état
|
// Indicateur d'état
|
||||||
@ -156,7 +163,7 @@ fun AllergenSelectionChip(
|
|||||||
Text(
|
Text(
|
||||||
text = level.emoji,
|
text = level.emoji,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -171,7 +178,7 @@ fun AllergenSelectionChip(
|
|||||||
fun AllergenDisplayGrid(
|
fun AllergenDisplayGrid(
|
||||||
severeAllergens: Set<AllergenType>,
|
severeAllergens: Set<AllergenType>,
|
||||||
moderateIntolerances: Set<AllergenType>,
|
moderateIntolerances: Set<AllergenType>,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
|
|
||||||
@ -180,29 +187,29 @@ fun AllergenDisplayGrid(
|
|||||||
text = "Aucune allergie détectée ✅",
|
text = "Aucune allergie détectée ✅",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = modifier.padding(dimens.spacingSm)
|
modifier = modifier.padding(dimens.spacingSm),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
|
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm),
|
||||||
) {
|
) {
|
||||||
// Allergènes sévères
|
// Allergènes sévères
|
||||||
if (severeAllergens.isNotEmpty()) {
|
if (severeAllergens.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "❌ Allergies sévères :",
|
text = "❌ Allergies sévères :",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = Color(0xFFE74C3C)
|
color = Color(0xFFE74C3C),
|
||||||
)
|
)
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
severeAllergens.forEach { allergen ->
|
severeAllergens.forEach { allergen ->
|
||||||
AllergenBadge(
|
AllergenBadge(
|
||||||
allergen = allergen,
|
allergen = allergen,
|
||||||
level = AllergenLevel.SEVERE
|
level = AllergenLevel.SEVERE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -213,15 +220,15 @@ fun AllergenDisplayGrid(
|
|||||||
Text(
|
Text(
|
||||||
text = "⚠️ Intolérances :",
|
text = "⚠️ Intolérances :",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = Color(0xFFF39C12)
|
color = Color(0xFFF39C12),
|
||||||
)
|
)
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
moderateIntolerances.forEach { allergen ->
|
moderateIntolerances.forEach { allergen ->
|
||||||
AllergenBadge(
|
AllergenBadge(
|
||||||
allergen = allergen,
|
allergen = allergen,
|
||||||
level = AllergenLevel.TRACE
|
level = AllergenLevel.TRACE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -236,30 +243,31 @@ fun AllergenDisplayGrid(
|
|||||||
fun AllergenBadge(
|
fun AllergenBadge(
|
||||||
allergen: AllergenType,
|
allergen: AllergenType,
|
||||||
level: AllergenLevel,
|
level: AllergenLevel,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.background(
|
.background(
|
||||||
color = level.backgroundColor(),
|
color = level.backgroundColor(),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp),
|
||||||
)
|
)
|
||||||
.border(
|
.border(
|
||||||
width = 1.dp,
|
width = 1.dp,
|
||||||
color = level.borderColor(),
|
color = level.borderColor(),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp),
|
||||||
)
|
)
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
Text(text = allergen.icon, style = MaterialTheme.typography.bodySmall)
|
Text(text = allergen.icon, style = MaterialTheme.typography.bodySmall)
|
||||||
Text(
|
Text(
|
||||||
text = allergen.displayNameFr,
|
text = allergen.displayNameFr,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,7 +32,8 @@ fun SafeBiteTopAppBar(
|
|||||||
actions: @Composable RowScope.() -> Unit = {},
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
) {
|
) {
|
||||||
val colors = TopAppBarDefaults.topAppBarColors(
|
val colors =
|
||||||
|
TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
@ -42,11 +43,12 @@ fun SafeBiteTopAppBar(
|
|||||||
val titleComposable: @Composable () -> Unit = {
|
val titleComposable: @Composable () -> Unit = {
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style = when (variant) {
|
style =
|
||||||
|
when (variant) {
|
||||||
AppBarVariant.Large -> MaterialTheme.typography.headlineMedium
|
AppBarVariant.Large -> MaterialTheme.typography.headlineMedium
|
||||||
AppBarVariant.CenterAligned -> MaterialTheme.typography.titleLarge
|
AppBarVariant.CenterAligned -> MaterialTheme.typography.titleLarge
|
||||||
AppBarVariant.Small -> MaterialTheme.typography.titleLarge
|
AppBarVariant.Small -> MaterialTheme.typography.titleLarge
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val navIcon: @Composable () -> Unit = {
|
val navIcon: @Composable () -> Unit = {
|
||||||
@ -57,7 +59,8 @@ fun SafeBiteTopAppBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (variant) {
|
when (variant) {
|
||||||
AppBarVariant.Small -> TopAppBar(
|
AppBarVariant.Small ->
|
||||||
|
TopAppBar(
|
||||||
title = titleComposable,
|
title = titleComposable,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
navigationIcon = navIcon,
|
navigationIcon = navIcon,
|
||||||
@ -65,7 +68,8 @@ fun SafeBiteTopAppBar(
|
|||||||
colors = colors,
|
colors = colors,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
AppBarVariant.CenterAligned -> CenterAlignedTopAppBar(
|
AppBarVariant.CenterAligned ->
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
title = titleComposable,
|
title = titleComposable,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
navigationIcon = navIcon,
|
navigationIcon = navIcon,
|
||||||
@ -73,12 +77,14 @@ fun SafeBiteTopAppBar(
|
|||||||
colors = colors,
|
colors = colors,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
AppBarVariant.Large -> LargeTopAppBar(
|
AppBarVariant.Large ->
|
||||||
|
LargeTopAppBar(
|
||||||
title = titleComposable,
|
title = titleComposable,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
navigationIcon = navIcon,
|
navigationIcon = navIcon,
|
||||||
actions = actions,
|
actions = actions,
|
||||||
colors = TopAppBarDefaults.largeTopAppBarColors(
|
colors =
|
||||||
|
TopAppBarDefaults.largeTopAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
scrolledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
|||||||
@ -51,7 +51,7 @@ private fun pressedScale(interactionSource: MutableInteractionSource): Float {
|
|||||||
val scale by animateFloatAsState(
|
val scale by animateFloatAsState(
|
||||||
targetValue = if (pressed) 0.96f else 1f,
|
targetValue = if (pressed) 0.96f else 1f,
|
||||||
animationSpec = tween(durationMillis = 120),
|
animationSpec = tween(durationMillis = 120),
|
||||||
label = "buttonPressScale"
|
label = "buttonPressScale",
|
||||||
)
|
)
|
||||||
return scale
|
return scale
|
||||||
}
|
}
|
||||||
@ -76,7 +76,8 @@ fun PrimaryButton(
|
|||||||
Button(
|
Button(
|
||||||
onClick = { if (!loading) onClick() },
|
onClick = { if (!loading) onClick() },
|
||||||
enabled = enabled && !loading,
|
enabled = enabled && !loading,
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.scale(scale)
|
.scale(scale)
|
||||||
.heightIn(min = if (large) ButtonTokens.MinHeightLarge else ButtonTokens.MinHeight)
|
.heightIn(min = if (large) ButtonTokens.MinHeightLarge else ButtonTokens.MinHeight)
|
||||||
.defaultMinSize(minHeight = if (large) ButtonTokens.MinHeightLarge else ButtonTokens.MinHeight),
|
.defaultMinSize(minHeight = if (large) ButtonTokens.MinHeightLarge else ButtonTokens.MinHeight),
|
||||||
@ -104,7 +105,8 @@ fun SecondaryButton(
|
|||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { if (!loading) onClick() },
|
onClick = { if (!loading) onClick() },
|
||||||
enabled = enabled && !loading,
|
enabled = enabled && !loading,
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.scale(scale)
|
.scale(scale)
|
||||||
.heightIn(min = ButtonTokens.MinHeight)
|
.heightIn(min = ButtonTokens.MinHeight)
|
||||||
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
|
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
|
||||||
@ -132,7 +134,8 @@ fun OutlinedActionButton(
|
|||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { if (!loading) onClick() },
|
onClick = { if (!loading) onClick() },
|
||||||
enabled = enabled && !loading,
|
enabled = enabled && !loading,
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.scale(scale)
|
.scale(scale)
|
||||||
.heightIn(min = ButtonTokens.MinHeight)
|
.heightIn(min = ButtonTokens.MinHeight)
|
||||||
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
|
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
|
||||||
@ -158,7 +161,8 @@ fun TertiaryButton(
|
|||||||
TextButton(
|
TextButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.scale(scale)
|
.scale(scale)
|
||||||
.heightIn(min = ButtonTokens.MinHeight)
|
.heightIn(min = ButtonTokens.MinHeight)
|
||||||
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
|
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
|
||||||
@ -182,14 +186,16 @@ fun DestructiveButton(
|
|||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
val interaction = remember { MutableInteractionSource() }
|
val interaction = remember { MutableInteractionSource() }
|
||||||
val scale = pressedScale(interaction)
|
val scale = pressedScale(interaction)
|
||||||
val colors: ButtonColors = ButtonDefaults.filledTonalButtonColors(
|
val colors: ButtonColors =
|
||||||
|
ButtonDefaults.filledTonalButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
)
|
)
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { if (!loading) onClick() },
|
onClick = { if (!loading) onClick() },
|
||||||
enabled = enabled && !loading,
|
enabled = enabled && !loading,
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.scale(scale)
|
.scale(scale)
|
||||||
.heightIn(min = ButtonTokens.MinHeight)
|
.heightIn(min = ButtonTokens.MinHeight)
|
||||||
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
|
.defaultMinSize(minHeight = ButtonTokens.MinHeight),
|
||||||
@ -203,20 +209,24 @@ fun DestructiveButton(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ButtonContent(text: String, icon: ImageVector?, loading: Boolean) {
|
private fun ButtonContent(
|
||||||
|
text: String,
|
||||||
|
icon: ImageVector?,
|
||||||
|
loading: Boolean,
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(ButtonTokens.ProgressSize),
|
modifier = Modifier.size(ButtonTokens.ProgressSize),
|
||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
color = LocalContentColor.current
|
color = LocalContentColor.current,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(ButtonTokens.IconSpacer))
|
Spacer(Modifier.width(ButtonTokens.IconSpacer))
|
||||||
} else if (icon != null) {
|
} else if (icon != null) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(ButtonTokens.IconSize)
|
modifier = Modifier.size(ButtonTokens.IconSize),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(ButtonTokens.IconSpacer))
|
Spacer(Modifier.width(ButtonTokens.IconSpacer))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,19 +46,21 @@ fun StandardCard(
|
|||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
shape = shape,
|
shape = shape,
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
),
|
||||||
) { InnerPadding(contentPadding, content) }
|
) { InnerPadding(contentPadding, content) }
|
||||||
} else {
|
} else {
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
shape = shape,
|
shape = shape,
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
),
|
||||||
) { InnerPadding(contentPadding, content) }
|
) { InnerPadding(contentPadding, content) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -79,6 +81,9 @@ fun StandardCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InnerPadding(pad: PaddingValues, content: @Composable () -> Unit) {
|
private fun InnerPadding(
|
||||||
|
pad: PaddingValues,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
androidx.compose.foundation.layout.Box(Modifier.padding(pad)) { content() }
|
androidx.compose.foundation.layout.Box(Modifier.padding(pad)) { content() }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,19 +51,19 @@ fun DonutChart(
|
|||||||
progressColor: Color = LocalStatusColors.current.safe,
|
progressColor: Color = LocalStatusColors.current.safe,
|
||||||
centerText: String = "${(progress * 100).toInt()}%",
|
centerText: String = "${(progress * 100).toInt()}%",
|
||||||
centerSubText: String? = null,
|
centerSubText: String? = null,
|
||||||
animated: Boolean = true
|
animated: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = progress.coerceIn(0f, 1f),
|
targetValue = progress.coerceIn(0f, 1f),
|
||||||
animationSpec = tween(durationMillis = 800),
|
animationSpec = tween(durationMillis = 800),
|
||||||
label = "donutProgress"
|
label = "donutProgress",
|
||||||
)
|
)
|
||||||
val displayProgress = if (animated) animatedProgress else progress
|
val displayProgress = if (animated) animatedProgress else progress
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier.size(size),
|
modifier = modifier.size(size),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
val sweepAngle = 360f * displayProgress
|
val sweepAngle = 360f * displayProgress
|
||||||
@ -75,7 +75,7 @@ fun DonutChart(
|
|||||||
startAngle = -90f,
|
startAngle = -90f,
|
||||||
sweepAngle = 360f,
|
sweepAngle = 360f,
|
||||||
useCenter = false,
|
useCenter = false,
|
||||||
style = Stroke(width = stroke, cap = StrokeCap.Round)
|
style = Stroke(width = stroke, cap = StrokeCap.Round),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Arc de progression
|
// Arc de progression
|
||||||
@ -85,27 +85,27 @@ fun DonutChart(
|
|||||||
startAngle = -90f,
|
startAngle = -90f,
|
||||||
sweepAngle = sweepAngle,
|
sweepAngle = sweepAngle,
|
||||||
useCenter = false,
|
useCenter = false,
|
||||||
style = Stroke(width = stroke, cap = StrokeCap.Round)
|
style = Stroke(width = stroke, cap = StrokeCap.Round),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Texte central
|
// Texte central
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = centerText,
|
text = centerText,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
if (centerSubText != null) {
|
if (centerSubText != null) {
|
||||||
Text(
|
Text(
|
||||||
text = centerSubText,
|
text = centerSubText,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -117,9 +117,10 @@ fun DonutChart(
|
|||||||
*/
|
*/
|
||||||
data class SparklineData(
|
data class SparklineData(
|
||||||
val values: List<Float>,
|
val values: List<Float>,
|
||||||
val labels: List<String> = emptyList()
|
val labels: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
fun max(): Float = values.maxOrNull() ?: 0f
|
fun max(): Float = values.maxOrNull() ?: 0f
|
||||||
|
|
||||||
fun min(): Float = values.minOrNull() ?: 0f
|
fun min(): Float = values.minOrNull() ?: 0f
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,20 +139,21 @@ fun Sparkline(
|
|||||||
lineColor: Color = LocalStatusColors.current.safe,
|
lineColor: Color = LocalStatusColors.current.safe,
|
||||||
fillColor: Color = LocalStatusColors.current.safe.copy(alpha = 0.15f),
|
fillColor: Color = LocalStatusColors.current.safe.copy(alpha = 0.15f),
|
||||||
height: Dp = 80.dp,
|
height: Dp = 80.dp,
|
||||||
showDots: Boolean = true
|
showDots: Boolean = true,
|
||||||
) {
|
) {
|
||||||
if (data.values.isEmpty()) return
|
if (data.values.isEmpty()) return
|
||||||
|
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = 1f,
|
targetValue = 1f,
|
||||||
animationSpec = tween(durationMillis = 600),
|
animationSpec = tween(durationMillis = 600),
|
||||||
label = "sparklineProgress"
|
label = "sparklineProgress",
|
||||||
)
|
)
|
||||||
|
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(height)
|
.height(height),
|
||||||
) {
|
) {
|
||||||
val width = size.width
|
val width = size.width
|
||||||
val height = size.height
|
val height = size.height
|
||||||
@ -163,7 +165,8 @@ fun Sparkline(
|
|||||||
val stepX = (width - 2 * padding) / (data.values.size - 1).coerceAtLeast(1)
|
val stepX = (width - 2 * padding) / (data.values.size - 1).coerceAtLeast(1)
|
||||||
|
|
||||||
// Calcul des points
|
// Calcul des points
|
||||||
val points = data.values.mapIndexed { index, value ->
|
val points =
|
||||||
|
data.values.mapIndexed { index, value ->
|
||||||
val x = padding + index * stepX
|
val x = padding + index * stepX
|
||||||
val normalizedValue = if (range > 0) (value - minVal) / range else 0.5f
|
val normalizedValue = if (range > 0) (value - minVal) / range else 0.5f
|
||||||
val y = height - padding - normalizedValue * (height - 2 * padding)
|
val y = height - padding - normalizedValue * (height - 2 * padding)
|
||||||
@ -172,7 +175,8 @@ fun Sparkline(
|
|||||||
|
|
||||||
// Zone de remplissage
|
// Zone de remplissage
|
||||||
if (points.size > 1) {
|
if (points.size > 1) {
|
||||||
val fillPath = androidx.compose.ui.graphics.Path().apply {
|
val fillPath =
|
||||||
|
androidx.compose.ui.graphics.Path().apply {
|
||||||
moveTo(points.first().x, height)
|
moveTo(points.first().x, height)
|
||||||
lineTo(points.first().x, points.first().y)
|
lineTo(points.first().x, points.first().y)
|
||||||
points.forEach { point ->
|
points.forEach { point ->
|
||||||
@ -186,7 +190,8 @@ fun Sparkline(
|
|||||||
|
|
||||||
// Ligne
|
// Ligne
|
||||||
if (points.size > 1) {
|
if (points.size > 1) {
|
||||||
val linePath = androidx.compose.ui.graphics.Path().apply {
|
val linePath =
|
||||||
|
androidx.compose.ui.graphics.Path().apply {
|
||||||
moveTo(points.first().x, points.first().y)
|
moveTo(points.first().x, points.first().y)
|
||||||
points.forEach { point ->
|
points.forEach { point ->
|
||||||
lineTo(point.x, point.y)
|
lineTo(point.x, point.y)
|
||||||
@ -195,7 +200,7 @@ fun Sparkline(
|
|||||||
drawPath(
|
drawPath(
|
||||||
path = linePath,
|
path = linePath,
|
||||||
color = lineColor,
|
color = lineColor,
|
||||||
style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round)
|
style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,12 +210,12 @@ fun Sparkline(
|
|||||||
drawCircle(
|
drawCircle(
|
||||||
color = lineColor,
|
color = lineColor,
|
||||||
radius = 4.dp.toPx(),
|
radius = 4.dp.toPx(),
|
||||||
center = point
|
center = point,
|
||||||
)
|
)
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
radius = 2.dp.toPx(),
|
radius = 2.dp.toPx(),
|
||||||
center = point
|
center = point,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -221,13 +226,13 @@ fun Sparkline(
|
|||||||
* Données pour un graphique à barres.
|
* Données pour un graphique à barres.
|
||||||
*/
|
*/
|
||||||
data class BarChartData(
|
data class BarChartData(
|
||||||
val items: List<BarChartItem>
|
val items: List<BarChartItem>,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BarChartItem(
|
data class BarChartItem(
|
||||||
val label: String,
|
val label: String,
|
||||||
val value: Int,
|
val value: Int,
|
||||||
val color: Color = Color.Unspecified
|
val color: Color = Color.Unspecified,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -244,7 +249,7 @@ fun HorizontalBarChart(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
maxValue: Int? = null,
|
maxValue: Int? = null,
|
||||||
height: Dp = 32.dp,
|
height: Dp = 32.dp,
|
||||||
spacing: Dp = LocalDimens.current.spacingSm
|
spacing: Dp = LocalDimens.current.spacingSm,
|
||||||
) {
|
) {
|
||||||
if (data.items.isEmpty()) return
|
if (data.items.isEmpty()) return
|
||||||
|
|
||||||
@ -252,13 +257,13 @@ fun HorizontalBarChart(
|
|||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(spacing)
|
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||||
) {
|
) {
|
||||||
data.items.forEach { item ->
|
data.items.forEach { item ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd)
|
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd),
|
||||||
) {
|
) {
|
||||||
// Label
|
// Label
|
||||||
Text(
|
Text(
|
||||||
@ -266,7 +271,7 @@ fun HorizontalBarChart(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.weight(0.4f),
|
modifier = Modifier.weight(0.4f),
|
||||||
maxLines = 1
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Barre
|
// Barre
|
||||||
@ -274,13 +279,14 @@ fun HorizontalBarChart(
|
|||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = progress,
|
targetValue = progress,
|
||||||
animationSpec = tween(durationMillis = 500),
|
animationSpec = tween(durationMillis = 500),
|
||||||
label = "barProgress"
|
label = "barProgress",
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.weight(0.4f)
|
.weight(0.4f)
|
||||||
.height(height)
|
.height(height),
|
||||||
) {
|
) {
|
||||||
// Fond
|
// Fond
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
@ -288,7 +294,7 @@ fun HorizontalBarChart(
|
|||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = androidx.compose.ui.graphics.Color(0xFFE3E2EC),
|
color = androidx.compose.ui.graphics.Color(0xFFE3E2EC),
|
||||||
size = Size(size.width, size.height),
|
size = Size(size.width, size.height),
|
||||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius)
|
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Progression
|
// Progression
|
||||||
@ -296,7 +302,7 @@ fun HorizontalBarChart(
|
|||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = item.color,
|
color = item.color,
|
||||||
size = Size(size.width * animatedProgress, size.height),
|
size = Size(size.width * animatedProgress, size.height),
|
||||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius)
|
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -306,8 +312,9 @@ fun HorizontalBarChart(
|
|||||||
text = "${item.value}",
|
text = "${item.value}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.align(Alignment.CenterEnd)
|
modifier =
|
||||||
.padding(end = 8.dp)
|
Modifier.align(Alignment.CenterEnd)
|
||||||
|
.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -324,30 +331,31 @@ fun StatCard(
|
|||||||
value: String,
|
value: String,
|
||||||
label: String,
|
label: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
valueColor: Color = MaterialTheme.colorScheme.onSurface
|
valueColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.padding(dimens.spacingSm),
|
.padding(dimens.spacingSm),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = icon,
|
text = icon,
|
||||||
style = MaterialTheme.typography.headlineSmall
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = value,
|
text = value,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = valueColor
|
color = valueColor,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -359,24 +367,24 @@ enum class TimeFilter(val label: String) {
|
|||||||
WEEK("Semaine"),
|
WEEK("Semaine"),
|
||||||
MONTH("Mois"),
|
MONTH("Mois"),
|
||||||
YEAR("Année"),
|
YEAR("Année"),
|
||||||
ALL("Tout")
|
ALL("Tout"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimeFilterRow(
|
fun TimeFilterRow(
|
||||||
selected: TimeFilter,
|
selected: TimeFilter,
|
||||||
onFilterChanged: (TimeFilter) -> Unit,
|
onFilterChanged: (TimeFilter) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm)
|
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm),
|
||||||
) {
|
) {
|
||||||
TimeFilter.values().forEach { filter ->
|
TimeFilter.values().forEach { filter ->
|
||||||
androidx.compose.material3.FilterChip(
|
androidx.compose.material3.FilterChip(
|
||||||
selected = selected == filter,
|
selected = selected == filter,
|
||||||
onClick = { onFilterChanged(filter) },
|
onClick = { onFilterChanged(filter) },
|
||||||
label = { Text(filter.label) }
|
label = { Text(filter.label) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import androidx.compose.foundation.BorderStroke
|
|||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -24,10 +23,8 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.role
|
import androidx.compose.ui.semantics.role
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
@ -69,14 +66,15 @@ fun AllergenChip(
|
|||||||
allergen: AllergenType,
|
allergen: AllergenType,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
val bg = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface
|
val bg = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface
|
||||||
val fg = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
val fg = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||||
val stateDesc = if (selected) "Sélectionné" else "Non sélectionné"
|
val stateDesc = if (selected) "Sélectionné" else "Non sélectionné"
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = "${allergen.displayNameFr} - $stateDesc"
|
contentDescription = "${allergen.displayNameFr} - $stateDesc"
|
||||||
role = Role.Checkbox
|
role = Role.Checkbox
|
||||||
@ -85,22 +83,22 @@ fun AllergenChip(
|
|||||||
shape = RoundedCornerShape(dimens.radiusPill),
|
shape = RoundedCornerShape(dimens.radiusPill),
|
||||||
color = bg,
|
color = bg,
|
||||||
border = if (selected) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
border = if (selected) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||||
onClick = onToggle
|
onClick = onToggle,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = dimens.spacingMd, vertical = dimens.spacingSm),
|
modifier = Modifier.padding(horizontal = dimens.spacingMd, vertical = dimens.spacingSm),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = allergen.icon,
|
text = allergen.icon,
|
||||||
color = fg,
|
color = fg,
|
||||||
modifier = Modifier.semantics { contentDescription = "" }
|
modifier = Modifier.semantics { contentDescription = "" },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(dimens.spacingXs + 2.dp))
|
Spacer(Modifier.width(dimens.spacingXs + 2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = allergen.displayNameFr,
|
text = allergen.displayNameFr,
|
||||||
color = fg,
|
color = fg,
|
||||||
style = MaterialTheme.typography.labelLarge
|
style = MaterialTheme.typography.labelLarge,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -120,28 +118,32 @@ fun SafetyStatusBanner(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
profileName: String? = null,
|
profileName: String? = null,
|
||||||
allergenName: String? = null,
|
allergenName: String? = null,
|
||||||
severity: String? = null
|
severity: String? = null,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
val colors = LocalStatusColors.current
|
val colors = LocalStatusColors.current
|
||||||
|
|
||||||
val a11yDescription = when (status) {
|
val a11yDescription =
|
||||||
|
when (status) {
|
||||||
SafetyStatus.SAFE -> stringResource(R.string.a11y_verdict_safe)
|
SafetyStatus.SAFE -> stringResource(R.string.a11y_verdict_safe)
|
||||||
SafetyStatus.WARNING -> stringResource(R.string.a11y_verdict_warning)
|
SafetyStatus.WARNING -> stringResource(R.string.a11y_verdict_warning)
|
||||||
SafetyStatus.DANGER -> if (profileName != null)
|
SafetyStatus.DANGER ->
|
||||||
|
if (profileName != null) {
|
||||||
stringResource(R.string.a11y_verdict_danger, profileName)
|
stringResource(R.string.a11y_verdict_danger, profileName)
|
||||||
else
|
} else {
|
||||||
stringResource(R.string.a11y_danger_status, "")
|
stringResource(R.string.a11y_danger_status, "")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val (titleRes, icon, shapeIcon, containerColor, onContainerColor) = when (status) {
|
val (titleRes, icon, shapeIcon, containerColor, onContainerColor) =
|
||||||
|
when (status) {
|
||||||
SafetyStatus.SAFE -> {
|
SafetyStatus.SAFE -> {
|
||||||
VerdictBannerData(
|
VerdictBannerData(
|
||||||
titleRes = R.string.result_safe_headline,
|
titleRes = R.string.result_safe_headline,
|
||||||
icon = "✅",
|
icon = "✅",
|
||||||
shapeIcon = "⭕",
|
shapeIcon = "⭕",
|
||||||
containerColor = colors.safe,
|
containerColor = colors.safe,
|
||||||
onContainerColor = colors.onSafe
|
onContainerColor = colors.onSafe,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SafetyStatus.WARNING -> {
|
SafetyStatus.WARNING -> {
|
||||||
@ -150,7 +152,7 @@ fun SafetyStatusBanner(
|
|||||||
icon = "⚠️",
|
icon = "⚠️",
|
||||||
shapeIcon = "🔺",
|
shapeIcon = "🔺",
|
||||||
containerColor = colors.warning,
|
containerColor = colors.warning,
|
||||||
onContainerColor = colors.onWarning
|
onContainerColor = colors.onWarning,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SafetyStatus.DANGER -> {
|
SafetyStatus.DANGER -> {
|
||||||
@ -159,53 +161,55 @@ fun SafetyStatusBanner(
|
|||||||
icon = "❌",
|
icon = "❌",
|
||||||
shapeIcon = "🔷",
|
shapeIcon = "🔷",
|
||||||
containerColor = colors.danger,
|
containerColor = colors.danger,
|
||||||
onContainerColor = colors.onDanger
|
onContainerColor = colors.onDanger,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = a11yDescription
|
contentDescription = a11yDescription
|
||||||
},
|
},
|
||||||
color = containerColor,
|
color = containerColor,
|
||||||
contentColor = onContainerColor
|
contentColor = onContainerColor,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(dimens.spacingLg)
|
modifier = Modifier.padding(dimens.spacingLg),
|
||||||
) {
|
) {
|
||||||
// Ligne supérieure : forme daltonienne + icône + titre
|
// Ligne supérieure : forme daltonienne + icône + titre
|
||||||
// Système daltonien : forme géométrique + couleur + icône
|
// Système daltonien : forme géométrique + couleur + icône
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
// Forme daltonienne (jamais couleur seule)
|
// Forme daltonienne (jamais couleur seule)
|
||||||
DaltonianShape(
|
DaltonianShape(
|
||||||
status = status,
|
status = status,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(dimens.spacingXs))
|
Spacer(Modifier.width(dimens.spacingXs))
|
||||||
Text(
|
Text(
|
||||||
text = icon,
|
text = icon,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
modifier = Modifier.semantics { contentDescription = "" }
|
modifier = Modifier.semantics { contentDescription = "" },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(dimens.spacingSm))
|
Spacer(Modifier.width(dimens.spacingSm))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(titleRes),
|
text = stringResource(titleRes),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sous-titre contextuel (si allergène et profil)
|
// Sous-titre contextuel (si allergène et profil)
|
||||||
if (allergenName != null && profileName != null) {
|
if (allergenName != null && profileName != null) {
|
||||||
Spacer(Modifier.height(dimens.spacingXs))
|
Spacer(Modifier.height(dimens.spacingXs))
|
||||||
val subtitle = when (status) {
|
val subtitle =
|
||||||
|
when (status) {
|
||||||
SafetyStatus.WARNING -> "⚠️ Attention pour $profileName : $allergenName"
|
SafetyStatus.WARNING -> "⚠️ Attention pour $profileName : $allergenName"
|
||||||
SafetyStatus.DANGER -> "❌ Interdit pour $profileName : $allergenName${if (severity == "anaphylaxis") " (anaphylaxie)" else ""}"
|
SafetyStatus.DANGER -> "❌ Interdit pour $profileName : $allergenName${if (severity == "anaphylaxis") " (anaphylaxie)" else ""}"
|
||||||
else -> ""
|
else -> ""
|
||||||
@ -214,7 +218,7 @@ fun SafetyStatusBanner(
|
|||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -225,7 +229,7 @@ fun SafetyStatusBanner(
|
|||||||
Text(
|
Text(
|
||||||
text = "Ne pas consommer",
|
text = "Ne pas consommer",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -242,10 +246,11 @@ fun SafetyStatusBanner(
|
|||||||
@Composable
|
@Composable
|
||||||
fun DaltonianShape(
|
fun DaltonianShape(
|
||||||
status: SafetyStatus,
|
status: SafetyStatus,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val colors = LocalStatusColors.current
|
val colors = LocalStatusColors.current
|
||||||
val color = when (status) {
|
val color =
|
||||||
|
when (status) {
|
||||||
SafetyStatus.SAFE -> colors.safe
|
SafetyStatus.SAFE -> colors.safe
|
||||||
SafetyStatus.WARNING -> colors.warning
|
SafetyStatus.WARNING -> colors.warning
|
||||||
SafetyStatus.DANGER -> colors.danger
|
SafetyStatus.DANGER -> colors.danger
|
||||||
@ -255,15 +260,17 @@ fun DaltonianShape(
|
|||||||
SafetyStatus.SAFE -> {
|
SafetyStatus.SAFE -> {
|
||||||
// Cercle pour SAFE
|
// Cercle pour SAFE
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.background(color, CircleShape)
|
.background(color, CircleShape)
|
||||||
.semantics { contentDescription = "" }
|
.semantics { contentDescription = "" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SafetyStatus.WARNING -> {
|
SafetyStatus.WARNING -> {
|
||||||
// Triangle pour WARNING (dessiné avec Canvas)
|
// Triangle pour WARNING (dessiné avec Canvas)
|
||||||
Canvas(modifier = modifier) {
|
Canvas(modifier = modifier) {
|
||||||
val path = androidx.compose.ui.graphics.Path().apply {
|
val path =
|
||||||
|
androidx.compose.ui.graphics.Path().apply {
|
||||||
moveTo(size.width / 2, 0f)
|
moveTo(size.width / 2, 0f)
|
||||||
lineTo(size.width, size.height)
|
lineTo(size.width, size.height)
|
||||||
lineTo(0f, size.height)
|
lineTo(0f, size.height)
|
||||||
@ -275,7 +282,8 @@ fun DaltonianShape(
|
|||||||
SafetyStatus.DANGER -> {
|
SafetyStatus.DANGER -> {
|
||||||
// Losange pour DANGER
|
// Losange pour DANGER
|
||||||
Canvas(modifier = modifier) {
|
Canvas(modifier = modifier) {
|
||||||
val path = androidx.compose.ui.graphics.Path().apply {
|
val path =
|
||||||
|
androidx.compose.ui.graphics.Path().apply {
|
||||||
moveTo(size.width / 2, 0f)
|
moveTo(size.width / 2, 0f)
|
||||||
lineTo(size.width, size.height / 2)
|
lineTo(size.width, size.height / 2)
|
||||||
lineTo(size.width / 2, size.height)
|
lineTo(size.width / 2, size.height)
|
||||||
@ -294,7 +302,7 @@ private data class VerdictBannerData(
|
|||||||
val icon: String,
|
val icon: String,
|
||||||
val shapeIcon: String,
|
val shapeIcon: String,
|
||||||
val containerColor: androidx.compose.ui.graphics.Color,
|
val containerColor: androidx.compose.ui.graphics.Color,
|
||||||
val onContainerColor: androidx.compose.ui.graphics.Color
|
val onContainerColor: androidx.compose.ui.graphics.Color,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -303,7 +311,7 @@ fun ProductCard(
|
|||||||
subtitle: String?,
|
subtitle: String?,
|
||||||
imageUrl: String?,
|
imageUrl: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
imageContentDescription: String? = null
|
imageContentDescription: String? = null,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
val imgDesc = imageContentDescription ?: "Image du produit"
|
val imgDesc = imageContentDescription ?: "Image du produit"
|
||||||
@ -317,26 +325,28 @@ fun ProductCard(
|
|||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = imageContentDescription,
|
contentDescription = imageContentDescription,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(64.dp)
|
.size(64.dp)
|
||||||
.background(
|
.background(
|
||||||
MaterialTheme.colorScheme.surfaceVariant,
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
RoundedCornerShape(dimens.radiusMd)
|
RoundedCornerShape(dimens.radiusMd),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(64.dp)
|
.size(64.dp)
|
||||||
.background(
|
.background(
|
||||||
MaterialTheme.colorScheme.surfaceVariant,
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
RoundedCornerShape(dimens.radiusMd)
|
RoundedCornerShape(dimens.radiusMd),
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "🛒",
|
text = "🛒",
|
||||||
modifier = Modifier.semantics { contentDescription = imgDesc }
|
modifier = Modifier.semantics { contentDescription = imgDesc },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -346,13 +356,13 @@ fun ProductCard(
|
|||||||
title,
|
title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 2
|
maxLines = 2,
|
||||||
)
|
)
|
||||||
if (!subtitle.isNullOrBlank()) {
|
if (!subtitle.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -361,18 +371,23 @@ fun ProductCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AvatarBubble(avatar: String, modifier: Modifier = Modifier, size: Dp = 40.dp) {
|
fun AvatarBubble(
|
||||||
|
avatar: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: Dp = 40.dp,
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.size(size)
|
.size(size)
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape)
|
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape)
|
||||||
.border(1.dp, MaterialTheme.colorScheme.primary, CircleShape),
|
.border(1.dp, MaterialTheme.colorScheme.primary, CircleShape),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
avatar,
|
avatar,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,25 +52,28 @@ fun ShimmerBox(
|
|||||||
val progress by transition.animateFloat(
|
val progress by transition.animateFloat(
|
||||||
initialValue = 0f,
|
initialValue = 0f,
|
||||||
targetValue = 1f,
|
targetValue = 1f,
|
||||||
animationSpec = infiniteRepeatable(
|
animationSpec =
|
||||||
|
infiniteRepeatable(
|
||||||
animation = tween(durationMillis = 1200, easing = FastOutSlowInEasing),
|
animation = tween(durationMillis = 1200, easing = FastOutSlowInEasing),
|
||||||
repeatMode = RepeatMode.Restart,
|
repeatMode = RepeatMode.Restart,
|
||||||
),
|
),
|
||||||
label = "shimmerProgress"
|
label = "shimmerProgress",
|
||||||
)
|
)
|
||||||
val base = MaterialTheme.colorScheme.surfaceVariant
|
val base = MaterialTheme.colorScheme.surfaceVariant
|
||||||
val highlight = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
|
val highlight = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
|
||||||
val colors = listOf(base, highlight, base)
|
val colors = listOf(base, highlight, base)
|
||||||
val offset = 1000f * progress
|
val offset = 1000f * progress
|
||||||
val brush = Brush.linearGradient(
|
val brush =
|
||||||
|
Brush.linearGradient(
|
||||||
colors = colors,
|
colors = colors,
|
||||||
start = Offset(offset - 500f, 0f),
|
start = Offset(offset - 500f, 0f),
|
||||||
end = Offset(offset, 0f),
|
end = Offset(offset, 0f),
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.clip(RoundedCornerShape(cornerRadius))
|
.clip(RoundedCornerShape(cornerRadius))
|
||||||
.background(brush)
|
.background(brush),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,7 +83,7 @@ fun ShimmerListItem(modifier: Modifier = Modifier) {
|
|||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
ShimmerBox(modifier = Modifier.size(64.dp), cornerRadius = dimens.radiusMd)
|
ShimmerBox(modifier = Modifier.size(64.dp), cornerRadius = dimens.radiusMd)
|
||||||
Spacer(Modifier.width(dimens.spacingMd))
|
Spacer(Modifier.width(dimens.spacingMd))
|
||||||
@ -109,54 +112,61 @@ fun ShimmerListItem(modifier: Modifier = Modifier) {
|
|||||||
fun ProductSkeleton(modifier: Modifier = Modifier) {
|
fun ProductSkeleton(modifier: Modifier = Modifier) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(dimens.spacingMd),
|
.padding(dimens.spacingMd),
|
||||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
// Image produit
|
// Image produit
|
||||||
ShimmerBox(
|
ShimmerBox(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(120.dp)
|
.size(120.dp)
|
||||||
.align(Alignment.CenterHorizontally),
|
.align(Alignment.CenterHorizontally),
|
||||||
cornerRadius = dimens.radiusMd
|
cornerRadius = dimens.radiusMd,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Nom produit
|
// Nom produit
|
||||||
ShimmerBox(
|
ShimmerBox(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth(0.8f)
|
.fillMaxWidth(0.8f)
|
||||||
.height(20.dp)
|
.height(20.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Marque
|
// Marque
|
||||||
ShimmerBox(
|
ShimmerBox(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth(0.5f)
|
.fillMaxWidth(0.5f)
|
||||||
.height(14.dp)
|
.height(14.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Verdict banner (zone colorée)
|
// Verdict banner (zone colorée)
|
||||||
ShimmerBox(
|
ShimmerBox(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(56.dp),
|
.height(56.dp),
|
||||||
cornerRadius = dimens.radiusMd
|
cornerRadius = dimens.radiusMd,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
ShimmerBox(
|
ShimmerBox(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(48.dp),
|
.height(48.dp),
|
||||||
cornerRadius = dimens.radiusPill
|
cornerRadius = dimens.radiusPill,
|
||||||
)
|
)
|
||||||
|
|
||||||
ShimmerBox(
|
ShimmerBox(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(48.dp),
|
.height(48.dp),
|
||||||
cornerRadius = dimens.radiusPill
|
cornerRadius = dimens.radiusPill,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -172,7 +182,8 @@ fun EmptyState(
|
|||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(dimens.spacingXl),
|
.padding(dimens.spacingXl),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@ -182,7 +193,7 @@ fun EmptyState(
|
|||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
if (message != null) {
|
if (message != null) {
|
||||||
Spacer(Modifier.height(dimens.spacingSm))
|
Spacer(Modifier.height(dimens.spacingSm))
|
||||||
@ -212,17 +223,18 @@ fun LoadingIndicator(modifier: Modifier = Modifier) {
|
|||||||
fun OfflineIndicator(modifier: Modifier = Modifier) {
|
fun OfflineIndicator(modifier: Modifier = Modifier) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.clip(RoundedCornerShape(dimens.radiusLg))
|
.clip(RoundedCornerShape(dimens.radiusLg))
|
||||||
.background(MaterialTheme.colorScheme.errorContainer)
|
.background(MaterialTheme.colorScheme.errorContainer)
|
||||||
.padding(horizontal = dimens.spacingMd, vertical = dimens.spacingXs),
|
.padding(horizontal = dimens.spacingMd, vertical = dimens.spacingXs),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.CloudOff,
|
imageVector = Icons.Filled.CloudOff,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(dimens.spacingXs))
|
Spacer(Modifier.width(dimens.spacingXs))
|
||||||
Text(
|
Text(
|
||||||
@ -238,27 +250,28 @@ fun OfflineIndicator(modifier: Modifier = Modifier) {
|
|||||||
fun ErrorView(
|
fun ErrorView(
|
||||||
message: String,
|
message: String,
|
||||||
onRetry: (() -> Unit)? = null,
|
onRetry: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(dimens.spacingXl),
|
.padding(dimens.spacingXl),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Warning,
|
imageVector = Icons.Filled.Warning,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = MaterialTheme.colorScheme.error,
|
||||||
modifier = Modifier.size(48.dp)
|
modifier = Modifier.size(48.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(dimens.spacingMd))
|
Spacer(Modifier.height(dimens.spacingMd))
|
||||||
Text(
|
Text(
|
||||||
message,
|
message,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
if (onRetry != null) {
|
if (onRetry != null) {
|
||||||
Spacer(Modifier.height(dimens.spacingLg))
|
Spacer(Modifier.height(dimens.spacingLg))
|
||||||
|
|||||||
@ -62,7 +62,7 @@ fun ImageCropBottomSheet(
|
|||||||
bitmap: Bitmap,
|
bitmap: Bitmap,
|
||||||
onCropComplete: (String?) -> Unit,
|
onCropComplete: (String?) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@ -105,33 +105,35 @@ fun ImageCropBottomSheet(
|
|||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
dragHandle = null
|
dragHandle = null,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(560.dp)
|
.height(560.dp)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Ajuster le cadrage",
|
text = "Ajuster le cadrage",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(bottom = 12.dp)
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onSizeChanged { containerSize = it }
|
.onSizeChanged { containerSize = it },
|
||||||
) {
|
) {
|
||||||
// Photo fixe en arrière-plan (ContentScale.Fit)
|
// Photo fixe en arrière-plan (ContentScale.Fit)
|
||||||
Image(
|
Image(
|
||||||
bitmap = bitmap.asImageBitmap(),
|
bitmap = bitmap.asImageBitmap(),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Overlay sombre + cadre blanc
|
// Overlay sombre + cadre blanc
|
||||||
@ -145,22 +147,22 @@ fun ImageCropBottomSheet(
|
|||||||
drawRect(
|
drawRect(
|
||||||
color = Color.Black.copy(alpha = 0.5f),
|
color = Color.Black.copy(alpha = 0.5f),
|
||||||
topLeft = Offset(0f, 0f),
|
topLeft = Offset(0f, 0f),
|
||||||
size = Size(size.width, fT)
|
size = Size(size.width, fT),
|
||||||
)
|
)
|
||||||
drawRect(
|
drawRect(
|
||||||
color = Color.Black.copy(alpha = 0.5f),
|
color = Color.Black.copy(alpha = 0.5f),
|
||||||
topLeft = Offset(0f, fB),
|
topLeft = Offset(0f, fB),
|
||||||
size = Size(size.width, size.height - fB)
|
size = Size(size.width, size.height - fB),
|
||||||
)
|
)
|
||||||
drawRect(
|
drawRect(
|
||||||
color = Color.Black.copy(alpha = 0.5f),
|
color = Color.Black.copy(alpha = 0.5f),
|
||||||
topLeft = Offset(0f, fT),
|
topLeft = Offset(0f, fT),
|
||||||
size = Size(fL, fB - fT)
|
size = Size(fL, fB - fT),
|
||||||
)
|
)
|
||||||
drawRect(
|
drawRect(
|
||||||
color = Color.Black.copy(alpha = 0.5f),
|
color = Color.Black.copy(alpha = 0.5f),
|
||||||
topLeft = Offset(fR, fT),
|
topLeft = Offset(fR, fT),
|
||||||
size = Size(size.width - fR, fB - fT)
|
size = Size(size.width - fR, fB - fT),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bordure blanche
|
// Bordure blanche
|
||||||
@ -168,20 +170,21 @@ fun ImageCropBottomSheet(
|
|||||||
color = Color.White,
|
color = Color.White,
|
||||||
topLeft = Offset(fL, fT),
|
topLeft = Offset(fL, fT),
|
||||||
size = Size(fR - fL, fB - fT),
|
size = Size(fR - fL, fB - fT),
|
||||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f)
|
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zone centrale draggable pour déplacer le cadre entier
|
// Zone centrale draggable pour déplacer le cadre entier
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = with(density) { frameLeft.toDp() },
|
x = with(density) { frameLeft.toDp() },
|
||||||
y = with(density) { frameTop.toDp() }
|
y = with(density) { frameTop.toDp() },
|
||||||
)
|
)
|
||||||
.size(
|
.size(
|
||||||
width = with(density) { (frameRight - frameLeft).toDp() },
|
width = with(density) { (frameRight - frameLeft).toDp() },
|
||||||
height = with(density) { (frameBottom - frameTop).toDp() }
|
height = with(density) { (frameBottom - frameTop).toDp() },
|
||||||
)
|
)
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectDragGestures { change, dragAmount ->
|
detectDragGestures { change, dragAmount ->
|
||||||
@ -196,7 +199,7 @@ fun ImageCropBottomSheet(
|
|||||||
frameBottom = frameTop + h
|
frameBottom = frameTop + h
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.zIndex(1f)
|
.zIndex(1f),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Poignée coin haut-gauche
|
// Poignée coin haut-gauche
|
||||||
@ -207,7 +210,7 @@ fun ImageCropBottomSheet(
|
|||||||
onDrag = { dx, dy ->
|
onDrag = { dx, dy ->
|
||||||
frameLeft = min(frameLeft + dx, frameRight - minPx).coerceAtLeast(0f)
|
frameLeft = min(frameLeft + dx, frameRight - minPx).coerceAtLeast(0f)
|
||||||
frameTop = min(frameTop + dy, frameBottom - minPx).coerceAtLeast(0f)
|
frameTop = min(frameTop + dy, frameBottom - minPx).coerceAtLeast(0f)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Poignée coin haut-droit
|
// Poignée coin haut-droit
|
||||||
@ -218,7 +221,7 @@ fun ImageCropBottomSheet(
|
|||||||
onDrag = { dx, dy ->
|
onDrag = { dx, dy ->
|
||||||
frameRight = max(frameRight + dx, frameLeft + minPx).coerceAtMost(containerW)
|
frameRight = max(frameRight + dx, frameLeft + minPx).coerceAtMost(containerW)
|
||||||
frameTop = min(frameTop + dy, frameBottom - minPx).coerceAtLeast(0f)
|
frameTop = min(frameTop + dy, frameBottom - minPx).coerceAtLeast(0f)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Poignée coin bas-gauche
|
// Poignée coin bas-gauche
|
||||||
@ -229,7 +232,7 @@ fun ImageCropBottomSheet(
|
|||||||
onDrag = { dx, dy ->
|
onDrag = { dx, dy ->
|
||||||
frameLeft = min(frameLeft + dx, frameRight - minPx).coerceAtLeast(0f)
|
frameLeft = min(frameLeft + dx, frameRight - minPx).coerceAtLeast(0f)
|
||||||
frameBottom = max(frameBottom + dy, frameTop + minPx).coerceAtMost(containerH)
|
frameBottom = max(frameBottom + dy, frameTop + minPx).coerceAtMost(containerH)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Poignée coin bas-droit
|
// Poignée coin bas-droit
|
||||||
@ -240,21 +243,22 @@ fun ImageCropBottomSheet(
|
|||||||
onDrag = { dx, dy ->
|
onDrag = { dx, dy ->
|
||||||
frameRight = max(frameRight + dx, frameLeft + minPx).coerceAtMost(containerW)
|
frameRight = max(frameRight + dx, frameLeft + minPx).coerceAtMost(containerW)
|
||||||
frameBottom = max(frameBottom + dy, frameTop + minPx).coerceAtMost(containerH)
|
frameBottom = max(frameBottom + dy, frameTop + minPx).coerceAtMost(containerH)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.navigationBarsPadding(),
|
.navigationBarsPadding(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onDismiss,
|
onClick = onDismiss,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
Text("Annuler")
|
Text("Annuler")
|
||||||
}
|
}
|
||||||
@ -271,14 +275,17 @@ fun ImageCropBottomSheet(
|
|||||||
|
|
||||||
val cropped = Bitmap.createBitmap(bitmap, cropLeft, cropTop, cropW, cropH)
|
val cropped = Bitmap.createBitmap(bitmap, cropLeft, cropTop, cropW, cropH)
|
||||||
val maxSize = 512
|
val maxSize = 512
|
||||||
val out = if (cropW > maxSize || cropH > maxSize) {
|
val out =
|
||||||
|
if (cropW > maxSize || cropH > maxSize) {
|
||||||
val ratio = maxSize.toFloat() / max(cropW, cropH)
|
val ratio = maxSize.toFloat() / max(cropW, cropH)
|
||||||
val newW = (cropW * ratio).toInt()
|
val newW = (cropW * ratio).toInt()
|
||||||
val newH = (cropH * ratio).toInt()
|
val newH = (cropH * ratio).toInt()
|
||||||
Bitmap.createScaledBitmap(cropped, newW, newH, true).also {
|
Bitmap.createScaledBitmap(cropped, newW, newH, true).also {
|
||||||
if (it != cropped) cropped.recycle()
|
if (it != cropped) cropped.recycle()
|
||||||
}
|
}
|
||||||
} else cropped
|
} else {
|
||||||
|
cropped
|
||||||
|
}
|
||||||
|
|
||||||
val file = File(context.cacheDir, "item_${System.currentTimeMillis()}.jpg")
|
val file = File(context.cacheDir, "item_${System.currentTimeMillis()}.jpg")
|
||||||
file.outputStream().use { fos ->
|
file.outputStream().use { fos ->
|
||||||
@ -288,7 +295,7 @@ fun ImageCropBottomSheet(
|
|||||||
onCropComplete(Uri.fromFile(file).toString())
|
onCropComplete(Uri.fromFile(file).toString())
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
large = true
|
large = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@ -301,14 +308,15 @@ private fun FrameHandle(
|
|||||||
x: Float,
|
x: Float,
|
||||||
y: Float,
|
y: Float,
|
||||||
halfHandlePx: Float,
|
halfHandlePx: Float,
|
||||||
onDrag: (dx: Float, dy: Float) -> Unit
|
onDrag: (dx: Float, dy: Float) -> Unit,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = with(density) { (x - halfHandlePx).toDp() },
|
x = with(density) { (x - halfHandlePx).toDp() },
|
||||||
y = with(density) { (y - halfHandlePx).toDp() }
|
y = with(density) { (y - halfHandlePx).toDp() },
|
||||||
)
|
)
|
||||||
.size(HandleSize)
|
.size(HandleSize)
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
@ -318,23 +326,24 @@ private fun FrameHandle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.zIndex(2f),
|
.zIndex(2f),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
androidx.compose.foundation.layout.Box(
|
androidx.compose.foundation.layout.Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(12.dp)
|
.size(12.dp)
|
||||||
.zIndex(2f)
|
.zIndex(2f),
|
||||||
) {
|
) {
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
radius = size.minDimension / 2f,
|
radius = size.minDimension / 2f,
|
||||||
center = Offset(size.width / 2f, size.height / 2f)
|
center = Offset(size.width / 2f, size.height / 2f),
|
||||||
)
|
)
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = Color(0xFF1976D2),
|
color = Color(0xFF1976D2),
|
||||||
radius = size.minDimension / 2f - 2f,
|
radius = size.minDimension / 2f - 2f,
|
||||||
center = Offset(size.width / 2f, size.height / 2f)
|
center = Offset(size.width / 2f, size.height / 2f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,14 +4,13 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.safebite.app.presentation.theme.LocalDimens
|
import com.safebite.app.presentation.theme.LocalDimens
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -65,8 +64,12 @@ fun StandardTextField(
|
|||||||
Text(
|
Text(
|
||||||
msg,
|
msg,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = if (isError) MaterialTheme.colorScheme.error
|
color =
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
if (isError) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showCounter && maxLength != null) {
|
if (showCounter && maxLength != null) {
|
||||||
@ -74,7 +77,7 @@ fun StandardTextField(
|
|||||||
Text(
|
Text(
|
||||||
"${value.length}/$maxLength",
|
"${value.length}/$maxLength",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,10 @@ package com.safebite.app.presentation.common.util
|
|||||||
|
|
||||||
sealed interface UiState<out T> {
|
sealed interface UiState<out T> {
|
||||||
data object Idle : UiState<Nothing>
|
data object Idle : UiState<Nothing>
|
||||||
|
|
||||||
data object Loading : UiState<Nothing>
|
data object Loading : UiState<Nothing>
|
||||||
|
|
||||||
data class Success<T>(val data: T) : UiState<T>
|
data class Success<T>(val data: T) : UiState<T>
|
||||||
|
|
||||||
data class Error(val message: String, val offline: Boolean = false) : UiState<Nothing>
|
data class Error(val message: String, val offline: Boolean = false) : UiState<Nothing>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
package com.safebite.app.presentation.navigation
|
package com.safebite.app.presentation.navigation
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
@ -13,29 +13,28 @@ import androidx.navigation.compose.composable
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.safebite.app.presentation.screen.catalog.CatalogScreen
|
import com.safebite.app.presentation.screen.catalog.CatalogScreen
|
||||||
import com.safebite.app.presentation.screen.catalog.CategoryItemsScreen
|
|
||||||
import com.safebite.app.presentation.screen.catalog.CatalogSearchScreen
|
import com.safebite.app.presentation.screen.catalog.CatalogSearchScreen
|
||||||
|
import com.safebite.app.presentation.screen.catalog.CategoryItemsScreen
|
||||||
import com.safebite.app.presentation.screen.catalog.DomainCategoriesScreen
|
import com.safebite.app.presentation.screen.catalog.DomainCategoriesScreen
|
||||||
import com.safebite.app.presentation.screen.product.ProductDetailScreen
|
|
||||||
import com.safebite.app.presentation.screen.tracking.TrackingScreen
|
|
||||||
import com.safebite.app.presentation.screen.lists.ListDetailScreen
|
import com.safebite.app.presentation.screen.lists.ListDetailScreen
|
||||||
import com.safebite.app.presentation.screen.lists.ListsScreen
|
|
||||||
import com.safebite.app.presentation.screen.lists.create.CreateListScreen
|
import com.safebite.app.presentation.screen.lists.create.CreateListScreen
|
||||||
|
import com.safebite.app.presentation.screen.lists.settings.ListMembersScreen
|
||||||
|
import com.safebite.app.presentation.screen.lists.settings.ListNameImageScreen
|
||||||
|
import com.safebite.app.presentation.screen.lists.settings.ListRegionScreen
|
||||||
import com.safebite.app.presentation.screen.lists.settings.ListSettingsScreen
|
import com.safebite.app.presentation.screen.lists.settings.ListSettingsScreen
|
||||||
import com.safebite.app.presentation.screen.lists.settings.ListSortScreen
|
import com.safebite.app.presentation.screen.lists.settings.ListSortScreen
|
||||||
import com.safebite.app.presentation.screen.lists.settings.ListRegionScreen
|
|
||||||
import com.safebite.app.presentation.screen.lists.settings.ListNameImageScreen
|
|
||||||
import com.safebite.app.presentation.screen.lists.settings.ListMembersScreen
|
|
||||||
import com.safebite.app.presentation.screen.main.MainScreen
|
import com.safebite.app.presentation.screen.main.MainScreen
|
||||||
import com.safebite.app.presentation.screen.ocr.OcrCaptureScreen
|
import com.safebite.app.presentation.screen.ocr.OcrCaptureScreen
|
||||||
import com.safebite.app.presentation.screen.ocr.OcrReviewScreen
|
import com.safebite.app.presentation.screen.ocr.OcrReviewScreen
|
||||||
import com.safebite.app.presentation.screen.onboarding.OnboardingScreen
|
import com.safebite.app.presentation.screen.onboarding.OnboardingScreen
|
||||||
|
import com.safebite.app.presentation.screen.product.ProductDetailScreen
|
||||||
import com.safebite.app.presentation.screen.profile.ProfileEditScreen
|
import com.safebite.app.presentation.screen.profile.ProfileEditScreen
|
||||||
import com.safebite.app.presentation.screen.profile.ProfileListScreen
|
import com.safebite.app.presentation.screen.profile.ProfileListScreen
|
||||||
import com.safebite.app.presentation.screen.result.ResultScreen
|
import com.safebite.app.presentation.screen.result.ResultScreen
|
||||||
import com.safebite.app.presentation.screen.scanner.ScannerScreen
|
import com.safebite.app.presentation.screen.scanner.ScannerScreen
|
||||||
import com.safebite.app.presentation.screen.settings.SettingsScreen
|
import com.safebite.app.presentation.screen.settings.SettingsScreen
|
||||||
import com.safebite.app.presentation.screen.splash.SplashScreen
|
import com.safebite.app.presentation.screen.splash.SplashScreen
|
||||||
|
import com.safebite.app.presentation.screen.tracking.TrackingScreen
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Graph de navigation principal de l'application SafeBite.
|
* Graph de navigation principal de l'application SafeBite.
|
||||||
@ -46,19 +45,25 @@ import com.safebite.app.presentation.screen.splash.SplashScreen
|
|||||||
* - Écrans de navigation : Scanner, Result, OCR, Settings, etc.
|
* - Écrans de navigation : Scanner, Result, OCR, Settings, etc.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false) {
|
fun SafeBiteNavGraph(
|
||||||
|
onboardingCompleted: Boolean,
|
||||||
|
showSplash: Boolean = false,
|
||||||
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val startDestination = when {
|
val startDestination =
|
||||||
|
when {
|
||||||
showSplash -> Screen.Splash.route
|
showSplash -> Screen.Splash.route
|
||||||
onboardingCompleted -> Screen.Dashboard.route
|
onboardingCompleted -> Screen.Dashboard.route
|
||||||
else -> Screen.Onboarding.route
|
else -> Screen.Onboarding.route
|
||||||
}
|
}
|
||||||
|
|
||||||
val enterAnim = fadeIn(animationSpec = tween(250)) +
|
val enterAnim =
|
||||||
|
fadeIn(animationSpec = tween(250)) +
|
||||||
slideInHorizontally(animationSpec = tween(250)) { it / 24 }
|
slideInHorizontally(animationSpec = tween(250)) { it / 24 }
|
||||||
val exitAnim = fadeOut(animationSpec = tween(200))
|
val exitAnim = fadeOut(animationSpec = tween(200))
|
||||||
val popEnterAnim = fadeIn(animationSpec = tween(250))
|
val popEnterAnim = fadeIn(animationSpec = tween(250))
|
||||||
val popExitAnim = fadeOut(animationSpec = tween(200)) +
|
val popExitAnim =
|
||||||
|
fadeOut(animationSpec = tween(200)) +
|
||||||
slideOutHorizontally(animationSpec = tween(250)) { it / 24 }
|
slideOutHorizontally(animationSpec = tween(250)) { it / 24 }
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
@ -76,7 +81,7 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
|
|||||||
navController.navigate(Screen.Dashboard.route) {
|
navController.navigate(Screen.Dashboard.route) {
|
||||||
popUpTo(Screen.Splash.route) { inclusive = true }
|
popUpTo(Screen.Splash.route) { inclusive = true }
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,7 +103,7 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
|
|||||||
onOpenListDetail = { id, name -> navController.navigate(Screen.ListDetail.build(id, name)) },
|
onOpenListDetail = { id, name -> navController.navigate(Screen.ListDetail.build(id, name)) },
|
||||||
onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) },
|
onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) },
|
||||||
onOpenListCreate = { navController.navigate(Screen.ListCreate.route) },
|
onOpenListCreate = { navController.navigate(Screen.ListCreate.route) },
|
||||||
onOpenListSettings = { id -> navController.navigate(Screen.ListSettings.build(id)) }
|
onOpenListSettings = { id -> navController.navigate(Screen.ListSettings.build(id)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,7 +115,7 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
|
|||||||
navController.navigate(Screen.Result.fromBarcode(code)) {
|
navController.navigate(Screen.Result.fromBarcode(code)) {
|
||||||
popUpTo(Screen.Dashboard.route)
|
popUpTo(Screen.Dashboard.route)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,14 +123,14 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
|
|||||||
composable(Screen.OcrCapture.route) {
|
composable(Screen.OcrCapture.route) {
|
||||||
OcrCaptureScreen(
|
OcrCaptureScreen(
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onCaptured = { text -> navController.navigate(Screen.OcrReview.build(text)) }
|
onCaptured = { text -> navController.navigate(Screen.OcrReview.build(text)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── OCR Review ──
|
// ── OCR Review ──
|
||||||
composable(
|
composable(
|
||||||
route = Screen.OcrReview.route,
|
route = Screen.OcrReview.route,
|
||||||
arguments = listOf(navArgument("text") { type = NavType.StringType })
|
arguments = listOf(navArgument("text") { type = NavType.StringType }),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val text = entry.arguments?.getString("text").orEmpty()
|
val text = entry.arguments?.getString("text").orEmpty()
|
||||||
OcrReviewScreen(
|
OcrReviewScreen(
|
||||||
@ -135,18 +140,26 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
|
|||||||
navController.navigate(Screen.Result.fromOcr(edited)) {
|
navController.navigate(Screen.Result.fromOcr(edited)) {
|
||||||
popUpTo(Screen.Dashboard.route)
|
popUpTo(Screen.Dashboard.route)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Result ──
|
// ── Result ──
|
||||||
composable(
|
composable(
|
||||||
route = Screen.Result.route,
|
route = Screen.Result.route,
|
||||||
arguments = listOf(
|
arguments =
|
||||||
|
listOf(
|
||||||
navArgument("barcode") { type = NavType.StringType },
|
navArgument("barcode") { type = NavType.StringType },
|
||||||
navArgument("fromOcr") { type = NavType.BoolType; defaultValue = false },
|
navArgument("fromOcr") {
|
||||||
navArgument("ocrText") { type = NavType.StringType; nullable = true; defaultValue = null }
|
type = NavType.BoolType
|
||||||
)
|
defaultValue = false
|
||||||
|
},
|
||||||
|
navArgument("ocrText") {
|
||||||
|
type = NavType.StringType
|
||||||
|
nullable = true
|
||||||
|
defaultValue = null
|
||||||
|
},
|
||||||
|
),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val barcode = entry.arguments?.getString("barcode")
|
val barcode = entry.arguments?.getString("barcode")
|
||||||
val fromOcr = entry.arguments?.getBoolean("fromOcr") == true
|
val fromOcr = entry.arguments?.getBoolean("fromOcr") == true
|
||||||
@ -165,7 +178,12 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
|
|||||||
navController.navigate(Screen.OcrCapture.route) {
|
navController.navigate(Screen.OcrCapture.route) {
|
||||||
popUpTo(Screen.Dashboard.route)
|
popUpTo(Screen.Dashboard.route)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onOpenAlternatives = {
|
||||||
|
barcode?.let {
|
||||||
|
navController.navigate(Screen.ProductDetail.build(it))
|
||||||
}
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,24 +192,24 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
|
|||||||
ProfileListScreen(
|
ProfileListScreen(
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onNew = { navController.navigate(Screen.ProfileEdit.new()) },
|
onNew = { navController.navigate(Screen.ProfileEdit.new()) },
|
||||||
onEdit = { id -> navController.navigate(Screen.ProfileEdit.edit(id)) }
|
onEdit = { id -> navController.navigate(Screen.ProfileEdit.edit(id)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Screen.ProfileEdit.route,
|
route = Screen.ProfileEdit.route,
|
||||||
arguments = listOf(navArgument("id") { type = NavType.LongType })
|
arguments = listOf(navArgument("id") { type = NavType.LongType }),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val id = entry.arguments?.getLong("id") ?: 0L
|
val id = entry.arguments?.getLong("id") ?: 0L
|
||||||
ProfileEditScreen(
|
ProfileEditScreen(
|
||||||
id = id,
|
id = id,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onSaved = { navController.popBackStack() }
|
onSaved = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.Tracking.route) {
|
composable(Screen.Tracking.route) {
|
||||||
TrackingScreen(
|
TrackingScreen(
|
||||||
onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) },
|
onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) },
|
||||||
onOpenScanner = { navController.navigate(Screen.Scanner.route) }
|
onOpenScanner = { navController.navigate(Screen.Scanner.route) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.Settings.route) {
|
composable(Screen.Settings.route) {
|
||||||
@ -202,10 +220,14 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
|
|||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
composable(
|
composable(
|
||||||
route = Screen.ListDetail.route,
|
route = Screen.ListDetail.route,
|
||||||
arguments = listOf(
|
arguments =
|
||||||
|
listOf(
|
||||||
navArgument("id") { type = NavType.LongType },
|
navArgument("id") { type = NavType.LongType },
|
||||||
navArgument("name") { type = NavType.StringType; defaultValue = "Ma liste" }
|
navArgument("name") {
|
||||||
)
|
type = NavType.StringType
|
||||||
|
defaultValue = "Ma liste"
|
||||||
|
},
|
||||||
|
),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val listId = entry.arguments?.getLong("id") ?: 0L
|
val listId = entry.arguments?.getLong("id") ?: 0L
|
||||||
val listName = entry.arguments?.getString("name") ?: "Ma liste"
|
val listName = entry.arguments?.getString("name") ?: "Ma liste"
|
||||||
@ -215,14 +237,14 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
|
|||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onOpenScanner = { navController.navigate(Screen.Scanner.route) },
|
onOpenScanner = { navController.navigate(Screen.Scanner.route) },
|
||||||
onOpenProduct = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) },
|
onOpenProduct = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) },
|
||||||
onOpenCatalog = { id -> navController.navigate(Screen.Catalog.build(id)) }
|
onOpenCatalog = { id -> navController.navigate(Screen.Catalog.build(id)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Catalogue (refonte) ──
|
// ── Catalogue (refonte) ──
|
||||||
composable(
|
composable(
|
||||||
route = Screen.Catalog.route,
|
route = Screen.Catalog.route,
|
||||||
arguments = listOf(navArgument("listId") { type = NavType.LongType })
|
arguments = listOf(navArgument("listId") { type = NavType.LongType }),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val listId = entry.arguments?.getLong("listId") ?: 0L
|
val listId = entry.arguments?.getLong("listId") ?: 0L
|
||||||
CatalogScreen(
|
CatalogScreen(
|
||||||
@ -233,15 +255,16 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
|
|||||||
},
|
},
|
||||||
onOpenSearch = {
|
onOpenSearch = {
|
||||||
navController.navigate(Screen.CatalogSearch.build(listId))
|
navController.navigate(Screen.CatalogSearch.build(listId))
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Screen.CatalogDomain.route,
|
route = Screen.CatalogDomain.route,
|
||||||
arguments = listOf(
|
arguments =
|
||||||
|
listOf(
|
||||||
navArgument("listId") { type = NavType.LongType },
|
navArgument("listId") { type = NavType.LongType },
|
||||||
navArgument("domainId") { type = NavType.StringType }
|
navArgument("domainId") { type = NavType.StringType },
|
||||||
)
|
),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val listId = entry.arguments?.getLong("listId") ?: 0L
|
val listId = entry.arguments?.getLong("listId") ?: 0L
|
||||||
val domainId = entry.arguments?.getString("domainId").orEmpty()
|
val domainId = entry.arguments?.getString("domainId").orEmpty()
|
||||||
@ -250,45 +273,46 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
|
|||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onOpenCategory = { categoryId ->
|
onOpenCategory = { categoryId ->
|
||||||
navController.navigate(Screen.CatalogCategory.build(listId, categoryId))
|
navController.navigate(Screen.CatalogCategory.build(listId, categoryId))
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Screen.CatalogCategory.route,
|
route = Screen.CatalogCategory.route,
|
||||||
arguments = listOf(
|
arguments =
|
||||||
|
listOf(
|
||||||
navArgument("listId") { type = NavType.LongType },
|
navArgument("listId") { type = NavType.LongType },
|
||||||
navArgument("categoryId") { type = NavType.StringType }
|
navArgument("categoryId") { type = NavType.StringType },
|
||||||
)
|
),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val listId = entry.arguments?.getLong("listId") ?: 0L
|
val listId = entry.arguments?.getLong("listId") ?: 0L
|
||||||
val categoryId = entry.arguments?.getString("categoryId").orEmpty()
|
val categoryId = entry.arguments?.getString("categoryId").orEmpty()
|
||||||
CategoryItemsScreen(
|
CategoryItemsScreen(
|
||||||
categoryId = categoryId,
|
categoryId = categoryId,
|
||||||
listId = listId,
|
listId = listId,
|
||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Screen.CatalogSearch.route,
|
route = Screen.CatalogSearch.route,
|
||||||
arguments = listOf(navArgument("listId") { type = NavType.LongType })
|
arguments = listOf(navArgument("listId") { type = NavType.LongType }),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val listId = entry.arguments?.getLong("listId") ?: 0L
|
val listId = entry.arguments?.getLong("listId") ?: 0L
|
||||||
CatalogSearchScreen(
|
CatalogSearchScreen(
|
||||||
listId = listId,
|
listId = listId,
|
||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Product Detail (Phase 5) ──
|
// ── Product Detail (Phase 5) ──
|
||||||
composable(
|
composable(
|
||||||
route = Screen.ProductDetail.route,
|
route = Screen.ProductDetail.route,
|
||||||
arguments = listOf(navArgument("barcode") { type = NavType.StringType })
|
arguments = listOf(navArgument("barcode") { type = NavType.StringType }),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val barcode = entry.arguments?.getString("barcode").orEmpty()
|
val barcode = entry.arguments?.getString("barcode").orEmpty()
|
||||||
ProductDetailScreen(
|
ProductDetailScreen(
|
||||||
barcode = barcode,
|
barcode = barcode,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onOpenProduct = { b -> navController.navigate(Screen.ProductDetail.build(b)) }
|
onOpenProduct = { b -> navController.navigate(Screen.ProductDetail.build(b)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,12 +320,12 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
|
|||||||
composable(Screen.ListCreate.route) {
|
composable(Screen.ListCreate.route) {
|
||||||
CreateListScreen(
|
CreateListScreen(
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onListCreated = { navController.popBackStack() }
|
onListCreated = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Screen.ListSettings.route,
|
route = Screen.ListSettings.route,
|
||||||
arguments = listOf(navArgument("id") { type = NavType.LongType })
|
arguments = listOf(navArgument("id") { type = NavType.LongType }),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val listId = entry.arguments?.getLong("id") ?: 0L
|
val listId = entry.arguments?.getLong("id") ?: 0L
|
||||||
ListSettingsScreen(
|
ListSettingsScreen(
|
||||||
@ -310,47 +334,47 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
|
|||||||
onOpenSort = { navController.navigate(Screen.ListSort.build(listId)) },
|
onOpenSort = { navController.navigate(Screen.ListSort.build(listId)) },
|
||||||
onOpenRegion = { navController.navigate(Screen.ListRegion.build(listId)) },
|
onOpenRegion = { navController.navigate(Screen.ListRegion.build(listId)) },
|
||||||
onOpenNameImage = { navController.navigate(Screen.ListNameImage.build(listId)) },
|
onOpenNameImage = { navController.navigate(Screen.ListNameImage.build(listId)) },
|
||||||
onOpenMembers = { navController.navigate(Screen.ListMembers.build(listId)) }
|
onOpenMembers = { navController.navigate(Screen.ListMembers.build(listId)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Screen.ListSort.route,
|
route = Screen.ListSort.route,
|
||||||
arguments = listOf(navArgument("id") { type = NavType.LongType })
|
arguments = listOf(navArgument("id") { type = NavType.LongType }),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val listId = entry.arguments?.getLong("id") ?: 0L
|
val listId = entry.arguments?.getLong("id") ?: 0L
|
||||||
ListSortScreen(
|
ListSortScreen(
|
||||||
listId = listId,
|
listId = listId,
|
||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Screen.ListRegion.route,
|
route = Screen.ListRegion.route,
|
||||||
arguments = listOf(navArgument("id") { type = NavType.LongType })
|
arguments = listOf(navArgument("id") { type = NavType.LongType }),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val listId = entry.arguments?.getLong("id") ?: 0L
|
val listId = entry.arguments?.getLong("id") ?: 0L
|
||||||
ListRegionScreen(
|
ListRegionScreen(
|
||||||
listId = listId,
|
listId = listId,
|
||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Screen.ListNameImage.route,
|
route = Screen.ListNameImage.route,
|
||||||
arguments = listOf(navArgument("id") { type = NavType.LongType })
|
arguments = listOf(navArgument("id") { type = NavType.LongType }),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val listId = entry.arguments?.getLong("id") ?: 0L
|
val listId = entry.arguments?.getLong("id") ?: 0L
|
||||||
ListNameImageScreen(
|
ListNameImageScreen(
|
||||||
listId = listId,
|
listId = listId,
|
||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Screen.ListMembers.route,
|
route = Screen.ListMembers.route,
|
||||||
arguments = listOf(navArgument("id") { type = NavType.LongType })
|
arguments = listOf(navArgument("id") { type = NavType.LongType }),
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val listId = entry.arguments?.getLong("id") ?: 0L
|
val listId = entry.arguments?.getLong("id") ?: 0L
|
||||||
ListMembersScreen(
|
ListMembersScreen(
|
||||||
listId = listId,
|
listId = listId,
|
||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,56 +22,81 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
sealed class Screen(val route: String) {
|
sealed class Screen(val route: String) {
|
||||||
// ── Onglets principaux (Bottom Navigation) ──
|
// ── Onglets principaux (Bottom Navigation) ──
|
||||||
data object Dashboard : Screen("dashboard")
|
data object Dashboard : Screen("dashboard")
|
||||||
|
|
||||||
data object Lists : Screen("lists")
|
data object Lists : Screen("lists")
|
||||||
|
|
||||||
data object Tracking : Screen("tracking")
|
data object Tracking : Screen("tracking")
|
||||||
|
|
||||||
data object Family : Screen("family")
|
data object Family : Screen("family")
|
||||||
|
|
||||||
// ── Écrans de navigation (non dans bottom nav) ──
|
// ── Écrans de navigation (non dans bottom nav) ──
|
||||||
data object Scanner : Screen("scanner")
|
data object Scanner : Screen("scanner")
|
||||||
|
|
||||||
data object OcrCapture : Screen("ocr/capture")
|
data object OcrCapture : Screen("ocr/capture")
|
||||||
|
|
||||||
data object OcrReview : Screen("ocr/review/{text}") {
|
data object OcrReview : Screen("ocr/review/{text}") {
|
||||||
fun build(text: String) = "ocr/review/${android.net.Uri.encode(text)}"
|
fun build(text: String) = "ocr/review/${android.net.Uri.encode(text)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
data object Result : Screen("result/{barcode}?fromOcr={fromOcr}&ocrText={ocrText}") {
|
data object Result : Screen("result/{barcode}?fromOcr={fromOcr}&ocrText={ocrText}") {
|
||||||
fun fromBarcode(barcode: String) = "result/$barcode?fromOcr=false&ocrText="
|
fun fromBarcode(barcode: String) = "result/$barcode?fromOcr=false&ocrText="
|
||||||
|
|
||||||
fun fromOcr(text: String) = "result/ocr?fromOcr=true&ocrText=${android.net.Uri.encode(text)}"
|
fun fromOcr(text: String) = "result/ocr?fromOcr=true&ocrText=${android.net.Uri.encode(text)}"
|
||||||
|
|
||||||
fun fromHistory(barcode: String) = "result/$barcode?fromOcr=false&ocrText="
|
fun fromHistory(barcode: String) = "result/$barcode?fromOcr=false&ocrText="
|
||||||
}
|
}
|
||||||
|
|
||||||
data object Onboarding : Screen("onboarding")
|
data object Onboarding : Screen("onboarding")
|
||||||
|
|
||||||
data object Settings : Screen("settings")
|
data object Settings : Screen("settings")
|
||||||
|
|
||||||
data object Splash : Screen("splash")
|
data object Splash : Screen("splash")
|
||||||
|
|
||||||
// ── Sous-écrans ──
|
// ── Sous-écrans ──
|
||||||
data object ProfileList : Screen("profiles")
|
data object ProfileList : Screen("profiles")
|
||||||
|
|
||||||
data object ProfileEdit : Screen("profile/edit/{id}") {
|
data object ProfileEdit : Screen("profile/edit/{id}") {
|
||||||
fun new() = "profile/edit/0"
|
fun new() = "profile/edit/0"
|
||||||
|
|
||||||
fun edit(id: Long) = "profile/edit/$id"
|
fun edit(id: Long) = "profile/edit/$id"
|
||||||
}
|
}
|
||||||
|
|
||||||
data object ProductDetail : Screen("product/{barcode}") {
|
data object ProductDetail : Screen("product/{barcode}") {
|
||||||
fun build(barcode: String) = "product/$barcode"
|
fun build(barcode: String) = "product/$barcode"
|
||||||
}
|
}
|
||||||
|
|
||||||
data object ListDetail : Screen("list/{id}?name={name}") {
|
data object ListDetail : Screen("list/{id}?name={name}") {
|
||||||
fun build(id: Long, name: String = "Ma liste") = "list/$id?name=${android.net.Uri.encode(name)}"
|
fun build(
|
||||||
|
id: Long,
|
||||||
|
name: String = "Ma liste",
|
||||||
|
) = "list/$id?name=${android.net.Uri.encode(name)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
data object ListEdit : Screen("list/edit?id={id}") {
|
data object ListEdit : Screen("list/edit?id={id}") {
|
||||||
fun new() = "list/edit?id=0"
|
fun new() = "list/edit?id=0"
|
||||||
|
|
||||||
fun edit(id: Long) = "list/edit?id=$id"
|
fun edit(id: Long) = "list/edit?id=$id"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── List management (refonte) ──
|
// ── List management (refonte) ──
|
||||||
data object ListCreate : Screen("list/create")
|
data object ListCreate : Screen("list/create")
|
||||||
|
|
||||||
data object ListSettings : Screen("list/settings/{id}") {
|
data object ListSettings : Screen("list/settings/{id}") {
|
||||||
fun build(id: Long) = "list/settings/$id"
|
fun build(id: Long) = "list/settings/$id"
|
||||||
}
|
}
|
||||||
|
|
||||||
data object ListSort : Screen("list/sort/{id}") {
|
data object ListSort : Screen("list/sort/{id}") {
|
||||||
fun build(id: Long) = "list/sort/$id"
|
fun build(id: Long) = "list/sort/$id"
|
||||||
}
|
}
|
||||||
|
|
||||||
data object ListRegion : Screen("list/region/{id}") {
|
data object ListRegion : Screen("list/region/{id}") {
|
||||||
fun build(id: Long) = "list/region/$id"
|
fun build(id: Long) = "list/region/$id"
|
||||||
}
|
}
|
||||||
|
|
||||||
data object ListNameImage : Screen("list/nameimage/{id}") {
|
data object ListNameImage : Screen("list/nameimage/{id}") {
|
||||||
fun build(id: Long) = "list/nameimage/$id"
|
fun build(id: Long) = "list/nameimage/$id"
|
||||||
}
|
}
|
||||||
|
|
||||||
data object ListMembers : Screen("list/members/{id}") {
|
data object ListMembers : Screen("list/members/{id}") {
|
||||||
fun build(id: Long) = "list/members/$id"
|
fun build(id: Long) = "list/members/$id"
|
||||||
}
|
}
|
||||||
@ -80,12 +105,21 @@ sealed class Screen(val route: String) {
|
|||||||
data object Catalog : Screen("catalog/{listId}") {
|
data object Catalog : Screen("catalog/{listId}") {
|
||||||
fun build(listId: Long) = "catalog/$listId"
|
fun build(listId: Long) = "catalog/$listId"
|
||||||
}
|
}
|
||||||
|
|
||||||
data object CatalogDomain : Screen("catalog/{listId}/domain/{domainId}") {
|
data object CatalogDomain : Screen("catalog/{listId}/domain/{domainId}") {
|
||||||
fun build(listId: Long, domainId: String) = "catalog/$listId/domain/$domainId"
|
fun build(
|
||||||
|
listId: Long,
|
||||||
|
domainId: String,
|
||||||
|
) = "catalog/$listId/domain/$domainId"
|
||||||
}
|
}
|
||||||
|
|
||||||
data object CatalogCategory : Screen("catalog/{listId}/category/{categoryId}") {
|
data object CatalogCategory : Screen("catalog/{listId}/category/{categoryId}") {
|
||||||
fun build(listId: Long, categoryId: String) = "catalog/$listId/category/$categoryId"
|
fun build(
|
||||||
|
listId: Long,
|
||||||
|
categoryId: String,
|
||||||
|
) = "catalog/$listId/category/$categoryId"
|
||||||
}
|
}
|
||||||
|
|
||||||
data object CatalogSearch : Screen("catalog/{listId}/search") {
|
data object CatalogSearch : Screen("catalog/{listId}/search") {
|
||||||
fun build(listId: Long) = "catalog/$listId/search"
|
fun build(listId: Long) = "catalog/$listId/search"
|
||||||
}
|
}
|
||||||
@ -100,36 +134,37 @@ data class BottomNavItem(
|
|||||||
val iconUnselected: ImageVector,
|
val iconUnselected: ImageVector,
|
||||||
val label: String,
|
val label: String,
|
||||||
val contentDescription: String,
|
val contentDescription: String,
|
||||||
val badgeCount: Int = 0
|
val badgeCount: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
val bottomNavItems = listOf(
|
val bottomNavItems =
|
||||||
|
listOf(
|
||||||
BottomNavItem(
|
BottomNavItem(
|
||||||
screen = Screen.Dashboard,
|
screen = Screen.Dashboard,
|
||||||
iconSelected = Icons.Filled.Home,
|
iconSelected = Icons.Filled.Home,
|
||||||
iconUnselected = Icons.Outlined.Home,
|
iconUnselected = Icons.Outlined.Home,
|
||||||
label = "Accueil",
|
label = "Accueil",
|
||||||
contentDescription = "Tableau de bord"
|
contentDescription = "Tableau de bord",
|
||||||
),
|
),
|
||||||
BottomNavItem(
|
BottomNavItem(
|
||||||
screen = Screen.Lists,
|
screen = Screen.Lists,
|
||||||
iconSelected = Icons.Filled.List,
|
iconSelected = Icons.Filled.List,
|
||||||
iconUnselected = Icons.Outlined.List,
|
iconUnselected = Icons.Outlined.List,
|
||||||
label = "Listes",
|
label = "Listes",
|
||||||
contentDescription = "Mes listes de courses"
|
contentDescription = "Mes listes de courses",
|
||||||
),
|
),
|
||||||
BottomNavItem(
|
BottomNavItem(
|
||||||
screen = Screen.Tracking,
|
screen = Screen.Tracking,
|
||||||
iconSelected = Icons.Filled.ShowChart,
|
iconSelected = Icons.Filled.ShowChart,
|
||||||
iconUnselected = Icons.Outlined.ShowChart,
|
iconUnselected = Icons.Outlined.ShowChart,
|
||||||
label = "Suivi",
|
label = "Suivi",
|
||||||
contentDescription = "Statistiques et historique"
|
contentDescription = "Statistiques et historique",
|
||||||
),
|
),
|
||||||
BottomNavItem(
|
BottomNavItem(
|
||||||
screen = Screen.Family,
|
screen = Screen.Family,
|
||||||
iconSelected = Icons.Filled.People,
|
iconSelected = Icons.Filled.People,
|
||||||
iconUnselected = Icons.Outlined.People,
|
iconUnselected = Icons.Outlined.People,
|
||||||
label = "Famille",
|
label = "Famille",
|
||||||
contentDescription = "Profils et réglages"
|
contentDescription = "Profils et réglages",
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -62,7 +62,8 @@ import com.safebite.app.data.local.database.entity.CategoryEntity
|
|||||||
import com.safebite.app.data.local.database.relation.DomainWithCategoriesAndItems
|
import com.safebite.app.data.local.database.relation.DomainWithCategoriesAndItems
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private fun parseColor(hex: String?): Color? = runCatching {
|
private fun parseColor(hex: String?): Color? =
|
||||||
|
runCatching {
|
||||||
hex?.takeIf { it.startsWith("#") }?.let { Color(android.graphics.Color.parseColor(it)) }
|
hex?.takeIf { it.startsWith("#") }?.let { Color(android.graphics.Color.parseColor(it)) }
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
@ -73,7 +74,7 @@ fun CatalogScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenDomain: (String) -> Unit,
|
onOpenDomain: (String) -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
viewModel: CatalogViewModel = hiltViewModel()
|
viewModel: CatalogViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(listId) { viewModel.setActiveList(listId) }
|
LaunchedEffect(listId) { viewModel.setActiveList(listId) }
|
||||||
val domains by viewModel.domains.collectAsStateWithLifecycle()
|
val domains by viewModel.domains.collectAsStateWithLifecycle()
|
||||||
@ -91,9 +92,9 @@ fun CatalogScreen(
|
|||||||
IconButton(onClick = onOpenSearch) {
|
IconButton(onClick = onOpenSearch) {
|
||||||
Icon(Icons.Filled.Search, contentDescription = "Rechercher")
|
Icon(Icons.Filled.Search, contentDescription = "Rechercher")
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
if (domains.isEmpty()) {
|
if (domains.isEmpty()) {
|
||||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||||
@ -106,12 +107,12 @@ fun CatalogScreen(
|
|||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
items(domains, key = { it.domain.domainId }) { domain ->
|
items(domains, key = { it.domain.domainId }) { domain ->
|
||||||
DomainCard(
|
DomainCard(
|
||||||
domain = domain,
|
domain = domain,
|
||||||
onClick = { onOpenDomain(domain.domain.domainId) }
|
onClick = { onOpenDomain(domain.domain.domainId) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -121,22 +122,22 @@ fun CatalogScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DomainCard(
|
private fun DomainCard(
|
||||||
domain: DomainWithCategoriesAndItems,
|
domain: DomainWithCategoriesAndItems,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val color = parseColor(domain.domain.color) ?: MaterialTheme.colorScheme.primaryContainer
|
val color = parseColor(domain.domain.color) ?: MaterialTheme.colorScheme.primaryContainer
|
||||||
val itemCount = domain.categoriesWithItems.sumOf { it.items.size }
|
val itemCount = domain.categoriesWithItems.sumOf { it.items.size }
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clickable(onClick = onClick),
|
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clickable(onClick = onClick),
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(20.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.18f))
|
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.18f)),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
verticalArrangement = Arrangement.SpaceBetween
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = domain.domain.emoji,
|
text = domain.domain.emoji,
|
||||||
style = MaterialTheme.typography.displayMedium
|
style = MaterialTheme.typography.displayMedium,
|
||||||
)
|
)
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
@ -144,13 +145,13 @@ private fun DomainCard(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "${domain.categoriesWithItems.size} catégories • $itemCount articles",
|
text = "${domain.categoriesWithItems.size} catégories • $itemCount articles",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -163,7 +164,7 @@ fun DomainCategoriesScreen(
|
|||||||
domainId: String,
|
domainId: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenCategory: (String) -> Unit,
|
onOpenCategory: (String) -> Unit,
|
||||||
viewModel: CatalogViewModel = hiltViewModel()
|
viewModel: CatalogViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(domainId) { viewModel.selectDomain(domainId) }
|
LaunchedEffect(domainId) { viewModel.selectDomain(domainId) }
|
||||||
val categories by viewModel.categoriesForSelectedDomain.collectAsStateWithLifecycle()
|
val categories by viewModel.categoriesForSelectedDomain.collectAsStateWithLifecycle()
|
||||||
@ -178,14 +179,14 @@ fun DomainCategoriesScreen(
|
|||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(categories, key = { it.categoryId }) { cat ->
|
items(categories, key = { it.categoryId }) { cat ->
|
||||||
CategoryRow(category = cat, onClick = { onOpenCategory(cat.categoryId) })
|
CategoryRow(category = cat, onClick = { onOpenCategory(cat.categoryId) })
|
||||||
@ -195,23 +196,27 @@ fun DomainCategoriesScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CategoryRow(category: CategoryEntity, onClick: () -> Unit) {
|
private fun CategoryRow(
|
||||||
|
category: CategoryEntity,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
val color = parseColor(category.color) ?: MaterialTheme.colorScheme.surfaceVariant
|
val color = parseColor(category.color) ?: MaterialTheme.colorScheme.surfaceVariant
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.20f))
|
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.20f)),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(color.copy(alpha = 0.4f)),
|
.background(color.copy(alpha = 0.4f)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(text = category.emoji, style = MaterialTheme.typography.headlineSmall)
|
Text(text = category.emoji, style = MaterialTheme.typography.headlineSmall)
|
||||||
}
|
}
|
||||||
@ -220,7 +225,7 @@ private fun CategoryRow(category: CategoryEntity, onClick: () -> Unit) {
|
|||||||
text = category.name,
|
text = category.name,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -232,7 +237,7 @@ fun CategoryItemsScreen(
|
|||||||
categoryId: String,
|
categoryId: String,
|
||||||
listId: Long,
|
listId: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: CatalogViewModel = hiltViewModel()
|
viewModel: CatalogViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(categoryId, listId) {
|
LaunchedEffect(categoryId, listId) {
|
||||||
viewModel.setActiveList(listId)
|
viewModel.setActiveList(listId)
|
||||||
@ -250,17 +255,17 @@ fun CategoryItemsScreen(
|
|||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
snackbarHost = { SnackbarHost(snackbar) }
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
) { padding ->
|
) { padding ->
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(3),
|
columns = GridCells.Fixed(3),
|
||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
items(items, key = { it.itemId }) { item ->
|
items(items, key = { it.itemId }) { item ->
|
||||||
ItemTile(item = item, onClick = {
|
ItemTile(item = item, onClick = {
|
||||||
@ -273,19 +278,22 @@ fun CategoryItemsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ItemTile(item: CatalogItemEntity, onClick: () -> Unit) {
|
private fun ItemTile(
|
||||||
|
item: CatalogItemEntity,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clickable(onClick = onClick),
|
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clickable(onClick = onClick),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().padding(8.dp),
|
modifier = Modifier.fillMaxSize().padding(8.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(text = item.emoji, style = MaterialTheme.typography.displayMedium)
|
Text(text = item.emoji, style = MaterialTheme.typography.displayMedium)
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
@ -295,14 +303,14 @@ private fun ItemTile(item: CatalogItemEntity, onClick: () -> Unit) {
|
|||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Add,
|
imageVector = Icons.Filled.Add,
|
||||||
contentDescription = "Ajouter",
|
contentDescription = "Ajouter",
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.align(Alignment.TopEnd).size(20.dp)
|
modifier = Modifier.align(Alignment.TopEnd).size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -313,7 +321,7 @@ private fun ItemTile(item: CatalogItemEntity, onClick: () -> Unit) {
|
|||||||
fun CatalogSearchScreen(
|
fun CatalogSearchScreen(
|
||||||
listId: Long,
|
listId: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: CatalogViewModel = hiltViewModel()
|
viewModel: CatalogViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(listId) { viewModel.setActiveList(listId) }
|
LaunchedEffect(listId) { viewModel.setActiveList(listId) }
|
||||||
val query by viewModel.searchQuery.collectAsStateWithLifecycle()
|
val query by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||||
@ -329,10 +337,10 @@ fun CatalogSearchScreen(
|
|||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Retour")
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
snackbarHost = { SnackbarHost(snackbar) }
|
snackbarHost = { SnackbarHost(snackbar) },
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@ -349,12 +357,12 @@ fun CatalogSearchScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search)
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
)
|
)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(results, key = { it.itemId }) { item ->
|
items(results, key = { it.itemId }) { item ->
|
||||||
SearchResultRow(item = item, onAdd = {
|
SearchResultRow(item = item, onAdd = {
|
||||||
@ -368,15 +376,18 @@ fun CatalogSearchScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResultRow(item: CatalogItemEntity, onAdd: () -> Unit) {
|
private fun SearchResultRow(
|
||||||
|
item: CatalogItemEntity,
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onAdd),
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onAdd),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(text = item.emoji, style = MaterialTheme.typography.headlineMedium)
|
Text(text = item.emoji, style = MaterialTheme.typography.headlineMedium)
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
@ -384,7 +395,7 @@ private fun SearchResultRow(item: CatalogItemEntity, onAdd: () -> Unit) {
|
|||||||
Text(
|
Text(
|
||||||
text = item.name,
|
text = item.name,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
if (item.aliases.isNotBlank()) {
|
if (item.aliases.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
@ -392,7 +403,7 @@ private fun SearchResultRow(item: CatalogItemEntity, onAdd: () -> Unit) {
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,17 +27,20 @@ import javax.inject.Inject
|
|||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CatalogViewModel @Inject constructor(
|
class CatalogViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val repository: CatalogRepository,
|
private val repository: CatalogRepository,
|
||||||
private val manageListUseCase: ManageShoppingListUseCase
|
private val manageListUseCase: ManageShoppingListUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _activeListId = MutableStateFlow<Long?>(null)
|
private val _activeListId = MutableStateFlow<Long?>(null)
|
||||||
val activeListId: StateFlow<Long?> = _activeListId.asStateFlow()
|
val activeListId: StateFlow<Long?> = _activeListId.asStateFlow()
|
||||||
|
|
||||||
val domains: StateFlow<List<DomainWithCategoriesAndItems>> =
|
val domains: StateFlow<List<DomainWithCategoriesAndItems>> =
|
||||||
repository.observeDomainsWithCategoriesAndItems().stateIn(
|
repository.observeDomainsWithCategoriesAndItems().stateIn(
|
||||||
viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()
|
viewModelScope,
|
||||||
|
SharingStarted.WhileSubscribed(5_000),
|
||||||
|
emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val _selectedDomainId = MutableStateFlow<String?>(null)
|
private val _selectedDomainId = MutableStateFlow<String?>(null)
|
||||||
@ -46,8 +49,11 @@ class CatalogViewModel @Inject constructor(
|
|||||||
val categoriesForSelectedDomain: StateFlow<List<CategoryEntity>> =
|
val categoriesForSelectedDomain: StateFlow<List<CategoryEntity>> =
|
||||||
_selectedDomainId
|
_selectedDomainId
|
||||||
.flatMapLatest { id ->
|
.flatMapLatest { id ->
|
||||||
if (id == null) flowOf(emptyList())
|
if (id == null) {
|
||||||
else repository.observeCategoriesForDomain(id)
|
flowOf(emptyList())
|
||||||
|
} else {
|
||||||
|
repository.observeCategoriesForDomain(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
@ -57,8 +63,11 @@ class CatalogViewModel @Inject constructor(
|
|||||||
val itemsForSelectedCategory: StateFlow<List<CatalogItemEntity>> =
|
val itemsForSelectedCategory: StateFlow<List<CatalogItemEntity>> =
|
||||||
_selectedCategoryId
|
_selectedCategoryId
|
||||||
.flatMapLatest { id ->
|
.flatMapLatest { id ->
|
||||||
if (id == null) flowOf(emptyList())
|
if (id == null) {
|
||||||
else repository.observeItemsForCategory(id)
|
flowOf(emptyList())
|
||||||
|
} else {
|
||||||
|
repository.observeItemsForCategory(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
@ -68,8 +77,11 @@ class CatalogViewModel @Inject constructor(
|
|||||||
val searchResults: StateFlow<List<CatalogItemEntity>> =
|
val searchResults: StateFlow<List<CatalogItemEntity>> =
|
||||||
_searchQuery
|
_searchQuery
|
||||||
.flatMapLatest { q ->
|
.flatMapLatest { q ->
|
||||||
if (q.isBlank()) flowOf(emptyList())
|
if (q.isBlank()) {
|
||||||
else repository.search(q, limit = 30)
|
flowOf(emptyList())
|
||||||
|
} else {
|
||||||
|
repository.search(q, limit = 30)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
@ -94,17 +106,22 @@ class CatalogViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ajoute l'article du catalogue à la liste active courante. */
|
/** Ajoute l'article du catalogue à la liste active courante. */
|
||||||
fun addItemToActiveList(item: CatalogItemEntity, categoryNameOverride: String? = null) {
|
fun addItemToActiveList(
|
||||||
|
item: CatalogItemEntity,
|
||||||
|
categoryNameOverride: String? = null,
|
||||||
|
) {
|
||||||
val listId = _activeListId.value ?: return
|
val listId = _activeListId.value ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Évite les doublons par nom (ignore-case).
|
// Évite les doublons par nom (ignore-case).
|
||||||
val existing = manageListUseCase.getItems(listId)
|
val existing =
|
||||||
|
manageListUseCase.getItems(listId)
|
||||||
.firstOrNull { it.productName.equals(item.name, ignoreCase = true) }
|
.firstOrNull { it.productName.equals(item.name, ignoreCase = true) }
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val categoryName = categoryNameOverride
|
val categoryName =
|
||||||
|
categoryNameOverride
|
||||||
?: item.primaryCategoryId?.let { repository.getCategory(it)?.name }
|
?: item.primaryCategoryId?.let { repository.getCategory(it)?.name }
|
||||||
manageListUseCase.addItemToList(
|
manageListUseCase.addItemToList(
|
||||||
listId,
|
listId,
|
||||||
@ -112,8 +129,8 @@ class CatalogViewModel @Inject constructor(
|
|||||||
listId = listId,
|
listId = listId,
|
||||||
productName = item.name,
|
productName = item.name,
|
||||||
category = categoryName,
|
category = categoryName,
|
||||||
customEmoji = item.emoji
|
customEmoji = item.emoji,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
repository.incrementPopularity(item.itemId)
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -27,87 +32,227 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.safebite.app.R
|
import com.safebite.app.R
|
||||||
|
import com.safebite.app.domain.model.SafetyStatus
|
||||||
|
import com.safebite.app.domain.model.ScanHistoryItem
|
||||||
|
import com.safebite.app.presentation.common.components.CardVariant
|
||||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||||
import com.safebite.app.presentation.common.components.StandardCard
|
import com.safebite.app.presentation.common.components.StandardCard
|
||||||
import com.safebite.app.presentation.common.components.CardVariant
|
import com.safebite.app.presentation.theme.LocalStatusColors
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard contextuel (spec UX §5.3).
|
* Dashboard contextuel (spec UX §5.3).
|
||||||
*
|
*
|
||||||
* Trois modes :
|
* Trois modes :
|
||||||
* - first_time : aucun scan dans l'historique
|
* - FIRST_TIME : aucun scan → CTA "Commencer"
|
||||||
* - store_mode : détecté via géolocalisation/heure
|
* - STORE : créneau magasin ou liste active → scan prominent + liste en cours
|
||||||
* - home_mode : mode par défaut
|
* - HOME : soirée/weekend → résumé hebdomadaire + derniers scans
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreen(
|
fun DashboardScreen(
|
||||||
onScan: () -> Unit,
|
onScan: () -> Unit,
|
||||||
onOpenList: (Long, String) -> Unit,
|
onOpenList: (Long, String) -> Unit,
|
||||||
onOpenHistoryItem: (String) -> Unit,
|
onOpenHistoryItem: (String) -> Unit,
|
||||||
viewModel: DashboardViewModel = hiltViewModel()
|
viewModel: DashboardViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.background
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
|
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_current_list),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
state.lists.forEach { list ->
|
||||||
|
StandardCard(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
variant = CardVariant.Filled,
|
||||||
|
onClick = { onOpenList(list.id, list.name) },
|
||||||
|
contentPadding = PaddingValues(12.dp),
|
||||||
|
) {
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HOME ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HomeContent(
|
||||||
|
state: DashboardUiState,
|
||||||
|
onScan: () -> Unit,
|
||||||
|
onOpenList: (Long, String) -> Unit,
|
||||||
|
onOpenHistoryItem: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val statusColors = LocalStatusColors.current
|
||||||
|
|
||||||
// Greeting
|
// Greeting
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.dashboard_greeting, state.greetingName),
|
text =
|
||||||
|
if (state.greetingName.isNotEmpty()) {
|
||||||
|
stringResource(R.string.dashboard_greeting, state.greetingName)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.app_name)
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Quick actions
|
// Quick actions
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
text = stringResource(R.string.dashboard_scan_button),
|
text = stringResource(R.string.dashboard_scan_button),
|
||||||
onClick = onScan,
|
onClick = onScan,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shopping lists quick access
|
// Shopping lists quick access
|
||||||
if (state.lists.isNotEmpty()) {
|
if (state.lists.isNotEmpty()) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
state.lists.forEach { list ->
|
state.lists.forEach { list ->
|
||||||
StandardCard(
|
StandardCard(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(72.dp),
|
.height(72.dp),
|
||||||
variant = CardVariant.Filled,
|
variant = CardVariant.Filled,
|
||||||
onClick = { onOpenList(list.id, list.name) },
|
onClick = { onOpenList(list.id, list.name) },
|
||||||
contentPadding = PaddingValues(8.dp)
|
contentPadding = PaddingValues(8.dp),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = list.name,
|
text = list.name,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(
|
text = stringResource(R.string.dashboard_remaining, list.remaining),
|
||||||
R.string.dashboard_remaining,
|
|
||||||
list.remaining
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -115,39 +260,146 @@ fun DashboardScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weekly stats placeholder
|
// Weekly stats
|
||||||
Card(
|
if (state.weeklyStats != null) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
WeeklyStatsCard(state.weeklyStats!!)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent scans
|
// Recent scans
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.dashboard_recent_scans),
|
text = stringResource(R.string.dashboard_recent_scans),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
|
if (state.recentScans.isNotEmpty()) {
|
||||||
|
state.recentScans.forEach { scan ->
|
||||||
|
RecentScanRow(
|
||||||
|
scan = scan,
|
||||||
|
onClick = { onOpenHistoryItem(scan.barcode) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.dashboard_no_scans),
|
text = stringResource(R.string.dashboard_no_scans),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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_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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.safebite.app.data.local.database.entity.ShoppingListEntity
|
import com.safebite.app.domain.model.SafetyStatus
|
||||||
|
import com.safebite.app.domain.model.ScanHistoryItem
|
||||||
import com.safebite.app.domain.model.UserProfile
|
import com.safebite.app.domain.model.UserProfile
|
||||||
|
import com.safebite.app.domain.usecase.GetScanHistoryUseCase
|
||||||
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
|
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
|
||||||
import com.safebite.app.domain.usecase.ManageProfileUseCase
|
import com.safebite.app.domain.usecase.ManageProfileUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@ -15,38 +17,75 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import java.util.Calendar
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** Mode contextuel du dashboard. */
|
||||||
|
enum class DashboardContextMode {
|
||||||
|
/** Aucun scan dans l'historique — onboarding implicite. */
|
||||||
|
FIRST_TIME,
|
||||||
|
|
||||||
|
/** Créneau 8h-20h en semaine OU liste active avec restants → mode magasin. */
|
||||||
|
STORE,
|
||||||
|
|
||||||
|
/** Soirée, weekend, ou aucune condition magasin remplie. */
|
||||||
|
HOME,
|
||||||
|
}
|
||||||
|
|
||||||
data class DashboardUiState(
|
data class DashboardUiState(
|
||||||
val greetingName: String = "",
|
val greetingName: String = "",
|
||||||
val lists: List<ListSummary> = emptyList()
|
val contextMode: DashboardContextMode = DashboardContextMode.FIRST_TIME,
|
||||||
|
val lists: List<ListSummary> = emptyList(),
|
||||||
|
val weeklyStats: WeeklyStats? = null,
|
||||||
|
val recentScans: List<ScanHistoryItem> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WeeklyStats(
|
||||||
|
val safePercentage: Int,
|
||||||
|
val warningCount: Int,
|
||||||
|
val dangerCount: Int,
|
||||||
|
val totalScans: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ListSummary(
|
data class ListSummary(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val remaining: Int
|
val remaining: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DashboardViewModel @Inject constructor(
|
class DashboardViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val manageProfile: ManageProfileUseCase,
|
private val manageProfile: ManageProfileUseCase,
|
||||||
private val getShoppingLists: GetShoppingListsUseCase
|
private val getShoppingLists: GetShoppingListsUseCase,
|
||||||
|
private val getScanHistory: GetScanHistoryUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
val state: StateFlow<DashboardUiState> = combine(
|
val state: StateFlow<DashboardUiState> =
|
||||||
|
combine(
|
||||||
manageProfile.observe(),
|
manageProfile.observe(),
|
||||||
manageProfile.observeActiveIds()
|
manageProfile.observeActiveIds(),
|
||||||
) { profiles, activeIds ->
|
) { profiles, activeIds ->
|
||||||
profiles to activeIds
|
profiles to activeIds
|
||||||
}.flatMapLatest { (profiles, activeIds) ->
|
}.flatMapLatest { (profiles, activeIds) ->
|
||||||
val greetingName = resolveGreetingName(profiles, activeIds)
|
val greetingName = resolveGreetingName(profiles, activeIds)
|
||||||
observeListsWithStats(greetingName)
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
initialValue = DashboardUiState()
|
initialValue = DashboardUiState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@ -56,22 +95,77 @@ class DashboardViewModel @Inject constructor(
|
|||||||
if (sortedLists.isEmpty()) {
|
if (sortedLists.isEmpty()) {
|
||||||
flowOf(DashboardUiState(greetingName = greetingName, lists = emptyList()))
|
flowOf(DashboardUiState(greetingName = greetingName, lists = emptyList()))
|
||||||
} else {
|
} else {
|
||||||
val listFlows = sortedLists.map { list ->
|
val listFlows =
|
||||||
|
sortedLists.map { list ->
|
||||||
combine(
|
combine(
|
||||||
getShoppingLists.observeItemCount(list.id),
|
getShoppingLists.observeItemCount(list.id),
|
||||||
getShoppingLists.observeCheckedCount(list.id)
|
getShoppingLists.observeCheckedCount(list.id),
|
||||||
) { total, checked ->
|
) { total, checked ->
|
||||||
ListSummary(list.id, list.name, total - checked)
|
ListSummary(list.id, list.name, total - checked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
combine(listFlows) { array ->
|
combine(listFlows) { array ->
|
||||||
DashboardUiState(greetingName, array.toList())
|
DashboardUiState(greetingName = greetingName, lists = array.toList())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveGreetingName(profiles: List<UserProfile>, activeIds: Set<Long>): String {
|
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 {
|
return when {
|
||||||
activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }.firstOrNull()?.name
|
activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }.firstOrNull()?.name
|
||||||
else -> profiles.filter { it.isDefault }.firstOrNull()?.name ?: profiles.firstOrNull()?.name
|
else -> profiles.filter { it.isDefault }.firstOrNull()?.name ?: profiles.firstOrNull()?.name
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package com.safebite.app.presentation.screen.family
|
package com.safebite.app.presentation.screen.family
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@ -20,7 +19,6 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material.icons.filled.StarBorder
|
import androidx.compose.material.icons.filled.StarBorder
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@ -43,13 +41,9 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.role
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.stateDescription
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@ -65,7 +59,7 @@ import com.safebite.app.presentation.theme.LocalDimens
|
|||||||
fun FamilyScreen(
|
fun FamilyScreen(
|
||||||
onOpenProfile: (Long) -> Unit,
|
onOpenProfile: (Long) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
viewModel: FamilyViewModel = hiltViewModel()
|
viewModel: FamilyViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val activeProfileIds by viewModel.activeProfileIds.collectAsStateWithLifecycle()
|
val activeProfileIds by viewModel.activeProfileIds.collectAsStateWithLifecycle()
|
||||||
@ -78,7 +72,7 @@ fun FamilyScreen(
|
|||||||
SafeBiteTopAppBar(
|
SafeBiteTopAppBar(
|
||||||
title = stringResource(R.string.family_title),
|
title = stringResource(R.string.family_title),
|
||||||
onBack = null,
|
onBack = null,
|
||||||
backContentDescription = null
|
backContentDescription = null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@ -86,39 +80,42 @@ fun FamilyScreen(
|
|||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { onOpenProfile(0L) },
|
onClick = { onOpenProfile(0L) },
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.semantics {
|
modifier =
|
||||||
|
Modifier.semantics {
|
||||||
contentDescription = addContentDesc
|
contentDescription = addContentDesc
|
||||||
}
|
},
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Add,
|
Icons.Filled.Add,
|
||||||
contentDescription = null
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
if (uiState.profiles.isEmpty()) {
|
if (uiState.profiles.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding),
|
.padding(padding),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = stringResource(R.string.family_no_profiles),
|
title = stringResource(R.string.family_no_profiles),
|
||||||
message = stringResource(R.string.family_no_profiles_body),
|
message = stringResource(R.string.family_no_profiles_body),
|
||||||
emoji = "👨👩👧👦"
|
emoji = "👨👩👧👦",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(2),
|
columns = GridCells.Fixed(2),
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = LocalDimens.current.spacingMd),
|
.padding(horizontal = LocalDimens.current.spacingMd),
|
||||||
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd),
|
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd),
|
||||||
verticalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd)
|
verticalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd),
|
||||||
) {
|
) {
|
||||||
items(uiState.profiles, key = { it.id }) { profile ->
|
items(uiState.profiles, key = { it.id }) { profile ->
|
||||||
val isActive = profile.id in activeProfileIds
|
val isActive = profile.id in activeProfileIds
|
||||||
@ -128,7 +125,7 @@ fun FamilyScreen(
|
|||||||
onToggleActive = { viewModel.toggleProfileActive(profile.id) },
|
onToggleActive = { viewModel.toggleProfileActive(profile.id) },
|
||||||
onEdit = { onOpenProfile(profile.id) },
|
onEdit = { onOpenProfile(profile.id) },
|
||||||
onDelete = { showDeleteDialog = profile.id },
|
onDelete = { showDeleteDialog = profile.id },
|
||||||
onSetDefault = { viewModel.setDefaultProfile(profile.id) }
|
onSetDefault = { viewModel.setDefaultProfile(profile.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -148,7 +145,7 @@ fun FamilyScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
viewModel.deleteProfile(profile)
|
viewModel.deleteProfile(profile)
|
||||||
showDeleteDialog = null
|
showDeleteDialog = null
|
||||||
}
|
},
|
||||||
) {
|
) {
|
||||||
Text("Supprimer", color = MaterialTheme.colorScheme.error)
|
Text("Supprimer", color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
@ -157,7 +154,7 @@ fun FamilyScreen(
|
|||||||
TextButton(onClick = { showDeleteDialog = null }) {
|
TextButton(onClick = { showDeleteDialog = null }) {
|
||||||
Text("Annuler")
|
Text("Annuler")
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -174,82 +171,96 @@ fun ProfileCard(
|
|||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onSetDefault: () -> Unit,
|
onSetDefault: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier =
|
||||||
|
modifier
|
||||||
.clickable(onClick = onEdit),
|
.clickable(onClick = onEdit),
|
||||||
shape = RoundedCornerShape(dimens.radiusMd),
|
shape = RoundedCornerShape(dimens.radiusMd),
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(dimens.spacingMd)
|
.padding(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
// En-tête avec avatar et nom
|
// En-tête avec avatar et nom
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
// Avatar
|
// Avatar
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
if (isActive) MaterialTheme.colorScheme.primaryContainer
|
if (isActive) {
|
||||||
else MaterialTheme.colorScheme.surfaceVariant
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = profile.avatar,
|
text = profile.avatar,
|
||||||
style = MaterialTheme.typography.headlineMedium
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nom et badge
|
// Nom et badge
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f).padding(horizontal = dimens.spacingSm)
|
modifier = Modifier.weight(1f).padding(horizontal = dimens.spacingSm),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = profile.name,
|
text = profile.name,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 1
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
if (profile.isDefault) {
|
if (profile.isDefault) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.profile_default_badge),
|
text = stringResource(R.string.profile_default_badge),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bouton actif
|
// Bouton actif
|
||||||
val a11yDesc = if (isActive)
|
val a11yDesc =
|
||||||
|
if (isActive) {
|
||||||
stringResource(R.string.a11y_profile_inactive, profile.name)
|
stringResource(R.string.a11y_profile_inactive, profile.name)
|
||||||
else
|
} else {
|
||||||
stringResource(R.string.a11y_profile_active, profile.name)
|
stringResource(R.string.a11y_profile_active, profile.name)
|
||||||
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onToggleActive,
|
onClick = onToggleActive,
|
||||||
modifier = Modifier.semantics {
|
modifier =
|
||||||
|
Modifier.semantics {
|
||||||
contentDescription = a11yDesc
|
contentDescription = a11yDesc
|
||||||
}
|
},
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isActive) Icons.Filled.Star else Icons.Filled.StarBorder,
|
imageVector = if (isActive) Icons.Filled.Star else Icons.Filled.StarBorder,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = if (isActive) MaterialTheme.colorScheme.primary
|
tint =
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
if (isActive) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -260,7 +271,7 @@ fun ProfileCard(
|
|||||||
AllergenDisplayGrid(
|
AllergenDisplayGrid(
|
||||||
severeAllergens = profile.severeAllergens,
|
severeAllergens = profile.severeAllergens,
|
||||||
moderateIntolerances = profile.moderateIntolerances,
|
moderateIntolerances = profile.moderateIntolerances,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
||||||
@ -271,7 +282,7 @@ fun ProfileCard(
|
|||||||
text = profile.dietaryRestrictions.joinToString(", ") { it.displayFr },
|
text = profile.dietaryRestrictions.joinToString(", ") { it.displayFr },
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 2
|
maxLines = 2,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,45 +19,51 @@ import javax.inject.Inject
|
|||||||
data class FamilyUiState(
|
data class FamilyUiState(
|
||||||
val profiles: List<UserProfile> = emptyList(),
|
val profiles: List<UserProfile> = emptyList(),
|
||||||
val activeProfileIds: Set<Long> = emptySet(),
|
val activeProfileIds: Set<Long> = emptySet(),
|
||||||
val isLoading: Boolean = true
|
val isLoading: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class FamilyViewModel @Inject constructor(
|
class FamilyViewModel
|
||||||
private val manageProfileUseCase: ManageProfileUseCase
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val manageProfileUseCase: ManageProfileUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
val uiState: StateFlow<FamilyUiState> =
|
||||||
val uiState: StateFlow<FamilyUiState> = manageProfileUseCase.observe()
|
manageProfileUseCase.observe()
|
||||||
.map { profiles ->
|
.map { profiles ->
|
||||||
FamilyUiState(
|
FamilyUiState(
|
||||||
profiles = profiles,
|
profiles = profiles,
|
||||||
isLoading = false
|
isLoading = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.stateIn(
|
.stateIn(
|
||||||
viewModelScope,
|
viewModelScope,
|
||||||
SharingStarted.WhileSubscribed(5_000),
|
SharingStarted.WhileSubscribed(5_000),
|
||||||
FamilyUiState()
|
FamilyUiState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
val activeProfileIds: StateFlow<Set<Long>> = manageProfileUseCase.observeActiveIds()
|
val activeProfileIds: StateFlow<Set<Long>> =
|
||||||
|
manageProfileUseCase.observeActiveIds()
|
||||||
.stateIn(
|
.stateIn(
|
||||||
viewModelScope,
|
viewModelScope,
|
||||||
SharingStarted.WhileSubscribed(5_000),
|
SharingStarted.WhileSubscribed(5_000),
|
||||||
emptySet()
|
emptySet(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun toggleProfileActive(id: Long) = viewModelScope.launch {
|
fun toggleProfileActive(id: Long) =
|
||||||
|
viewModelScope.launch {
|
||||||
val current = manageProfileUseCase.observeActiveIds().first()
|
val current = manageProfileUseCase.observeActiveIds().first()
|
||||||
val newIds = if (id in current) current - id else current + id
|
val newIds = if (id in current) current - id else current + id
|
||||||
manageProfileUseCase.setActive(newIds)
|
manageProfileUseCase.setActive(newIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteProfile(profile: UserProfile) = viewModelScope.launch {
|
fun deleteProfile(profile: UserProfile) =
|
||||||
|
viewModelScope.launch {
|
||||||
manageProfileUseCase.delete(profile)
|
manageProfileUseCase.delete(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDefaultProfile(id: Long) = viewModelScope.launch {
|
fun setDefaultProfile(id: Long) =
|
||||||
|
viewModelScope.launch {
|
||||||
manageProfileUseCase.setDefault(id)
|
manageProfileUseCase.setDefault(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,7 +40,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.safebite.app.R
|
import com.safebite.app.R
|
||||||
import com.safebite.app.domain.model.SafetyStatus
|
|
||||||
import com.safebite.app.domain.model.UserProfile
|
import com.safebite.app.domain.model.UserProfile
|
||||||
import com.safebite.app.presentation.common.components.AvatarBubble
|
import com.safebite.app.presentation.common.components.AvatarBubble
|
||||||
import com.safebite.app.presentation.common.components.OutlinedActionButton
|
import com.safebite.app.presentation.common.components.OutlinedActionButton
|
||||||
@ -60,7 +59,7 @@ fun HomeScreen(
|
|||||||
onHistory: () -> Unit,
|
onHistory: () -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
onOpenHistoryItem: (String) -> Unit,
|
onOpenHistoryItem: (String) -> Unit,
|
||||||
viewModel: HomeViewModel = hiltViewModel()
|
viewModel: HomeViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@ -71,29 +70,36 @@ fun HomeScreen(
|
|||||||
SafeBiteTopAppBar(
|
SafeBiteTopAppBar(
|
||||||
title = stringResource(R.string.app_name),
|
title = stringResource(R.string.app_name),
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = onProfiles) { Icon(Icons.Filled.Person, contentDescription = stringResource(R.string.nav_profiles)) }
|
IconButton(
|
||||||
IconButton(onClick = onHistory) { Icon(Icons.Filled.History, contentDescription = stringResource(R.string.nav_history)) }
|
onClick = onProfiles,
|
||||||
IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.nav_settings)) }
|
) { Icon(Icons.Filled.Person, contentDescription = stringResource(R.string.nav_profiles)) }
|
||||||
|
IconButton(
|
||||||
|
onClick = onHistory,
|
||||||
|
) { Icon(Icons.Filled.History, contentDescription = stringResource(R.string.nav_history)) }
|
||||||
|
IconButton(
|
||||||
|
onClick = onSettings,
|
||||||
|
) { Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.nav_settings)) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
if (state.profiles.isEmpty()) {
|
if (state.profiles.isEmpty()) {
|
||||||
NoProfileBlock(modifier = Modifier.padding(padding), onCreate = onCreateProfile)
|
NoProfileBlock(modifier = Modifier.padding(padding), onCreate = onCreateProfile)
|
||||||
return@Scaffold
|
return@Scaffold
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
|
.padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
|
||||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingLg)
|
verticalArrangement = Arrangement.spacedBy(dimens.spacingLg),
|
||||||
) {
|
) {
|
||||||
ActiveProfilesRow(
|
ActiveProfilesRow(
|
||||||
profiles = state.profiles,
|
profiles = state.profiles,
|
||||||
active = state.activeProfiles,
|
active = state.activeProfiles,
|
||||||
onToggle = viewModel::toggleActive,
|
onToggle = viewModel::toggleActive,
|
||||||
onManage = onProfiles
|
onManage = onProfiles,
|
||||||
)
|
)
|
||||||
|
|
||||||
ScanButton(onClick = onScan)
|
ScanButton(onClick = onScan)
|
||||||
@ -102,35 +108,38 @@ fun HomeScreen(
|
|||||||
text = stringResource(R.string.home_ocr_button),
|
text = stringResource(R.string.home_ocr_button),
|
||||||
onClick = onOcr,
|
onClick = onOcr,
|
||||||
icon = Icons.Filled.TextFields,
|
icon = Icons.Filled.TextFields,
|
||||||
modifier = Modifier.fillMaxWidth().height(dimens.buttonHeightLg)
|
modifier = Modifier.fillMaxWidth().height(dimens.buttonHeightLg),
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.home_recent_scans),
|
stringResource(R.string.home_recent_scans),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
if (state.recent.isEmpty()) {
|
if (state.recent.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.home_no_recent),
|
stringResource(R.string.home_no_recent),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
state.recent.forEach { item ->
|
state.recent.forEach { item ->
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { onOpenHistoryItem(item.barcode) }
|
.clickable { onOpenHistoryItem(item.barcode) },
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.size(12.dp).background(statusColor(item.safetyStatus), CircleShape)
|
modifier = Modifier.size(12.dp).background(statusColor(item.safetyStatus), CircleShape),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
ProductCard(
|
ProductCard(
|
||||||
title = item.productName ?: item.barcode,
|
title = item.productName ?: item.barcode,
|
||||||
subtitle = item.brand,
|
subtitle = item.brand,
|
||||||
imageUrl = item.imageUrl,
|
imageUrl = item.imageUrl,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -144,7 +153,7 @@ private fun ActiveProfilesRow(
|
|||||||
profiles: List<UserProfile>,
|
profiles: List<UserProfile>,
|
||||||
active: List<UserProfile>,
|
active: List<UserProfile>,
|
||||||
onToggle: (UserProfile) -> Unit,
|
onToggle: (UserProfile) -> Unit,
|
||||||
onManage: () -> Unit
|
onManage: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@ -159,7 +168,7 @@ private fun ActiveProfilesRow(
|
|||||||
onClick = { onToggle(p) },
|
onClick = { onToggle(p) },
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
|
||||||
) {
|
) {
|
||||||
Row(modifier = Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier = Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
AvatarBubble(avatar = p.avatar, size = 32.dp)
|
AvatarBubble(avatar = p.avatar, size = 32.dp)
|
||||||
@ -180,7 +189,8 @@ private fun ScanButton(onClick: () -> Unit) {
|
|||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
icon = Icons.Filled.QrCodeScanner,
|
icon = Icons.Filled.QrCodeScanner,
|
||||||
large = true,
|
large = true,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(dimens.buttonHeightHero)
|
.height(dimens.buttonHeightHero)
|
||||||
.semantics { contentDescription = "Scan a product" },
|
.semantics { contentDescription = "Scan a product" },
|
||||||
@ -188,29 +198,31 @@ private fun ScanButton(onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NoProfileBlock(modifier: Modifier, onCreate: () -> Unit) {
|
private fun NoProfileBlock(
|
||||||
|
modifier: Modifier,
|
||||||
|
onCreate: () -> Unit,
|
||||||
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxSize().padding(dimens.spacingXl),
|
modifier = modifier.fillMaxSize().padding(dimens.spacingXl),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.home_no_profile_title),
|
stringResource(R.string.home_no_profile_title),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(dimens.spacingSm))
|
Spacer(Modifier.size(dimens.spacingSm))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.home_no_profile_body),
|
stringResource(R.string.home_no_profile_body),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(dimens.spacingLg))
|
Spacer(Modifier.size(dimens.spacingLg))
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
text = stringResource(R.string.home_create_profile),
|
text = stringResource(R.string.home_create_profile),
|
||||||
onClick = onCreate
|
onClick = onCreate,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,34 +17,39 @@ import javax.inject.Inject
|
|||||||
data class HomeUi(
|
data class HomeUi(
|
||||||
val profiles: List<UserProfile> = emptyList(),
|
val profiles: List<UserProfile> = emptyList(),
|
||||||
val activeProfiles: List<UserProfile> = emptyList(),
|
val activeProfiles: List<UserProfile> = emptyList(),
|
||||||
val recent: List<ScanHistoryItem> = emptyList()
|
val recent: List<ScanHistoryItem> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class HomeViewModel @Inject constructor(
|
class HomeViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val manageProfile: ManageProfileUseCase,
|
private val manageProfile: ManageProfileUseCase,
|
||||||
private val history: GetScanHistoryUseCase
|
private val history: GetScanHistoryUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
val state: StateFlow<HomeUi> =
|
||||||
val state: StateFlow<HomeUi> = combine(
|
combine(
|
||||||
manageProfile.observe(),
|
manageProfile.observe(),
|
||||||
manageProfile.observeActiveIds(),
|
manageProfile.observeActiveIds(),
|
||||||
history.observe()
|
history.observe(),
|
||||||
) { profiles, activeIds, scans ->
|
) { profiles, activeIds, scans ->
|
||||||
val resolvedActive = when {
|
val resolvedActive =
|
||||||
|
when {
|
||||||
activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }
|
activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }
|
||||||
else -> profiles.filter { it.isDefault }.ifEmpty { profiles.take(1) }
|
else -> profiles.filter { it.isDefault }.ifEmpty { profiles.take(1) }
|
||||||
}
|
}
|
||||||
HomeUi(profiles = profiles, activeProfiles = resolvedActive, recent = scans.take(3))
|
HomeUi(profiles = profiles, activeProfiles = resolvedActive, recent = scans.take(3))
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeUi())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeUi())
|
||||||
|
|
||||||
fun toggleActive(profile: UserProfile) = viewModelScope.launch {
|
fun toggleActive(profile: UserProfile) =
|
||||||
|
viewModelScope.launch {
|
||||||
val current = state.value.activeProfiles.map { it.id }.toMutableSet()
|
val current = state.value.activeProfiles.map { it.id }.toMutableSet()
|
||||||
if (profile.id in current) current.remove(profile.id) else current.add(profile.id)
|
if (profile.id in current) current.remove(profile.id) else current.add(profile.id)
|
||||||
manageProfile.setActive(current)
|
manageProfile.setActive(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setActiveOnly(profile: UserProfile) = viewModelScope.launch {
|
fun setActiveOnly(profile: UserProfile) =
|
||||||
|
viewModelScope.launch {
|
||||||
manageProfile.setActive(setOf(profile.id))
|
manageProfile.setActive(setOf(profile.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,9 +38,7 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -52,13 +50,11 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.safebite.app.domain.engine.CatalogProvider
|
import com.safebite.app.domain.engine.CatalogProvider
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -67,7 +63,7 @@ fun IconPickerSheet(
|
|||||||
categories: List<String>,
|
categories: List<String>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSelectIcon: (String) -> Unit,
|
onSelectIcon: (String) -> Unit,
|
||||||
catalogProvider: CatalogProvider = hiltViewModel<ListDetailViewModel>().catalog
|
catalogProvider: CatalogProvider = hiltViewModel<ListDetailViewModel>().catalog,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
var searchQuery by remember { mutableStateOf("") }
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
@ -75,18 +71,19 @@ fun IconPickerSheet(
|
|||||||
|
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
sheetState = sheetState
|
sheetState = sheetState,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
// Header
|
// Header
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onDismiss) {
|
IconButton(onClick = onDismiss) {
|
||||||
Icon(Icons.Filled.Close, contentDescription = "Fermer")
|
Icon(Icons.Filled.Close, contentDescription = "Fermer")
|
||||||
@ -95,34 +92,36 @@ fun IconPickerSheet(
|
|||||||
text = "Choisir une icône",
|
text = "Choisir une icône",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
IconButton(onClick = { onSelectIcon("") }) {
|
IconButton(onClick = { onSelectIcon("") }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Delete,
|
Icons.Filled.Delete,
|
||||||
contentDescription = "Supprimer l'icône",
|
contentDescription = "Supprimer l'icône",
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current icon display
|
// Current icon display
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 12.dp),
|
.padding(vertical = 12.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(120.dp)
|
.size(120.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = currentEmoji,
|
text = currentEmoji,
|
||||||
style = MaterialTheme.typography.displayLarge
|
style = MaterialTheme.typography.displayLarge,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -131,7 +130,8 @@ fun IconPickerSheet(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = searchQuery,
|
value = searchQuery,
|
||||||
onValueChange = { searchQuery = it },
|
onValueChange = { searchQuery = it },
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(bottom = 12.dp),
|
.padding(bottom = 12.dp),
|
||||||
placeholder = { Text("Chercher une icône") },
|
placeholder = { Text("Chercher une icône") },
|
||||||
@ -146,17 +146,20 @@ fun IconPickerSheet(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
shape = RoundedCornerShape(28.dp)
|
shape = RoundedCornerShape(28.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Icon grid by category
|
// Icon grid by category (catalogue food items first, then extra emoji categories)
|
||||||
|
val extraCategories = remember { ExtraEmojiCategories.all }
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
|
// 1. Catégories du catalogue alimentaire
|
||||||
categories.forEach { category ->
|
categories.forEach { category ->
|
||||||
val categoryItems = catalogProvider.itemsForCategory(category)
|
val categoryItems = catalogProvider.itemsForCategory(category)
|
||||||
val filteredItems = if (searchQuery.isNotBlank()) {
|
val filteredItems =
|
||||||
|
if (searchQuery.isNotBlank()) {
|
||||||
categoryItems.filter {
|
categoryItems.filter {
|
||||||
it.name.contains(searchQuery, ignoreCase = true)
|
it.name.contains(searchQuery, ignoreCase = true)
|
||||||
}
|
}
|
||||||
@ -174,7 +177,7 @@ fun IconPickerSheet(
|
|||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onToggle = {
|
onToggle = {
|
||||||
expandedCategories[category] = !expanded
|
expandedCategories[category] = !expanded
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,7 +186,51 @@ fun IconPickerSheet(
|
|||||||
IconGrid(
|
IconGrid(
|
||||||
items = filteredItems,
|
items = filteredItems,
|
||||||
currentEmoji = currentEmoji,
|
currentEmoji = currentEmoji,
|
||||||
onSelectIcon = onSelectIcon
|
onSelectIcon = onSelectIcon,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Catégories d'émojis supplémentaires (non-alimentaires)
|
||||||
|
extraCategories.forEach { (category, items) ->
|
||||||
|
val filteredItems =
|
||||||
|
if (searchQuery.isNotBlank()) {
|
||||||
|
items.filter {
|
||||||
|
it.name.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
it.emoji.contains(searchQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredItems.isNotEmpty()) {
|
||||||
|
val expanded = expandedCategories[category] ?: (searchQuery.isNotBlank())
|
||||||
|
|
||||||
|
item(key = "header-extra-$category") {
|
||||||
|
CategoryHeader(
|
||||||
|
title = category,
|
||||||
|
count = filteredItems.size,
|
||||||
|
expanded = expanded,
|
||||||
|
onToggle = {
|
||||||
|
expandedCategories[category] = !expanded
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
item(key = "grid-extra-$category") {
|
||||||
|
IconGrid(
|
||||||
|
items = filteredItems.map {
|
||||||
|
CatalogProvider.CatalogItem(
|
||||||
|
name = it.name,
|
||||||
|
category = category,
|
||||||
|
emoji = it.emoji,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
currentEmoji = currentEmoji,
|
||||||
|
onSelectIcon = onSelectIcon,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -201,34 +248,35 @@ private fun CategoryHeader(
|
|||||||
title: String,
|
title: String,
|
||||||
count: Int,
|
count: Int,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
onToggle: () -> Unit
|
onToggle: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable(onClick = onToggle)
|
.clickable(onClick = onToggle)
|
||||||
.padding(vertical = 12.dp, horizontal = 4.dp),
|
.padding(vertical = 12.dp, horizontal = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.KeyboardArrowRight,
|
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.rotate(if (expanded) 90f else 0f),
|
modifier = Modifier.rotate(if (expanded) 90f else 0f),
|
||||||
tint = MaterialTheme.colorScheme.onSurface
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.KeyboardArrowDown,
|
imageVector = Icons.Filled.KeyboardArrowDown,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -237,23 +285,24 @@ private fun CategoryHeader(
|
|||||||
private fun IconGrid(
|
private fun IconGrid(
|
||||||
items: List<CatalogProvider.CatalogItem>,
|
items: List<CatalogProvider.CatalogItem>,
|
||||||
currentEmoji: String,
|
currentEmoji: String,
|
||||||
onSelectIcon: (String) -> Unit
|
onSelectIcon: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(3),
|
columns = GridCells.Fixed(3),
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(((items.size + 2) / 3 * 100).dp.coerceAtMost(400.dp)),
|
.height(((items.size + 2) / 3 * 100).dp.coerceAtMost(400.dp)),
|
||||||
contentPadding = PaddingValues(4.dp),
|
contentPadding = PaddingValues(4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(items) { item ->
|
items(items) { item ->
|
||||||
IconCard(
|
IconCard(
|
||||||
emoji = item.emoji,
|
emoji = item.emoji,
|
||||||
label = item.name,
|
label = item.name,
|
||||||
isSelected = item.emoji == currentEmoji,
|
isSelected = item.emoji == currentEmoji,
|
||||||
onClick = { onSelectIcon(item.emoji) }
|
onClick = { onSelectIcon(item.emoji) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -264,39 +313,42 @@ private fun IconCard(
|
|||||||
emoji: String,
|
emoji: String,
|
||||||
label: String,
|
label: String,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val backgroundColor = if (isSelected) {
|
val backgroundColor =
|
||||||
|
if (isSelected) {
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
}
|
}
|
||||||
val contentColor = if (isSelected) {
|
val contentColor =
|
||||||
|
if (isSelected) {
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = backgroundColor)
|
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
modifier = Modifier.padding(8.dp)
|
modifier = Modifier.padding(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = emoji,
|
text = emoji,
|
||||||
style = MaterialTheme.typography.headlineMedium
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
@ -305,7 +357,7 @@ private fun IconCard(
|
|||||||
color = contentColor,
|
color = contentColor,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal
|
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
@ -313,12 +365,150 @@ private fun IconCard(
|
|||||||
imageVector = Icons.Filled.Check,
|
imageVector = Icons.Filled.Check,
|
||||||
contentDescription = "Sélectionné",
|
contentDescription = "Sélectionné",
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(4.dp)
|
.padding(4.dp)
|
||||||
.size(20.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
@ -2,9 +2,9 @@ package com.safebite.app.presentation.screen.lists
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.safebite.app.data.local.database.entity.CatalogItemEntity
|
||||||
import com.safebite.app.data.local.database.entity.ShoppingListEntity
|
import com.safebite.app.data.local.database.entity.ShoppingListEntity
|
||||||
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
|
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
|
||||||
import com.safebite.app.data.local.database.entity.CatalogItemEntity
|
|
||||||
import com.safebite.app.data.local.database.relation.DomainWithCategoriesAndItems
|
import com.safebite.app.data.local.database.relation.DomainWithCategoriesAndItems
|
||||||
import com.safebite.app.data.repository.CatalogRepository
|
import com.safebite.app.data.repository.CatalogRepository
|
||||||
import com.safebite.app.domain.engine.CatalogProvider
|
import com.safebite.app.domain.engine.CatalogProvider
|
||||||
@ -40,23 +40,26 @@ import javax.inject.Inject
|
|||||||
* aucune correspondance, on peut créer un *own item*.
|
* aucune correspondance, on peut créer un *own item*.
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ListDetailViewModel @Inject constructor(
|
class ListDetailViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val manageListUseCase: ManageShoppingListUseCase,
|
private val manageListUseCase: ManageShoppingListUseCase,
|
||||||
private val getListsUseCase: GetShoppingListsUseCase,
|
private val getListsUseCase: GetShoppingListsUseCase,
|
||||||
private val categoryEngine: CategoryEngine,
|
private val categoryEngine: CategoryEngine,
|
||||||
private val catalogRepository: CatalogRepository,
|
private val catalogRepository: CatalogRepository,
|
||||||
val catalog: CatalogProvider
|
val catalog: CatalogProvider,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
sealed class UiState {
|
sealed class UiState {
|
||||||
data object Loading : UiState()
|
data object Loading : UiState()
|
||||||
|
|
||||||
data class Ready(
|
data class Ready(
|
||||||
val listId: Long,
|
val listId: Long,
|
||||||
val listName: String,
|
val listName: String,
|
||||||
val list: ShoppingListEntity?,
|
val list: ShoppingListEntity?,
|
||||||
val activeItems: List<ShoppingListItemUi>,
|
val activeItems: List<ShoppingListItemUi>,
|
||||||
val recentlyUsed: List<ShoppingListItemUi>
|
val recentlyUsed: List<ShoppingListItemUi>,
|
||||||
) : UiState()
|
) : UiState()
|
||||||
|
|
||||||
data class Error(val message: String) : UiState()
|
data class Error(val message: String) : UiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,7 +75,7 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
val allergenWarning: String?,
|
val allergenWarning: String?,
|
||||||
val note: String?,
|
val note: String?,
|
||||||
val emoji: String,
|
val emoji: String,
|
||||||
val tag: String?
|
val tags: Set<String> = emptySet(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -87,8 +90,8 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
val variants: List<String>,
|
val variants: List<String>,
|
||||||
val selectedQuantity: Int? = null,
|
val selectedQuantity: Int? = null,
|
||||||
val selectedVariant: String? = null,
|
val selectedVariant: String? = null,
|
||||||
val selectedTag: String? = null,
|
val selectedTags: Set<String> = emptySet(),
|
||||||
val note: String = ""
|
val note: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -104,18 +107,22 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
override val label: String = item.name
|
override val label: String = item.name
|
||||||
override val emoji: String = item.emoji
|
override val emoji: String = item.emoji
|
||||||
}
|
}
|
||||||
|
|
||||||
data class RoomCatalog(val item: CatalogItemEntity, val categoryName: String?) : Suggestion() {
|
data class RoomCatalog(val item: CatalogItemEntity, val categoryName: String?) : Suggestion() {
|
||||||
override val label: String = item.name
|
override val label: String = item.name
|
||||||
override val emoji: String = item.emoji
|
override val emoji: String = item.emoji
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Recent(val item: ShoppingListItemUi) : Suggestion() {
|
data class Recent(val item: ShoppingListItemUi) : Suggestion() {
|
||||||
override val label: String = item.productName
|
override val label: String = item.productName
|
||||||
override val emoji: String = item.emoji
|
override val emoji: String = item.emoji
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Active(val item: ShoppingListItemUi) : Suggestion() {
|
data class Active(val item: ShoppingListItemUi) : Suggestion() {
|
||||||
override val label: String = item.productName
|
override val label: String = item.productName
|
||||||
override val emoji: String = item.emoji
|
override val emoji: String = item.emoji
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Create(val rawText: String) : Suggestion() {
|
data class Create(val rawText: String) : Suggestion() {
|
||||||
override val label: String = rawText
|
override val label: String = rawText
|
||||||
override val emoji: String = "✨"
|
override val emoji: String = "✨"
|
||||||
@ -141,22 +148,28 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
val isSearchActive: StateFlow<Boolean> = _isSearchActive
|
val isSearchActive: StateFlow<Boolean> = _isSearchActive
|
||||||
|
|
||||||
/** Listes disponibles pour l'action "Déplacer l'article". */
|
/** Listes disponibles pour l'action "Déplacer l'article". */
|
||||||
val otherLists: StateFlow<List<ShoppingListEntity>> = getListsUseCase
|
val otherLists: StateFlow<List<ShoppingListEntity>> =
|
||||||
|
getListsUseCase
|
||||||
.observeActive()
|
.observeActive()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
/** Catalogue Room hiérarchique (domaines → catégories → articles). */
|
/** Catalogue Room hiérarchique (domaines → catégories → articles). */
|
||||||
val catalogDomains: StateFlow<List<DomainWithCategoriesAndItems>> = catalogRepository
|
val catalogDomains: StateFlow<List<DomainWithCategoriesAndItems>> =
|
||||||
|
catalogRepository
|
||||||
.observeDomainsWithCategoriesAndItems()
|
.observeDomainsWithCategoriesAndItems()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
fun initList(listId: Long, listName: String) {
|
fun initList(
|
||||||
|
listId: Long,
|
||||||
|
listName: String,
|
||||||
|
) {
|
||||||
_listIdFlow.value = listId
|
_listIdFlow.value = listId
|
||||||
_listName.value = listName
|
_listName.value = listName
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
val state: StateFlow<UiState> = _listIdFlow.flatMapLatest { listId ->
|
val state: StateFlow<UiState> =
|
||||||
|
_listIdFlow.flatMapLatest { listId ->
|
||||||
manageListUseCase.observeItems(listId).map { items ->
|
manageListUseCase.observeItems(listId).map { items ->
|
||||||
val list = getListsUseCase.getList(listId)
|
val list = getListsUseCase.getList(listId)
|
||||||
val ui = items.map { it.toUi() }
|
val ui = items.map { it.toUi() }
|
||||||
@ -164,23 +177,26 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
listId = listId,
|
listId = listId,
|
||||||
listName = list?.name ?: _listName.value,
|
listName = list?.name ?: _listName.value,
|
||||||
list = list,
|
list = list,
|
||||||
activeItems = ui.filterNot { it.isChecked }
|
activeItems =
|
||||||
|
ui.filterNot { it.isChecked }
|
||||||
.sortedBy { it.productName.lowercase() },
|
.sortedBy { it.productName.lowercase() },
|
||||||
recentlyUsed = ui.filter { it.isChecked }
|
recentlyUsed =
|
||||||
|
ui.filter { it.isChecked }
|
||||||
// Most-recently bought first (proxy: addedAt ordering preserved by DAO)
|
// Most-recently bought first (proxy: addedAt ordering preserved by DAO)
|
||||||
.take(MAX_RECENTLY_USED)
|
.take(MAX_RECENTLY_USED),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
initialValue = UiState.Loading
|
initialValue = UiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Liste filtrée des suggestions affichées au-dessus de la barre de saisie. */
|
/** Liste filtrée des suggestions affichées au-dessus de la barre de saisie. */
|
||||||
val suggestions: StateFlow<List<Suggestion>> = combine(
|
val suggestions: StateFlow<List<Suggestion>> =
|
||||||
|
combine(
|
||||||
_searchQuery,
|
_searchQuery,
|
||||||
state
|
state,
|
||||||
) { rawQuery, uiState -> rawQuery to uiState }
|
) { rawQuery, uiState -> rawQuery to uiState }
|
||||||
.flatMapLatest { (rawQuery, uiState) ->
|
.flatMapLatest { (rawQuery, uiState) ->
|
||||||
val query = rawQuery.trim()
|
val query = rawQuery.trim()
|
||||||
@ -204,10 +220,12 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
.forEach { staticResults.add(Suggestion.Recent(it)) }
|
.forEach { staticResults.add(Suggestion.Recent(it)) }
|
||||||
|
|
||||||
// 3) Catalogue statique (fallback si absent de la DB)
|
// 3) Catalogue statique (fallback si absent de la DB)
|
||||||
val staticCatalogItems = catalog.search(query, limit = 20)
|
val staticCatalogItems =
|
||||||
|
catalog.search(query, limit = 20)
|
||||||
.filter { item -> staticResults.none { it.label.equals(item.name, ignoreCase = true) } }
|
.filter { item -> staticResults.none { it.label.equals(item.name, ignoreCase = true) } }
|
||||||
|
|
||||||
val categoryMap = catalogDomains.value.flatMap { it.categoriesWithItems }
|
val categoryMap =
|
||||||
|
catalogDomains.value.flatMap { it.categoriesWithItems }
|
||||||
.associate { it.category.categoryId to it.category.name }
|
.associate { it.category.categoryId to it.category.name }
|
||||||
|
|
||||||
// 3bis) Catalogue Room (tous domaines/catégories + items futurs)
|
// 3bis) Catalogue Room (tous domaines/catégories + items futurs)
|
||||||
@ -272,7 +290,8 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun applySuggestion(suggestion: Suggestion) {
|
fun applySuggestion(suggestion: Suggestion) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val itemId = when (suggestion) {
|
val itemId =
|
||||||
|
when (suggestion) {
|
||||||
is Suggestion.Catalog -> addCatalogItemAndGetId(suggestion.item)
|
is Suggestion.Catalog -> addCatalogItemAndGetId(suggestion.item)
|
||||||
is Suggestion.RoomCatalog -> addCatalogItemAndGetId(suggestion.item)
|
is Suggestion.RoomCatalog -> addCatalogItemAndGetId(suggestion.item)
|
||||||
is Suggestion.Recent -> {
|
is Suggestion.Recent -> {
|
||||||
@ -282,7 +301,8 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
is Suggestion.Active -> suggestion.item.id
|
is Suggestion.Active -> suggestion.item.id
|
||||||
is Suggestion.Create -> addCustomItemAndGetId(suggestion.rawText)
|
is Suggestion.Create -> addCustomItemAndGetId(suggestion.rawText)
|
||||||
}
|
}
|
||||||
val variants = when (suggestion) {
|
val variants =
|
||||||
|
when (suggestion) {
|
||||||
is Suggestion.RoomCatalog ->
|
is Suggestion.RoomCatalog ->
|
||||||
suggestion.item.variants.split(",")
|
suggestion.item.variants.split(",")
|
||||||
.map { it.trim() }.filter { it.isNotEmpty() }
|
.map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
@ -291,15 +311,17 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
is Suggestion.Active -> emptyList()
|
is Suggestion.Active -> emptyList()
|
||||||
is Suggestion.Create -> emptyList()
|
is Suggestion.Create -> emptyList()
|
||||||
}
|
}
|
||||||
val existingItem = manageListUseCase.getItems(_listIdFlow.value)
|
val existingItem =
|
||||||
|
manageListUseCase.getItems(_listIdFlow.value)
|
||||||
.firstOrNull { it.id == itemId }
|
.firstOrNull { it.id == itemId }
|
||||||
_pendingItem.value = PendingItem(
|
_pendingItem.value =
|
||||||
|
PendingItem(
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
name = suggestion.label,
|
name = suggestion.label,
|
||||||
emoji = suggestion.emoji,
|
emoji = suggestion.emoji,
|
||||||
variants = variants,
|
variants = variants,
|
||||||
selectedTag = existingItem?.tag,
|
selectedTags = existingItem?.parseTags() ?: emptySet(),
|
||||||
note = existingItem?.note.orEmpty()
|
note = existingItem?.note.orEmpty(),
|
||||||
)
|
)
|
||||||
_searchQuery.value = ""
|
_searchQuery.value = ""
|
||||||
}
|
}
|
||||||
@ -321,15 +343,22 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
savePendingNote(updated)
|
savePendingNote(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Met à jour le tag priorité et sauvegarde en DB. */
|
/** Active/désactive un tag dans le panneau pending et sauvegarde en DB. */
|
||||||
fun updatePendingTag(tag: String?) {
|
fun togglePendingTag(tag: String) {
|
||||||
val pending = _pendingItem.value ?: return
|
val pending = _pendingItem.value ?: return
|
||||||
_pendingItem.value = pending.copy(selectedTag = tag)
|
val newTags =
|
||||||
|
if (pending.selectedTags.contains(tag)) {
|
||||||
|
pending.selectedTags - tag
|
||||||
|
} else {
|
||||||
|
pending.selectedTags + tag
|
||||||
|
}
|
||||||
|
_pendingItem.value = pending.copy(selectedTags = newTags)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val listId = _listIdFlow.value
|
val listId = _listIdFlow.value
|
||||||
val item = manageListUseCase.getItems(listId)
|
val item =
|
||||||
|
manageListUseCase.getItems(listId)
|
||||||
.firstOrNull { it.id == pending.itemId } ?: return@launch
|
.firstOrNull { it.id == pending.itemId } ?: return@launch
|
||||||
manageListUseCase.updateItem(item.copy(tag = tag))
|
manageListUseCase.updateItem(item.copy(tag = newTags.serializeTags()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -344,7 +373,8 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
private fun savePendingNote(pending: PendingItem) {
|
private fun savePendingNote(pending: PendingItem) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val listId = _listIdFlow.value
|
val listId = _listIdFlow.value
|
||||||
val item = manageListUseCase.getItems(listId)
|
val item =
|
||||||
|
manageListUseCase.getItems(listId)
|
||||||
.firstOrNull { it.id == pending.itemId } ?: return@launch
|
.firstOrNull { it.id == pending.itemId } ?: return@launch
|
||||||
manageListUseCase.updateItem(item.copy(note = buildNote(pending)))
|
manageListUseCase.updateItem(item.copy(note = buildNote(pending)))
|
||||||
}
|
}
|
||||||
@ -361,7 +391,8 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun addCatalogItemAndGetId(catalogItem: CatalogProvider.CatalogItem): Long {
|
private suspend fun addCatalogItemAndGetId(catalogItem: CatalogProvider.CatalogItem): Long {
|
||||||
val listId = _listIdFlow.value
|
val listId = _listIdFlow.value
|
||||||
val existing = manageListUseCase.getItems(listId)
|
val existing =
|
||||||
|
manageListUseCase.getItems(listId)
|
||||||
.firstOrNull { it.productName.equals(catalogItem.name, ignoreCase = true) }
|
.firstOrNull { it.productName.equals(catalogItem.name, ignoreCase = true) }
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
||||||
@ -371,20 +402,22 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
ShoppingListItemEntity(
|
ShoppingListItemEntity(
|
||||||
listId = listId,
|
listId = listId,
|
||||||
productName = catalogItem.name,
|
productName = catalogItem.name,
|
||||||
category = catalogItem.category
|
category = catalogItem.category,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun addCatalogItemAndGetId(item: CatalogItemEntity): Long {
|
private suspend fun addCatalogItemAndGetId(item: CatalogItemEntity): Long {
|
||||||
val listId = _listIdFlow.value
|
val listId = _listIdFlow.value
|
||||||
val existing = manageListUseCase.getItems(listId)
|
val existing =
|
||||||
|
manageListUseCase.getItems(listId)
|
||||||
.firstOrNull { it.productName.equals(item.name, ignoreCase = true) }
|
.firstOrNull { it.productName.equals(item.name, ignoreCase = true) }
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
||||||
return existing.id
|
return existing.id
|
||||||
}
|
}
|
||||||
val categoryName = item.primaryCategoryId?.let { catId ->
|
val categoryName =
|
||||||
|
item.primaryCategoryId?.let { catId ->
|
||||||
catalogRepository.getCategory(catId)?.name
|
catalogRepository.getCategory(catId)?.name
|
||||||
} ?: categoryEngine.detectCategory(item.name)
|
} ?: categoryEngine.detectCategory(item.name)
|
||||||
return manageListUseCase.addItem(
|
return manageListUseCase.addItem(
|
||||||
@ -392,8 +425,8 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
listId = listId,
|
listId = listId,
|
||||||
productName = item.name,
|
productName = item.name,
|
||||||
category = categoryName,
|
category = categoryName,
|
||||||
customEmoji = item.emoji
|
customEmoji = item.emoji,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -401,7 +434,8 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
val trimmed = rawText.trim().ifEmpty { return -1L }
|
val trimmed = rawText.trim().ifEmpty { return -1L }
|
||||||
val (quantity, name) = parseQuantityAndName(trimmed)
|
val (quantity, name) = parseQuantityAndName(trimmed)
|
||||||
val listId = _listIdFlow.value
|
val listId = _listIdFlow.value
|
||||||
val existing = manageListUseCase.getItems(listId)
|
val existing =
|
||||||
|
manageListUseCase.getItems(listId)
|
||||||
.firstOrNull { it.productName.equals(name, ignoreCase = true) }
|
.firstOrNull { it.productName.equals(name, ignoreCase = true) }
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
if (existing.isChecked) manageListUseCase.setItemChecked(existing.id, false)
|
||||||
@ -413,8 +447,8 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
listId = listId,
|
listId = listId,
|
||||||
productName = name,
|
productName = name,
|
||||||
category = category,
|
category = category,
|
||||||
note = quantity
|
note = quantity,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -430,23 +464,30 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
fun addCatalogItem(item: CatalogItemEntity) {
|
fun addCatalogItem(item: CatalogItemEntity) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val itemId = addCatalogItemAndGetId(item)
|
val itemId = addCatalogItemAndGetId(item)
|
||||||
val variants = item.variants.split(",")
|
val variants =
|
||||||
|
item.variants.split(",")
|
||||||
.map { it.trim() }.filter { it.isNotEmpty() }
|
.map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
val existingItem = manageListUseCase.getItems(_listIdFlow.value)
|
val existingItem =
|
||||||
|
manageListUseCase.getItems(_listIdFlow.value)
|
||||||
.firstOrNull { it.id == itemId }
|
.firstOrNull { it.id == itemId }
|
||||||
_pendingItem.value = PendingItem(
|
_pendingItem.value =
|
||||||
|
PendingItem(
|
||||||
itemId = itemId,
|
itemId = itemId,
|
||||||
name = item.name,
|
name = item.name,
|
||||||
emoji = item.emoji,
|
emoji = item.emoji,
|
||||||
variants = variants,
|
variants = variants,
|
||||||
selectedTag = existingItem?.tag,
|
selectedTags = existingItem?.parseTags() ?: emptySet(),
|
||||||
note = existingItem?.note.orEmpty()
|
note = existingItem?.note.orEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Crée un item avec photo et description. */
|
/** Crée un item avec photo et description. */
|
||||||
fun addCustomItemWithImage(name: String, note: String?, imageUri: String?) {
|
fun addCustomItemWithImage(
|
||||||
|
name: String,
|
||||||
|
note: String?,
|
||||||
|
imageUri: String?,
|
||||||
|
) {
|
||||||
val trimmedName = name.trim()
|
val trimmedName = name.trim()
|
||||||
if (trimmedName.isEmpty()) return
|
if (trimmedName.isEmpty()) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@ -460,8 +501,8 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
category = category,
|
category = category,
|
||||||
note = note?.trim()?.ifEmpty { null },
|
note = note?.trim()?.ifEmpty { null },
|
||||||
imageUrl = imageUri,
|
imageUrl = imageUri,
|
||||||
isChecked = false
|
isChecked = false,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -516,7 +557,10 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Met à jour la note (quantité / description) d'un article. */
|
/** Met à jour la note (quantité / description) d'un article. */
|
||||||
fun updateItemNote(id: Long, note: String) {
|
fun updateItemNote(
|
||||||
|
id: Long,
|
||||||
|
note: String,
|
||||||
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val listId = _listIdFlow.value
|
val listId = _listIdFlow.value
|
||||||
val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch
|
val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch
|
||||||
@ -525,7 +569,10 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Change la catégorie/section d'un article. */
|
/** Change la catégorie/section d'un article. */
|
||||||
fun updateItemCategory(id: Long, category: String) {
|
fun updateItemCategory(
|
||||||
|
id: Long,
|
||||||
|
category: String,
|
||||||
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val listId = _listIdFlow.value
|
val listId = _listIdFlow.value
|
||||||
val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch
|
val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch
|
||||||
@ -533,17 +580,25 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Change le tag visuel d'un article. */
|
/** Active/désactive un tag visuel d'un article ( depuis le long-press sheet ). */
|
||||||
fun updateItemTag(id: Long, tag: String?) {
|
fun toggleItemTag(
|
||||||
|
id: Long,
|
||||||
|
tag: String,
|
||||||
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val listId = _listIdFlow.value
|
val listId = _listIdFlow.value
|
||||||
val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch
|
val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch
|
||||||
manageListUseCase.updateItem(item.copy(tag = tag))
|
val currentTags = item.parseTags()
|
||||||
|
val newTags = if (currentTags.contains(tag)) currentTags - tag else currentTags + tag
|
||||||
|
manageListUseCase.updateItem(item.copy(tag = newTags.serializeTags()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Change l'image (URL/URI) d'un article. */
|
/** Change l'image (URL/URI) d'un article. */
|
||||||
fun updateItemImageUrl(id: Long, imageUrl: String?) {
|
fun updateItemImageUrl(
|
||||||
|
id: Long,
|
||||||
|
imageUrl: String?,
|
||||||
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val listId = _listIdFlow.value
|
val listId = _listIdFlow.value
|
||||||
val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch
|
val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch
|
||||||
@ -552,7 +607,10 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Change l'emoji personnalisé d'un article. */
|
/** Change l'emoji personnalisé d'un article. */
|
||||||
fun updateItemEmoji(id: Long, emoji: String?) {
|
fun updateItemEmoji(
|
||||||
|
id: Long,
|
||||||
|
emoji: String?,
|
||||||
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val listId = _listIdFlow.value
|
val listId = _listIdFlow.value
|
||||||
val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch
|
val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch
|
||||||
@ -561,7 +619,10 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Déplace un article vers une autre liste. */
|
/** Déplace un article vers une autre liste. */
|
||||||
fun moveItemToList(id: Long, targetListId: Long) {
|
fun moveItemToList(
|
||||||
|
id: Long,
|
||||||
|
targetListId: Long,
|
||||||
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val sourceListId = _listIdFlow.value
|
val sourceListId = _listIdFlow.value
|
||||||
if (targetListId == sourceListId) return@launch
|
if (targetListId == sourceListId) return@launch
|
||||||
@ -569,7 +630,7 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
manageListUseCase.deleteItem(item)
|
manageListUseCase.deleteItem(item)
|
||||||
manageListUseCase.addItemToList(
|
manageListUseCase.addItemToList(
|
||||||
targetListId,
|
targetListId,
|
||||||
item.copy(id = 0L, listId = targetListId, isChecked = false)
|
item.copy(id = 0L, listId = targetListId, isChecked = false),
|
||||||
)
|
)
|
||||||
if (_selectedItemId.value == id) _selectedItemId.value = null
|
if (_selectedItemId.value == id) _selectedItemId.value = null
|
||||||
}
|
}
|
||||||
@ -582,7 +643,10 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun shareList(listName: String, items: List<ShoppingListItemUi>): String {
|
fun shareList(
|
||||||
|
listName: String,
|
||||||
|
items: List<ShoppingListItemUi>,
|
||||||
|
): String {
|
||||||
val active = items.filterNot { it.isChecked }
|
val active = items.filterNot { it.isChecked }
|
||||||
val sb = StringBuilder()
|
val sb = StringBuilder()
|
||||||
sb.appendLine("📋 $listName")
|
sb.appendLine("📋 $listName")
|
||||||
@ -595,7 +659,7 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
categoryItems.forEach { item ->
|
categoryItems.forEach { item ->
|
||||||
val note = item.note?.let { " — $it" } ?: ""
|
val note = item.note?.let { " — $it" } ?: ""
|
||||||
val warning = if (!item.allergenWarning.isNullOrBlank()) " ⚠️${item.allergenWarning}" else ""
|
val warning = if (!item.allergenWarning.isNullOrBlank()) " ⚠️${item.allergenWarning}" else ""
|
||||||
sb.appendLine(" ☐ ${item.productName}${note}${warning}")
|
sb.appendLine(" ☐ ${item.productName}${note}$warning")
|
||||||
}
|
}
|
||||||
sb.appendLine()
|
sb.appendLine()
|
||||||
}
|
}
|
||||||
@ -620,7 +684,8 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ShoppingListItemEntity.toUi() = ShoppingListItemUi(
|
private fun ShoppingListItemEntity.toUi() =
|
||||||
|
ShoppingListItemUi(
|
||||||
id = id,
|
id = id,
|
||||||
barcode = barcode,
|
barcode = barcode,
|
||||||
productName = productName,
|
productName = productName,
|
||||||
@ -632,9 +697,14 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
allergenWarning = allergenWarning,
|
allergenWarning = allergenWarning,
|
||||||
note = note,
|
note = note,
|
||||||
emoji = customEmoji ?: catalog.emojiFor(productName, category),
|
emoji = customEmoji ?: catalog.emojiFor(productName, category),
|
||||||
tag = tag
|
tags = parseTags(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun ShoppingListItemEntity.parseTags(): Set<String> =
|
||||||
|
tag?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet() ?: emptySet()
|
||||||
|
|
||||||
|
private fun Set<String>.serializeTags(): String? = if (isEmpty()) null else joinToString(",")
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MAX_RECENTLY_USED = 30
|
private const val MAX_RECENTLY_USED = 30
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
package com.safebite.app.presentation.screen.lists
|
package com.safebite.app.presentation.screen.lists
|
||||||
|
|
||||||
import android.content.res.Resources
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@ -25,9 +23,7 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.DragHandle
|
import androidx.compose.material.icons.filled.DragHandle
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
@ -62,7 +58,6 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@ -72,7 +67,6 @@ import com.safebite.app.presentation.common.components.EmptyState
|
|||||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||||
import com.safebite.app.presentation.screen.lists.util.backgroundByResName
|
import com.safebite.app.presentation.screen.lists.util.backgroundByResName
|
||||||
import com.safebite.app.presentation.theme.LocalDimens
|
import com.safebite.app.presentation.theme.LocalDimens
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -82,7 +76,7 @@ fun ListsScreen(
|
|||||||
onOpenScanner: () -> Unit,
|
onOpenScanner: () -> Unit,
|
||||||
onOpenListCreate: () -> Unit,
|
onOpenListCreate: () -> Unit,
|
||||||
onOpenListSettings: (Long) -> Unit,
|
onOpenListSettings: (Long) -> Unit,
|
||||||
viewModel: ListsViewModel = hiltViewModel()
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val isEditMode by viewModel.isEditMode.collectAsStateWithLifecycle()
|
val isEditMode by viewModel.isEditMode.collectAsStateWithLifecycle()
|
||||||
@ -108,7 +102,7 @@ fun ListsScreen(
|
|||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@ -116,16 +110,17 @@ fun ListsScreen(
|
|||||||
onClick = onOpenListCreate,
|
onClick = onOpenListCreate,
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
shape = MaterialTheme.shapes.medium
|
shape = MaterialTheme.shapes.medium,
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.lists_new))
|
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.lists_new))
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding),
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is ListsViewModel.UiState.Loading -> {
|
is ListsViewModel.UiState.Loading -> {
|
||||||
@ -139,9 +134,9 @@ fun ListsScreen(
|
|||||||
action = {
|
action = {
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
text = stringResource(R.string.lists_new),
|
text = stringResource(R.string.lists_new),
|
||||||
onClick = onOpenListCreate
|
onClick = onOpenListCreate,
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is ListsViewModel.UiState.Success -> {
|
is ListsViewModel.UiState.Success -> {
|
||||||
@ -150,14 +145,14 @@ fun ListsScreen(
|
|||||||
isEditMode = isEditMode,
|
isEditMode = isEditMode,
|
||||||
onItemClick = { item -> onOpenList(item.list.id, item.list.name) },
|
onItemClick = { item -> onOpenList(item.list.id, item.list.name) },
|
||||||
onSettingsClick = { item -> onOpenListSettings(item.list.id) },
|
onSettingsClick = { item -> onOpenListSettings(item.list.id) },
|
||||||
onReorder = { from, to -> viewModel.reorderLists(from, to) }
|
onReorder = { from, to -> viewModel.reorderLists(from, to) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is ListsViewModel.UiState.Error -> {
|
is ListsViewModel.UiState.Error -> {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = "Erreur",
|
title = "Erreur",
|
||||||
message = s.message,
|
message = s.message,
|
||||||
emoji = "❌"
|
emoji = "❌",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -171,7 +166,7 @@ private fun ReorderableList(
|
|||||||
isEditMode: Boolean,
|
isEditMode: Boolean,
|
||||||
onItemClick: (ListsViewModel.ShoppingListWithStats) -> Unit,
|
onItemClick: (ListsViewModel.ShoppingListWithStats) -> Unit,
|
||||||
onSettingsClick: (ListsViewModel.ShoppingListWithStats) -> Unit,
|
onSettingsClick: (ListsViewModel.ShoppingListWithStats) -> Unit,
|
||||||
onReorder: (Int, Int) -> Unit
|
onReorder: (Int, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
var draggedIndex by remember { mutableStateOf<Int?>(null) }
|
var draggedIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||||
@ -182,11 +177,11 @@ private fun ReorderableList(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = items,
|
items = items,
|
||||||
key = { _, item -> item.list.id }
|
key = { _, item -> item.list.id },
|
||||||
) { index, item ->
|
) { index, item ->
|
||||||
val isDragged = draggedIndex == index
|
val isDragged = draggedIndex == index
|
||||||
val zIndex = if (isDragged) 1f else 0f
|
val zIndex = if (isDragged) 1f else 0f
|
||||||
@ -194,7 +189,8 @@ private fun ReorderableList(
|
|||||||
val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0
|
val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.zIndex(zIndex)
|
.zIndex(zIndex)
|
||||||
.offset { IntOffset(0, offsetY) }
|
.offset { IntOffset(0, offsetY) }
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
@ -208,7 +204,8 @@ private fun ReorderableList(
|
|||||||
onDragStart = { draggedIndex = index },
|
onDragStart = { draggedIndex = index },
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
draggedIndex?.let { from ->
|
draggedIndex?.let { from ->
|
||||||
val to = (from + (dragOffsetY / itemPx).roundToInt())
|
val to =
|
||||||
|
(from + (dragOffsetY / itemPx).roundToInt())
|
||||||
.coerceIn(0, items.size - 1)
|
.coerceIn(0, items.size - 1)
|
||||||
if (from != to) onReorder(from, to)
|
if (from != to) onReorder(from, to)
|
||||||
}
|
}
|
||||||
@ -222,17 +219,19 @@ private fun ReorderableList(
|
|||||||
onDrag = { change, dragAmount ->
|
onDrag = { change, dragAmount ->
|
||||||
change.consume()
|
change.consume()
|
||||||
dragOffsetY += dragAmount.y
|
dragOffsetY += dragAmount.y
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else Modifier
|
} else {
|
||||||
)
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
ShoppingListCard(
|
ShoppingListCard(
|
||||||
item = item,
|
item = item,
|
||||||
isEditMode = isEditMode,
|
isEditMode = isEditMode,
|
||||||
onClick = { onItemClick(item) },
|
onClick = { onItemClick(item) },
|
||||||
onSettingsClick = { onSettingsClick(item) }
|
onSettingsClick = { onSettingsClick(item) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -244,18 +243,19 @@ private fun ShoppingListCard(
|
|||||||
item: ListsViewModel.ShoppingListWithStats,
|
item: ListsViewModel.ShoppingListWithStats,
|
||||||
isEditMode: Boolean,
|
isEditMode: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onSettingsClick: () -> Unit
|
onSettingsClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
val bg = backgroundByResName(item.list.backgroundResName)
|
val bg = backgroundByResName(item.list.backgroundResName)
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(160.dp)
|
.height(160.dp)
|
||||||
.then(if (!isEditMode) Modifier.clickable(onClick = onClick) else Modifier),
|
.then(if (!isEditMode) Modifier.clickable(onClick = onClick) else Modifier),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// Background image
|
// Background image
|
||||||
@ -264,37 +264,40 @@ private fun ShoppingListCard(
|
|||||||
painter = painterResource(id = bg.drawableRes),
|
painter = painterResource(id = bg.drawableRes),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black.copy(alpha = 0.35f))
|
.background(Color.Black.copy(alpha = 0.35f)),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(dimens.spacingMd)
|
.padding(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.DragHandle,
|
imageVector = Icons.Filled.DragHandle,
|
||||||
contentDescription = "Reorder",
|
contentDescription = "Reorder",
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier.size(24.dp),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Spacer(modifier = Modifier.width(24.dp))
|
Spacer(modifier = Modifier.width(24.dp))
|
||||||
@ -302,13 +305,13 @@ private fun ShoppingListCard(
|
|||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onSettingsClick,
|
onClick = onSettingsClick,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Settings,
|
imageVector = Icons.Filled.Settings,
|
||||||
contentDescription = stringResource(R.string.lists_settings),
|
contentDescription = stringResource(R.string.lists_settings),
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -316,7 +319,8 @@ private fun ShoppingListCard(
|
|||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
// List name
|
// List name
|
||||||
val regionFlagEmoji = item.list.region?.let { code ->
|
val regionFlagEmoji =
|
||||||
|
item.list.region?.let { code ->
|
||||||
when (code) {
|
when (code) {
|
||||||
"de" -> "🇩🇪"
|
"de" -> "🇩🇪"
|
||||||
"au" -> "🇦🇺"
|
"au" -> "🇦🇺"
|
||||||
@ -342,14 +346,14 @@ private fun ShoppingListCard(
|
|||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// Item count badge
|
// Item count badge
|
||||||
val remaining = item.itemCount - item.checkedCount
|
val remaining = item.itemCount - item.checkedCount
|
||||||
@ -358,16 +362,17 @@ private fun ShoppingListCard(
|
|||||||
text = "$remaining articles",
|
text = "$remaining articles",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.background(badgeColor.copy(alpha = 0.85f), RoundedCornerShape(12.dp))
|
.background(badgeColor.copy(alpha = 0.85f), RoundedCornerShape(12.dp))
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
// Member avatars
|
// Member avatars
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy((-8).dp)
|
horizontalArrangement = Arrangement.spacedBy((-8).dp),
|
||||||
) {
|
) {
|
||||||
item.members.take(3).forEach { member ->
|
item.members.take(3).forEach { member ->
|
||||||
MemberAvatar(member = member)
|
MemberAvatar(member = member)
|
||||||
@ -382,17 +387,18 @@ private fun ShoppingListCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MemberAvatar(member: ShoppingListMemberEntity) {
|
private fun MemberAvatar(member: ShoppingListMemberEntity) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = member.name.take(1).uppercase(),
|
text = member.name.take(1).uppercase(),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,8 @@ import com.safebite.app.data.local.database.entity.ShoppingListMemberEntity
|
|||||||
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
|
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
@ -21,16 +21,20 @@ import javax.inject.Inject
|
|||||||
* ViewModel pour l'écran Listes (Phase 2).
|
* ViewModel pour l'écran Listes (Phase 2).
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ListsViewModel @Inject constructor(
|
class ListsViewModel
|
||||||
private val getShoppingListsUseCase: GetShoppingListsUseCase
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val getShoppingListsUseCase: GetShoppingListsUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
sealed class UiState {
|
sealed class UiState {
|
||||||
object Loading : UiState()
|
object Loading : UiState()
|
||||||
|
|
||||||
data class Success(
|
data class Success(
|
||||||
val lists: List<ShoppingListWithStats>
|
val lists: List<ShoppingListWithStats>,
|
||||||
) : UiState()
|
) : UiState()
|
||||||
|
|
||||||
data class Empty(val message: String = "") : UiState()
|
data class Empty(val message: String = "") : UiState()
|
||||||
|
|
||||||
data class Error(val message: String) : UiState()
|
data class Error(val message: String) : UiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,23 +42,25 @@ class ListsViewModel @Inject constructor(
|
|||||||
val list: ShoppingListEntity,
|
val list: ShoppingListEntity,
|
||||||
val itemCount: Int,
|
val itemCount: Int,
|
||||||
val checkedCount: Int,
|
val checkedCount: Int,
|
||||||
val members: List<ShoppingListMemberEntity> = emptyList()
|
val members: List<ShoppingListMemberEntity> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val _isEditMode = MutableStateFlow(false)
|
private val _isEditMode = MutableStateFlow(false)
|
||||||
val isEditMode: StateFlow<Boolean> = _isEditMode
|
val isEditMode: StateFlow<Boolean> = _isEditMode
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
val state: StateFlow<UiState> = getShoppingListsUseCase.observeActive()
|
val state: StateFlow<UiState> =
|
||||||
|
getShoppingListsUseCase.observeActive()
|
||||||
.flatMapLatest { lists ->
|
.flatMapLatest { lists ->
|
||||||
if (lists.isEmpty()) {
|
if (lists.isEmpty()) {
|
||||||
flowOf(UiState.Empty("Aucune liste de courses. Créez votre première liste !"))
|
flowOf(UiState.Empty("Aucune liste de courses. Créez votre première liste !"))
|
||||||
} else {
|
} else {
|
||||||
val statsFlows = lists.sortedBy { it.displayOrder }.map { list ->
|
val statsFlows =
|
||||||
|
lists.sortedBy { it.displayOrder }.map { list ->
|
||||||
combine(
|
combine(
|
||||||
getShoppingListsUseCase.observeItemCount(list.id),
|
getShoppingListsUseCase.observeItemCount(list.id),
|
||||||
getShoppingListsUseCase.observeCheckedCount(list.id),
|
getShoppingListsUseCase.observeCheckedCount(list.id),
|
||||||
getShoppingListsUseCase.observeMembers(list.id)
|
getShoppingListsUseCase.observeMembers(list.id),
|
||||||
) { itemCount, checkedCount, members ->
|
) { itemCount, checkedCount, members ->
|
||||||
ShoppingListWithStats(list, itemCount, checkedCount, members.take(3))
|
ShoppingListWithStats(list, itemCount, checkedCount, members.take(3))
|
||||||
}
|
}
|
||||||
@ -67,10 +73,13 @@ class ListsViewModel @Inject constructor(
|
|||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5000),
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
initialValue = UiState.Loading
|
initialValue = UiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun createList(name: String, backgroundResName: String? = null) {
|
fun createList(
|
||||||
|
name: String,
|
||||||
|
backgroundResName: String? = null,
|
||||||
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
getShoppingListsUseCase.createList(name, backgroundResName)
|
getShoppingListsUseCase.createList(name, backgroundResName)
|
||||||
}
|
}
|
||||||
@ -92,7 +101,10 @@ class ListsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reorderLists(fromIndex: Int, toIndex: Int) {
|
fun reorderLists(
|
||||||
|
fromIndex: Int,
|
||||||
|
toIndex: Int,
|
||||||
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val current = state.value as? UiState.Success ?: return@launch
|
val current = state.value as? UiState.Success ?: return@launch
|
||||||
val mutable = current.lists.toMutableList()
|
val mutable = current.lists.toMutableList()
|
||||||
@ -100,7 +112,7 @@ class ListsViewModel @Inject constructor(
|
|||||||
mutable.add(toIndex.coerceIn(0, mutable.size), moved)
|
mutable.add(toIndex.coerceIn(0, mutable.size), moved)
|
||||||
mutable.forEachIndexed { index, item ->
|
mutable.forEachIndexed { index, item ->
|
||||||
getShoppingListsUseCase.updateList(
|
getShoppingListsUseCase.updateList(
|
||||||
item.list.copy(displayOrder = index)
|
item.list.copy(displayOrder = index),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
package com.safebite.app.presentation.screen.lists.create
|
package com.safebite.app.presentation.screen.lists.create
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@ -22,9 +21,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@ -57,7 +54,7 @@ import com.safebite.app.presentation.screen.lists.util.allListBackgrounds
|
|||||||
fun CreateListScreen(
|
fun CreateListScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onListCreated: () -> Unit,
|
onListCreated: () -> Unit,
|
||||||
viewModel: ListsViewModel = hiltViewModel()
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
var listName by remember { mutableStateOf("") }
|
var listName by remember { mutableStateOf("") }
|
||||||
var selectedBg by remember { mutableStateOf(allListBackgrounds.firstOrNull()?.resName) }
|
var selectedBg by remember { mutableStateOf(allListBackgrounds.firstOrNull()?.resName) }
|
||||||
@ -79,26 +76,27 @@ fun CreateListScreen(
|
|||||||
onListCreated()
|
onListCreated()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = listName.isNotBlank()
|
enabled = listName.isNotBlank(),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.list_create_next))
|
Text(stringResource(R.string.list_create_next))
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(16.dp)
|
.padding(16.dp),
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = listName,
|
value = listName,
|
||||||
onValueChange = { listName = it },
|
onValueChange = { listName = it },
|
||||||
label = { Text(stringResource(R.string.list_name_hint)) },
|
label = { Text(stringResource(R.string.list_name_hint)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@ -106,7 +104,7 @@ fun CreateListScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.list_choose_background),
|
text = stringResource(R.string.list_choose_background),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@ -116,43 +114,46 @@ fun CreateListScreen(
|
|||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
items(allListBackgrounds) { bg ->
|
items(allListBackgrounds) { bg ->
|
||||||
val isSelected = selectedBg == bg.resName
|
val isSelected = selectedBg == bg.resName
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(1.5f),
|
.aspectRatio(1.5f),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
|
border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
|
||||||
onClick = { selectedBg = bg.resName }
|
onClick = { selectedBg = bg.resName },
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(id = bg.drawableRes),
|
painter = painterResource(id = bg.drawableRes),
|
||||||
contentDescription = bg.label,
|
contentDescription = bg.label,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
contentAlignment = Alignment.TopEnd
|
contentAlignment = Alignment.TopEnd,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(28.dp)
|
.size(28.dp)
|
||||||
.background(Color.White, RoundedCornerShape(14.dp)),
|
.background(Color.White, RoundedCornerShape(14.dp)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Check,
|
imageVector = Icons.Filled.Check,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(18.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,6 @@ import androidx.compose.material3.Card
|
|||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@ -38,7 +37,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@ -53,7 +51,7 @@ import com.safebite.app.presentation.screen.lists.ListsViewModel
|
|||||||
fun ListMembersScreen(
|
fun ListMembersScreen(
|
||||||
listId: Long,
|
listId: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: ListsViewModel = hiltViewModel()
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
|
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
|
||||||
@ -67,15 +65,16 @@ fun ListMembersScreen(
|
|||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
if (listData == null) {
|
if (listData == null) {
|
||||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
@ -85,19 +84,19 @@ fun ListMembersScreen(
|
|||||||
text = stringResource(R.string.list_members_count, members.size),
|
text = stringResource(R.string.list_members_count, members.size),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(vertical = 8.dp)
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
items(members) { member ->
|
items(members) { member ->
|
||||||
MemberRow(
|
MemberRow(
|
||||||
member = member,
|
member = member,
|
||||||
onRemove = {
|
onRemove = {
|
||||||
// TODO: remove member via viewmodel/usecase
|
// TODO: remove member via viewmodel/usecase
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -108,15 +107,16 @@ fun ListMembersScreen(
|
|||||||
onClick = { /* TODO: invite UI placeholder */ },
|
onClick = { /* TODO: invite UI placeholder */ },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors =
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Add,
|
imageVector = Icons.Filled.Add,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(stringResource(R.string.list_invite_member))
|
Text(stringResource(R.string.list_invite_member))
|
||||||
@ -132,33 +132,36 @@ fun ListMembersScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MemberRow(
|
private fun MemberRow(
|
||||||
member: ShoppingListMemberEntity,
|
member: ShoppingListMemberEntity,
|
||||||
onRemove: () -> Unit
|
onRemove: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
CardDefaults.cardColors(
|
||||||
)
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = member.name.take(1).uppercase(),
|
text = member.name.take(1).uppercase(),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,12 +171,12 @@ private fun MemberRow(
|
|||||||
Text(
|
Text(
|
||||||
text = member.name,
|
text = member.name,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = member.email,
|
text = member.email,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,7 +184,7 @@ private fun MemberRow(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.RemoveCircleOutline,
|
imageVector = Icons.Filled.RemoveCircleOutline,
|
||||||
contentDescription = stringResource(R.string.list_remove_member),
|
contentDescription = stringResource(R.string.list_remove_member),
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@ -57,7 +56,7 @@ import com.safebite.app.presentation.screen.lists.util.backgroundByResName
|
|||||||
fun ListNameImageScreen(
|
fun ListNameImageScreen(
|
||||||
listId: Long,
|
listId: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: ListsViewModel = hiltViewModel()
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
|
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
|
||||||
@ -68,9 +67,10 @@ fun ListNameImageScreen(
|
|||||||
|
|
||||||
val onSave = {
|
val onSave = {
|
||||||
listData?.let {
|
listData?.let {
|
||||||
val updated = it.list.copy(
|
val updated =
|
||||||
|
it.list.copy(
|
||||||
name = listName.ifBlank { it.list.name },
|
name = listName.ifBlank { it.list.name },
|
||||||
backgroundResName = selectedBg
|
backgroundResName = selectedBg,
|
||||||
)
|
)
|
||||||
viewModel.updateList(updated)
|
viewModel.updateList(updated)
|
||||||
}
|
}
|
||||||
@ -90,18 +90,19 @@ fun ListNameImageScreen(
|
|||||||
IconButton(onClick = onSave) {
|
IconButton(onClick = onSave) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Check,
|
imageVector = Icons.Filled.Check,
|
||||||
contentDescription = stringResource(R.string.action_save)
|
contentDescription = stringResource(R.string.action_save),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
if (listData == null) {
|
if (listData == null) {
|
||||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
@ -110,10 +111,11 @@ fun ListNameImageScreen(
|
|||||||
// Preview
|
// Preview
|
||||||
val bg = backgroundByResName(selectedBg)
|
val bg = backgroundByResName(selectedBg)
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(120.dp),
|
.height(120.dp),
|
||||||
shape = RoundedCornerShape(16.dp)
|
shape = RoundedCornerShape(16.dp),
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
if (bg != null) {
|
if (bg != null) {
|
||||||
@ -121,18 +123,20 @@ fun ListNameImageScreen(
|
|||||||
painter = painterResource(id = bg.drawableRes),
|
painter = painterResource(id = bg.drawableRes),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black.copy(alpha = 0.35f))
|
.background(Color.Black.copy(alpha = 0.35f)),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
@ -140,9 +144,10 @@ fun ListNameImageScreen(
|
|||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.padding(16.dp)
|
.padding(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -154,7 +159,7 @@ fun ListNameImageScreen(
|
|||||||
onValueChange = { listName = it },
|
onValueChange = { listName = it },
|
||||||
label = { Text(stringResource(R.string.list_name_hint)) },
|
label = { Text(stringResource(R.string.list_name_hint)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@ -162,7 +167,7 @@ fun ListNameImageScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.list_choose_background),
|
text = stringResource(R.string.list_choose_background),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
@ -172,45 +177,49 @@ fun ListNameImageScreen(
|
|||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f),
|
||||||
) {
|
) {
|
||||||
items(allListBackgrounds) { bg ->
|
items(allListBackgrounds) { bg ->
|
||||||
val isSelected = selectedBg == bg.resName
|
val isSelected = selectedBg == bg.resName
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(1.5f),
|
.aspectRatio(1.5f),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
|
border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
|
||||||
onClick = { selectedBg = bg.resName }
|
onClick = { selectedBg = bg.resName },
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(id = bg.drawableRes),
|
painter = painterResource(id = bg.drawableRes),
|
||||||
contentDescription = bg.label,
|
contentDescription = bg.label,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
contentAlignment = Alignment.TopEnd
|
contentAlignment = Alignment.TopEnd,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(28.dp)
|
.size(28.dp)
|
||||||
.background(Color.White, RoundedCornerShape(14.dp)),
|
.background(Color.White, RoundedCornerShape(14.dp)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Check,
|
imageVector = Icons.Filled.Check,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(18.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@ -35,7 +34,6 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@ -44,7 +42,8 @@ import com.safebite.app.presentation.screen.lists.ListsViewModel
|
|||||||
|
|
||||||
private data class Region(val name: String, val code: String, val flag: String)
|
private data class Region(val name: String, val code: String, val flag: String)
|
||||||
|
|
||||||
private val availableRegions = listOf(
|
private val availableRegions =
|
||||||
|
listOf(
|
||||||
Region("Allemagne", "de", "🇩🇪"),
|
Region("Allemagne", "de", "🇩🇪"),
|
||||||
Region("Australie", "au", "🇦🇺"),
|
Region("Australie", "au", "🇦🇺"),
|
||||||
Region("Autriche", "at", "🇦🇹"),
|
Region("Autriche", "at", "🇦🇹"),
|
||||||
@ -60,7 +59,7 @@ private val availableRegions = listOf(
|
|||||||
Region("Royaume-Uni", "gb", "🇬🇧"),
|
Region("Royaume-Uni", "gb", "🇬🇧"),
|
||||||
Region("Russie", "ru", "🇷🇺"),
|
Region("Russie", "ru", "🇷🇺"),
|
||||||
Region("Suisse (Allemand)", "ch_de", "🇨🇭"),
|
Region("Suisse (Allemand)", "ch_de", "🇨🇭"),
|
||||||
Region("Suisse (français)", "ch_fr", "🇨🇭")
|
Region("Suisse (français)", "ch_fr", "🇨🇭"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -68,7 +67,7 @@ private val availableRegions = listOf(
|
|||||||
fun ListRegionScreen(
|
fun ListRegionScreen(
|
||||||
listId: Long,
|
listId: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: ListsViewModel = hiltViewModel()
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
|
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
|
||||||
@ -96,59 +95,63 @@ fun ListRegionScreen(
|
|||||||
IconButton(onClick = onSave) {
|
IconButton(onClick = onSave) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Check,
|
imageVector = Icons.Filled.Check,
|
||||||
contentDescription = stringResource(R.string.action_save)
|
contentDescription = stringResource(R.string.action_save),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.list_region_description),
|
text = stringResource(R.string.list_region_description),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(vertical = 8.dp)
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f),
|
||||||
) {
|
) {
|
||||||
items(availableRegions) { region ->
|
items(availableRegions) { region ->
|
||||||
val isSelected = selectedRegion == region.code
|
val isSelected = selectedRegion == region.code
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { selectedRegion = region.code }
|
.clickable { selectedRegion = region.code }
|
||||||
.padding(vertical = 14.dp, horizontal = 8.dp),
|
.padding(vertical = 14.dp, horizontal = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "${region.flag} ${region.name}",
|
text = "${region.flag} ${region.name}",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(24.dp)
|
.size(24.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.primary),
|
.background(MaterialTheme.colorScheme.primary),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Check,
|
imageVector = Icons.Filled.Check,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onPrimary,
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,6 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@ -21,7 +19,6 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.Sort
|
import androidx.compose.material.icons.automirrored.filled.Sort
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Brush
|
import androidx.compose.material.icons.filled.Brush
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.People
|
import androidx.compose.material.icons.filled.People
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@ -62,7 +59,7 @@ fun ListSettingsScreen(
|
|||||||
onOpenRegion: () -> Unit,
|
onOpenRegion: () -> Unit,
|
||||||
onOpenNameImage: () -> Unit,
|
onOpenNameImage: () -> Unit,
|
||||||
onOpenMembers: () -> Unit,
|
onOpenMembers: () -> Unit,
|
||||||
viewModel: ListsViewModel = hiltViewModel()
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
|
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
|
||||||
@ -75,15 +72,16 @@ fun ListSettingsScreen(
|
|||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
if (listData == null) {
|
if (listData == null) {
|
||||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
@ -92,10 +90,11 @@ fun ListSettingsScreen(
|
|||||||
// Header card with list preview
|
// Header card with list preview
|
||||||
val bg = backgroundByResName(listData.list.backgroundResName)
|
val bg = backgroundByResName(listData.list.backgroundResName)
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(140.dp),
|
.height(140.dp),
|
||||||
shape = RoundedCornerShape(16.dp)
|
shape = RoundedCornerShape(16.dp),
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
if (bg != null) {
|
if (bg != null) {
|
||||||
@ -103,18 +102,20 @@ fun ListSettingsScreen(
|
|||||||
painter = painterResource(id = bg.drawableRes),
|
painter = painterResource(id = bg.drawableRes),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black.copy(alpha = 0.35f))
|
.background(Color.Black.copy(alpha = 0.35f)),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
@ -122,9 +123,10 @@ fun ListSettingsScreen(
|
|||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.padding(16.dp)
|
.padding(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -135,7 +137,7 @@ fun ListSettingsScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.list_personalize),
|
text = stringResource(R.string.list_personalize),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
@ -144,40 +146,43 @@ fun ListSettingsScreen(
|
|||||||
columns = GridCells.Fixed(2),
|
columns = GridCells.Fixed(2),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
icon = Icons.AutoMirrored.Filled.Sort,
|
icon = Icons.AutoMirrored.Filled.Sort,
|
||||||
label = stringResource(R.string.list_sort),
|
label = stringResource(R.string.list_sort),
|
||||||
onClick = onOpenSort
|
onClick = onOpenSort,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
val regionCode = listData.list.region
|
val regionCode = listData.list.region
|
||||||
val regionSubtitle = if (regionCode != null) {
|
val regionSubtitle =
|
||||||
|
if (regionCode != null) {
|
||||||
val (flag, name) = regionFlagAndName(regionCode)
|
val (flag, name) = regionFlagAndName(regionCode)
|
||||||
"$flag $name"
|
"$flag $name"
|
||||||
} else null
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
icon = Icons.Filled.Language,
|
icon = Icons.Filled.Language,
|
||||||
label = stringResource(R.string.list_region_language),
|
label = stringResource(R.string.list_region_language),
|
||||||
subtitle = regionSubtitle,
|
subtitle = regionSubtitle,
|
||||||
onClick = onOpenRegion
|
onClick = onOpenRegion,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
icon = Icons.Filled.People,
|
icon = Icons.Filled.People,
|
||||||
label = stringResource(R.string.list_members),
|
label = stringResource(R.string.list_members),
|
||||||
onClick = onOpenMembers
|
onClick = onOpenMembers,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
icon = Icons.Filled.Brush,
|
icon = Icons.Filled.Brush,
|
||||||
label = stringResource(R.string.list_name_image),
|
label = stringResource(R.string.list_name_image),
|
||||||
onClick = onOpenNameImage
|
onClick = onOpenNameImage,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -192,10 +197,11 @@ fun ListSettingsScreen(
|
|||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors =
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.error,
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
contentColor = MaterialTheme.colorScheme.onError
|
contentColor = MaterialTheme.colorScheme.onError,
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.list_leave))
|
Text(stringResource(R.string.list_leave))
|
||||||
}
|
}
|
||||||
@ -207,7 +213,8 @@ fun ListSettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun regionFlagAndName(code: String): Pair<String, String> = when (code) {
|
private fun regionFlagAndName(code: String): Pair<String, String> =
|
||||||
|
when (code) {
|
||||||
"de" -> "🇩🇪" to "Allemagne"
|
"de" -> "🇩🇪" to "Allemagne"
|
||||||
"au" -> "🇦🇺" to "Australie"
|
"au" -> "🇦🇺" to "Australie"
|
||||||
"at" -> "🇦🇹" to "Autriche"
|
"at" -> "🇦🇹" to "Autriche"
|
||||||
@ -232,30 +239,33 @@ private fun SettingsTile(
|
|||||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
label: String,
|
label: String,
|
||||||
subtitle: String? = null,
|
subtitle: String? = null,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(1.2f),
|
.aspectRatio(1.2f),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
CardDefaults.cardColors(
|
||||||
)
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
@ -263,7 +273,7 @@ private fun SettingsTile(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (subtitle != null) {
|
if (subtitle != null) {
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
@ -272,7 +282,7 @@ private fun SettingsTile(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,14 +70,15 @@ import kotlin.math.roundToInt
|
|||||||
fun ListSortScreen(
|
fun ListSortScreen(
|
||||||
listId: Long,
|
listId: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: ListsViewModel = hiltViewModel()
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
|
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
|
||||||
val catalog = remember { CatalogProvider() }
|
val catalog = remember { CatalogProvider() }
|
||||||
|
|
||||||
val savedOrder = listData?.list?.categoryOrder?.split(",")?.filter { it.isNotBlank() }
|
val savedOrder = listData?.list?.categoryOrder?.split(",")?.filter { it.isNotBlank() }
|
||||||
val orderedCategories = remember(listData?.list?.categoryOrder) {
|
val orderedCategories =
|
||||||
|
remember(listData?.list?.categoryOrder) {
|
||||||
mutableStateListOf<String>().apply {
|
mutableStateListOf<String>().apply {
|
||||||
addAll(savedOrder ?: catalog.categories)
|
addAll(savedOrder ?: catalog.categories)
|
||||||
}
|
}
|
||||||
@ -109,99 +110,105 @@ fun ListSortScreen(
|
|||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.padding(end = 16.dp)
|
.padding(end = 16.dp)
|
||||||
.clickable {
|
.clickable {
|
||||||
listData?.let {
|
listData?.let {
|
||||||
viewModel.updateList(
|
viewModel.updateList(
|
||||||
it.list.copy(
|
it.list.copy(
|
||||||
visibleCategories = visibleCategories.joinToString(","),
|
visibleCategories = visibleCategories.joinToString(","),
|
||||||
categoryOrder = orderedCategories.joinToString(",")
|
categoryOrder = orderedCategories.joinToString(","),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onBack()
|
onBack()
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.list_sort_description),
|
text = stringResource(R.string.list_sort_description),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(vertical = 8.dp)
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Preview card (collapsible)
|
// Preview card (collapsible)
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp)
|
.padding(vertical = 8.dp)
|
||||||
.clickable { previewExpanded = !previewExpanded },
|
.clickable { previewExpanded = !previewExpanded },
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
CardDefaults.cardColors(
|
||||||
)
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.Sort,
|
imageVector = Icons.AutoMirrored.Filled.Sort,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier.size(24.dp),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.list_sort_preview),
|
text = stringResource(R.string.list_sort_preview),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (previewExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
imageVector = if (previewExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||||
contentDescription = if (previewExpanded) "Réduire" else "Développer"
|
contentDescription = if (previewExpanded) "Réduire" else "Développer",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = previewExpanded,
|
visible = previewExpanded,
|
||||||
enter = expandVertically(),
|
enter = expandVertically(),
|
||||||
exit = shrinkVertically()
|
exit = shrinkVertically(),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
orderedCategories.forEach { category ->
|
orderedCategories.forEach { category ->
|
||||||
val isVisible = category in visibleCategories
|
val isVisible = category in visibleCategories
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (isVisible) "●" else "○",
|
text = if (isVisible) "●" else "○",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
color = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = category,
|
text = category,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = if (isVisible) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
|
color = if (isVisible) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -216,18 +223,19 @@ fun ListSortScreen(
|
|||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
contentPadding = PaddingValues(vertical = 8.dp)
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = orderedCategories,
|
items = orderedCategories,
|
||||||
key = { _, item -> item }
|
key = { _, item -> item },
|
||||||
) { index, category ->
|
) { index, category ->
|
||||||
val isDragged = draggedIndex == index
|
val isDragged = draggedIndex == index
|
||||||
val zIndex = if (isDragged) 1f else 0f
|
val zIndex = if (isDragged) 1f else 0f
|
||||||
val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0
|
val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.zIndex(zIndex)
|
.zIndex(zIndex)
|
||||||
.offset { IntOffset(0, offsetY) }
|
.offset { IntOffset(0, offsetY) }
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
@ -239,7 +247,8 @@ fun ListSortScreen(
|
|||||||
onDragStart = { draggedIndex = index },
|
onDragStart = { draggedIndex = index },
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
draggedIndex?.let { from ->
|
draggedIndex?.let { from ->
|
||||||
val to = (from + (dragOffsetY / itemPx).roundToInt())
|
val to =
|
||||||
|
(from + (dragOffsetY / itemPx).roundToInt())
|
||||||
.coerceIn(0, orderedCategories.size - 1)
|
.coerceIn(0, orderedCategories.size - 1)
|
||||||
if (from != to) {
|
if (from != to) {
|
||||||
val moved = orderedCategories.removeAt(from)
|
val moved = orderedCategories.removeAt(from)
|
||||||
@ -256,36 +265,42 @@ fun ListSortScreen(
|
|||||||
onDrag = { change, dragAmount ->
|
onDrag = { change, dragAmount ->
|
||||||
change.consume()
|
change.consume()
|
||||||
dragOffsetY += dragAmount.y
|
dragOffsetY += dragAmount.y
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) {
|
) {
|
||||||
val isVisible = category in visibleCategories
|
val isVisible = category in visibleCategories
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(
|
.background(
|
||||||
if (isDragged) MaterialTheme.colorScheme.primaryContainer
|
if (isDragged) {
|
||||||
else MaterialTheme.colorScheme.surface
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.clickable {
|
.clickable {
|
||||||
visibleCategories = if (isVisible) {
|
visibleCategories =
|
||||||
|
if (isVisible) {
|
||||||
visibleCategories - category
|
visibleCategories - category
|
||||||
} else {
|
} else {
|
||||||
visibleCategories + category
|
visibleCategories + category
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = category,
|
text = category,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
visibleCategories = if (isVisible) {
|
visibleCategories =
|
||||||
|
if (isVisible) {
|
||||||
visibleCategories - category
|
visibleCategories - category
|
||||||
} else {
|
} else {
|
||||||
visibleCategories + category
|
visibleCategories + category
|
||||||
@ -294,14 +309,14 @@ fun ListSortScreen(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
imageVector = if (isVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||||
contentDescription = if (isVisible) "Masquer" else "Afficher",
|
contentDescription = if (isVisible) "Masquer" else "Afficher",
|
||||||
tint = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
tint = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.DragHandle,
|
imageVector = Icons.Filled.DragHandle,
|
||||||
contentDescription = "Réordonner",
|
contentDescription = "Réordonner",
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,10 +5,11 @@ import com.safebite.app.R
|
|||||||
data class ListBackground(
|
data class ListBackground(
|
||||||
val resName: String,
|
val resName: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
val drawableRes: Int
|
val drawableRes: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
val allListBackgrounds: List<ListBackground> = listOf(
|
val allListBackgrounds: List<ListBackground> =
|
||||||
|
listOf(
|
||||||
ListBackground("bg_animaux", "Animaux", R.drawable.bg_animaux),
|
ListBackground("bg_animaux", "Animaux", R.drawable.bg_animaux),
|
||||||
ListBackground("bg_baby", "Bébé", R.drawable.bg_baby),
|
ListBackground("bg_baby", "Bébé", R.drawable.bg_baby),
|
||||||
ListBackground("bg_epicerie", "Épicerie", R.drawable.bg_epicerie),
|
ListBackground("bg_epicerie", "Épicerie", R.drawable.bg_epicerie),
|
||||||
@ -18,11 +19,9 @@ val allListBackgrounds: List<ListBackground> = listOf(
|
|||||||
ListBackground("bg_party", "Fête", R.drawable.bg_party),
|
ListBackground("bg_party", "Fête", R.drawable.bg_party),
|
||||||
ListBackground("bg_pharmacie", "Pharmacie", R.drawable.bg_pharmacie),
|
ListBackground("bg_pharmacie", "Pharmacie", R.drawable.bg_pharmacie),
|
||||||
ListBackground("bg_plage", "Plage", R.drawable.bg_plage),
|
ListBackground("bg_plage", "Plage", R.drawable.bg_plage),
|
||||||
ListBackground("bg_renovation", "Rénovation", R.drawable.bg_renovation)
|
ListBackground("bg_renovation", "Rénovation", R.drawable.bg_renovation),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun backgroundByResName(name: String?): ListBackground? =
|
fun backgroundByResName(name: String?): ListBackground? = allListBackgrounds.firstOrNull { it.resName == name }
|
||||||
allListBackgrounds.firstOrNull { it.resName == name }
|
|
||||||
|
|
||||||
fun backgroundLabel(name: String?): String =
|
fun backgroundLabel(name: String?): String = backgroundByResName(name)?.label ?: ""
|
||||||
backgroundByResName(name)?.label ?: ""
|
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
package com.safebite.app.presentation.screen.main
|
package com.safebite.app.presentation.screen.main
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@ -31,11 +35,12 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@ -80,11 +85,13 @@ fun MainScreen(
|
|||||||
val currentDestination = currentBackStackEntry?.destination
|
val currentDestination = currentBackStackEntry?.destination
|
||||||
|
|
||||||
// Déterminer si le FAB doit être visible
|
// Déterminer si le FAB doit être visible
|
||||||
val fabVisible = currentDestination?.route in listOf(
|
val fabVisible =
|
||||||
|
currentDestination?.route in
|
||||||
|
listOf(
|
||||||
Screen.Dashboard.route,
|
Screen.Dashboard.route,
|
||||||
Screen.Lists.route,
|
Screen.Lists.route,
|
||||||
Screen.Tracking.route,
|
Screen.Tracking.route,
|
||||||
Screen.Family.route
|
Screen.Family.route,
|
||||||
)
|
)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@ -96,12 +103,12 @@ fun MainScreen(
|
|||||||
Image(
|
Image(
|
||||||
painter = painterResource(id = R.drawable.safebite_logo_nobg),
|
painter = painterResource(id = R.drawable.safebite_logo_nobg),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.app_name),
|
text = stringResource(R.string.app_name),
|
||||||
color = Color.White
|
color = Color.White,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -110,47 +117,49 @@ fun MainScreen(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Settings,
|
imageVector = Icons.Filled.Settings,
|
||||||
contentDescription = stringResource(R.string.nav_settings),
|
contentDescription = stringResource(R.string.nav_settings),
|
||||||
tint = Color.White
|
tint = Color.White,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors =
|
||||||
|
TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.primary,
|
scrolledContainerColor = MaterialTheme.colorScheme.primary,
|
||||||
titleContentColor = Color.White,
|
titleContentColor = Color.White,
|
||||||
navigationIconContentColor = Color.White,
|
navigationIconContentColor = Color.White,
|
||||||
actionIconContentColor = Color.White,
|
actionIconContentColor = Color.White,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
SafeBiteBottomNavigation(
|
SafeBiteBottomNavigation(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
currentDestination = currentDestination,
|
currentDestination = currentDestination,
|
||||||
items = bottomNavItems
|
items = bottomNavItems,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
SafeBiteFab(
|
SafeBiteFab(
|
||||||
visible = fabVisible,
|
visible = fabVisible,
|
||||||
onClick = onOpenScanner
|
onClick = onOpenScanner,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButtonPosition = FabPosition.Center
|
floatingActionButtonPosition = FabPosition.Center,
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
// NavHost pour les 4 onglets principaux
|
// NavHost pour les 4 onglets principaux
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Screen.Dashboard.route,
|
startDestination = Screen.Dashboard.route,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues),
|
||||||
) {
|
) {
|
||||||
composable(Screen.Dashboard.route) {
|
composable(Screen.Dashboard.route) {
|
||||||
DashboardScreen(
|
DashboardScreen(
|
||||||
onScan = onOpenScanner,
|
onScan = onOpenScanner,
|
||||||
onOpenList = onOpenListDetail,
|
onOpenList = onOpenListDetail,
|
||||||
onOpenHistoryItem = onOpenHistoryItem
|
onOpenHistoryItem = onOpenHistoryItem,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.Lists.route) {
|
composable(Screen.Lists.route) {
|
||||||
@ -158,19 +167,19 @@ fun MainScreen(
|
|||||||
onOpenList = { id, name -> onOpenListDetail(id, name) },
|
onOpenList = { id, name -> onOpenListDetail(id, name) },
|
||||||
onOpenScanner = onOpenScanner,
|
onOpenScanner = onOpenScanner,
|
||||||
onOpenListCreate = onOpenListCreate,
|
onOpenListCreate = onOpenListCreate,
|
||||||
onOpenListSettings = onOpenListSettings
|
onOpenListSettings = onOpenListSettings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.Tracking.route) {
|
composable(Screen.Tracking.route) {
|
||||||
TrackingScreen(
|
TrackingScreen(
|
||||||
onOpenHistoryItem = onOpenHistoryItem,
|
onOpenHistoryItem = onOpenHistoryItem,
|
||||||
onOpenScanner = onOpenScanner
|
onOpenScanner = onOpenScanner,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.Family.route) {
|
composable(Screen.Family.route) {
|
||||||
FamilyScreen(
|
FamilyScreen(
|
||||||
onOpenProfile = onOpenProfile,
|
onOpenProfile = onOpenProfile,
|
||||||
onOpenSettings = onOpenSettings
|
onOpenSettings = onOpenSettings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -188,11 +197,11 @@ fun MainScreen(
|
|||||||
private fun SafeBiteBottomNavigation(
|
private fun SafeBiteBottomNavigation(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
currentDestination: NavDestination?,
|
currentDestination: NavDestination?,
|
||||||
items: List<BottomNavItem>
|
items: List<BottomNavItem>,
|
||||||
) {
|
) {
|
||||||
NavigationBar(
|
NavigationBar(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
tonalElevation = 2.dp
|
tonalElevation = 2.dp,
|
||||||
) {
|
) {
|
||||||
items.forEach { item ->
|
items.forEach { item ->
|
||||||
val selected = currentDestination?.hierarchy?.any { it.route == item.screen.route } == true
|
val selected = currentDestination?.hierarchy?.any { it.route == item.screen.route } == true
|
||||||
@ -211,23 +220,24 @@ private fun SafeBiteBottomNavigation(
|
|||||||
val icon = if (selected) item.iconSelected else item.iconUnselected
|
val icon = if (selected) item.iconSelected else item.iconUnselected
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = null
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
text = item.label,
|
text = item.label,
|
||||||
style = MaterialTheme.typography.labelMedium
|
style = MaterialTheme.typography.labelMedium,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
alwaysShowLabel = true,
|
alwaysShowLabel = true,
|
||||||
colors = androidx.compose.material3.NavigationBarItemDefaults.colors(
|
colors =
|
||||||
|
androidx.compose.material3.NavigationBarItemDefaults.colors(
|
||||||
selectedIconColor = Color.White,
|
selectedIconColor = Color.White,
|
||||||
selectedTextColor = Color.White,
|
selectedTextColor = Color.White,
|
||||||
unselectedIconColor = Color.White.copy(alpha = 0.7f),
|
unselectedIconColor = Color.White.copy(alpha = 0.7f),
|
||||||
unselectedTextColor = Color.White.copy(alpha = 0.7f),
|
unselectedTextColor = Color.White.copy(alpha = 0.7f),
|
||||||
indicatorColor = Color.White.copy(alpha = 0.2f)
|
indicatorColor = Color.White.copy(alpha = 0.2f),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -243,38 +253,60 @@ private fun SafeBiteBottomNavigation(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SafeBiteFab(
|
private fun SafeBiteFab(
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(animationSpec = tween(200)) +
|
enter =
|
||||||
|
fadeIn(animationSpec = tween(200)) +
|
||||||
scaleIn(initialScale = 0.8f, animationSpec = tween(200)) +
|
scaleIn(initialScale = 0.8f, animationSpec = tween(200)) +
|
||||||
slideInVertically(
|
slideInVertically(
|
||||||
initialOffsetY = { it },
|
initialOffsetY = { it },
|
||||||
animationSpec = tween(200)
|
animationSpec = tween(200),
|
||||||
),
|
),
|
||||||
exit = fadeOut(animationSpec = tween(200)) +
|
exit =
|
||||||
|
fadeOut(animationSpec = tween(200)) +
|
||||||
scaleOut(targetScale = 0.8f, animationSpec = tween(200)) +
|
scaleOut(targetScale = 0.8f, animationSpec = tween(200)) +
|
||||||
slideOutVertically(
|
slideOutVertically(
|
||||||
targetOffsetY = { it },
|
targetOffsetY = { it },
|
||||||
animationSpec = tween(200)
|
animationSpec = tween(200),
|
||||||
)
|
),
|
||||||
) {
|
) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = onClick,
|
onClick = {
|
||||||
|
triggerFabHaptic(context)
|
||||||
|
onClick()
|
||||||
|
},
|
||||||
containerColor = MaterialTheme.colorScheme.onSurface,
|
containerColor = MaterialTheme.colorScheme.onSurface,
|
||||||
contentColor = MaterialTheme.colorScheme.surface,
|
contentColor = MaterialTheme.colorScheme.surface,
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
elevation = androidx.compose.material3.FloatingActionButtonDefaults.elevation(
|
elevation =
|
||||||
defaultElevation = 6.dp
|
androidx.compose.material3.FloatingActionButtonDefaults.elevation(
|
||||||
)
|
defaultElevation = 6.dp,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.QrCodeScanner,
|
imageVector = Icons.Filled.QrCodeScanner,
|
||||||
contentDescription = stringResource(R.string.fab_scan),
|
contentDescription = stringResource(R.string.fab_scan),
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier.size(24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retour haptique léger (15ms) au tap du FAB Scanner, distinct du scan (60ms). */
|
||||||
|
private fun triggerFabHaptic(context: android.content.Context) {
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val vm = context.getSystemService(VibratorManager::class.java) ?: return
|
||||||
|
vm.defaultVibrator.vibrate(VibrationEffect.createOneShot(15, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val v = context.getSystemService(android.content.Context.VIBRATOR_SERVICE) as? Vibrator
|
||||||
|
v?.vibrate(VibrationEffect.createOneShot(15, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// Silencieux si le matériel ne supporte pas la vibration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -30,10 +30,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.google.accompanist.permissions.isGranted
|
import com.google.accompanist.permissions.isGranted
|
||||||
import com.google.accompanist.permissions.rememberPermissionState
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
@ -50,7 +50,7 @@ import java.util.concurrent.Executors
|
|||||||
@Composable
|
@Composable
|
||||||
fun OcrCaptureScreen(
|
fun OcrCaptureScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onCaptured: (String) -> Unit
|
onCaptured: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val permission = rememberPermissionState(android.Manifest.permission.CAMERA)
|
val permission = rememberPermissionState(android.Manifest.permission.CAMERA)
|
||||||
LaunchedEffect(Unit) { if (!permission.status.isGranted) permission.launchPermissionRequest() }
|
LaunchedEffect(Unit) { if (!permission.status.isGranted) permission.launchPermissionRequest() }
|
||||||
@ -65,41 +65,44 @@ fun OcrCaptureScreen(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
backContentDescription = stringResource(R.string.action_back),
|
backContentDescription = stringResource(R.string.action_back),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||||
if (!permission.status.isGranted) {
|
if (!permission.status.isGranted) {
|
||||||
ErrorView(
|
ErrorView(
|
||||||
message = stringResource(R.string.scanner_camera_denied),
|
message = stringResource(R.string.scanner_camera_denied),
|
||||||
onRetry = { permission.launchPermissionRequest() }
|
onRetry = { permission.launchPermissionRequest() },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
OcrCameraView(
|
OcrCameraView(
|
||||||
onTextUpdate = { livePreviewText = it },
|
onTextUpdate = { livePreviewText = it },
|
||||||
onCapture = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) }
|
onCapture = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) },
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(16.dp)
|
.padding(16.dp),
|
||||||
) {
|
) {
|
||||||
if (livePreviewText.isNotBlank()) {
|
if (livePreviewText.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
livePreviewText.take(300),
|
livePreviewText.take(300),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(Color(0xAA000000), RoundedCornerShape(8.dp))
|
.background(Color(0xAA000000), RoundedCornerShape(8.dp))
|
||||||
.padding(8.dp)
|
.padding(8.dp),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.ocr_capture_hint),
|
stringResource(R.string.ocr_capture_hint),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.background(Color(0xAA000000), RoundedCornerShape(8.dp))
|
.background(Color(0xAA000000), RoundedCornerShape(8.dp))
|
||||||
.padding(8.dp)
|
.padding(8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
@ -108,7 +111,7 @@ fun OcrCaptureScreen(
|
|||||||
onClick = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) },
|
onClick = { if (livePreviewText.isNotBlank()) onCaptured(livePreviewText) },
|
||||||
enabled = livePreviewText.isNotBlank(),
|
enabled = livePreviewText.isNotBlank(),
|
||||||
large = true,
|
large = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -117,7 +120,10 @@ fun OcrCaptureScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OcrCameraView(onTextUpdate: (String) -> Unit, onCapture: () -> Unit) {
|
private fun OcrCameraView(
|
||||||
|
onTextUpdate: (String) -> Unit,
|
||||||
|
onCapture: () -> Unit,
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val executor = remember { Executors.newSingleThreadExecutor() }
|
val executor = remember { Executors.newSingleThreadExecutor() }
|
||||||
@ -137,13 +143,17 @@ private fun OcrCameraView(onTextUpdate: (String) -> Unit, onCapture: () -> Unit)
|
|||||||
providerFuture.addListener({
|
providerFuture.addListener({
|
||||||
val provider = providerFuture.get()
|
val provider = providerFuture.get()
|
||||||
val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) }
|
val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) }
|
||||||
val analysis = ImageAnalysis.Builder()
|
val analysis =
|
||||||
|
ImageAnalysis.Builder()
|
||||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
.build()
|
.build()
|
||||||
analysis.setAnalyzer(executor) { proxy: ImageProxy ->
|
analysis.setAnalyzer(executor) { proxy: ImageProxy ->
|
||||||
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
|
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
|
||||||
val media = proxy.image
|
val media = proxy.image
|
||||||
if (media == null) { proxy.close(); return@setAnalyzer }
|
if (media == null) {
|
||||||
|
proxy.close()
|
||||||
|
return@setAnalyzer
|
||||||
|
}
|
||||||
val input = InputImage.fromMediaImage(media, proxy.imageInfo.rotationDegrees)
|
val input = InputImage.fromMediaImage(media, proxy.imageInfo.rotationDegrees)
|
||||||
recognizer.process(input)
|
recognizer.process(input)
|
||||||
.addOnSuccessListener { result -> onTextUpdate(result.text) }
|
.addOnSuccessListener { result -> onTextUpdate(result.text) }
|
||||||
@ -152,11 +162,15 @@ private fun OcrCameraView(onTextUpdate: (String) -> Unit, onCapture: () -> Unit)
|
|||||||
try {
|
try {
|
||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
provider.bindToLifecycle(
|
provider.bindToLifecycle(
|
||||||
lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
|
lifecycleOwner,
|
||||||
|
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||||
|
preview,
|
||||||
|
analysis,
|
||||||
)
|
)
|
||||||
} catch (_: Throwable) {}
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
}, ContextCompat.getMainExecutor(ctx))
|
}, ContextCompat.getMainExecutor(ctx))
|
||||||
previewView
|
previewView
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,19 +20,18 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import com.safebite.app.R
|
import com.safebite.app.R
|
||||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
|
||||||
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
|
||||||
import com.safebite.app.domain.engine.AllergenAnalysisEngine
|
import com.safebite.app.domain.engine.AllergenAnalysisEngine
|
||||||
import com.safebite.app.domain.model.AllergenType
|
import com.safebite.app.domain.model.AllergenType
|
||||||
|
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||||
|
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OcrReviewScreen(
|
fun OcrReviewScreen(
|
||||||
initialText: String,
|
initialText: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAnalyze: (String) -> Unit
|
onAnalyze: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
var text by remember { mutableStateOf(initialText) }
|
var text by remember { mutableStateOf(initialText) }
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@ -43,11 +42,11 @@ fun OcrReviewScreen(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
backContentDescription = stringResource(R.string.action_back),
|
backContentDescription = stringResource(R.string.action_back),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()),
|
Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.ocr_review_hint), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(stringResource(R.string.ocr_review_hint), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@ -55,7 +54,7 @@ fun OcrReviewScreen(
|
|||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text(stringResource(R.string.result_ingredients)) },
|
label = { Text(stringResource(R.string.result_ingredients)) },
|
||||||
minLines = 8
|
minLines = 8,
|
||||||
)
|
)
|
||||||
val highlights = remember(text) { findHighlights(text) }
|
val highlights = remember(text) { findHighlights(text) }
|
||||||
if (highlights.isNotEmpty()) {
|
if (highlights.isNotEmpty()) {
|
||||||
@ -66,7 +65,7 @@ fun OcrReviewScreen(
|
|||||||
onClick = { onAnalyze(text) },
|
onClick = { onAnalyze(text) },
|
||||||
enabled = text.isNotBlank(),
|
enabled = text.isNotBlank(),
|
||||||
large = true,
|
large = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,9 +8,13 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class OcrViewModel @Inject constructor() : ViewModel() {
|
class OcrViewModel
|
||||||
|
@Inject
|
||||||
|
constructor() : ViewModel() {
|
||||||
private val _capturedText = MutableStateFlow("")
|
private val _capturedText = MutableStateFlow("")
|
||||||
val capturedText: StateFlow<String> = _capturedText.asStateFlow()
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@ -20,7 +22,9 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -30,38 +34,33 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.google.accompanist.permissions.isGranted
|
import com.google.accompanist.permissions.isGranted
|
||||||
import com.google.accompanist.permissions.rememberPermissionState
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
import com.google.accompanist.permissions.shouldShowRationale
|
||||||
import com.safebite.app.R
|
import com.safebite.app.R
|
||||||
import com.safebite.app.domain.model.AllergenType
|
import com.safebite.app.domain.model.AllergenType
|
||||||
import com.safebite.app.domain.model.CustomDietItem
|
import com.safebite.app.domain.model.CustomDietItem
|
||||||
import com.safebite.app.domain.model.CustomItemTag
|
import com.safebite.app.domain.model.CustomItemTag
|
||||||
import com.safebite.app.domain.model.DietaryRestriction
|
import com.safebite.app.domain.model.DietaryRestriction
|
||||||
|
import com.safebite.app.presentation.common.components.AllergenLevel
|
||||||
|
import com.safebite.app.presentation.common.components.AllergenSelectionGrid
|
||||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||||
import com.safebite.app.presentation.common.components.StandardTextField
|
import com.safebite.app.presentation.common.components.StandardTextField
|
||||||
import com.safebite.app.presentation.common.components.TertiaryButton
|
import com.safebite.app.presentation.common.components.TertiaryButton
|
||||||
import com.safebite.app.presentation.common.components.AllergenLevel
|
|
||||||
import com.safebite.app.presentation.common.components.AllergenSelectionGrid
|
|
||||||
import com.safebite.app.presentation.screen.profile.CustomItemAdder
|
import com.safebite.app.presentation.screen.profile.CustomItemAdder
|
||||||
import com.safebite.app.presentation.screen.profile.CustomItemsList
|
import com.safebite.app.presentation.screen.profile.CustomItemsList
|
||||||
import com.google.accompanist.permissions.shouldShowRationale
|
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OnboardingScreen(
|
fun OnboardingScreen(
|
||||||
onFinished: () -> Unit,
|
onFinished: () -> Unit,
|
||||||
viewModel: OnboardingViewModel = hiltViewModel()
|
viewModel: OnboardingViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
var step by rememberSaveable { mutableStateOf(0) }
|
var step by rememberSaveable { mutableStateOf(0) }
|
||||||
var name by rememberSaveable { mutableStateOf("") }
|
var name by rememberSaveable { mutableStateOf("") }
|
||||||
@ -77,7 +76,8 @@ fun OnboardingScreen(
|
|||||||
when (step) {
|
when (step) {
|
||||||
0 -> WelcomeStep(onNext = { step = 1 })
|
0 -> WelcomeStep(onNext = { step = 1 })
|
||||||
1 -> HowStep(onNext = { step = 2 })
|
1 -> HowStep(onNext = { step = 2 })
|
||||||
2 -> CreateProfileStep(
|
2 ->
|
||||||
|
CreateProfileStep(
|
||||||
name = name,
|
name = name,
|
||||||
onNameChange = { name = it },
|
onNameChange = { name = it },
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
@ -86,7 +86,8 @@ fun OnboardingScreen(
|
|||||||
onSetDefault = { isDefault = it },
|
onSetDefault = { isDefault = it },
|
||||||
allergenLevels = allergenLevels.value,
|
allergenLevels = allergenLevels.value,
|
||||||
onSetAllergenLevel = { a, level ->
|
onSetAllergenLevel = { a, level ->
|
||||||
allergenLevels.value = if (level == AllergenLevel.NONE) {
|
allergenLevels.value =
|
||||||
|
if (level == AllergenLevel.NONE) {
|
||||||
allergenLevels.value - a
|
allergenLevels.value - a
|
||||||
} else {
|
} else {
|
||||||
allergenLevels.value + (a to level)
|
allergenLevels.value + (a to level)
|
||||||
@ -108,15 +109,17 @@ fun OnboardingScreen(
|
|||||||
val moderate = allergenLevels.value.filterValues { it == AllergenLevel.TRACE }.keys
|
val moderate = allergenLevels.value.filterValues { it == AllergenLevel.TRACE }.keys
|
||||||
viewModel.createProfile(name, avatar, severe, moderate, restrictions.value, customItems.value)
|
viewModel.createProfile(name, avatar, severe, moderate, restrictions.value, customItems.value)
|
||||||
step = 3
|
step = 3
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
3 -> PermissionStep(
|
3 ->
|
||||||
|
PermissionStep(
|
||||||
granted = cameraPermission.status.isGranted,
|
granted = cameraPermission.status.isGranted,
|
||||||
rationale = cameraPermission.status.shouldShowRationale,
|
rationale = cameraPermission.status.shouldShowRationale,
|
||||||
onRequest = { cameraPermission.launchPermissionRequest() },
|
onRequest = { cameraPermission.launchPermissionRequest() },
|
||||||
onNext = { step = 4 }
|
onNext = { step = 4 },
|
||||||
)
|
)
|
||||||
4 -> ReadyStep(onFinish = {
|
4 ->
|
||||||
|
ReadyStep(onFinish = {
|
||||||
viewModel.complete()
|
viewModel.complete()
|
||||||
onFinished()
|
onFinished()
|
||||||
})
|
})
|
||||||
@ -129,31 +132,31 @@ private fun WelcomeStep(onNext: () -> Unit) {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(id = R.drawable.safebite_logo),
|
painter = painterResource(id = R.drawable.safebite_logo),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(120.dp)
|
modifier = Modifier.size(120.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.onboarding_welcome_title),
|
stringResource(R.string.onboarding_welcome_title),
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.onboarding_welcome_subtitle),
|
stringResource(R.string.onboarding_welcome_subtitle),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
text = stringResource(R.string.action_continue),
|
text = stringResource(R.string.action_continue),
|
||||||
onClick = onNext,
|
onClick = onNext,
|
||||||
large = true,
|
large = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -162,66 +165,70 @@ private fun WelcomeStep(onNext: () -> Unit) {
|
|||||||
private fun HowStep(onNext: () -> Unit) {
|
private fun HowStep(onNext: () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.onboarding_how_title),
|
stringResource(R.string.onboarding_how_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.onboarding_how_subtitle),
|
stringResource(R.string.onboarding_how_subtitle),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
val steps = listOf(
|
val steps =
|
||||||
|
listOf(
|
||||||
Triple("1", "👤", stringResource(R.string.onboarding_how_step1)),
|
Triple("1", "👤", stringResource(R.string.onboarding_how_step1)),
|
||||||
Triple("2", "📷", stringResource(R.string.onboarding_how_step2)),
|
Triple("2", "📷", stringResource(R.string.onboarding_how_step2)),
|
||||||
Triple("3", "✅", stringResource(R.string.onboarding_how_step3))
|
Triple("3", "✅", stringResource(R.string.onboarding_how_step3)),
|
||||||
)
|
)
|
||||||
|
|
||||||
for ((number, emoji, label) in steps) {
|
for ((number, emoji, label) in steps) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
CardDefaults.cardColors(
|
||||||
)
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.background(
|
.background(
|
||||||
MaterialTheme.colorScheme.primaryContainer,
|
MaterialTheme.colorScheme.primaryContainer,
|
||||||
CircleShape
|
CircleShape,
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = number,
|
text = number,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = emoji,
|
text = emoji,
|
||||||
style = MaterialTheme.typography.headlineSmall
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -233,7 +240,7 @@ private fun HowStep(onNext: () -> Unit) {
|
|||||||
text = stringResource(R.string.action_continue),
|
text = stringResource(R.string.action_continue),
|
||||||
onClick = onNext,
|
onClick = onNext,
|
||||||
large = true,
|
large = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -254,20 +261,20 @@ private fun CreateProfileStep(
|
|||||||
customItems: List<CustomDietItem>,
|
customItems: List<CustomDietItem>,
|
||||||
onAddCustomItem: (String, CustomItemTag) -> Unit,
|
onAddCustomItem: (String, CustomItemTag) -> Unit,
|
||||||
onRemoveCustomItem: (CustomDietItem) -> Unit,
|
onRemoveCustomItem: (CustomDietItem) -> Unit,
|
||||||
onNext: () -> Unit
|
onNext: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val avatars = listOf("🙂", "😀", "👧", "👦", "👨", "👩", "👵", "👴", "🧑", "👶", "🧒", "🧓", "🍽️", "🛒", "🥗", "🍎")
|
val avatars = listOf("🙂", "😀", "👧", "👦", "👨", "👩", "👵", "👴", "🧑", "👶", "🧒", "🧓", "🍽️", "🛒", "🥗", "🍎")
|
||||||
val dimens = com.safebite.app.presentation.theme.LocalDimens.current
|
val dimens = com.safebite.app.presentation.theme.LocalDimens.current
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.onboarding_profile_title),
|
stringResource(R.string.onboarding_profile_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@ -288,7 +295,7 @@ private fun CreateProfileStep(
|
|||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
color = bg,
|
color = bg,
|
||||||
border = if (selected) androidx.compose.foundation.BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
|
border = if (selected) androidx.compose.foundation.BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
|
||||||
modifier = Modifier.size(72.dp)
|
modifier = Modifier.size(72.dp),
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(a, fontSize = MaterialTheme.typography.displaySmall.fontSize)
|
Text(a, fontSize = MaterialTheme.typography.displaySmall.fontSize)
|
||||||
@ -312,7 +319,7 @@ private fun CreateProfileStep(
|
|||||||
item {
|
item {
|
||||||
AllergenSelectionGrid(
|
AllergenSelectionGrid(
|
||||||
selectedAllergens = allergenLevels,
|
selectedAllergens = allergenLevels,
|
||||||
onLevelChanged = onSetAllergenLevel
|
onLevelChanged = onSetAllergenLevel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -324,7 +331,7 @@ private fun CreateProfileStep(
|
|||||||
selected = r in restrictions,
|
selected = r in restrictions,
|
||||||
onClick = { onToggleRestriction(r) },
|
onClick = { onToggleRestriction(r) },
|
||||||
label = { Text(r.displayFr) },
|
label = { Text(r.displayFr) },
|
||||||
modifier = Modifier.padding(4.dp)
|
modifier = Modifier.padding(4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -347,27 +354,32 @@ private fun CreateProfileStep(
|
|||||||
onClick = onNext,
|
onClick = onNext,
|
||||||
enabled = name.isNotBlank(),
|
enabled = name.isNotBlank(),
|
||||||
large = true,
|
large = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PermissionStep(granted: Boolean, rationale: Boolean, onRequest: () -> Unit, onNext: () -> Unit) {
|
private fun PermissionStep(
|
||||||
|
granted: Boolean,
|
||||||
|
rationale: Boolean,
|
||||||
|
onRequest: () -> Unit,
|
||||||
|
onNext: () -> Unit,
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.onboarding_permission_title),
|
stringResource(R.string.onboarding_permission_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.onboarding_permission_body),
|
stringResource(R.string.onboarding_permission_body),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
if (granted) {
|
if (granted) {
|
||||||
@ -375,14 +387,14 @@ private fun PermissionStep(granted: Boolean, rationale: Boolean, onRequest: () -
|
|||||||
text = stringResource(R.string.action_continue),
|
text = stringResource(R.string.action_continue),
|
||||||
onClick = onNext,
|
onClick = onNext,
|
||||||
large = true,
|
large = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
text = stringResource(R.string.onboarding_permission_grant),
|
text = stringResource(R.string.onboarding_permission_grant),
|
||||||
onClick = onRequest,
|
onClick = onRequest,
|
||||||
large = true,
|
large = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
TertiaryButton(
|
TertiaryButton(
|
||||||
text = stringResource(R.string.action_continue),
|
text = stringResource(R.string.action_continue),
|
||||||
@ -397,27 +409,27 @@ private fun ReadyStep(onFinish: () -> Unit) {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text("🎉", style = MaterialTheme.typography.displayLarge)
|
Text("🎉", style = MaterialTheme.typography.displayLarge)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.onboarding_ready_title),
|
stringResource(R.string.onboarding_ready_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.onboarding_ready_body),
|
stringResource(R.string.onboarding_ready_body),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
text = stringResource(R.string.onboarding_start),
|
text = stringResource(R.string.onboarding_start),
|
||||||
onClick = onFinish,
|
onClick = onFinish,
|
||||||
large = true,
|
large = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,9 +13,11 @@ import kotlinx.coroutines.launch
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class OnboardingViewModel @Inject constructor(
|
class OnboardingViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val manageProfile: ManageProfileUseCase,
|
private val manageProfile: ManageProfileUseCase,
|
||||||
private val settings: SettingsRepository
|
private val settings: SettingsRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
fun createProfile(
|
fun createProfile(
|
||||||
name: String,
|
name: String,
|
||||||
@ -23,9 +25,10 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
severe: Set<AllergenType>,
|
severe: Set<AllergenType>,
|
||||||
moderate: Set<AllergenType>,
|
moderate: Set<AllergenType>,
|
||||||
restrictions: Set<DietaryRestriction> = emptySet(),
|
restrictions: Set<DietaryRestriction> = emptySet(),
|
||||||
customItems: List<CustomDietItem> = emptyList()
|
customItems: List<CustomDietItem> = emptyList(),
|
||||||
) = viewModelScope.launch {
|
) = viewModelScope.launch {
|
||||||
val id = manageProfile.save(
|
val id =
|
||||||
|
manageProfile.save(
|
||||||
UserProfile(
|
UserProfile(
|
||||||
name = name.ifBlank { "Moi" },
|
name = name.ifBlank { "Moi" },
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
@ -33,8 +36,8 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
moderateIntolerances = moderate,
|
moderateIntolerances = moderate,
|
||||||
dietaryRestrictions = restrictions,
|
dietaryRestrictions = restrictions,
|
||||||
customItems = customItems,
|
customItems = customItems,
|
||||||
isDefault = true
|
isDefault = true,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
manageProfile.setActive(setOf(id))
|
manageProfile.setActive(setOf(id))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,10 +13,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@ -71,7 +68,7 @@ fun ProductDetailScreen(
|
|||||||
barcode: String,
|
barcode: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenProduct: (String) -> Unit,
|
onOpenProduct: (String) -> Unit,
|
||||||
viewModel: ProductDetailViewModel = hiltViewModel()
|
viewModel: ProductDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@ -83,7 +80,7 @@ fun ProductDetailScreen(
|
|||||||
SafeBiteTopAppBar(
|
SafeBiteTopAppBar(
|
||||||
title = stringResource(R.string.result_ingredients),
|
title = stringResource(R.string.result_ingredients),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
backContentDescription = stringResource(R.string.a11y_back)
|
backContentDescription = stringResource(R.string.a11y_back),
|
||||||
)
|
)
|
||||||
|
|
||||||
when (val state = uiState) {
|
when (val state = uiState) {
|
||||||
@ -105,7 +102,7 @@ fun ProductDetailScreen(
|
|||||||
ProductDetailContent(
|
ProductDetailContent(
|
||||||
product = state.product,
|
product = state.product,
|
||||||
scanResult = state.scanResult,
|
scanResult = state.scanResult,
|
||||||
onOpenProduct = onOpenProduct
|
onOpenProduct = onOpenProduct,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -116,7 +113,7 @@ fun ProductDetailScreen(
|
|||||||
private fun ProductDetailContent(
|
private fun ProductDetailContent(
|
||||||
product: Product,
|
product: Product,
|
||||||
scanResult: ScanResult?,
|
scanResult: ScanResult?,
|
||||||
onOpenProduct: (String) -> Unit
|
onOpenProduct: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
val tabTitles = listOf("Résumé", "Allergènes", "Additifs", "Alternatives")
|
val tabTitles = listOf("Résumé", "Allergènes", "Additifs", "Alternatives")
|
||||||
@ -133,15 +130,15 @@ private fun ProductDetailContent(
|
|||||||
edgePadding = dimens.spacingMd,
|
edgePadding = dimens.spacingMd,
|
||||||
indicator = { tabPositions ->
|
indicator = { tabPositions ->
|
||||||
TabRowDefaults.SecondaryIndicator(
|
TabRowDefaults.SecondaryIndicator(
|
||||||
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTab])
|
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTab]),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) {
|
) {
|
||||||
tabTitles.forEachIndexed { index, title ->
|
tabTitles.forEachIndexed { index, title ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = selectedTab == index,
|
selected = selectedTab == index,
|
||||||
onClick = { selectedTab = index },
|
onClick = { selectedTab = index },
|
||||||
text = { Text(title, style = MaterialTheme.typography.labelLarge) }
|
text = { Text(title, style = MaterialTheme.typography.labelLarge) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -157,24 +154,28 @@ private fun ProductDetailContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProductHeader(product: Product, scanResult: ScanResult?) {
|
private fun ProductHeader(
|
||||||
|
product: Product,
|
||||||
|
scanResult: ScanResult?,
|
||||||
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(dimens.spacingMd),
|
modifier = Modifier.padding(dimens.spacingMd),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
// Image produit
|
// Image produit
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = product.imageUrl,
|
model = product.imageUrl,
|
||||||
contentDescription = stringResource(R.string.a11y_product_image),
|
contentDescription = stringResource(R.string.a11y_product_image),
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(120.dp)
|
.size(120.dp)
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.medium)
|
.background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.medium),
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(dimens.spacingSm))
|
Spacer(Modifier.height(dimens.spacingSm))
|
||||||
@ -184,14 +185,14 @@ private fun ProductHeader(product: Product, scanResult: ScanResult?) {
|
|||||||
text = product.name ?: "Produit inconnu",
|
text = product.name ?: "Produit inconnu",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!product.brand.isNullOrBlank()) {
|
if (!product.brand.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = product.brand,
|
text = product.brand,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -206,17 +207,19 @@ private fun ProductHeader(product: Product, scanResult: ScanResult?) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun VerdictBadge(status: SafetyStatus) {
|
private fun VerdictBadge(status: SafetyStatus) {
|
||||||
val (text, color, a11yDesc) = when (status) {
|
val (text, color, a11yDesc) =
|
||||||
|
when (status) {
|
||||||
SafetyStatus.SAFE -> Triple("✅ Sûr", Color(0xFF2ECC71), "Produit sûr")
|
SafetyStatus.SAFE -> Triple("✅ Sûr", Color(0xFF2ECC71), "Produit sûr")
|
||||||
SafetyStatus.WARNING -> Triple("⚠️ Attention", Color(0xFFF39C12), "Attention : traces d'allergènes")
|
SafetyStatus.WARNING -> Triple("⚠️ Attention", Color(0xFFF39C12), "Attention : traces d'allergènes")
|
||||||
SafetyStatus.DANGER -> Triple("❌ Danger", Color(0xFFE74C3C), "Danger : allergènes détectés")
|
SafetyStatus.DANGER -> Triple("❌ Danger", Color(0xFFE74C3C), "Danger : allergènes détectés")
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.background(color.copy(alpha = 0.15f), MaterialTheme.shapes.medium)
|
.background(color.copy(alpha = 0.15f), MaterialTheme.shapes.medium)
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
.semantics { contentDescription = a11yDesc }
|
.semantics { contentDescription = a11yDesc },
|
||||||
) {
|
) {
|
||||||
Text(text = text, color = color, fontWeight = FontWeight.Bold)
|
Text(text = text, color = color, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
@ -225,14 +228,18 @@ private fun VerdictBadge(status: SafetyStatus) {
|
|||||||
// ── Tab Résumé ──
|
// ── Tab Résumé ──
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SummaryTab(product: Product, scanResult: ScanResult?) {
|
private fun SummaryTab(
|
||||||
|
product: Product,
|
||||||
|
scanResult: ScanResult?,
|
||||||
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(dimens.spacingMd),
|
.padding(dimens.spacingMd),
|
||||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
// Nutri-Score
|
// Nutri-Score
|
||||||
if (scanResult?.health?.nutriScore != null) {
|
if (scanResult?.health?.nutriScore != null) {
|
||||||
@ -265,7 +272,8 @@ private fun SummaryTab(product: Product, scanResult: ScanResult?) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun NutriScoreCard(grade: String) {
|
private fun NutriScoreCard(grade: String) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
val color = when (grade.uppercase()) {
|
val color =
|
||||||
|
when (grade.uppercase()) {
|
||||||
"A" -> Color(0xFF1E8E3E)
|
"A" -> Color(0xFF1E8E3E)
|
||||||
"B" -> Color(0xFF7CB342)
|
"B" -> Color(0xFF7CB342)
|
||||||
"C" -> Color(0xFFFBC02D)
|
"C" -> Color(0xFFFBC02D)
|
||||||
@ -278,17 +286,18 @@ private fun NutriScoreCard(grade: String) {
|
|||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(dimens.spacingMd),
|
modifier = Modifier.padding(dimens.spacingMd),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text("Nutri-Score", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
Text("Nutri-Score", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.background(color, MaterialTheme.shapes.medium)
|
.background(color, MaterialTheme.shapes.medium)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = a11yDesc
|
contentDescription = a11yDesc
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(grade.uppercase(), color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineSmall)
|
Text(grade.uppercase(), color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineSmall)
|
||||||
}
|
}
|
||||||
@ -303,7 +312,7 @@ private fun CaloriesCard(kcal: Double) {
|
|||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(dimens.spacingMd),
|
modifier = Modifier.padding(dimens.spacingMd),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text("🔥", style = MaterialTheme.typography.headlineMedium)
|
Text("🔥", style = MaterialTheme.typography.headlineMedium)
|
||||||
Spacer(Modifier.width(dimens.spacingMd))
|
Spacer(Modifier.width(dimens.spacingMd))
|
||||||
@ -332,7 +341,12 @@ private fun NutritionGauges(nutriments: Nutriments) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GaugeRow(label: String, value: Double, max: Double, color: Color) {
|
private fun GaugeRow(
|
||||||
|
label: String,
|
||||||
|
value: Double,
|
||||||
|
max: Double,
|
||||||
|
color: Color,
|
||||||
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
val progress = (value / max).toFloat().coerceIn(0f, 1f)
|
val progress = (value / max).toFloat().coerceIn(0f, 1f)
|
||||||
|
|
||||||
@ -343,16 +357,18 @@ private fun GaugeRow(label: String, value: Double, max: Double, color: Color) {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(8.dp)
|
.height(8.dp)
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.small)
|
.background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.small),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth(progress)
|
.fillMaxWidth(progress)
|
||||||
.height(8.dp)
|
.height(8.dp)
|
||||||
.background(color, MaterialTheme.shapes.small)
|
.background(color, MaterialTheme.shapes.small),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(dimens.spacingSm))
|
Spacer(Modifier.height(dimens.spacingSm))
|
||||||
@ -362,7 +378,8 @@ private fun GaugeRow(label: String, value: Double, max: Double, color: Color) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun HealthVerdictCard(health: HealthAssessment) {
|
private fun HealthVerdictCard(health: HealthAssessment) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
val (emoji, text, color) = when (health.rating) {
|
val (emoji, text, color) =
|
||||||
|
when (health.rating) {
|
||||||
HealthRating.HEALTHY -> Triple("💪", "Plutôt sain", Color(0xFF2E7D32))
|
HealthRating.HEALTHY -> Triple("💪", "Plutôt sain", Color(0xFF2E7D32))
|
||||||
HealthRating.MODERATE -> Triple("🙂", "Modération", Color(0xFFF57C00))
|
HealthRating.MODERATE -> Triple("🙂", "Modération", Color(0xFFF57C00))
|
||||||
HealthRating.UNHEALTHY -> Triple("🚫", "Peu recommandable", Color(0xFFC62828))
|
HealthRating.UNHEALTHY -> Triple("🚫", "Peu recommandable", Color(0xFFC62828))
|
||||||
@ -371,11 +388,11 @@ private fun HealthVerdictCard(health: HealthAssessment) {
|
|||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f))
|
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f)),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(dimens.spacingMd),
|
modifier = Modifier.padding(dimens.spacingMd),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(emoji, style = MaterialTheme.typography.headlineMedium)
|
Text(emoji, style = MaterialTheme.typography.headlineMedium)
|
||||||
Spacer(Modifier.width(dimens.spacingMd))
|
Spacer(Modifier.width(dimens.spacingMd))
|
||||||
@ -394,10 +411,11 @@ private fun AllergensTab(scanResult: ScanResult?) {
|
|||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(dimens.spacingMd),
|
.padding(dimens.spacingMd),
|
||||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
|
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Text("14 allergènes réglementaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
Text("14 allergènes réglementaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
@ -406,7 +424,11 @@ private fun AllergensTab(scanResult: ScanResult?) {
|
|||||||
|
|
||||||
if (scanResult == null) {
|
if (scanResult == null) {
|
||||||
item {
|
item {
|
||||||
Text("Aucune analyse disponible", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(
|
||||||
|
"Aucune analyse disponible",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(AllergenType.entries.toList()) { allergen ->
|
items(AllergenType.entries.toList()) { allergen ->
|
||||||
@ -418,25 +440,30 @@ private fun AllergensTab(scanResult: ScanResult?) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AllergenStatusRow(allergen: AllergenType, detected: com.safebite.app.domain.model.DetectedAllergen?) {
|
private fun AllergenStatusRow(
|
||||||
|
allergen: AllergenType,
|
||||||
|
detected: com.safebite.app.domain.model.DetectedAllergen?,
|
||||||
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
val a11yAbsent = stringResource(R.string.a11y_allergen_absent)
|
val a11yAbsent = stringResource(R.string.a11y_allergen_absent)
|
||||||
val a11yTrace = stringResource(R.string.a11y_allergen_trace)
|
val a11yTrace = stringResource(R.string.a11y_allergen_trace)
|
||||||
val a11yPresent = stringResource(R.string.a11y_allergen_present)
|
val a11yPresent = stringResource(R.string.a11y_allergen_present)
|
||||||
val (status, emoji, bgColor, a11yDesc) = when {
|
val (status, emoji, bgColor, a11yDesc) =
|
||||||
|
when {
|
||||||
detected == null -> Quad("Absent", "✅", Color(0xFFE8F8F5), a11yAbsent)
|
detected == null -> Quad("Absent", "✅", Color(0xFFE8F8F5), a11yAbsent)
|
||||||
detected.detectionLevel == DetectionLevel.TRACE -> Quad("Traces", "⚠️", Color(0xFFFEF5E7), a11yTrace)
|
detected.detectionLevel == DetectionLevel.TRACE -> Quad("Traces", "⚠️", Color(0xFFFEF5E7), a11yTrace)
|
||||||
else -> Quad("Présent", "❌", Color(0xFFFDEDEC), a11yPresent)
|
else -> Quad("Présent", "❌", Color(0xFFFDEDEC), a11yPresent)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(bgColor, MaterialTheme.shapes.small)
|
.background(bgColor, MaterialTheme.shapes.small)
|
||||||
.padding(dimens.spacingSm)
|
.padding(dimens.spacingSm)
|
||||||
.semantics { contentDescription = "${allergen.displayNameFr}: $a11yDesc" },
|
.semantics { contentDescription = "${allergen.displayNameFr}: $a11yDesc" },
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(allergen.icon, style = MaterialTheme.typography.bodyLarge)
|
Text(allergen.icon, style = MaterialTheme.typography.bodyLarge)
|
||||||
@ -456,9 +483,10 @@ private fun AdditivesTab(product: Product) {
|
|||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(dimens.spacingMd)
|
.padding(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Text("Additifs alimentaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
Text("Additifs alimentaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
@ -479,9 +507,10 @@ private fun AlternativesTab(onOpenProduct: (String) -> Unit) {
|
|||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(dimens.spacingMd)
|
.padding(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
Text("Produits similaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
Text("Produits similaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
Spacer(Modifier.height(dimens.spacingMd))
|
Spacer(Modifier.height(dimens.spacingMd))
|
||||||
@ -492,7 +521,7 @@ private fun AlternativesTab(onOpenProduct: (String) -> Unit) {
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,32 +21,39 @@ import javax.inject.Inject
|
|||||||
*/
|
*/
|
||||||
sealed class ProductDetailUiState {
|
sealed class ProductDetailUiState {
|
||||||
data object Loading : ProductDetailUiState()
|
data object Loading : ProductDetailUiState()
|
||||||
|
|
||||||
data class Success(
|
data class Success(
|
||||||
val product: Product,
|
val product: Product,
|
||||||
val scanResult: ScanResult?
|
val scanResult: ScanResult?,
|
||||||
) : ProductDetailUiState()
|
) : ProductDetailUiState()
|
||||||
|
|
||||||
data class Error(val message: String) : ProductDetailUiState()
|
data class Error(val message: String) : ProductDetailUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ProductDetailViewModel @Inject constructor(
|
class ProductDetailViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val fetchProduct: FetchProductUseCase,
|
private val fetchProduct: FetchProductUseCase,
|
||||||
private val analyzeProduct: AnalyzeProductUseCase,
|
private val analyzeProduct: AnalyzeProductUseCase,
|
||||||
private val manageProfile: ManageProfileUseCase
|
private val manageProfile: ManageProfileUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow<ProductDetailUiState>(ProductDetailUiState.Loading)
|
private val _uiState = MutableStateFlow<ProductDetailUiState>(ProductDetailUiState.Loading)
|
||||||
val uiState: StateFlow<ProductDetailUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ProductDetailUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
fun loadProduct(barcode: String) = viewModelScope.launch {
|
fun loadProduct(barcode: String) =
|
||||||
|
viewModelScope.launch {
|
||||||
_uiState.value = ProductDetailUiState.Loading
|
_uiState.value = ProductDetailUiState.Loading
|
||||||
|
|
||||||
when (val result = fetchProduct(barcode)) {
|
when (val result = fetchProduct(barcode)) {
|
||||||
is ProductFetchResult.Found -> {
|
is ProductFetchResult.Found -> {
|
||||||
val profiles = resolveProfiles()
|
val profiles = resolveProfiles()
|
||||||
val scanResult = if (profiles.isNotEmpty()) {
|
val scanResult =
|
||||||
|
if (profiles.isNotEmpty()) {
|
||||||
analyzeProduct(result.product, profiles, com.safebite.app.domain.model.DataSource.API)
|
analyzeProduct(result.product, profiles, com.safebite.app.domain.model.DataSource.API)
|
||||||
} else null
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
_uiState.value = ProductDetailUiState.Success(result.product, scanResult)
|
_uiState.value = ProductDetailUiState.Success(result.product, scanResult)
|
||||||
}
|
}
|
||||||
is ProductFetchResult.NotFound -> {
|
is ProductFetchResult.NotFound -> {
|
||||||
@ -58,7 +65,8 @@ class ProductDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resolveProfiles() = run {
|
private suspend fun resolveProfiles() =
|
||||||
|
run {
|
||||||
val all = manageProfile.observe().first()
|
val all = manageProfile.observe().first()
|
||||||
val activeIds = manageProfile.observeActiveIds().first()
|
val activeIds = manageProfile.observeActiveIds().first()
|
||||||
when {
|
when {
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@ -40,7 +39,10 @@ import com.safebite.app.domain.model.CustomItemTag
|
|||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AllergenGrid(selected: Set<AllergenType>, onToggle: (AllergenType) -> Unit) {
|
fun AllergenGrid(
|
||||||
|
selected: Set<AllergenType>,
|
||||||
|
onToggle: (AllergenType) -> Unit,
|
||||||
|
) {
|
||||||
FlowRow {
|
FlowRow {
|
||||||
AllergenType.entries.forEach { a ->
|
AllergenType.entries.forEach { a ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
@ -48,7 +50,7 @@ fun AllergenGrid(selected: Set<AllergenType>, onToggle: (AllergenType) -> Unit)
|
|||||||
onClick = { onToggle(a) },
|
onClick = { onToggle(a) },
|
||||||
leadingIcon = { Text(a.icon) },
|
leadingIcon = { Text(a.icon) },
|
||||||
label = { Text(a.displayNameFr) },
|
label = { Text(a.displayNameFr) },
|
||||||
modifier = Modifier.padding(4.dp)
|
modifier = Modifier.padding(4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -66,7 +68,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
|
|||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
label = { Text(stringResource(R.string.profile_custom_name)) },
|
label = { Text(stringResource(R.string.profile_custom_name)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true,
|
||||||
)
|
)
|
||||||
Text(stringResource(R.string.profile_custom_tag), style = MaterialTheme.typography.labelLarge)
|
Text(stringResource(R.string.profile_custom_tag), style = MaterialTheme.typography.labelLarge)
|
||||||
FlowRow {
|
FlowRow {
|
||||||
@ -75,7 +77,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
|
|||||||
selected = tag == t,
|
selected = tag == t,
|
||||||
onClick = { tag = t },
|
onClick = { tag = t },
|
||||||
label = { Text(tagLabel(t)) },
|
label = { Text(tagLabel(t)) },
|
||||||
modifier = Modifier.padding(4.dp)
|
modifier = Modifier.padding(4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -85,7 +87,7 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
|
|||||||
name = ""
|
name = ""
|
||||||
},
|
},
|
||||||
enabled = name.isNotBlank(),
|
enabled = name.isNotBlank(),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Add, null)
|
Icon(Icons.Filled.Add, null)
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
@ -97,7 +99,10 @@ fun CustomItemAdder(onAdd: (String, CustomItemTag) -> Unit) {
|
|||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CustomItemsList(items: List<CustomDietItem>, onRemove: (CustomDietItem) -> Unit) {
|
fun CustomItemsList(
|
||||||
|
items: List<CustomDietItem>,
|
||||||
|
onRemove: (CustomDietItem) -> Unit,
|
||||||
|
) {
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
Text(stringResource(R.string.profile_custom_empty), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(stringResource(R.string.profile_custom_empty), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
return
|
return
|
||||||
@ -109,28 +114,31 @@ fun CustomItemsList(items: List<CustomDietItem>, onRemove: (CustomDietItem) -> U
|
|||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
"${tagIcon(item.tag)} ${item.name}",
|
"${tagIcon(item.tag)} ${item.name}",
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingIcon = { Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
trailingIcon = { Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
||||||
colors = AssistChipDefaults.assistChipColors(
|
colors =
|
||||||
containerColor = tagColor(item.tag).copy(alpha = 0.18f)
|
AssistChipDefaults.assistChipColors(
|
||||||
|
containerColor = tagColor(item.tag).copy(alpha = 0.18f),
|
||||||
),
|
),
|
||||||
modifier = Modifier.padding(4.dp)
|
modifier = Modifier.padding(4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun tagLabel(tag: CustomItemTag): String = when (tag) {
|
fun tagLabel(tag: CustomItemTag): String =
|
||||||
|
when (tag) {
|
||||||
CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy)
|
CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy)
|
||||||
CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance)
|
CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance)
|
||||||
CustomItemTag.DIET -> stringResource(R.string.profile_custom_tag_diet)
|
CustomItemTag.DIET -> stringResource(R.string.profile_custom_tag_diet)
|
||||||
CustomItemTag.UNHEALTHY -> stringResource(R.string.profile_custom_tag_unhealthy)
|
CustomItemTag.UNHEALTHY -> stringResource(R.string.profile_custom_tag_unhealthy)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tagIcon(tag: CustomItemTag): String = when (tag) {
|
fun tagIcon(tag: CustomItemTag): String =
|
||||||
|
when (tag) {
|
||||||
CustomItemTag.ALLERGY -> "⛔"
|
CustomItemTag.ALLERGY -> "⛔"
|
||||||
CustomItemTag.INTOLERANCE -> "⚠️"
|
CustomItemTag.INTOLERANCE -> "⚠️"
|
||||||
CustomItemTag.DIET -> "🥗"
|
CustomItemTag.DIET -> "🥗"
|
||||||
@ -138,7 +146,8 @@ fun tagIcon(tag: CustomItemTag): String = when (tag) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun tagColor(tag: CustomItemTag): Color = when (tag) {
|
fun tagColor(tag: CustomItemTag): Color =
|
||||||
|
when (tag) {
|
||||||
CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.error
|
CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.error
|
||||||
CustomItemTag.INTOLERANCE -> Color(0xFFFFA000)
|
CustomItemTag.INTOLERANCE -> Color(0xFFFFA000)
|
||||||
CustomItemTag.DIET -> MaterialTheme.colorScheme.tertiary
|
CustomItemTag.DIET -> MaterialTheme.colorScheme.tertiary
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package com.safebite.app.presentation.screen.profile
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@ -28,20 +27,16 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.safebite.app.R
|
import com.safebite.app.R
|
||||||
import com.safebite.app.presentation.common.components.AllergenLevel
|
import com.safebite.app.domain.model.DietaryRestriction
|
||||||
import com.safebite.app.presentation.common.components.AllergenSelectionGrid
|
import com.safebite.app.presentation.common.components.AllergenSelectionGrid
|
||||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||||
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
||||||
import com.safebite.app.presentation.common.components.StandardTextField
|
import com.safebite.app.presentation.common.components.StandardTextField
|
||||||
import com.safebite.app.presentation.theme.LocalDimens
|
import com.safebite.app.presentation.theme.LocalDimens
|
||||||
import com.safebite.app.domain.model.CustomDietItem
|
|
||||||
import com.safebite.app.domain.model.CustomItemTag
|
|
||||||
import com.safebite.app.domain.model.DietaryRestriction
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -49,7 +44,7 @@ fun ProfileEditScreen(
|
|||||||
id: Long,
|
id: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSaved: () -> Unit,
|
onSaved: () -> Unit,
|
||||||
viewModel: ProfileViewModel = hiltViewModel()
|
viewModel: ProfileViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(id) { viewModel.load(id) }
|
LaunchedEffect(id) { viewModel.load(id) }
|
||||||
val ui by viewModel.edit.collectAsStateWithLifecycle()
|
val ui by viewModel.edit.collectAsStateWithLifecycle()
|
||||||
@ -64,13 +59,13 @@ fun ProfileEditScreen(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
backContentDescription = stringResource(R.string.action_back),
|
backContentDescription = stringResource(R.string.action_back),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
if (!ui.loaded) return@Scaffold
|
if (!ui.loaded) return@Scaffold
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
|
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
|
||||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
StandardTextField(
|
StandardTextField(
|
||||||
@ -89,8 +84,16 @@ fun ProfileEditScreen(
|
|||||||
onClick = { viewModel.setAvatar(a) },
|
onClick = { viewModel.setAvatar(a) },
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
color = bg,
|
color = bg,
|
||||||
border = if (selected) androidx.compose.foundation.BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
|
border =
|
||||||
modifier = Modifier.size(72.dp)
|
if (selected) {
|
||||||
|
androidx.compose.foundation.BorderStroke(
|
||||||
|
3.dp,
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(72.dp),
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(a, fontSize = MaterialTheme.typography.displaySmall.fontSize)
|
Text(a, fontSize = MaterialTheme.typography.displaySmall.fontSize)
|
||||||
@ -114,7 +117,7 @@ fun ProfileEditScreen(
|
|||||||
item {
|
item {
|
||||||
AllergenSelectionGrid(
|
AllergenSelectionGrid(
|
||||||
selectedAllergens = ui.allergenLevels,
|
selectedAllergens = ui.allergenLevels,
|
||||||
onLevelChanged = viewModel::setAllergenLevel
|
onLevelChanged = viewModel::setAllergenLevel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,7 +129,7 @@ fun ProfileEditScreen(
|
|||||||
selected = r in ui.restrictions,
|
selected = r in ui.restrictions,
|
||||||
onClick = { viewModel.toggleRestriction(r) },
|
onClick = { viewModel.toggleRestriction(r) },
|
||||||
label = { Text(r.displayFr) },
|
label = { Text(r.displayFr) },
|
||||||
modifier = Modifier.padding(4.dp)
|
modifier = Modifier.padding(4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -147,11 +150,9 @@ fun ProfileEditScreen(
|
|||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
text = stringResource(R.string.action_save),
|
text = stringResource(R.string.action_save),
|
||||||
onClick = { viewModel.save(onSaved) },
|
onClick = { viewModel.save(onSaved) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ fun ProfileListScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onNew: () -> Unit,
|
onNew: () -> Unit,
|
||||||
onEdit: (Long) -> Unit,
|
onEdit: (Long) -> Unit,
|
||||||
viewModel: ProfileViewModel = hiltViewModel()
|
viewModel: ProfileViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val profiles by viewModel.profiles.collectAsStateWithLifecycle()
|
val profiles by viewModel.profiles.collectAsStateWithLifecycle()
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
@ -61,14 +61,15 @@ fun ProfileListScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
) { Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.action_save)) }
|
) { Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.action_save)) }
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
|
.padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
|
||||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
items(profiles, key = { it.id }) { profile ->
|
items(profiles, key = { it.id }) { profile ->
|
||||||
StandardCard(
|
StandardCard(
|
||||||
@ -78,7 +79,7 @@ fun ProfileListScreen(
|
|||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
AvatarBubble(avatar = profile.avatar)
|
AvatarBubble(avatar = profile.avatar)
|
||||||
Spacer(Modifier.size(dimens.spacingMd))
|
Spacer(Modifier.size(dimens.spacingMd))
|
||||||
@ -87,34 +88,34 @@ fun ProfileListScreen(
|
|||||||
Text(
|
Text(
|
||||||
profile.name,
|
profile.name,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
if (profile.isDefault) {
|
if (profile.isDefault) {
|
||||||
Spacer(Modifier.size(dimens.spacingXs + 2.dp))
|
Spacer(Modifier.size(dimens.spacingXs + 2.dp))
|
||||||
androidx.compose.material3.AssistChip(
|
androidx.compose.material3.AssistChip(
|
||||||
onClick = {},
|
onClick = {},
|
||||||
label = { Text(stringResource(R.string.profile_default_badge)) }
|
label = { Text(stringResource(R.string.profile_default_badge)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"${profile.severeAllergens.size + profile.moderateIntolerances.size} allergènes",
|
"${profile.severeAllergens.size + profile.moderateIntolerances.size} allergènes",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { onEdit(profile.id) }) {
|
IconButton(onClick = { onEdit(profile.id) }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Edit,
|
Icons.Filled.Edit,
|
||||||
contentDescription = stringResource(R.string.action_save),
|
contentDescription = stringResource(R.string.action_save),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { viewModel.delete(profile) }) {
|
IconButton(onClick = { viewModel.delete(profile) }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Delete,
|
Icons.Filled.Delete,
|
||||||
contentDescription = stringResource(R.string.action_delete),
|
contentDescription = stringResource(R.string.action_delete),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ data class ProfileEditUi(
|
|||||||
val restrictions: Set<DietaryRestriction> = emptySet(),
|
val restrictions: Set<DietaryRestriction> = emptySet(),
|
||||||
val customItems: List<CustomDietItem> = emptyList(),
|
val customItems: List<CustomDietItem> = emptyList(),
|
||||||
val isDefault: Boolean = false,
|
val isDefault: Boolean = false,
|
||||||
val loaded: Boolean = false
|
val loaded: Boolean = false,
|
||||||
) {
|
) {
|
||||||
// Propriétés calculées pour la compatibilité
|
// Propriétés calculées pour la compatibilité
|
||||||
val severe: Set<AllergenType>
|
val severe: Set<AllergenType>
|
||||||
@ -38,17 +38,20 @@ data class ProfileEditUi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ProfileViewModel @Inject constructor(
|
class ProfileViewModel
|
||||||
private val manage: ManageProfileUseCase
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val manage: ManageProfileUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
val profiles: StateFlow<List<UserProfile>> =
|
||||||
val profiles: StateFlow<List<UserProfile>> = manage.observe()
|
manage.observe()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
private val _edit = MutableStateFlow(ProfileEditUi())
|
private val _edit = MutableStateFlow(ProfileEditUi())
|
||||||
val edit: StateFlow<ProfileEditUi> = _edit.asStateFlow()
|
val edit: StateFlow<ProfileEditUi> = _edit.asStateFlow()
|
||||||
|
|
||||||
fun load(id: Long) = viewModelScope.launch {
|
fun load(id: Long) =
|
||||||
|
viewModelScope.launch {
|
||||||
if (id == 0L) {
|
if (id == 0L) {
|
||||||
_edit.value = ProfileEditUi(loaded = true)
|
_edit.value = ProfileEditUi(loaded = true)
|
||||||
} else {
|
} else {
|
||||||
@ -59,7 +62,8 @@ class ProfileViewModel @Inject constructor(
|
|||||||
p.severeAllergens.forEach { allergenLevels[it] = AllergenLevel.SEVERE }
|
p.severeAllergens.forEach { allergenLevels[it] = AllergenLevel.SEVERE }
|
||||||
p.moderateIntolerances.forEach { allergenLevels[it] = AllergenLevel.TRACE }
|
p.moderateIntolerances.forEach { allergenLevels[it] = AllergenLevel.TRACE }
|
||||||
|
|
||||||
_edit.value = ProfileEditUi(
|
_edit.value =
|
||||||
|
ProfileEditUi(
|
||||||
id = p.id,
|
id = p.id,
|
||||||
name = p.name,
|
name = p.name,
|
||||||
avatar = p.avatar,
|
avatar = p.avatar,
|
||||||
@ -67,18 +71,23 @@ class ProfileViewModel @Inject constructor(
|
|||||||
restrictions = p.dietaryRestrictions,
|
restrictions = p.dietaryRestrictions,
|
||||||
customItems = p.customItems,
|
customItems = p.customItems,
|
||||||
isDefault = p.isDefault,
|
isDefault = p.isDefault,
|
||||||
loaded = true
|
loaded = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setName(v: String) = _edit.update { it.copy(name = v) }
|
fun setName(v: String) = _edit.update { it.copy(name = v) }
|
||||||
|
|
||||||
fun setAvatar(v: String) = _edit.update { it.copy(avatar = v) }
|
fun setAvatar(v: String) = _edit.update { it.copy(avatar = v) }
|
||||||
|
|
||||||
/** Met à jour le niveau d'un allergène (cycle : NONE → TRACE → SEVERE → NONE) */
|
/** Met à jour le niveau d'un allergène (cycle : NONE → TRACE → SEVERE → NONE) */
|
||||||
fun setAllergenLevel(allergen: AllergenType, level: AllergenLevel) = _edit.update { s ->
|
fun setAllergenLevel(
|
||||||
val newLevels = if (level == AllergenLevel.NONE) {
|
allergen: AllergenType,
|
||||||
|
level: AllergenLevel,
|
||||||
|
) = _edit.update { s ->
|
||||||
|
val newLevels =
|
||||||
|
if (level == AllergenLevel.NONE) {
|
||||||
s.allergenLevels - allergen
|
s.allergenLevels - allergen
|
||||||
} else {
|
} else {
|
||||||
s.allergenLevels + (allergen to level)
|
s.allergenLevels + (allergen to level)
|
||||||
@ -87,9 +96,11 @@ class ProfileViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Méthodes de compatibilité pour l'ancien AllergenGrid
|
// Méthodes de compatibilité pour l'ancien AllergenGrid
|
||||||
fun toggleSevere(a: AllergenType) = _edit.update { s ->
|
fun toggleSevere(a: AllergenType) =
|
||||||
|
_edit.update { s ->
|
||||||
val newLevel = if (a in s.severe) AllergenLevel.NONE else AllergenLevel.SEVERE
|
val newLevel = if (a in s.severe) AllergenLevel.NONE else AllergenLevel.SEVERE
|
||||||
val newLevels = if (newLevel == AllergenLevel.NONE) {
|
val newLevels =
|
||||||
|
if (newLevel == AllergenLevel.NONE) {
|
||||||
s.allergenLevels - a
|
s.allergenLevels - a
|
||||||
} else {
|
} else {
|
||||||
s.allergenLevels + (a to newLevel)
|
s.allergenLevels + (a to newLevel)
|
||||||
@ -97,9 +108,11 @@ class ProfileViewModel @Inject constructor(
|
|||||||
s.copy(allergenLevels = newLevels)
|
s.copy(allergenLevels = newLevels)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleModerate(a: AllergenType) = _edit.update { s ->
|
fun toggleModerate(a: AllergenType) =
|
||||||
|
_edit.update { s ->
|
||||||
val newLevel = if (a in s.moderate) AllergenLevel.NONE else AllergenLevel.TRACE
|
val newLevel = if (a in s.moderate) AllergenLevel.NONE else AllergenLevel.TRACE
|
||||||
val newLevels = if (newLevel == AllergenLevel.NONE) {
|
val newLevels =
|
||||||
|
if (newLevel == AllergenLevel.NONE) {
|
||||||
s.allergenLevels - a
|
s.allergenLevels - a
|
||||||
} else {
|
} else {
|
||||||
s.allergenLevels + (a to newLevel)
|
s.allergenLevels + (a to newLevel)
|
||||||
@ -107,27 +120,38 @@ class ProfileViewModel @Inject constructor(
|
|||||||
s.copy(allergenLevels = newLevels)
|
s.copy(allergenLevels = newLevels)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleRestriction(r: DietaryRestriction) = _edit.update { s ->
|
fun toggleRestriction(r: DietaryRestriction) =
|
||||||
|
_edit.update { s ->
|
||||||
s.copy(restrictions = if (r in s.restrictions) s.restrictions - r else s.restrictions + r)
|
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 setDefault(v: Boolean) = _edit.update { it.copy(isDefault = v) }
|
||||||
|
|
||||||
fun addCustomItem(name: String, tag: CustomItemTag) {
|
fun addCustomItem(
|
||||||
|
name: String,
|
||||||
|
tag: CustomItemTag,
|
||||||
|
) {
|
||||||
val trimmed = name.trim()
|
val trimmed = name.trim()
|
||||||
if (trimmed.isBlank()) return
|
if (trimmed.isBlank()) return
|
||||||
_edit.update { s ->
|
_edit.update { s ->
|
||||||
if (s.customItems.any { it.name.equals(trimmed, ignoreCase = true) && it.tag == tag }) s
|
if (s.customItems.any { it.name.equals(trimmed, ignoreCase = true) && it.tag == tag }) {
|
||||||
else s.copy(customItems = s.customItems + CustomDietItem(trimmed, tag))
|
s
|
||||||
|
} else {
|
||||||
|
s.copy(customItems = s.customItems + CustomDietItem(trimmed, tag))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeCustomItem(item: CustomDietItem) = _edit.update { s ->
|
fun removeCustomItem(item: CustomDietItem) =
|
||||||
|
_edit.update { s ->
|
||||||
s.copy(customItems = s.customItems.filterNot { it.name == item.name && it.tag == item.tag })
|
s.copy(customItems = s.customItems.filterNot { it.name == item.name && it.tag == item.tag })
|
||||||
}
|
}
|
||||||
|
|
||||||
fun save(onDone: () -> Unit) = viewModelScope.launch {
|
fun save(onDone: () -> Unit) =
|
||||||
|
viewModelScope.launch {
|
||||||
val ui = _edit.value
|
val ui = _edit.value
|
||||||
val id = manage.save(
|
val id =
|
||||||
|
manage.save(
|
||||||
UserProfile(
|
UserProfile(
|
||||||
id = ui.id,
|
id = ui.id,
|
||||||
name = ui.name.ifBlank { "Profil" },
|
name = ui.name.ifBlank { "Profil" },
|
||||||
@ -136,8 +160,8 @@ class ProfileViewModel @Inject constructor(
|
|||||||
moderateIntolerances = ui.moderate,
|
moderateIntolerances = ui.moderate,
|
||||||
dietaryRestrictions = ui.restrictions,
|
dietaryRestrictions = ui.restrictions,
|
||||||
customItems = ui.customItems,
|
customItems = ui.customItems,
|
||||||
isDefault = ui.isDefault
|
isDefault = ui.isDefault,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
if (ui.isDefault) manage.setDefault(id)
|
if (ui.isDefault) manage.setDefault(id)
|
||||||
onDone()
|
onDone()
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package com.safebite.app.presentation.screen.result
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@ -10,7 +9,6 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CameraAlt
|
import androidx.compose.material.icons.filled.CameraAlt
|
||||||
@ -33,9 +31,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.safebite.app.R
|
import com.safebite.app.R
|
||||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||||
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
||||||
@ -58,7 +54,7 @@ fun ProductNotFoundScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenOcr: () -> Unit,
|
onOpenOcr: () -> Unit,
|
||||||
onManualSubmit: (String) -> Unit,
|
onManualSubmit: (String) -> Unit,
|
||||||
onScanAgain: () -> Unit
|
onScanAgain: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
var manualBarcode by remember { mutableStateOf("") }
|
var manualBarcode by remember { mutableStateOf("") }
|
||||||
@ -71,19 +67,20 @@ fun ProductNotFoundScreen(
|
|||||||
SafeBiteTopAppBar(
|
SafeBiteTopAppBar(
|
||||||
title = stringResource(R.string.result_product_not_found),
|
title = stringResource(R.string.result_product_not_found),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
backContentDescription = stringResource(R.string.a11y_back)
|
backContentDescription = stringResource(R.string.a11y_back),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
if (submitted) {
|
if (submitted) {
|
||||||
// Message de confirmation
|
// Message de confirmation
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(dimens.spacingXl),
|
.padding(dimens.spacingXl),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text("✅", style = MaterialTheme.typography.displayMedium)
|
Text("✅", style = MaterialTheme.typography.displayMedium)
|
||||||
Spacer(Modifier.height(dimens.spacingMd))
|
Spacer(Modifier.height(dimens.spacingMd))
|
||||||
@ -91,55 +88,57 @@ fun ProductNotFoundScreen(
|
|||||||
text = "Merci pour votre contribution !",
|
text = "Merci pour votre contribution !",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(dimens.spacingSm))
|
Spacer(Modifier.height(dimens.spacingSm))
|
||||||
Text(
|
Text(
|
||||||
text = "Le produit sera analysé sous 24h. Vous recevrez une notification quand le résultat sera disponible.",
|
text = "Le produit sera analysé sous 24h. Vous recevrez une notification quand le résultat sera disponible.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(dimens.spacingLg))
|
Spacer(Modifier.height(dimens.spacingLg))
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
text = "Scanner un autre produit",
|
text = "Scanner un autre produit",
|
||||||
onClick = onScanAgain,
|
onClick = onScanAgain,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(dimens.spacingLg),
|
.padding(dimens.spacingLg),
|
||||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
// Message principal
|
// Message principal
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
CardDefaults.cardColors(
|
||||||
)
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(dimens.spacingMd),
|
modifier = Modifier.padding(dimens.spacingMd),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text("🔍", style = MaterialTheme.typography.displayMedium)
|
Text("🔍", style = MaterialTheme.typography.displayMedium)
|
||||||
Spacer(Modifier.height(dimens.spacingSm))
|
Spacer(Modifier.height(dimens.spacingSm))
|
||||||
Text(
|
Text(
|
||||||
text = "Produit non reconnu",
|
text = "Produit non reconnu",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(dimens.spacingSm))
|
Spacer(Modifier.height(dimens.spacingSm))
|
||||||
Text(
|
Text(
|
||||||
text = "Ce produit (code: $barcode) n'est pas dans notre base de données.",
|
text = "Ce produit (code: $barcode) n'est pas dans notre base de données.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -148,13 +147,14 @@ fun ProductNotFoundScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Option 1 : Photographier les ingrédients",
|
text = "Option 1 : Photographier les ingrédients",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onOpenOcr,
|
onClick = onOpenOcr,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.semantics { contentDescription = "Prendre une photo des ingrédients" }
|
.semantics { contentDescription = "Prendre une photo des ingrédients" },
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.CameraAlt, contentDescription = null)
|
Icon(Icons.Filled.CameraAlt, contentDescription = null)
|
||||||
Spacer(Modifier.size(dimens.spacingSm))
|
Spacer(Modifier.size(dimens.spacingSm))
|
||||||
@ -165,7 +165,7 @@ fun ProductNotFoundScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = "Option 2 : Saisie manuelle",
|
text = "Option 2 : Saisie manuelle",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.padding(dimens.spacingMd)) {
|
Column(modifier = Modifier.padding(dimens.spacingMd)) {
|
||||||
@ -173,7 +173,7 @@ fun ProductNotFoundScreen(
|
|||||||
value = productName,
|
value = productName,
|
||||||
onValueChange = { productName = it },
|
onValueChange = { productName = it },
|
||||||
label = "Nom du produit (optionnel)",
|
label = "Nom du produit (optionnel)",
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(dimens.spacingSm))
|
Spacer(Modifier.height(dimens.spacingSm))
|
||||||
StandardTextField(
|
StandardTextField(
|
||||||
@ -181,7 +181,7 @@ fun ProductNotFoundScreen(
|
|||||||
onValueChange = { manualBarcode = it },
|
onValueChange = { manualBarcode = it },
|
||||||
label = "Code-barres",
|
label = "Code-barres",
|
||||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) },
|
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(dimens.spacingMd))
|
Spacer(Modifier.height(dimens.spacingMd))
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
@ -193,7 +193,7 @@ fun ProductNotFoundScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = manualBarcode.isNotBlank(),
|
enabled = manualBarcode.isNotBlank(),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -203,7 +203,7 @@ fun ProductNotFoundScreen(
|
|||||||
text = "💡 Vous pouvez aussi scanner un autre produit similaire en magasin.",
|
text = "💡 Vous pouvez aussi scanner un autre produit similaire en magasin.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,9 @@ package com.safebite.app.presentation.screen.result
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@ -10,7 +13,6 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@ -52,7 +54,6 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.LiveRegionMode
|
import androidx.compose.ui.semantics.LiveRegionMode
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.liveRegion
|
import androidx.compose.ui.semantics.liveRegion
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
@ -74,7 +75,6 @@ import com.safebite.app.domain.model.HealthRating
|
|||||||
import com.safebite.app.domain.model.Nutriments
|
import com.safebite.app.domain.model.Nutriments
|
||||||
import com.safebite.app.domain.model.ScanResult
|
import com.safebite.app.domain.model.ScanResult
|
||||||
import com.safebite.app.presentation.common.components.ErrorView
|
import com.safebite.app.presentation.common.components.ErrorView
|
||||||
import com.safebite.app.presentation.common.components.LoadingIndicator
|
|
||||||
import com.safebite.app.presentation.common.components.OutlinedActionButton
|
import com.safebite.app.presentation.common.components.OutlinedActionButton
|
||||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||||
import com.safebite.app.presentation.common.components.ProductCard
|
import com.safebite.app.presentation.common.components.ProductCard
|
||||||
@ -82,7 +82,6 @@ import com.safebite.app.presentation.common.components.ProductSkeleton
|
|||||||
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
||||||
import com.safebite.app.presentation.common.components.SafetyStatusBanner
|
import com.safebite.app.presentation.common.components.SafetyStatusBanner
|
||||||
import com.safebite.app.presentation.common.util.UiState
|
import com.safebite.app.presentation.common.util.UiState
|
||||||
import com.safebite.app.presentation.theme.LocalDimens
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -93,7 +92,8 @@ fun ResultScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onScanAgain: () -> Unit,
|
onScanAgain: () -> Unit,
|
||||||
onOcr: () -> Unit,
|
onOcr: () -> Unit,
|
||||||
viewModel: ResultViewModel = hiltViewModel()
|
onOpenAlternatives: () -> Unit,
|
||||||
|
viewModel: ResultViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val lists by viewModel.lists.collectAsStateWithLifecycle()
|
val lists by viewModel.lists.collectAsStateWithLifecycle()
|
||||||
@ -114,45 +114,49 @@ fun ResultScreen(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
backContentDescription = stringResource(R.string.a11y_back),
|
backContentDescription = stringResource(R.string.a11y_back),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
UiState.Idle, UiState.Loading -> ProductSkeleton()
|
UiState.Idle, UiState.Loading -> ProductSkeleton()
|
||||||
is UiState.Error -> {
|
is UiState.Error -> {
|
||||||
val msg = when {
|
val msg =
|
||||||
|
when {
|
||||||
s.offline -> stringResource(R.string.error_no_connection)
|
s.offline -> stringResource(R.string.error_no_connection)
|
||||||
s.message == "not_found" -> stringResource(R.string.result_product_not_found)
|
s.message == "not_found" -> stringResource(R.string.result_product_not_found)
|
||||||
else -> stringResource(R.string.error_product_unavailable)
|
else -> stringResource(R.string.error_product_unavailable)
|
||||||
}
|
}
|
||||||
val errorContentDesc = stringResource(R.string.a11y_error, msg)
|
val errorContentDesc = stringResource(R.string.a11y_error, msg)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = errorContentDesc
|
contentDescription = errorContentDesc
|
||||||
},
|
},
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
ErrorView(message = msg, modifier = Modifier.weight(1f))
|
ErrorView(message = msg, modifier = Modifier.weight(1f))
|
||||||
OutlinedActionButton(
|
OutlinedActionButton(
|
||||||
text = stringResource(R.string.action_read_ingredients),
|
text = stringResource(R.string.action_read_ingredients),
|
||||||
onClick = onOcr,
|
onClick = onOcr,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
text = stringResource(R.string.action_scan_again),
|
text = stringResource(R.string.action_scan_again),
|
||||||
onClick = onScanAgain,
|
onClick = onScanAgain,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is UiState.Success -> ResultContent(
|
is UiState.Success ->
|
||||||
|
ResultContent(
|
||||||
result = s.data,
|
result = s.data,
|
||||||
onScanAgain = onScanAgain,
|
onScanAgain = onScanAgain,
|
||||||
onOcr = onOcr,
|
onOcr = onOcr,
|
||||||
onAddToList = { showListPicker = true }
|
onAddToList = { showListPicker = true },
|
||||||
|
onOpenAlternatives = onOpenAlternatives,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,7 +167,7 @@ fun ResultScreen(
|
|||||||
viewModel.addToList(listId)
|
viewModel.addToList(listId)
|
||||||
showListPicker = false
|
showListPicker = false
|
||||||
},
|
},
|
||||||
onDismiss = { showListPicker = false }
|
onDismiss = { showListPicker = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -176,18 +180,32 @@ private fun ResultContent(
|
|||||||
result: ScanResult,
|
result: ScanResult,
|
||||||
onScanAgain: () -> Unit,
|
onScanAgain: () -> Unit,
|
||||||
onOcr: () -> Unit,
|
onOcr: () -> Unit,
|
||||||
onAddToList: () -> Unit
|
onAddToList: () -> Unit,
|
||||||
|
onOpenAlternatives: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var ingredientsExpanded by remember { mutableStateOf(false) }
|
var ingredientsExpanded by remember { mutableStateOf(false) }
|
||||||
|
var actionsVisible by remember { mutableStateOf(false) }
|
||||||
|
var contentVisible by remember { mutableStateOf(false) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
contentVisible = true
|
||||||
|
actionsVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = contentVisible,
|
||||||
|
enter = fadeIn(tween(250)) + slideInVertically(tween(250)) { it / 8 },
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
// Annonce TalkBack pour le verdict
|
// Annonce TalkBack pour le verdict
|
||||||
val verdictAnnouncement = when (result.safetyStatus) {
|
val verdictAnnouncement =
|
||||||
|
when (result.safetyStatus) {
|
||||||
com.safebite.app.domain.model.SafetyStatus.SAFE -> stringResource(R.string.a11y_verdict_safe)
|
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.WARNING -> stringResource(R.string.a11y_verdict_warning)
|
||||||
com.safebite.app.domain.model.SafetyStatus.DANGER -> {
|
com.safebite.app.domain.model.SafetyStatus.DANGER -> {
|
||||||
@ -201,18 +219,19 @@ private fun ResultContent(
|
|||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "",
|
text = "",
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.semantics {
|
.semantics {
|
||||||
liveRegion = LiveRegionMode.Assertive
|
liveRegion = LiveRegionMode.Assertive
|
||||||
contentDescription = verdictAnnouncement
|
contentDescription = verdictAnnouncement
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
SafetyStatusBanner(
|
SafetyStatusBanner(
|
||||||
status = result.safetyStatus,
|
status = result.safetyStatus,
|
||||||
profileName = result.analyzedProfiles.firstOrNull()?.name,
|
profileName = result.analyzedProfiles.firstOrNull()?.name,
|
||||||
allergenName = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr,
|
allergenName = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr,
|
||||||
severity = if (result.detectedAllergens.any { it.severe }) "anaphylaxis" else null
|
severity = if (result.detectedAllergens.any { it.severe }) "anaphylaxis" else null,
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
@ -220,11 +239,12 @@ private fun ResultContent(
|
|||||||
title = result.product.name ?: result.product.barcode,
|
title = result.product.name ?: result.product.barcode,
|
||||||
subtitle = result.product.brand,
|
subtitle = result.product.brand,
|
||||||
imageUrl = result.product.imageUrl,
|
imageUrl = result.product.imageUrl,
|
||||||
imageContentDescription = stringResource(R.string.a11y_product_image)
|
imageContentDescription = stringResource(R.string.a11y_product_image),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Open on OFF (only when we have a real barcode, not an OCR synthetic one).
|
// Open on OFF (only when we have a real barcode, not an OCR synthetic one).
|
||||||
if (result.source != DataSource.OCR) {
|
if (result.source != DataSource.OCR) {
|
||||||
|
StaggeredAction(visible = actionsVisible, delayMs = 0) {
|
||||||
OutlinedActionButton(
|
OutlinedActionButton(
|
||||||
text = stringResource(R.string.result_open_in_off),
|
text = stringResource(R.string.result_open_in_off),
|
||||||
onClick = {
|
onClick = {
|
||||||
@ -232,9 +252,10 @@ private fun ResultContent(
|
|||||||
ContextCompat.startActivity(context, intent, null)
|
ContextCompat.startActivity(context, intent, null)
|
||||||
},
|
},
|
||||||
icon = Icons.AutoMirrored.Filled.OpenInNew,
|
icon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ConfidenceRow(result.confidence, result.source)
|
ConfidenceRow(result.confidence, result.source)
|
||||||
|
|
||||||
@ -243,7 +264,7 @@ private fun ResultContent(
|
|||||||
stringResource(R.string.result_profiles_checked) + ": " +
|
stringResource(R.string.result_profiles_checked) + ": " +
|
||||||
result.analyzedProfiles.joinToString { it.name },
|
result.analyzedProfiles.joinToString { it.name },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,7 +272,7 @@ private fun ResultContent(
|
|||||||
if (result.detectedAllergens.isEmpty()) {
|
if (result.detectedAllergens.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.result_no_allergen_detected),
|
stringResource(R.string.result_no_allergen_detected),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
result.detectedAllergens.forEach { AllergenRow(it) }
|
result.detectedAllergens.forEach { AllergenRow(it) }
|
||||||
@ -274,15 +295,17 @@ private fun ResultContent(
|
|||||||
Text(
|
Text(
|
||||||
stringResource(R.string.result_ingredients),
|
stringResource(R.string.result_ingredients),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
IconButton(onClick = { ingredientsExpanded = !ingredientsExpanded }) {
|
IconButton(onClick = { ingredientsExpanded = !ingredientsExpanded }) {
|
||||||
Icon(
|
Icon(
|
||||||
if (ingredientsExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
if (ingredientsExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||||
contentDescription = if (ingredientsExpanded)
|
contentDescription =
|
||||||
|
if (ingredientsExpanded) {
|
||||||
stringResource(R.string.a11y_collapse)
|
stringResource(R.string.a11y_collapse)
|
||||||
else
|
} else {
|
||||||
stringResource(R.string.a11y_expand)
|
stringResource(R.string.a11y_expand)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -290,7 +313,7 @@ private fun ResultContent(
|
|||||||
Text(
|
Text(
|
||||||
result.product.ingredientsText
|
result.product.ingredientsText
|
||||||
?: stringResource(R.string.result_ingredients_unavailable),
|
?: stringResource(R.string.result_ingredients_unavailable),
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -300,39 +323,60 @@ private fun ResultContent(
|
|||||||
Text(
|
Text(
|
||||||
stringResource(R.string.result_disclaimer),
|
stringResource(R.string.result_disclaimer),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
StaggeredAction(visible = actionsVisible, delayMs = 50) {
|
||||||
OutlinedActionButton(
|
OutlinedActionButton(
|
||||||
text = stringResource(R.string.result_add_to_list),
|
text = stringResource(R.string.result_add_to_list),
|
||||||
onClick = onAddToList,
|
onClick = onAddToList,
|
||||||
modifier = Modifier.fillMaxWidth()
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedActionButton(
|
OutlinedActionButton(
|
||||||
text = stringResource(R.string.action_read_ingredients),
|
text = stringResource(R.string.action_read_ingredients),
|
||||||
onClick = onOcr,
|
onClick = onOcr,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
text = stringResource(R.string.action_scan_again),
|
text = stringResource(R.string.action_scan_again),
|
||||||
onClick = onScanAgain,
|
onClick = onScanAgain,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // AnimatedVisibility (slide-up transition)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConfidenceRow(confidence: AnalysisConfidence, source: DataSource) {
|
private fun ConfidenceRow(
|
||||||
val label = when (confidence) {
|
confidence: AnalysisConfidence,
|
||||||
|
source: DataSource,
|
||||||
|
) {
|
||||||
|
val label =
|
||||||
|
when (confidence) {
|
||||||
AnalysisConfidence.HIGH -> R.string.result_confidence_high
|
AnalysisConfidence.HIGH -> R.string.result_confidence_high
|
||||||
AnalysisConfidence.MEDIUM -> R.string.result_confidence_medium
|
AnalysisConfidence.MEDIUM -> R.string.result_confidence_medium
|
||||||
AnalysisConfidence.LOW -> R.string.result_confidence_low
|
AnalysisConfidence.LOW -> R.string.result_confidence_low
|
||||||
}
|
}
|
||||||
val src = when (source) {
|
val src =
|
||||||
|
when (source) {
|
||||||
DataSource.API -> R.string.result_source_api
|
DataSource.API -> R.string.result_source_api
|
||||||
DataSource.CACHE -> R.string.result_source_cache
|
DataSource.CACHE -> R.string.result_source_cache
|
||||||
DataSource.OCR -> R.string.result_source_ocr
|
DataSource.OCR -> R.string.result_source_ocr
|
||||||
@ -347,18 +391,23 @@ private fun ConfidenceRow(confidence: AnalysisConfidence, source: DataSource) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AllergenRow(d: DetectedAllergen) {
|
private fun AllergenRow(d: DetectedAllergen) {
|
||||||
val levelText = when (d.detectionLevel) {
|
val levelText =
|
||||||
|
when (d.detectionLevel) {
|
||||||
DetectionLevel.CONFIRMED -> stringResource(R.string.result_level_confirmed)
|
DetectionLevel.CONFIRMED -> stringResource(R.string.result_level_confirmed)
|
||||||
DetectionLevel.TRACE -> stringResource(R.string.result_level_trace)
|
DetectionLevel.TRACE -> stringResource(R.string.result_level_trace)
|
||||||
DetectionLevel.SUSPECTED -> stringResource(R.string.result_level_suspected)
|
DetectionLevel.SUSPECTED -> stringResource(R.string.result_level_suspected)
|
||||||
}
|
}
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
containerColor = if (d.severe && d.detectionLevel == DetectionLevel.CONFIRMED)
|
CardDefaults.cardColors(
|
||||||
|
containerColor =
|
||||||
|
if (d.severe && d.detectionLevel == DetectionLevel.CONFIRMED) {
|
||||||
MaterialTheme.colorScheme.errorContainer
|
MaterialTheme.colorScheme.errorContainer
|
||||||
else MaterialTheme.colorScheme.surfaceVariant
|
} else {
|
||||||
)
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(d.allergenType.icon, style = MaterialTheme.typography.headlineMedium)
|
Text(d.allergenType.icon, style = MaterialTheme.typography.headlineMedium)
|
||||||
@ -367,14 +416,14 @@ private fun AllergenRow(d: DetectedAllergen) {
|
|||||||
Text(
|
Text(
|
||||||
d.allergenType.displayNameFr,
|
d.allergenType.displayNameFr,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Text("$levelText · ${d.source}", style = MaterialTheme.typography.bodySmall)
|
Text("$levelText · ${d.source}", style = MaterialTheme.typography.bodySmall)
|
||||||
if (d.matchedKeywords.isNotEmpty()) {
|
if (d.matchedKeywords.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
d.matchedKeywords.joinToString(),
|
d.matchedKeywords.joinToString(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -384,25 +433,28 @@ private fun AllergenRow(d: DetectedAllergen) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CustomItemRow(d: DetectedCustomItem) {
|
private fun CustomItemRow(d: DetectedCustomItem) {
|
||||||
val tagLabel = when (d.item.tag) {
|
val tagLabel =
|
||||||
|
when (d.item.tag) {
|
||||||
CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy)
|
CustomItemTag.ALLERGY -> stringResource(R.string.profile_custom_tag_allergy)
|
||||||
CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance)
|
CustomItemTag.INTOLERANCE -> stringResource(R.string.profile_custom_tag_intolerance)
|
||||||
CustomItemTag.DIET -> stringResource(R.string.profile_custom_tag_diet)
|
CustomItemTag.DIET -> stringResource(R.string.profile_custom_tag_diet)
|
||||||
CustomItemTag.UNHEALTHY -> stringResource(R.string.profile_custom_tag_unhealthy)
|
CustomItemTag.UNHEALTHY -> stringResource(R.string.profile_custom_tag_unhealthy)
|
||||||
}
|
}
|
||||||
val icon = when (d.item.tag) {
|
val icon =
|
||||||
|
when (d.item.tag) {
|
||||||
CustomItemTag.ALLERGY -> "⛔"
|
CustomItemTag.ALLERGY -> "⛔"
|
||||||
CustomItemTag.INTOLERANCE -> "⚠️"
|
CustomItemTag.INTOLERANCE -> "⚠️"
|
||||||
CustomItemTag.DIET -> "🥗"
|
CustomItemTag.DIET -> "🥗"
|
||||||
CustomItemTag.UNHEALTHY -> "🍩"
|
CustomItemTag.UNHEALTHY -> "🍩"
|
||||||
}
|
}
|
||||||
val bg = when (d.item.tag) {
|
val bg =
|
||||||
|
when (d.item.tag) {
|
||||||
CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.errorContainer
|
CustomItemTag.ALLERGY -> MaterialTheme.colorScheme.errorContainer
|
||||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
}
|
}
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(containerColor = bg)
|
colors = CardDefaults.cardColors(containerColor = bg),
|
||||||
) {
|
) {
|
||||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(icon, style = MaterialTheme.typography.headlineMedium)
|
Text(icon, style = MaterialTheme.typography.headlineMedium)
|
||||||
@ -414,7 +466,7 @@ private fun CustomItemRow(d: DetectedCustomItem) {
|
|||||||
Text(
|
Text(
|
||||||
d.matchedKeywords.joinToString(),
|
d.matchedKeywords.joinToString(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -424,7 +476,8 @@ private fun CustomItemRow(d: DetectedCustomItem) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HealthSection(health: HealthAssessment) {
|
private fun HealthSection(health: HealthAssessment) {
|
||||||
val (ratingText, ratingColor, emoji) = when (health.rating) {
|
val (ratingText, ratingColor, emoji) =
|
||||||
|
when (health.rating) {
|
||||||
HealthRating.HEALTHY -> Triple(stringResource(R.string.result_health_healthy), Color(0xFF2E7D32), "💪")
|
HealthRating.HEALTHY -> Triple(stringResource(R.string.result_health_healthy), Color(0xFF2E7D32), "💪")
|
||||||
HealthRating.MODERATE -> Triple(stringResource(R.string.result_health_moderate), Color(0xFFF57C00), "🙂")
|
HealthRating.MODERATE -> Triple(stringResource(R.string.result_health_moderate), Color(0xFFF57C00), "🙂")
|
||||||
HealthRating.UNHEALTHY -> Triple(stringResource(R.string.result_health_unhealthy), Color(0xFFC62828), "🚫")
|
HealthRating.UNHEALTHY -> Triple(stringResource(R.string.result_health_unhealthy), Color(0xFFC62828), "🚫")
|
||||||
@ -432,7 +485,7 @@ private fun HealthSection(health: HealthAssessment) {
|
|||||||
}
|
}
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(containerColor = ratingColor.copy(alpha = 0.12f))
|
colors = CardDefaults.cardColors(containerColor = ratingColor.copy(alpha = 0.12f)),
|
||||||
) {
|
) {
|
||||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(emoji, style = MaterialTheme.typography.displaySmall)
|
Text(emoji, style = MaterialTheme.typography.displaySmall)
|
||||||
@ -441,14 +494,14 @@ private fun HealthSection(health: HealthAssessment) {
|
|||||||
Text(
|
Text(
|
||||||
stringResource(R.string.result_health_verdict),
|
stringResource(R.string.result_health_verdict),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Text(ratingText, style = MaterialTheme.typography.titleLarge, color = ratingColor, fontWeight = FontWeight.Bold)
|
Text(ratingText, style = MaterialTheme.typography.titleLarge, color = ratingColor, fontWeight = FontWeight.Bold)
|
||||||
if (health.reasons.isNotEmpty()) {
|
if (health.reasons.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
health.reasons.joinToString(" · "),
|
health.reasons.joinToString(" · "),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -457,7 +510,10 @@ private fun HealthSection(health: HealthAssessment) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NutritionSection(n: Nutriments, servingSize: String?) {
|
private fun NutritionSection(
|
||||||
|
n: Nutriments,
|
||||||
|
servingSize: String?,
|
||||||
|
) {
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(12.dp)) {
|
Column(Modifier.padding(12.dp)) {
|
||||||
Text(stringResource(R.string.result_nutrition), style = MaterialTheme.typography.titleMedium)
|
Text(stringResource(R.string.result_nutrition), style = MaterialTheme.typography.titleMedium)
|
||||||
@ -465,7 +521,7 @@ private fun NutritionSection(n: Nutriments, servingSize: String?) {
|
|||||||
Text(
|
Text(
|
||||||
stringResource(R.string.result_nutrition_unavailable),
|
stringResource(R.string.result_nutrition_unavailable),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
return@Card
|
return@Card
|
||||||
}
|
}
|
||||||
@ -473,22 +529,38 @@ private fun NutritionSection(n: Nutriments, servingSize: String?) {
|
|||||||
Text(
|
Text(
|
||||||
stringResource(R.string.result_nutrition_serving_size, servingSize),
|
stringResource(R.string.result_nutrition_serving_size, servingSize),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row {
|
Row {
|
||||||
Text("", modifier = Modifier.weight(1f))
|
Text("", modifier = Modifier.weight(1f))
|
||||||
Text(stringResource(R.string.result_nutrition_per_100g), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(80.dp), textAlign = TextAlign.End)
|
Text(
|
||||||
|
stringResource(R.string.result_nutrition_per_100g),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.width(80.dp),
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
)
|
||||||
if (n.energyKcalServing != null) {
|
if (n.energyKcalServing != null) {
|
||||||
Text(stringResource(R.string.result_nutrition_per_serving), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(80.dp), textAlign = TextAlign.End)
|
Text(
|
||||||
|
stringResource(R.string.result_nutrition_per_serving),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.width(80.dp),
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
color = MaterialTheme.colorScheme.outlineVariant
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
)
|
||||||
|
NutritionRow(
|
||||||
|
stringResource(R.string.result_nutrition_energy),
|
||||||
|
n.energyKcal100g,
|
||||||
|
n.energyKcalServing,
|
||||||
|
unit = "kcal",
|
||||||
|
emphasize = true,
|
||||||
)
|
)
|
||||||
NutritionRow(stringResource(R.string.result_nutrition_energy), n.energyKcal100g, n.energyKcalServing, unit = "kcal", emphasize = true)
|
|
||||||
NutritionRow(stringResource(R.string.result_nutrition_fat), n.fat100g, null, unit = "g")
|
NutritionRow(stringResource(R.string.result_nutrition_fat), n.fat100g, null, unit = "g")
|
||||||
NutritionRow(" ${stringResource(R.string.result_nutrition_saturated_fat)}", n.saturatedFat100g, null, unit = "g")
|
NutritionRow(" ${stringResource(R.string.result_nutrition_saturated_fat)}", n.saturatedFat100g, null, unit = "g")
|
||||||
NutritionRow(stringResource(R.string.result_nutrition_carbs), n.carbohydrates100g, null, unit = "g")
|
NutritionRow(stringResource(R.string.result_nutrition_carbs), n.carbohydrates100g, null, unit = "g")
|
||||||
@ -501,7 +573,13 @@ private fun NutritionSection(n: Nutriments, servingSize: String?) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NutritionRow(label: String, per100: Double?, perServing: Double?, unit: String, emphasize: Boolean = false) {
|
private fun NutritionRow(
|
||||||
|
label: String,
|
||||||
|
per100: Double?,
|
||||||
|
perServing: Double?,
|
||||||
|
unit: String,
|
||||||
|
emphasize: Boolean = false,
|
||||||
|
) {
|
||||||
if (per100 == null && perServing == null) return
|
if (per100 == null && perServing == null) return
|
||||||
val style = if (emphasize) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium
|
val style = if (emphasize) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium
|
||||||
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
@ -511,7 +589,7 @@ private fun NutritionRow(label: String, per100: Double?, perServing: Double?, un
|
|||||||
modifier = Modifier.width(80.dp),
|
modifier = Modifier.width(80.dp),
|
||||||
textAlign = TextAlign.End,
|
textAlign = TextAlign.End,
|
||||||
style = style,
|
style = style,
|
||||||
fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal
|
fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal,
|
||||||
)
|
)
|
||||||
if (perServing != null) {
|
if (perServing != null) {
|
||||||
Text(
|
Text(
|
||||||
@ -519,16 +597,20 @@ private fun NutritionRow(label: String, per100: Double?, perServing: Double?, un
|
|||||||
modifier = Modifier.width(80.dp),
|
modifier = Modifier.width(80.dp),
|
||||||
textAlign = TextAlign.End,
|
textAlign = TextAlign.End,
|
||||||
style = style,
|
style = style,
|
||||||
fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal
|
fontWeight = if (emphasize) FontWeight.Bold else FontWeight.Normal,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatNumber(d: Double): String {
|
private fun formatNumber(d: Double): String {
|
||||||
return if (d >= 100) d.toInt().toString()
|
return if (d >= 100) {
|
||||||
else if (d >= 10) "%.1f".format(d)
|
d.toInt().toString()
|
||||||
else "%.2f".format(d).trimEnd('0').trimEnd('.', ',')
|
} else if (d >= 10) {
|
||||||
|
"%.1f".format(d)
|
||||||
|
} else {
|
||||||
|
"%.2f".format(d).trimEnd('0').trimEnd('.', ',')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@ -542,11 +624,12 @@ private fun ScoresSection(health: HealthAssessment) {
|
|||||||
ScoreRow(
|
ScoreRow(
|
||||||
title = stringResource(R.string.result_nutriscore),
|
title = stringResource(R.string.result_nutriscore),
|
||||||
details = stringResource(R.string.result_nutriscore_details),
|
details = stringResource(R.string.result_nutriscore_details),
|
||||||
badge = { NutriScoreBadge(health.nutriScore) }
|
badge = { NutriScoreBadge(health.nutriScore) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (health.novaGroup != null) {
|
if (health.novaGroup != null) {
|
||||||
val desc = when (health.novaGroup) {
|
val desc =
|
||||||
|
when (health.novaGroup) {
|
||||||
1 -> stringResource(R.string.result_nova_1)
|
1 -> stringResource(R.string.result_nova_1)
|
||||||
2 -> stringResource(R.string.result_nova_2)
|
2 -> stringResource(R.string.result_nova_2)
|
||||||
3 -> stringResource(R.string.result_nova_3)
|
3 -> stringResource(R.string.result_nova_3)
|
||||||
@ -556,14 +639,14 @@ private fun ScoresSection(health: HealthAssessment) {
|
|||||||
ScoreRow(
|
ScoreRow(
|
||||||
title = stringResource(R.string.result_nova),
|
title = stringResource(R.string.result_nova),
|
||||||
details = desc,
|
details = desc,
|
||||||
badge = { NovaBadge(health.novaGroup) }
|
badge = { NovaBadge(health.novaGroup) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (health.ecoScore != null) {
|
if (health.ecoScore != null) {
|
||||||
ScoreRow(
|
ScoreRow(
|
||||||
title = stringResource(R.string.result_ecoscore),
|
title = stringResource(R.string.result_ecoscore),
|
||||||
details = stringResource(R.string.result_ecoscore_details),
|
details = stringResource(R.string.result_ecoscore_details),
|
||||||
badge = { EcoScoreBadge(health.ecoScore) }
|
badge = { EcoScoreBadge(health.ecoScore) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -571,7 +654,11 @@ private fun ScoresSection(health: HealthAssessment) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScoreRow(title: String, details: String, badge: @Composable () -> Unit) {
|
private fun ScoreRow(
|
||||||
|
title: String,
|
||||||
|
details: String,
|
||||||
|
badge: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
badge()
|
badge()
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
@ -585,7 +672,8 @@ private fun ScoreRow(title: String, details: String, badge: @Composable () -> Un
|
|||||||
@Composable
|
@Composable
|
||||||
private fun NutriScoreBadge(grade: String) {
|
private fun NutriScoreBadge(grade: String) {
|
||||||
val upper = grade.uppercase()
|
val upper = grade.uppercase()
|
||||||
val color = when (upper) {
|
val color =
|
||||||
|
when (upper) {
|
||||||
"A" -> Color(0xFF1E8E3E)
|
"A" -> Color(0xFF1E8E3E)
|
||||||
"B" -> Color(0xFF7CB342)
|
"B" -> Color(0xFF7CB342)
|
||||||
"C" -> Color(0xFFFBC02D)
|
"C" -> Color(0xFFFBC02D)
|
||||||
@ -594,11 +682,12 @@ private fun NutriScoreBadge(grade: String) {
|
|||||||
else -> Color(0xFF757575)
|
else -> Color(0xFF757575)
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(56.dp)
|
.size(56.dp)
|
||||||
.background(color, RoundedCornerShape(12.dp))
|
.background(color, RoundedCornerShape(12.dp))
|
||||||
.border(2.dp, color.copy(alpha = 0.8f), RoundedCornerShape(12.dp)),
|
.border(2.dp, color.copy(alpha = 0.8f), RoundedCornerShape(12.dp)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(upper, color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineMedium)
|
Text(upper, color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineMedium)
|
||||||
}
|
}
|
||||||
@ -606,7 +695,8 @@ private fun NutriScoreBadge(grade: String) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NovaBadge(group: Int) {
|
private fun NovaBadge(group: Int) {
|
||||||
val color = when (group) {
|
val color =
|
||||||
|
when (group) {
|
||||||
1 -> Color(0xFF1E8E3E)
|
1 -> Color(0xFF1E8E3E)
|
||||||
2 -> Color(0xFF7CB342)
|
2 -> Color(0xFF7CB342)
|
||||||
3 -> Color(0xFFEF6C00)
|
3 -> Color(0xFFEF6C00)
|
||||||
@ -614,10 +704,11 @@ private fun NovaBadge(group: Int) {
|
|||||||
else -> Color(0xFF757575)
|
else -> Color(0xFF757575)
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(56.dp)
|
.size(56.dp)
|
||||||
.background(color, CircleShape),
|
.background(color, CircleShape),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(group.toString(), color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineMedium)
|
Text(group.toString(), color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineMedium)
|
||||||
}
|
}
|
||||||
@ -626,7 +717,8 @@ private fun NovaBadge(group: Int) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun EcoScoreBadge(grade: String) {
|
private fun EcoScoreBadge(grade: String) {
|
||||||
val upper = grade.uppercase()
|
val upper = grade.uppercase()
|
||||||
val color = when (upper) {
|
val color =
|
||||||
|
when (upper) {
|
||||||
"A" -> Color(0xFF2E7D32)
|
"A" -> Color(0xFF2E7D32)
|
||||||
"B" -> Color(0xFF558B2F)
|
"B" -> Color(0xFF558B2F)
|
||||||
"C" -> Color(0xFFFBC02D)
|
"C" -> Color(0xFFFBC02D)
|
||||||
@ -635,10 +727,11 @@ private fun EcoScoreBadge(grade: String) {
|
|||||||
else -> Color(0xFF757575)
|
else -> Color(0xFF757575)
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(56.dp)
|
.size(56.dp)
|
||||||
.background(color, RoundedCornerShape(28.dp)),
|
.background(color, RoundedCornerShape(28.dp)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text("🌿$upper", color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
|
Text("🌿$upper", color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
|
||||||
}
|
}
|
||||||
@ -649,48 +742,50 @@ private fun EcoScoreBadge(grade: String) {
|
|||||||
private fun ListPickerBottomSheet(
|
private fun ListPickerBottomSheet(
|
||||||
lists: List<com.safebite.app.data.local.database.entity.ShoppingListEntity>,
|
lists: List<com.safebite.app.data.local.database.entity.ShoppingListEntity>,
|
||||||
onSelect: (Long) -> Unit,
|
onSelect: (Long) -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 20.dp, vertical = 12.dp)
|
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.result_choose_list),
|
text = stringResource(R.string.result_choose_list),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
if (lists.isEmpty()) {
|
if (lists.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Aucune liste disponible",
|
text = "Aucune liste disponible",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
lists.forEach { list ->
|
lists.forEach { list ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable { onSelect(list.id) }
|
.clickable { onSelect(list.id) }
|
||||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = list.name,
|
text = list.name,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -699,3 +794,26 @@ private fun ListPickerBottomSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation stagger pour les actions du verdict.
|
||||||
|
* Chaque bouton apparaît avec un délai progressif (+50ms).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun StaggeredAction(
|
||||||
|
visible: Boolean,
|
||||||
|
delayMs: Int,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter =
|
||||||
|
fadeIn(animationSpec = tween(durationMillis = 300, delayMillis = delayMs)) +
|
||||||
|
slideInVertically(
|
||||||
|
initialOffsetY = { it / 4 },
|
||||||
|
animationSpec = tween(durationMillis = 300, delayMillis = delayMs),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -2,13 +2,13 @@ package com.safebite.app.presentation.screen.result
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
|
||||||
import com.safebite.app.domain.model.DataSource
|
import com.safebite.app.domain.model.DataSource
|
||||||
import com.safebite.app.domain.model.ScanResult
|
import com.safebite.app.domain.model.ScanResult
|
||||||
import com.safebite.app.domain.model.UserProfile
|
import com.safebite.app.domain.model.UserProfile
|
||||||
import com.safebite.app.domain.repository.ProductFetchResult
|
import com.safebite.app.domain.repository.ProductFetchResult
|
||||||
import com.safebite.app.domain.usecase.AnalyzeIngredientsTextUseCase
|
import com.safebite.app.domain.usecase.AnalyzeIngredientsTextUseCase
|
||||||
import com.safebite.app.domain.usecase.AnalyzeProductUseCase
|
import com.safebite.app.domain.usecase.AnalyzeProductUseCase
|
||||||
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
|
|
||||||
import com.safebite.app.domain.usecase.FetchProductUseCase
|
import com.safebite.app.domain.usecase.FetchProductUseCase
|
||||||
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
|
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
|
||||||
import com.safebite.app.domain.usecase.ManageProfileUseCase
|
import com.safebite.app.domain.usecase.ManageProfileUseCase
|
||||||
@ -26,23 +26,26 @@ import kotlinx.coroutines.launch
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ResultViewModel @Inject constructor(
|
class ResultViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val fetchProduct: FetchProductUseCase,
|
private val fetchProduct: FetchProductUseCase,
|
||||||
private val analyzeProduct: AnalyzeProductUseCase,
|
private val analyzeProduct: AnalyzeProductUseCase,
|
||||||
private val analyzeText: AnalyzeIngredientsTextUseCase,
|
private val analyzeText: AnalyzeIngredientsTextUseCase,
|
||||||
private val manageProfile: ManageProfileUseCase,
|
private val manageProfile: ManageProfileUseCase,
|
||||||
private val saveScan: SaveScanUseCase,
|
private val saveScan: SaveScanUseCase,
|
||||||
private val getLists: GetShoppingListsUseCase,
|
private val getLists: GetShoppingListsUseCase,
|
||||||
private val manageList: ManageShoppingListUseCase
|
private val manageList: ManageShoppingListUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow<UiState<ScanResult>>(UiState.Idle)
|
private val _state = MutableStateFlow<UiState<ScanResult>>(UiState.Idle)
|
||||||
val state: StateFlow<UiState<ScanResult>> = _state.asStateFlow()
|
val state: StateFlow<UiState<ScanResult>> = _state.asStateFlow()
|
||||||
|
|
||||||
val lists = getLists.observeActive()
|
val lists =
|
||||||
|
getLists.observeActive()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
fun analyzeBarcode(barcode: String) = viewModelScope.launch {
|
fun analyzeBarcode(barcode: String) =
|
||||||
|
viewModelScope.launch {
|
||||||
_state.value = UiState.Loading
|
_state.value = UiState.Loading
|
||||||
val profiles = resolveProfiles()
|
val profiles = resolveProfiles()
|
||||||
if (profiles.isEmpty()) {
|
if (profiles.isEmpty()) {
|
||||||
@ -61,7 +64,8 @@ class ResultViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun analyzeOcrText(text: String) = viewModelScope.launch {
|
fun analyzeOcrText(text: String) =
|
||||||
|
viewModelScope.launch {
|
||||||
_state.value = UiState.Loading
|
_state.value = UiState.Loading
|
||||||
val profiles = resolveProfiles()
|
val profiles = resolveProfiles()
|
||||||
if (profiles.isEmpty()) {
|
if (profiles.isEmpty()) {
|
||||||
@ -82,11 +86,13 @@ class ResultViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addToList(listId: Long) = viewModelScope.launch {
|
fun addToList(listId: Long) =
|
||||||
|
viewModelScope.launch {
|
||||||
val currentState = _state.value
|
val currentState = _state.value
|
||||||
if (currentState !is UiState.Success) return@launch
|
if (currentState !is UiState.Success) return@launch
|
||||||
val result = currentState.data
|
val result = currentState.data
|
||||||
val entity = ShoppingListItemEntity(
|
val entity =
|
||||||
|
ShoppingListItemEntity(
|
||||||
listId = listId,
|
listId = listId,
|
||||||
barcode = result.product.barcode,
|
barcode = result.product.barcode,
|
||||||
productName = result.product.name ?: result.product.barcode,
|
productName = result.product.name ?: result.product.barcode,
|
||||||
@ -94,7 +100,7 @@ class ResultViewModel @Inject constructor(
|
|||||||
imageUrl = result.product.imageUrl,
|
imageUrl = result.product.imageUrl,
|
||||||
isChecked = false,
|
isChecked = false,
|
||||||
safetyStatus = result.safetyStatus.name,
|
safetyStatus = result.safetyStatus.name,
|
||||||
allergenWarning = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr
|
allergenWarning = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr,
|
||||||
)
|
)
|
||||||
manageList.addItemToList(listId, entity)
|
manageList.addItemToList(listId, entity)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,27 +13,33 @@ import timber.log.Timber
|
|||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
class BarcodeAnalyzer(
|
class BarcodeAnalyzer(
|
||||||
private val onBarcode: (String) -> Unit
|
private val onBarcode: (String) -> Unit,
|
||||||
) : ImageAnalysis.Analyzer {
|
) : ImageAnalysis.Analyzer {
|
||||||
|
private val scanner: BarcodeScanner =
|
||||||
private val scanner: BarcodeScanner = BarcodeScanning.getClient(
|
BarcodeScanning.getClient(
|
||||||
BarcodeScannerOptions.Builder()
|
BarcodeScannerOptions.Builder()
|
||||||
.setBarcodeFormats(
|
.setBarcodeFormats(
|
||||||
Barcode.FORMAT_EAN_13,
|
Barcode.FORMAT_EAN_13,
|
||||||
Barcode.FORMAT_EAN_8,
|
Barcode.FORMAT_EAN_8,
|
||||||
Barcode.FORMAT_UPC_A,
|
Barcode.FORMAT_UPC_A,
|
||||||
Barcode.FORMAT_UPC_E,
|
Barcode.FORMAT_UPC_E,
|
||||||
Barcode.FORMAT_QR_CODE
|
Barcode.FORMAT_QR_CODE,
|
||||||
).build()
|
).build(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val consumed = AtomicBoolean(false)
|
private val consumed = AtomicBoolean(false)
|
||||||
|
|
||||||
@OptIn(ExperimentalGetImage::class)
|
@OptIn(ExperimentalGetImage::class)
|
||||||
override fun analyze(image: ImageProxy) {
|
override fun analyze(image: ImageProxy) {
|
||||||
if (consumed.get()) { image.close(); return }
|
if (consumed.get()) {
|
||||||
|
image.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
val mediaImage = image.image
|
val mediaImage = image.image
|
||||||
if (mediaImage == null) { image.close(); return }
|
if (mediaImage == null) {
|
||||||
|
image.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
val input = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees)
|
val input = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees)
|
||||||
scanner.process(input)
|
scanner.process(input)
|
||||||
.addOnSuccessListener { barcodes ->
|
.addOnSuccessListener { barcodes ->
|
||||||
|
|||||||
@ -20,21 +20,27 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.FlashOff
|
import androidx.compose.material.icons.filled.FlashOff
|
||||||
import androidx.compose.material.icons.filled.FlashOn
|
import androidx.compose.material.icons.filled.FlashOn
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@ -49,13 +55,14 @@ import androidx.compose.ui.geometry.Size
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.google.accompanist.permissions.isGranted
|
import com.google.accompanist.permissions.isGranted
|
||||||
import com.google.accompanist.permissions.rememberPermissionState
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
@ -68,11 +75,15 @@ import java.util.concurrent.Executors
|
|||||||
@Composable
|
@Composable
|
||||||
fun ScannerScreen(
|
fun ScannerScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onBarcode: (String) -> Unit
|
onBarcode: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val permission = rememberPermissionState(android.Manifest.permission.CAMERA)
|
val permission = rememberPermissionState(android.Manifest.permission.CAMERA)
|
||||||
LaunchedEffect(Unit) { if (!permission.status.isGranted) permission.launchPermissionRequest() }
|
LaunchedEffect(Unit) { if (!permission.status.isGranted) permission.launchPermissionRequest() }
|
||||||
|
|
||||||
|
var showManualDialog by remember { mutableStateOf(false) }
|
||||||
|
var manualCode by remember { mutableStateOf("") }
|
||||||
|
var manualError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
topBar = {
|
topBar = {
|
||||||
@ -81,7 +92,7 @@ fun ScannerScreen(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
backContentDescription = stringResource(R.string.a11y_back),
|
backContentDescription = stringResource(R.string.a11y_back),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
val scanAreaDesc = stringResource(R.string.a11y_scan_area)
|
val scanAreaDesc = stringResource(R.string.a11y_scan_area)
|
||||||
Box(
|
Box(
|
||||||
@ -90,22 +101,98 @@ fun ScannerScreen(
|
|||||||
.padding(padding)
|
.padding(padding)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = scanAreaDesc
|
contentDescription = scanAreaDesc
|
||||||
}
|
},
|
||||||
) {
|
) {
|
||||||
if (!permission.status.isGranted) {
|
if (!permission.status.isGranted) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
ErrorView(
|
ErrorView(
|
||||||
message = stringResource(R.string.scanner_camera_denied),
|
message = stringResource(R.string.scanner_camera_denied),
|
||||||
onRetry = { permission.launchPermissionRequest() }
|
onRetry = { permission.launchPermissionRequest() },
|
||||||
)
|
)
|
||||||
} else {
|
Spacer(Modifier.size(16.dp))
|
||||||
CameraView(onBarcode = onBarcode)
|
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,
|
||||||
|
onManualEntry = { showManualDialog = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog de saisie manuelle
|
||||||
|
if (showManualDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
showManualDialog = false
|
||||||
|
manualCode = ""
|
||||||
|
manualError = null
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.scanner_manual_entry_title)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = manualCode,
|
||||||
|
onValueChange = {
|
||||||
|
manualCode = it.filter { c -> c.isDigit() }.take(13)
|
||||||
|
manualError = null
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.scanner_manual_entry_hint)) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
isError = manualError != null,
|
||||||
|
supportingText =
|
||||||
|
if (manualError != null) {
|
||||||
|
{ Text(manualError!!) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
val invalidMsg = stringResource(R.string.scanner_manual_entry_invalid)
|
||||||
|
TextButton(onClick = {
|
||||||
|
val code = manualCode.trim()
|
||||||
|
if (isValidBarcodeFormat(code)) {
|
||||||
|
showManualDialog = false
|
||||||
|
manualCode = ""
|
||||||
|
manualError = null
|
||||||
|
onBarcode(code)
|
||||||
|
} else {
|
||||||
|
manualError = invalidMsg
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.scanner_manual_entry_search))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
showManualDialog = false
|
||||||
|
manualCode = ""
|
||||||
|
manualError = null
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.a11y_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CameraView(onBarcode: (String) -> Unit) {
|
private fun CameraView(
|
||||||
|
onBarcode: (String) -> Unit,
|
||||||
|
onManualEntry: () -> Unit,
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
var torch by remember { mutableStateOf(false) }
|
var torch by remember { mutableStateOf(false) }
|
||||||
@ -119,70 +206,103 @@ private fun CameraView(onBarcode: (String) -> Unit) {
|
|||||||
androidx.compose.ui.viewinterop.AndroidView(
|
androidx.compose.ui.viewinterop.AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
val previewView = PreviewView(ctx).apply {
|
val previewView =
|
||||||
|
PreviewView(ctx).apply {
|
||||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||||
}
|
}
|
||||||
val providerFuture = ProcessCameraProvider.getInstance(ctx)
|
val providerFuture = ProcessCameraProvider.getInstance(ctx)
|
||||||
providerFuture.addListener({
|
providerFuture.addListener({
|
||||||
val provider = providerFuture.get()
|
val provider = providerFuture.get()
|
||||||
val preview = Preview.Builder().build().also {
|
val preview =
|
||||||
|
Preview.Builder().build().also {
|
||||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||||
}
|
}
|
||||||
val analysis = ImageAnalysis.Builder()
|
val analysis =
|
||||||
|
ImageAnalysis.Builder()
|
||||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
.build()
|
.build()
|
||||||
.also { it.setAnalyzer(executor, BarcodeAnalyzer { code ->
|
.also {
|
||||||
|
it.setAnalyzer(
|
||||||
|
executor,
|
||||||
|
BarcodeAnalyzer { code ->
|
||||||
if (!detected) {
|
if (!detected) {
|
||||||
detected = true
|
detected = true
|
||||||
triggerHaptic(ctx)
|
triggerHaptic(ctx)
|
||||||
onBarcode(code)
|
onBarcode(code)
|
||||||
}
|
}
|
||||||
}) }
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
val camera = provider.bindToLifecycle(
|
val camera =
|
||||||
lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
|
provider.bindToLifecycle(
|
||||||
|
lifecycleOwner,
|
||||||
|
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||||
|
preview,
|
||||||
|
analysis,
|
||||||
)
|
)
|
||||||
cameraControl = camera.cameraControl
|
cameraControl = camera.cameraControl
|
||||||
} catch (t: Throwable) { /* ignore */ }
|
} catch (t: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}, ContextCompat.getMainExecutor(ctx))
|
}, ContextCompat.getMainExecutor(ctx))
|
||||||
previewView
|
previewView
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
ScanOverlay(modifier = Modifier.fillMaxSize())
|
ScanOverlay(modifier = Modifier.fillMaxSize())
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.scanner_hint),
|
text = stringResource(R.string.scanner_hint),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.background(Color(0x99000000), RoundedCornerShape(12.dp))
|
.background(Color(0x99000000), RoundedCornerShape(12.dp))
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(12.dp))
|
Spacer(Modifier.size(12.dp))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
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(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
torch = !torch
|
torch = !torch
|
||||||
cameraControl?.enableTorch(torch)
|
cameraControl?.enableTorch(torch)
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(48.dp)
|
modifier = Modifier.size(48.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
if (torch) Icons.Filled.FlashOn else Icons.Filled.FlashOff,
|
if (torch) Icons.Filled.FlashOn else Icons.Filled.FlashOff,
|
||||||
contentDescription = stringResource(R.string.a11y_torch),
|
contentDescription = stringResource(R.string.a11y_torch),
|
||||||
tint = Color.White
|
tint = Color.White,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScanOverlay(modifier: Modifier = Modifier) {
|
private fun ScanOverlay(modifier: Modifier = Modifier) {
|
||||||
@ -190,11 +310,12 @@ private fun ScanOverlay(modifier: Modifier = Modifier) {
|
|||||||
val y by transition.animateFloat(
|
val y by transition.animateFloat(
|
||||||
initialValue = 0f,
|
initialValue = 0f,
|
||||||
targetValue = 1f,
|
targetValue = 1f,
|
||||||
animationSpec = infiniteRepeatable(
|
animationSpec =
|
||||||
|
infiniteRepeatable(
|
||||||
animation = tween(1800, easing = LinearEasing),
|
animation = tween(1800, easing = LinearEasing),
|
||||||
repeatMode = RepeatMode.Reverse
|
repeatMode = RepeatMode.Reverse,
|
||||||
),
|
),
|
||||||
label = "scanY"
|
label = "scanY",
|
||||||
)
|
)
|
||||||
Canvas(modifier = modifier) {
|
Canvas(modifier = modifier) {
|
||||||
val w = size.width
|
val w = size.width
|
||||||
@ -203,35 +324,35 @@ private fun ScanOverlay(modifier: Modifier = Modifier) {
|
|||||||
val topLeft = Offset((w - boxSize.width) / 2f, (h - boxSize.height) / 2f)
|
val topLeft = Offset((w - boxSize.width) / 2f, (h - boxSize.height) / 2f)
|
||||||
drawRect(
|
drawRect(
|
||||||
color = Color(0xB3000000),
|
color = Color(0xB3000000),
|
||||||
size = Size(w, topLeft.y)
|
size = Size(w, topLeft.y),
|
||||||
)
|
)
|
||||||
drawRect(
|
drawRect(
|
||||||
color = Color(0xB3000000),
|
color = Color(0xB3000000),
|
||||||
topLeft = Offset(0f, topLeft.y + boxSize.height),
|
topLeft = Offset(0f, topLeft.y + boxSize.height),
|
||||||
size = Size(w, h - topLeft.y - boxSize.height)
|
size = Size(w, h - topLeft.y - boxSize.height),
|
||||||
)
|
)
|
||||||
drawRect(
|
drawRect(
|
||||||
color = Color(0xB3000000),
|
color = Color(0xB3000000),
|
||||||
topLeft = Offset(0f, topLeft.y),
|
topLeft = Offset(0f, topLeft.y),
|
||||||
size = Size(topLeft.x, boxSize.height)
|
size = Size(topLeft.x, boxSize.height),
|
||||||
)
|
)
|
||||||
drawRect(
|
drawRect(
|
||||||
color = Color(0xB3000000),
|
color = Color(0xB3000000),
|
||||||
topLeft = Offset(topLeft.x + boxSize.width, topLeft.y),
|
topLeft = Offset(topLeft.x + boxSize.width, topLeft.y),
|
||||||
size = Size(w - topLeft.x - boxSize.width, boxSize.height)
|
size = Size(w - topLeft.x - boxSize.width, boxSize.height),
|
||||||
)
|
)
|
||||||
drawRect(
|
drawRect(
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
topLeft = topLeft,
|
topLeft = topLeft,
|
||||||
size = boxSize,
|
size = boxSize,
|
||||||
style = Stroke(width = 4f)
|
style = Stroke(width = 4f),
|
||||||
)
|
)
|
||||||
val lineY = topLeft.y + boxSize.height * y
|
val lineY = topLeft.y + boxSize.height * y
|
||||||
drawLine(
|
drawLine(
|
||||||
color = Color(0xFF00E676),
|
color = Color(0xFF00E676),
|
||||||
start = Offset(topLeft.x, lineY),
|
start = Offset(topLeft.x, lineY),
|
||||||
end = Offset(topLeft.x + boxSize.width, lineY),
|
end = Offset(topLeft.x + boxSize.width, lineY),
|
||||||
strokeWidth = 4f
|
strokeWidth = 4f,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -246,5 +367,15 @@ private fun triggerHaptic(context: android.content.Context) {
|
|||||||
val v = context.getSystemService(android.content.Context.VIBRATOR_SERVICE) as? Vibrator
|
val v = context.getSystemService(android.content.Context.VIBRATOR_SERVICE) as? Vibrator
|
||||||
v?.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE))
|
v?.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
}
|
}
|
||||||
} catch (_: Throwable) { /* ignore */ }
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valide le format d'un code-barres (EAN-13, EAN-8, UPC-A, UPC-E). */
|
||||||
|
private fun isValidBarcodeFormat(code: String): Boolean {
|
||||||
|
if (code.isEmpty()) return false
|
||||||
|
val digits = code.filter { it.isDigit() }
|
||||||
|
if (digits.length != code.length) return false
|
||||||
|
return digits.length in 8..13
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,7 +38,7 @@ import com.safebite.app.presentation.theme.LocalDimens
|
|||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: SettingsViewModel = hiltViewModel()
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val ui by viewModel.state.collectAsStateWithLifecycle()
|
val ui by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@ -51,27 +51,42 @@ fun SettingsScreen(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
backContentDescription = stringResource(R.string.action_back),
|
backContentDescription = stringResource(R.string.action_back),
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
|
.padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
|
||||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
Section(stringResource(R.string.settings_language)) {
|
Section(stringResource(R.string.settings_language)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
|
||||||
FilterChip(selected = ui.appLanguage == AppLanguage.FR, onClick = { viewModel.setAppLanguage(AppLanguage.FR) }, label = { Text("FR") })
|
FilterChip(
|
||||||
FilterChip(selected = ui.appLanguage == AppLanguage.EN, onClick = { viewModel.setAppLanguage(AppLanguage.EN) }, label = { Text("EN") })
|
selected = ui.appLanguage == AppLanguage.FR,
|
||||||
|
onClick = { viewModel.setAppLanguage(AppLanguage.FR) },
|
||||||
|
label = { Text("FR") },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = ui.appLanguage == AppLanguage.EN,
|
||||||
|
onClick = { viewModel.setAppLanguage(AppLanguage.EN) },
|
||||||
|
label = { Text("EN") },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section(stringResource(R.string.settings_detection_language)) {
|
Section(stringResource(R.string.settings_detection_language)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
|
||||||
FilterChip(selected = ui.detectionLanguage == DetectionLanguage.FR, onClick = { viewModel.setDetectionLanguage(DetectionLanguage.FR) }, label = { Text(stringResource(R.string.settings_detection_fr)) })
|
FilterChip(selected = ui.detectionLanguage == DetectionLanguage.FR, onClick = {
|
||||||
FilterChip(selected = ui.detectionLanguage == DetectionLanguage.EN, onClick = { viewModel.setDetectionLanguage(DetectionLanguage.EN) }, label = { Text(stringResource(R.string.settings_detection_en)) })
|
viewModel.setDetectionLanguage(DetectionLanguage.FR)
|
||||||
FilterChip(selected = ui.detectionLanguage == DetectionLanguage.BOTH, onClick = { viewModel.setDetectionLanguage(DetectionLanguage.BOTH) }, label = { Text(stringResource(R.string.settings_detection_both)) })
|
}, label = { Text(stringResource(R.string.settings_detection_fr)) })
|
||||||
|
FilterChip(selected = ui.detectionLanguage == DetectionLanguage.EN, onClick = {
|
||||||
|
viewModel.setDetectionLanguage(DetectionLanguage.EN)
|
||||||
|
}, label = { Text(stringResource(R.string.settings_detection_en)) })
|
||||||
|
FilterChip(selected = ui.detectionLanguage == DetectionLanguage.BOTH, onClick = {
|
||||||
|
viewModel.setDetectionLanguage(DetectionLanguage.BOTH)
|
||||||
|
}, label = { Text(stringResource(R.string.settings_detection_both)) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
StandardCard(variant = CardVariant.Filled) {
|
StandardCard(variant = CardVariant.Filled) {
|
||||||
@ -84,17 +99,29 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
Section(stringResource(R.string.settings_health_strictness)) {
|
Section(stringResource(R.string.settings_health_strictness)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
|
||||||
FilterChip(selected = ui.healthStrictness == HealthStrictness.LENIENT, onClick = { viewModel.setHealthStrictness(HealthStrictness.LENIENT) }, label = { Text(stringResource(R.string.settings_health_lenient)) })
|
FilterChip(selected = ui.healthStrictness == HealthStrictness.LENIENT, onClick = {
|
||||||
FilterChip(selected = ui.healthStrictness == HealthStrictness.NORMAL, onClick = { viewModel.setHealthStrictness(HealthStrictness.NORMAL) }, label = { Text(stringResource(R.string.settings_health_normal)) })
|
viewModel.setHealthStrictness(HealthStrictness.LENIENT)
|
||||||
FilterChip(selected = ui.healthStrictness == HealthStrictness.STRICT, onClick = { viewModel.setHealthStrictness(HealthStrictness.STRICT) }, label = { Text(stringResource(R.string.settings_health_strict)) })
|
}, label = { Text(stringResource(R.string.settings_health_lenient)) })
|
||||||
|
FilterChip(selected = ui.healthStrictness == HealthStrictness.NORMAL, onClick = {
|
||||||
|
viewModel.setHealthStrictness(HealthStrictness.NORMAL)
|
||||||
|
}, label = { Text(stringResource(R.string.settings_health_normal)) })
|
||||||
|
FilterChip(selected = ui.healthStrictness == HealthStrictness.STRICT, onClick = {
|
||||||
|
viewModel.setHealthStrictness(HealthStrictness.STRICT)
|
||||||
|
}, label = { Text(stringResource(R.string.settings_health_strict)) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(stringResource(R.string.settings_theme)) {
|
Section(stringResource(R.string.settings_theme)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
|
||||||
FilterChip(selected = ui.theme == ThemePref.LIGHT, onClick = { viewModel.setTheme(ThemePref.LIGHT) }, label = { Text(stringResource(R.string.settings_theme_light)) })
|
FilterChip(selected = ui.theme == ThemePref.LIGHT, onClick = {
|
||||||
FilterChip(selected = ui.theme == ThemePref.DARK, onClick = { viewModel.setTheme(ThemePref.DARK) }, label = { Text(stringResource(R.string.settings_theme_dark)) })
|
viewModel.setTheme(ThemePref.LIGHT)
|
||||||
FilterChip(selected = ui.theme == ThemePref.SYSTEM, onClick = { viewModel.setTheme(ThemePref.SYSTEM) }, label = { Text(stringResource(R.string.settings_theme_system)) })
|
}, label = { Text(stringResource(R.string.settings_theme_light)) })
|
||||||
|
FilterChip(selected = ui.theme == ThemePref.DARK, onClick = {
|
||||||
|
viewModel.setTheme(ThemePref.DARK)
|
||||||
|
}, label = { Text(stringResource(R.string.settings_theme_dark)) })
|
||||||
|
FilterChip(selected = ui.theme == ThemePref.SYSTEM, onClick = {
|
||||||
|
viewModel.setTheme(ThemePref.SYSTEM)
|
||||||
|
}, label = { Text(stringResource(R.string.settings_theme_system)) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,12 +129,12 @@ fun SettingsScreen(
|
|||||||
DestructiveButton(
|
DestructiveButton(
|
||||||
text = stringResource(R.string.settings_clear_cache),
|
text = stringResource(R.string.settings_clear_cache),
|
||||||
onClick = viewModel::clearCache,
|
onClick = viewModel::clearCache,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
DestructiveButton(
|
DestructiveButton(
|
||||||
text = stringResource(R.string.settings_clear_history),
|
text = stringResource(R.string.settings_clear_history),
|
||||||
onClick = viewModel::clearHistory,
|
onClick = viewModel::clearHistory,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
|
||||||
@ -115,43 +142,50 @@ fun SettingsScreen(
|
|||||||
Text(
|
Text(
|
||||||
stringResource(R.string.settings_version, BuildConfig.VERSION_NAME),
|
stringResource(R.string.settings_version, BuildConfig.VERSION_NAME),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.settings_off_attribution),
|
stringResource(R.string.settings_off_attribution),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"https://world.openfoodfacts.org",
|
"https://world.openfoodfacts.org",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Section(title: String, content: @Composable () -> Unit) {
|
private fun Section(
|
||||||
|
title: String,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
|
Column(verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ToggleRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
private fun ToggleRow(
|
||||||
|
label: String,
|
||||||
|
checked: Boolean,
|
||||||
|
onChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Switch(checked = checked, onCheckedChange = onChange)
|
Switch(checked = checked, onCheckedChange = onChange)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,41 +24,52 @@ data class SettingsUi(
|
|||||||
val sound: Boolean = true,
|
val sound: Boolean = true,
|
||||||
val theme: ThemePref = ThemePref.SYSTEM,
|
val theme: ThemePref = ThemePref.SYSTEM,
|
||||||
val healthStrictness: HealthStrictness = HealthStrictness.NORMAL,
|
val healthStrictness: HealthStrictness = HealthStrictness.NORMAL,
|
||||||
val splashScreenEnabled: Boolean = true
|
val splashScreenEnabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
private val settings: SettingsRepository,
|
private val settings: SettingsRepository,
|
||||||
private val productRepo: ProductRepository,
|
private val productRepo: ProductRepository,
|
||||||
private val historyRepo: ScanHistoryRepository
|
private val historyRepo: ScanHistoryRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
private val coreFlow =
|
||||||
private val coreFlow = combine(
|
combine(
|
||||||
settings.appLanguage,
|
settings.appLanguage,
|
||||||
settings.detectionLanguage,
|
settings.detectionLanguage,
|
||||||
settings.hapticsEnabled,
|
settings.hapticsEnabled,
|
||||||
settings.soundEnabled,
|
settings.soundEnabled,
|
||||||
settings.theme
|
settings.theme,
|
||||||
) { lang, detection, haptics, sound, theme ->
|
) { lang, detection, haptics, sound, theme ->
|
||||||
SettingsUi(lang, detection, haptics, sound, theme)
|
SettingsUi(lang, detection, haptics, sound, theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
val state: StateFlow<SettingsUi> = combine(
|
val state: StateFlow<SettingsUi> =
|
||||||
|
combine(
|
||||||
coreFlow,
|
coreFlow,
|
||||||
settings.healthStrictness,
|
settings.healthStrictness,
|
||||||
settings.splashScreenEnabled
|
settings.splashScreenEnabled,
|
||||||
) { core, strict, splash ->
|
) { core, strict, splash ->
|
||||||
core.copy(healthStrictness = strict, splashScreenEnabled = splash)
|
core.copy(healthStrictness = strict, splashScreenEnabled = splash)
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUi())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUi())
|
||||||
|
|
||||||
fun setAppLanguage(v: AppLanguage) = viewModelScope.launch { settings.setAppLanguage(v) }
|
fun setAppLanguage(v: AppLanguage) = viewModelScope.launch { settings.setAppLanguage(v) }
|
||||||
|
|
||||||
fun setDetectionLanguage(v: DetectionLanguage) = viewModelScope.launch { settings.setDetectionLanguage(v) }
|
fun setDetectionLanguage(v: DetectionLanguage) = viewModelScope.launch { settings.setDetectionLanguage(v) }
|
||||||
|
|
||||||
fun setHaptics(v: Boolean) = viewModelScope.launch { settings.setHaptics(v) }
|
fun setHaptics(v: Boolean) = viewModelScope.launch { settings.setHaptics(v) }
|
||||||
|
|
||||||
fun setSound(v: Boolean) = viewModelScope.launch { settings.setSound(v) }
|
fun setSound(v: Boolean) = viewModelScope.launch { settings.setSound(v) }
|
||||||
|
|
||||||
fun setTheme(v: ThemePref) = viewModelScope.launch { settings.setTheme(v) }
|
fun setTheme(v: ThemePref) = viewModelScope.launch { settings.setTheme(v) }
|
||||||
|
|
||||||
fun setHealthStrictness(v: HealthStrictness) = viewModelScope.launch { settings.setHealthStrictness(v) }
|
fun setHealthStrictness(v: HealthStrictness) = viewModelScope.launch { settings.setHealthStrictness(v) }
|
||||||
|
|
||||||
fun setSplashScreenEnabled(v: Boolean) = viewModelScope.launch { settings.setSplashScreenEnabled(v) }
|
fun setSplashScreenEnabled(v: Boolean) = viewModelScope.launch { settings.setSplashScreenEnabled(v) }
|
||||||
|
|
||||||
fun clearCache() = viewModelScope.launch { productRepo.clearCache() }
|
fun clearCache() = viewModelScope.launch { productRepo.clearCache() }
|
||||||
|
|
||||||
fun clearHistory() = viewModelScope.launch { historyRepo.clear() }
|
fun clearHistory() = viewModelScope.launch { historyRepo.clear() }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,7 +31,7 @@ import com.safebite.app.presentation.theme.ShieldGradient
|
|||||||
@Composable
|
@Composable
|
||||||
fun SplashScreen(
|
fun SplashScreen(
|
||||||
onFinished: () -> Unit,
|
onFinished: () -> Unit,
|
||||||
durationMillis: Int = 2500
|
durationMillis: Int = 2500,
|
||||||
) {
|
) {
|
||||||
val scale = remember { Animatable(0.6f) }
|
val scale = remember { Animatable(0.6f) }
|
||||||
val alpha = remember { Animatable(0f) }
|
val alpha = remember { Animatable(0f) }
|
||||||
@ -44,39 +44,43 @@ fun SplashScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(ShieldGradient),
|
.background(ShieldGradient),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
modifier = Modifier.padding(24.dp)
|
modifier = Modifier.padding(24.dp),
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(id = R.drawable.safebite_logo_nobg),
|
painter = painterResource(id = R.drawable.safebite_logo_nobg),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(160.dp)
|
.size(160.dp)
|
||||||
.scale(scale.value)
|
.scale(scale.value),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.app_name),
|
text = stringResource(R.string.app_name),
|
||||||
style = MaterialTheme.typography.headlineLarge.copy(
|
style =
|
||||||
|
MaterialTheme.typography.headlineLarge.copy(
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = androidx.compose.ui.graphics.Color.White
|
color = androidx.compose.ui.graphics.Color.White,
|
||||||
),
|
),
|
||||||
modifier = Modifier.alpha(alpha.value)
|
modifier = Modifier.alpha(alpha.value),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.onboarding_welcome_subtitle),
|
text = stringResource(R.string.onboarding_welcome_subtitle),
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(
|
style =
|
||||||
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f)
|
MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f),
|
||||||
),
|
),
|
||||||
modifier = Modifier.alpha(alpha.value)
|
modifier = Modifier.alpha(alpha.value),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,7 +60,7 @@ import java.util.Date
|
|||||||
fun TrackingScreen(
|
fun TrackingScreen(
|
||||||
onOpenHistoryItem: (String) -> Unit,
|
onOpenHistoryItem: (String) -> Unit,
|
||||||
onOpenScanner: () -> Unit,
|
onOpenScanner: () -> Unit,
|
||||||
viewModel: TrackingViewModel = hiltViewModel()
|
viewModel: TrackingViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val timeFilter by viewModel.timeFilter.collectAsStateWithLifecycle()
|
val timeFilter by viewModel.timeFilter.collectAsStateWithLifecycle()
|
||||||
@ -80,18 +80,19 @@ fun TrackingScreen(
|
|||||||
val clearAllDesc = stringResource(R.string.a11y_clear_all)
|
val clearAllDesc = stringResource(R.string.a11y_clear_all)
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = viewModel::clearAll,
|
onClick = viewModel::clearAll,
|
||||||
modifier = Modifier.semantics {
|
modifier =
|
||||||
|
Modifier.semantics {
|
||||||
contentDescription = clearAllDesc
|
contentDescription = clearAllDesc
|
||||||
}
|
},
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.DeleteSweep,
|
Icons.Filled.DeleteSweep,
|
||||||
contentDescription = null
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
when (uiState) {
|
when (uiState) {
|
||||||
is TrackingUiState.Loading -> {
|
is TrackingUiState.Loading -> {
|
||||||
@ -99,32 +100,34 @@ fun TrackingScreen(
|
|||||||
}
|
}
|
||||||
is TrackingUiState.Empty -> {
|
is TrackingUiState.Empty -> {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding),
|
.padding(padding),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = stringResource(R.string.tracking_empty_title),
|
title = stringResource(R.string.tracking_empty_title),
|
||||||
message = stringResource(R.string.tracking_empty_body),
|
message = stringResource(R.string.tracking_empty_body),
|
||||||
emoji = "📊"
|
emoji = "📊",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is TrackingUiState.Success -> {
|
is TrackingUiState.Success -> {
|
||||||
val success = uiState as TrackingUiState.Success
|
val success = uiState as TrackingUiState.Success
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = dimens.spacingLg),
|
.padding(horizontal = dimens.spacingLg),
|
||||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
// Filtres temporels
|
// Filtres temporels
|
||||||
item {
|
item {
|
||||||
TimeFilterRow(
|
TimeFilterRow(
|
||||||
selected = success.timeFilter,
|
selected = success.timeFilter,
|
||||||
onFilterChanged = viewModel::setTimeFilter
|
onFilterChanged = viewModel::setTimeFilter,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,7 +135,7 @@ fun TrackingScreen(
|
|||||||
item {
|
item {
|
||||||
StatsSection(
|
StatsSection(
|
||||||
stats = success.stats,
|
stats = success.stats,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,7 +143,7 @@ fun TrackingScreen(
|
|||||||
item {
|
item {
|
||||||
EvolutionChart(
|
EvolutionChart(
|
||||||
data = success.stats.sparklineData,
|
data = success.stats.sparklineData,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,7 +151,7 @@ fun TrackingScreen(
|
|||||||
item {
|
item {
|
||||||
VerdictDistribution(
|
VerdictDistribution(
|
||||||
data = success.stats.barChartData,
|
data = success.stats.barChartData,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,7 +160,7 @@ fun TrackingScreen(
|
|||||||
item {
|
item {
|
||||||
TopAllergensSection(
|
TopAllergensSection(
|
||||||
allergens = success.stats.topAllergens,
|
allergens = success.stats.topAllergens,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -166,7 +169,7 @@ fun TrackingScreen(
|
|||||||
item {
|
item {
|
||||||
StatusFilterRow(
|
StatusFilterRow(
|
||||||
selected = success.statusFilter,
|
selected = success.statusFilter,
|
||||||
onFilterChanged = viewModel::setStatusFilter
|
onFilterChanged = viewModel::setStatusFilter,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,7 +179,7 @@ fun TrackingScreen(
|
|||||||
value = searchQuery,
|
value = searchQuery,
|
||||||
onValueChange = viewModel::setSearchQuery,
|
onValueChange = viewModel::setSearchQuery,
|
||||||
placeholder = "Rechercher un produit...",
|
placeholder = "Rechercher un produit...",
|
||||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) }
|
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,7 +189,7 @@ fun TrackingScreen(
|
|||||||
text = stringResource(R.string.tracking_recent_scans),
|
text = stringResource(R.string.tracking_recent_scans),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,9 +200,10 @@ fun TrackingScreen(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = dimens.spacingXl)
|
.padding(vertical = dimens.spacingXl),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -207,7 +211,7 @@ fun TrackingScreen(
|
|||||||
HistoryItemCard(
|
HistoryItemCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = { onOpenHistoryItem(item.barcode) },
|
onClick = { onOpenHistoryItem(item.barcode) },
|
||||||
onDelete = { viewModel.deleteItem(item.id) }
|
onDelete = { viewModel.deleteItem(item.id) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -223,28 +227,30 @@ fun TrackingScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
fun StatsSection(
|
fun StatsSection(
|
||||||
stats: TrackingStats,
|
stats: TrackingStats,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(dimens.spacingMd),
|
.padding(dimens.spacingMd),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.tracking_stats_title),
|
text = stringResource(R.string.tracking_stats_title),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(dimens.spacingMd))
|
Spacer(modifier = Modifier.height(dimens.spacingMd))
|
||||||
@ -252,7 +258,7 @@ fun StatsSection(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
) {
|
) {
|
||||||
// Donut chart
|
// Donut chart
|
||||||
DonutChart(
|
DonutChart(
|
||||||
@ -260,32 +266,32 @@ fun StatsSection(
|
|||||||
size = 120.dp,
|
size = 120.dp,
|
||||||
strokeWidth = 12.dp,
|
strokeWidth = 12.dp,
|
||||||
centerText = "${(stats.safePercentage * 100).toInt()}%",
|
centerText = "${(stats.safePercentage * 100).toInt()}%",
|
||||||
centerSubText = stringResource(R.string.tracking_safe_rate)
|
centerSubText = stringResource(R.string.tracking_safe_rate),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Stats cards
|
// Stats cards
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
|
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm),
|
||||||
) {
|
) {
|
||||||
StatCardMini(
|
StatCardMini(
|
||||||
icon = "📊",
|
icon = "📊",
|
||||||
value = "${stats.totalScans}",
|
value = "${stats.totalScans}",
|
||||||
label = stringResource(R.string.tracking_total_scans)
|
label = stringResource(R.string.tracking_total_scans),
|
||||||
)
|
)
|
||||||
StatCardMini(
|
StatCardMini(
|
||||||
icon = "✅",
|
icon = "✅",
|
||||||
value = "${stats.safeCount}",
|
value = "${stats.safeCount}",
|
||||||
label = "Sûrs"
|
label = "Sûrs",
|
||||||
)
|
)
|
||||||
StatCardMini(
|
StatCardMini(
|
||||||
icon = "⚠️",
|
icon = "⚠️",
|
||||||
value = "${stats.warningCount}",
|
value = "${stats.warningCount}",
|
||||||
label = "Attention"
|
label = "Attention",
|
||||||
)
|
)
|
||||||
StatCardMini(
|
StatCardMini(
|
||||||
icon = "❌",
|
icon = "❌",
|
||||||
value = "${stats.dangerCount}",
|
value = "${stats.dangerCount}",
|
||||||
label = "Danger"
|
label = "Danger",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -297,11 +303,11 @@ fun StatsSection(
|
|||||||
fun StatCardMini(
|
fun StatCardMini(
|
||||||
icon: String,
|
icon: String,
|
||||||
value: String,
|
value: String,
|
||||||
label: String
|
label: String,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(text = icon, style = MaterialTheme.typography.bodyLarge)
|
Text(text = icon, style = MaterialTheme.typography.bodyLarge)
|
||||||
Column {
|
Column {
|
||||||
@ -309,12 +315,12 @@ fun StatCardMini(
|
|||||||
text = value,
|
text = value,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -326,7 +332,7 @@ fun StatCardMini(
|
|||||||
@Composable
|
@Composable
|
||||||
fun EvolutionChart(
|
fun EvolutionChart(
|
||||||
data: com.safebite.app.presentation.common.components.SparklineData,
|
data: com.safebite.app.presentation.common.components.SparklineData,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
|
|
||||||
@ -334,28 +340,30 @@ fun EvolutionChart(
|
|||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(dimens.spacingMd)
|
.padding(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.tracking_evolution),
|
text = stringResource(R.string.tracking_evolution),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
||||||
Sparkline(
|
Sparkline(
|
||||||
data = data,
|
data = data,
|
||||||
height = 100.dp,
|
height = 100.dp,
|
||||||
lineColor = MaterialTheme.colorScheme.primary,
|
lineColor = MaterialTheme.colorScheme.primary,
|
||||||
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -367,27 +375,29 @@ fun EvolutionChart(
|
|||||||
@Composable
|
@Composable
|
||||||
fun VerdictDistribution(
|
fun VerdictDistribution(
|
||||||
data: com.safebite.app.presentation.common.components.BarChartData,
|
data: com.safebite.app.presentation.common.components.BarChartData,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(dimens.spacingMd)
|
.padding(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.tracking_distribution),
|
text = stringResource(R.string.tracking_distribution),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
||||||
HorizontalBarChart(data = data)
|
HorizontalBarChart(data = data)
|
||||||
@ -401,47 +411,50 @@ fun VerdictDistribution(
|
|||||||
@Composable
|
@Composable
|
||||||
fun TopAllergensSection(
|
fun TopAllergensSection(
|
||||||
allergens: List<AllergenCount>,
|
allergens: List<AllergenCount>,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(dimens.spacingMd)
|
.padding(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.tracking_top_allergens),
|
text = stringResource(R.string.tracking_top_allergens),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
||||||
allergens.forEach { allergen ->
|
allergens.forEach { allergen ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "${allergen.emoji} ${allergen.name}",
|
text = "${allergen.emoji} ${allergen.name}",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "${allergen.count}",
|
text = "${allergen.count}",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -455,35 +468,35 @@ fun TopAllergensSection(
|
|||||||
@Composable
|
@Composable
|
||||||
fun StatusFilterRow(
|
fun StatusFilterRow(
|
||||||
selected: SafetyStatus?,
|
selected: SafetyStatus?,
|
||||||
onFilterChanged: (SafetyStatus?) -> Unit
|
onFilterChanged: (SafetyStatus?) -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm)
|
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm),
|
||||||
) {
|
) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = selected == null,
|
selected = selected == null,
|
||||||
onClick = { onFilterChanged(null) },
|
onClick = { onFilterChanged(null) },
|
||||||
label = { Text("Tous") },
|
label = { Text("Tous") },
|
||||||
modifier = Modifier.semantics { contentDescription = "Filtrer par tous les produits" }
|
modifier = Modifier.semantics { contentDescription = "Filtrer par tous les produits" },
|
||||||
)
|
)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = selected == SafetyStatus.DANGER,
|
selected = selected == SafetyStatus.DANGER,
|
||||||
onClick = { onFilterChanged(SafetyStatus.DANGER) },
|
onClick = { onFilterChanged(SafetyStatus.DANGER) },
|
||||||
label = { Text("❌ Danger") },
|
label = { Text("❌ Danger") },
|
||||||
modifier = Modifier.semantics { contentDescription = "Filtrer par produits dangereux" }
|
modifier = Modifier.semantics { contentDescription = "Filtrer par produits dangereux" },
|
||||||
)
|
)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = selected == SafetyStatus.WARNING,
|
selected = selected == SafetyStatus.WARNING,
|
||||||
onClick = { onFilterChanged(SafetyStatus.WARNING) },
|
onClick = { onFilterChanged(SafetyStatus.WARNING) },
|
||||||
label = { Text("⚠️ Attention") },
|
label = { Text("⚠️ Attention") },
|
||||||
modifier = Modifier.semantics { contentDescription = "Filtrer par produits avec attention" }
|
modifier = Modifier.semantics { contentDescription = "Filtrer par produits avec attention" },
|
||||||
)
|
)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = selected == SafetyStatus.SAFE,
|
selected = selected == SafetyStatus.SAFE,
|
||||||
onClick = { onFilterChanged(SafetyStatus.SAFE) },
|
onClick = { onFilterChanged(SafetyStatus.SAFE) },
|
||||||
label = { Text("✅ Sûr") },
|
label = { Text("✅ Sûr") },
|
||||||
modifier = Modifier.semantics { contentDescription = "Filtrer par produits sûrs" }
|
modifier = Modifier.semantics { contentDescription = "Filtrer par produits sûrs" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -495,31 +508,35 @@ fun StatusFilterRow(
|
|||||||
fun HistoryItemCard(
|
fun HistoryItemCard(
|
||||||
item: com.safebite.app.domain.model.ScanHistoryItem,
|
item: com.safebite.app.domain.model.ScanHistoryItem,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onDelete: () -> Unit
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
val dimens = LocalDimens.current
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
colors = CardDefaults.cardColors(
|
colors =
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(dimens.spacingMd),
|
.padding(dimens.spacingMd),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
horizontalArrangement = Arrangement.spacedBy(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
// Indicateur de couleur
|
// Indicateur de couleur
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.size(12.dp)
|
.size(12.dp)
|
||||||
.background(statusColor(item.safetyStatus), CircleShape)
|
.background(statusColor(item.safetyStatus), CircleShape),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Infos produit
|
// Infos produit
|
||||||
@ -528,19 +545,20 @@ fun HistoryItemCard(
|
|||||||
text = item.productName ?: item.barcode,
|
text = item.productName ?: item.barcode,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
|
text =
|
||||||
|
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
|
||||||
.format(Date(item.scannedAt)),
|
.format(Date(item.scannedAt)),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
if (item.profileNames.isNotEmpty()) {
|
if (item.profileNames.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = item.profileNames.joinToString(", "),
|
text = item.profileNames.joinToString(", "),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -549,14 +567,15 @@ fun HistoryItemCard(
|
|||||||
val deleteDesc = stringResource(R.string.a11y_delete, item.productName ?: item.barcode)
|
val deleteDesc = stringResource(R.string.a11y_delete, item.productName ?: item.barcode)
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onDelete,
|
onClick = onDelete,
|
||||||
modifier = Modifier.semantics {
|
modifier =
|
||||||
|
Modifier.semantics {
|
||||||
contentDescription = deleteDesc
|
contentDescription = deleteDesc
|
||||||
}
|
},
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Delete,
|
Icons.Filled.Delete,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -572,34 +591,38 @@ fun TrackingLoadingSkeleton(modifier: Modifier = Modifier) {
|
|||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.padding(horizontal = dimens.spacingLg),
|
modifier = modifier.padding(horizontal = dimens.spacingLg),
|
||||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
ShimmerBox(
|
ShimmerBox(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(48.dp)
|
.height(48.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
ShimmerBox(
|
ShimmerBox(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(200.dp)
|
.height(200.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
ShimmerBox(
|
ShimmerBox(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(150.dp)
|
.height(150.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
ShimmerBox(
|
ShimmerBox(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(150.dp)
|
.height(150.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,7 @@ data class TrackingStats(
|
|||||||
val weeklyScans: Int = 0,
|
val weeklyScans: Int = 0,
|
||||||
val weeklySafePercentage: Float = 0f,
|
val weeklySafePercentage: Float = 0f,
|
||||||
val sparklineData: SparklineData = SparklineData(emptyList()),
|
val sparklineData: SparklineData = SparklineData(emptyList()),
|
||||||
val barChartData: BarChartData = BarChartData(emptyList())
|
val barChartData: BarChartData = BarChartData(emptyList()),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -43,7 +43,7 @@ data class TrackingStats(
|
|||||||
data class AllergenCount(
|
data class AllergenCount(
|
||||||
val name: String,
|
val name: String,
|
||||||
val count: Int,
|
val count: Int,
|
||||||
val emoji: String = "⚠️"
|
val emoji: String = "⚠️",
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -51,21 +51,24 @@ data class AllergenCount(
|
|||||||
*/
|
*/
|
||||||
sealed class TrackingUiState {
|
sealed class TrackingUiState {
|
||||||
data object Loading : TrackingUiState()
|
data object Loading : TrackingUiState()
|
||||||
|
|
||||||
data class Success(
|
data class Success(
|
||||||
val stats: TrackingStats,
|
val stats: TrackingStats,
|
||||||
val historyItems: List<ScanHistoryItem>,
|
val historyItems: List<ScanHistoryItem>,
|
||||||
val timeFilter: TimeFilter,
|
val timeFilter: TimeFilter,
|
||||||
val statusFilter: SafetyStatus? = null,
|
val statusFilter: SafetyStatus? = null,
|
||||||
val searchQuery: String = ""
|
val searchQuery: String = "",
|
||||||
) : TrackingUiState()
|
) : TrackingUiState()
|
||||||
|
|
||||||
data object Empty : TrackingUiState()
|
data object Empty : TrackingUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class TrackingViewModel @Inject constructor(
|
class TrackingViewModel
|
||||||
private val getScanHistoryUseCase: GetScanHistoryUseCase
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val getScanHistoryUseCase: GetScanHistoryUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _timeFilter = MutableStateFlow(TimeFilter.WEEK)
|
private val _timeFilter = MutableStateFlow(TimeFilter.WEEK)
|
||||||
val timeFilter: StateFlow<TimeFilter> = _timeFilter.asStateFlow()
|
val timeFilter: StateFlow<TimeFilter> = _timeFilter.asStateFlow()
|
||||||
|
|
||||||
@ -75,13 +78,15 @@ class TrackingViewModel @Inject constructor(
|
|||||||
private val _searchQuery = MutableStateFlow("")
|
private val _searchQuery = MutableStateFlow("")
|
||||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||||
|
|
||||||
val uiState: StateFlow<TrackingUiState> = combine(
|
val uiState: StateFlow<TrackingUiState> =
|
||||||
|
combine(
|
||||||
getScanHistoryUseCase.observe(),
|
getScanHistoryUseCase.observe(),
|
||||||
_timeFilter,
|
_timeFilter,
|
||||||
_statusFilter,
|
_statusFilter,
|
||||||
_searchQuery
|
_searchQuery,
|
||||||
) { items, timeFilter, statusFilter, query ->
|
) { items, timeFilter, statusFilter, query ->
|
||||||
val filteredItems = items
|
val filteredItems =
|
||||||
|
items
|
||||||
.filterByTime(timeFilter)
|
.filterByTime(timeFilter)
|
||||||
.filter { statusFilter == null || it.safetyStatus == statusFilter }
|
.filter { statusFilter == null || it.safetyStatus == statusFilter }
|
||||||
.filter { query.isBlank() || matchesSearch(it, query) }
|
.filter { query.isBlank() || matchesSearch(it, query) }
|
||||||
@ -95,13 +100,13 @@ class TrackingViewModel @Inject constructor(
|
|||||||
historyItems = filteredItems,
|
historyItems = filteredItems,
|
||||||
timeFilter = timeFilter,
|
timeFilter = timeFilter,
|
||||||
statusFilter = statusFilter,
|
statusFilter = statusFilter,
|
||||||
searchQuery = query
|
searchQuery = query,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
viewModelScope,
|
viewModelScope,
|
||||||
SharingStarted.WhileSubscribed(5_000),
|
SharingStarted.WhileSubscribed(5_000),
|
||||||
TrackingUiState.Loading
|
TrackingUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun setTimeFilter(filter: TimeFilter) {
|
fun setTimeFilter(filter: TimeFilter) {
|
||||||
@ -116,17 +121,20 @@ class TrackingViewModel @Inject constructor(
|
|||||||
_searchQuery.value = query
|
_searchQuery.value = query
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteItem(id: Long) = viewModelScope.launch {
|
fun deleteItem(id: Long) =
|
||||||
|
viewModelScope.launch {
|
||||||
getScanHistoryUseCase.delete(id)
|
getScanHistoryUseCase.delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearAll() = viewModelScope.launch {
|
fun clearAll() =
|
||||||
|
viewModelScope.launch {
|
||||||
getScanHistoryUseCase.clear()
|
getScanHistoryUseCase.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<ScanHistoryItem>.filterByTime(filter: TimeFilter): List<ScanHistoryItem> {
|
private fun List<ScanHistoryItem>.filterByTime(filter: TimeFilter): List<ScanHistoryItem> {
|
||||||
val calendar = Calendar.getInstance()
|
val calendar = Calendar.getInstance()
|
||||||
val cutoffTime = when (filter) {
|
val cutoffTime =
|
||||||
|
when (filter) {
|
||||||
TimeFilter.WEEK -> {
|
TimeFilter.WEEK -> {
|
||||||
calendar.add(Calendar.DAY_OF_YEAR, -7)
|
calendar.add(Calendar.DAY_OF_YEAR, -7)
|
||||||
calendar.timeInMillis
|
calendar.timeInMillis
|
||||||
@ -144,13 +152,19 @@ class TrackingViewModel @Inject constructor(
|
|||||||
return this.filter { it.scannedAt >= cutoffTime }
|
return this.filter { it.scannedAt >= cutoffTime }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun matchesSearch(item: ScanHistoryItem, query: String): Boolean {
|
private fun matchesSearch(
|
||||||
|
item: ScanHistoryItem,
|
||||||
|
query: String,
|
||||||
|
): Boolean {
|
||||||
return item.productName?.contains(query, ignoreCase = true) == true ||
|
return item.productName?.contains(query, ignoreCase = true) == true ||
|
||||||
item.brand?.contains(query, ignoreCase = true) == true ||
|
item.brand?.contains(query, ignoreCase = true) == true ||
|
||||||
item.barcode.contains(query)
|
item.barcode.contains(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computeStats(allItems: List<ScanHistoryItem>, timeFilter: TimeFilter): TrackingStats {
|
private fun computeStats(
|
||||||
|
allItems: List<ScanHistoryItem>,
|
||||||
|
timeFilter: TimeFilter,
|
||||||
|
): TrackingStats {
|
||||||
val items = allItems.filterByTime(timeFilter)
|
val items = allItems.filterByTime(timeFilter)
|
||||||
val total = items.size
|
val total = items.size
|
||||||
val safeCount = items.count { it.safetyStatus == SafetyStatus.SAFE }
|
val safeCount = items.count { it.safetyStatus == SafetyStatus.SAFE }
|
||||||
@ -165,12 +179,14 @@ class TrackingViewModel @Inject constructor(
|
|||||||
val sparklineData = computeSparklineData(items, timeFilter)
|
val sparklineData = computeSparklineData(items, timeFilter)
|
||||||
|
|
||||||
// Données bar chart (répartition par statut)
|
// Données bar chart (répartition par statut)
|
||||||
val barChartData = BarChartData(
|
val barChartData =
|
||||||
items = listOf(
|
BarChartData(
|
||||||
|
items =
|
||||||
|
listOf(
|
||||||
BarChartItem("Sûr", safeCount, SemanticColors.Safe),
|
BarChartItem("Sûr", safeCount, SemanticColors.Safe),
|
||||||
BarChartItem("Attention", warningCount, SemanticColors.Warning),
|
BarChartItem("Attention", warningCount, SemanticColors.Warning),
|
||||||
BarChartItem("Danger", dangerCount, SemanticColors.Danger)
|
BarChartItem("Danger", dangerCount, SemanticColors.Danger),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return TrackingStats(
|
return TrackingStats(
|
||||||
@ -183,7 +199,7 @@ class TrackingViewModel @Inject constructor(
|
|||||||
weeklyScans = items.size,
|
weeklyScans = items.size,
|
||||||
weeklySafePercentage = safePercentage,
|
weeklySafePercentage = safePercentage,
|
||||||
sparklineData = sparklineData,
|
sparklineData = sparklineData,
|
||||||
barChartData = barChartData
|
barChartData = barChartData,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,9 +217,13 @@ class TrackingViewModel @Inject constructor(
|
|||||||
.map { AllergenCount(it.key, it.value) }
|
.map { AllergenCount(it.key, it.value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computeSparklineData(items: List<ScanHistoryItem>, timeFilter: TimeFilter): SparklineData {
|
private fun computeSparklineData(
|
||||||
|
items: List<ScanHistoryItem>,
|
||||||
|
timeFilter: TimeFilter,
|
||||||
|
): SparklineData {
|
||||||
val calendar = Calendar.getInstance()
|
val calendar = Calendar.getInstance()
|
||||||
val days = when (timeFilter) {
|
val days =
|
||||||
|
when (timeFilter) {
|
||||||
TimeFilter.WEEK -> 7
|
TimeFilter.WEEK -> 7
|
||||||
TimeFilter.MONTH -> 30
|
TimeFilter.MONTH -> 30
|
||||||
TimeFilter.YEAR -> 12
|
TimeFilter.YEAR -> 12
|
||||||
|
|||||||
@ -127,47 +127,60 @@ val DarkInversePrimary = Color(0xFF1B7A2B)
|
|||||||
val DarkScrim = Color(0xFF000000)
|
val DarkScrim = Color(0xFF000000)
|
||||||
|
|
||||||
// ---- Dégradé signature (rappel du fond du logo bouclier) --------------------
|
// ---- Dégradé signature (rappel du fond du logo bouclier) --------------------
|
||||||
val ShieldGradient = androidx.compose.ui.graphics.Brush.linearGradient(
|
val ShieldGradient =
|
||||||
colors = listOf(
|
androidx.compose.ui.graphics.Brush.linearGradient(
|
||||||
|
colors =
|
||||||
|
listOf(
|
||||||
Color(0xFF4CAF50), // Vert clair (haut-gauche)
|
Color(0xFF4CAF50), // Vert clair (haut-gauche)
|
||||||
Color(0xFF1B7A2B), // Vert moyen
|
Color(0xFF1B7A2B), // Vert moyen
|
||||||
Color(0xFF0D5E1A) // Vert foncé (bas-droite)
|
Color(0xFF0D5E1A), // Vert foncé (bas-droite)
|
||||||
),
|
),
|
||||||
start = androidx.compose.ui.geometry.Offset(0f, 0f),
|
start = androidx.compose.ui.geometry.Offset(0f, 0f),
|
||||||
end = androidx.compose.ui.geometry.Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY)
|
end = androidx.compose.ui.geometry.Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY),
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- Legacy aliases (backward compat pour code existant) -------------------
|
// ---- Legacy aliases (backward compat pour code existant) -------------------
|
||||||
@Deprecated("Use SemanticColors.Safe", ReplaceWith("SemanticColors.Safe"))
|
@Deprecated("Use SemanticColors.Safe", ReplaceWith("SemanticColors.Safe"))
|
||||||
val StatusSafe get() = SemanticColors.Safe
|
val StatusSafe get() = SemanticColors.Safe
|
||||||
|
|
||||||
@Deprecated("Use SemanticColors.SafeContainer", ReplaceWith("SemanticColors.SafeContainer"))
|
@Deprecated("Use SemanticColors.SafeContainer", ReplaceWith("SemanticColors.SafeContainer"))
|
||||||
val StatusSafeContainer get() = SemanticColors.SafeContainer
|
val StatusSafeContainer get() = SemanticColors.SafeContainer
|
||||||
|
|
||||||
@Deprecated("Use SemanticColors.OnSafe", ReplaceWith("SemanticColors.OnSafe"))
|
@Deprecated("Use SemanticColors.OnSafe", ReplaceWith("SemanticColors.OnSafe"))
|
||||||
val OnStatusSafe get() = SemanticColors.OnSafe
|
val OnStatusSafe get() = SemanticColors.OnSafe
|
||||||
|
|
||||||
@Deprecated("Use SemanticColors.Warning", ReplaceWith("SemanticColors.Warning"))
|
@Deprecated("Use SemanticColors.Warning", ReplaceWith("SemanticColors.Warning"))
|
||||||
val StatusWarning get() = SemanticColors.Warning
|
val StatusWarning get() = SemanticColors.Warning
|
||||||
|
|
||||||
@Deprecated("Use SemanticColors.WarningContainer", ReplaceWith("SemanticColors.WarningContainer"))
|
@Deprecated("Use SemanticColors.WarningContainer", ReplaceWith("SemanticColors.WarningContainer"))
|
||||||
val StatusWarningContainer get() = SemanticColors.WarningContainer
|
val StatusWarningContainer get() = SemanticColors.WarningContainer
|
||||||
|
|
||||||
@Deprecated("Use SemanticColors.OnWarning", ReplaceWith("SemanticColors.OnWarning"))
|
@Deprecated("Use SemanticColors.OnWarning", ReplaceWith("SemanticColors.OnWarning"))
|
||||||
val OnStatusWarning get() = SemanticColors.OnWarning
|
val OnStatusWarning get() = SemanticColors.OnWarning
|
||||||
|
|
||||||
@Deprecated("Use SemanticColors.Danger", ReplaceWith("SemanticColors.Danger"))
|
@Deprecated("Use SemanticColors.Danger", ReplaceWith("SemanticColors.Danger"))
|
||||||
val StatusDanger get() = SemanticColors.Danger
|
val StatusDanger get() = SemanticColors.Danger
|
||||||
|
|
||||||
@Deprecated("Use SemanticColors.DangerContainer", ReplaceWith("SemanticColors.DangerContainer"))
|
@Deprecated("Use SemanticColors.DangerContainer", ReplaceWith("SemanticColors.DangerContainer"))
|
||||||
val StatusDangerContainer get() = SemanticColors.DangerContainer
|
val StatusDangerContainer get() = SemanticColors.DangerContainer
|
||||||
|
|
||||||
@Deprecated("Use SemanticColors.OnDanger", ReplaceWith("SemanticColors.OnDanger"))
|
@Deprecated("Use SemanticColors.OnDanger", ReplaceWith("SemanticColors.OnDanger"))
|
||||||
val OnStatusDanger get() = SemanticColors.OnDanger
|
val OnStatusDanger get() = SemanticColors.OnDanger
|
||||||
|
|
||||||
@Deprecated("Use SemanticColors.SafeDark", ReplaceWith("SemanticColors.SafeDark"))
|
@Deprecated("Use SemanticColors.SafeDark", ReplaceWith("SemanticColors.SafeDark"))
|
||||||
val StatusSafeDark get() = SemanticColors.SafeDark
|
val StatusSafeDark get() = SemanticColors.SafeDark
|
||||||
|
|
||||||
@Deprecated("Use SemanticColors.SafeContainerDark", ReplaceWith("SemanticColors.SafeContainerDark"))
|
@Deprecated("Use SemanticColors.SafeContainerDark", ReplaceWith("SemanticColors.SafeContainerDark"))
|
||||||
val StatusSafeContainerDark get() = SemanticColors.SafeContainerDark
|
val StatusSafeContainerDark get() = SemanticColors.SafeContainerDark
|
||||||
|
|
||||||
@Deprecated("Use SemanticColors.WarningDark", ReplaceWith("SemanticColors.WarningDark"))
|
@Deprecated("Use SemanticColors.WarningDark", ReplaceWith("SemanticColors.WarningDark"))
|
||||||
val StatusWarningDark get() = SemanticColors.WarningDark
|
val StatusWarningDark get() = SemanticColors.WarningDark
|
||||||
|
|
||||||
@Deprecated("Use SemanticColors.WarningContainerDark", ReplaceWith("SemanticColors.WarningContainerDark"))
|
@Deprecated("Use SemanticColors.WarningContainerDark", ReplaceWith("SemanticColors.WarningContainerDark"))
|
||||||
val StatusWarningContainerDark get() = SemanticColors.WarningContainerDark
|
val StatusWarningContainerDark get() = SemanticColors.WarningContainerDark
|
||||||
|
|
||||||
@Deprecated("Use SemanticColors.DangerDark", ReplaceWith("SemanticColors.DangerDark"))
|
@Deprecated("Use SemanticColors.DangerDark", ReplaceWith("SemanticColors.DangerDark"))
|
||||||
val StatusDangerDark get() = SemanticColors.DangerDark
|
val StatusDangerDark get() = SemanticColors.DangerDark
|
||||||
|
|
||||||
@Deprecated("Use SemanticColors.DangerContainerDark", ReplaceWith("SemanticColors.DangerContainerDark"))
|
@Deprecated("Use SemanticColors.DangerContainerDark", ReplaceWith("SemanticColors.DangerContainerDark"))
|
||||||
val StatusDangerContainerDark get() = SemanticColors.DangerContainerDark
|
val StatusDangerContainerDark get() = SemanticColors.DangerContainerDark
|
||||||
|
|||||||
@ -22,7 +22,6 @@ data class Dimens(
|
|||||||
val spacingXl: Dp = 24.dp,
|
val spacingXl: Dp = 24.dp,
|
||||||
val spacingXxl: Dp = 32.dp,
|
val spacingXxl: Dp = 32.dp,
|
||||||
val spacingXxxl: Dp = 48.dp,
|
val spacingXxxl: Dp = 48.dp,
|
||||||
|
|
||||||
// Corner radius
|
// Corner radius
|
||||||
val radiusSm: Dp = 4.dp,
|
val radiusSm: Dp = 4.dp,
|
||||||
val radiusMd: Dp = 8.dp,
|
val radiusMd: Dp = 8.dp,
|
||||||
@ -30,14 +29,12 @@ data class Dimens(
|
|||||||
val radiusXl: Dp = 16.dp,
|
val radiusXl: Dp = 16.dp,
|
||||||
val radiusXxl: Dp = 24.dp,
|
val radiusXxl: Dp = 24.dp,
|
||||||
val radiusPill: Dp = 999.dp,
|
val radiusPill: Dp = 999.dp,
|
||||||
|
|
||||||
// Elevations
|
// Elevations
|
||||||
val elevationNone: Dp = 0.dp,
|
val elevationNone: Dp = 0.dp,
|
||||||
val elevationSm: Dp = 1.dp,
|
val elevationSm: Dp = 1.dp,
|
||||||
val elevationMd: Dp = 3.dp,
|
val elevationMd: Dp = 3.dp,
|
||||||
val elevationLg: Dp = 6.dp,
|
val elevationLg: Dp = 6.dp,
|
||||||
val elevationXl: Dp = 8.dp,
|
val elevationXl: Dp = 8.dp,
|
||||||
|
|
||||||
// Component heights
|
// Component heights
|
||||||
val buttonHeightSm: Dp = 40.dp,
|
val buttonHeightSm: Dp = 40.dp,
|
||||||
val buttonHeight: Dp = 48.dp,
|
val buttonHeight: Dp = 48.dp,
|
||||||
|
|||||||
@ -4,10 +4,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.Shapes
|
import androidx.compose.material3.Shapes
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
val SafeBiteShapes = Shapes(
|
val SafeBiteShapes =
|
||||||
|
Shapes(
|
||||||
extraSmall = RoundedCornerShape(4.dp),
|
extraSmall = RoundedCornerShape(4.dp),
|
||||||
small = RoundedCornerShape(8.dp),
|
small = RoundedCornerShape(8.dp),
|
||||||
medium = RoundedCornerShape(12.dp),
|
medium = RoundedCornerShape(12.dp),
|
||||||
large = RoundedCornerShape(16.dp),
|
large = RoundedCornerShape(16.dp),
|
||||||
extraLarge = RoundedCornerShape(24.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