feat: implement core application architecture, navigation system, database schema, and initial UI screens for SafeBite
This commit is contained in:
+11
-1
@@ -50,12 +50,15 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
applicationIdSuffix = ".debug"
|
||||
isDebuggable = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,4 +146,11 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.androidx.test.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
androidTestImplementation(libs.androidx.test.rules)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
|
||||
// LeakCanary pour détection de fuites mémoire (debug uniquement)
|
||||
debugImplementation(libs.leakcanary.android)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.safebite.app
|
||||
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Instrumented test pour vérifier l'infrastructure Compose Testing.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleComposeTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun testComposeInfrastructure() {
|
||||
// Test basique pour valider que l'infrastructure Compose Testing fonctionne
|
||||
composeTestRule.setContent {
|
||||
androidx.compose.material3.Text("Hello SafeBite!")
|
||||
}
|
||||
composeTestRule.onNodeWithText("Hello SafeBite!").assertExists()
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,23 @@ import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import com.safebite.app.data.local.database.dao.ProductCacheDao
|
||||
import com.safebite.app.data.local.database.dao.ScanHistoryDao
|
||||
import com.safebite.app.data.local.database.dao.ShoppingListDao
|
||||
import com.safebite.app.data.local.database.dao.UserProfileDao
|
||||
import com.safebite.app.data.local.database.entity.ProductCacheEntity
|
||||
import com.safebite.app.data.local.database.entity.ScanHistoryEntity
|
||||
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.UserProfileEntity
|
||||
|
||||
@Database(
|
||||
entities = [UserProfileEntity::class, ProductCacheEntity::class, ScanHistoryEntity::class],
|
||||
version = 2,
|
||||
entities = [
|
||||
UserProfileEntity::class,
|
||||
ProductCacheEntity::class,
|
||||
ScanHistoryEntity::class,
|
||||
ShoppingListEntity::class,
|
||||
ShoppingListItemEntity::class
|
||||
],
|
||||
version = 3,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@@ -20,6 +29,7 @@ abstract class SafeBiteDatabase : RoomDatabase() {
|
||||
abstract fun userProfileDao(): UserProfileDao
|
||||
abstract fun productCacheDao(): ProductCacheDao
|
||||
abstract fun scanHistoryDao(): ScanHistoryDao
|
||||
abstract fun shoppingListDao(): ShoppingListDao
|
||||
|
||||
companion object {
|
||||
const val NAME = "safebite.db"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.safebite.app.data.local.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import com.safebite.app.data.local.database.entity.ShoppingListEntity
|
||||
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* DAO pour la gestion des listes de courses (Phase 2).
|
||||
*/
|
||||
@Dao
|
||||
interface ShoppingListDao {
|
||||
|
||||
// ── Shopping Lists ──────────────────────────────────────────────────────
|
||||
|
||||
@Query("SELECT * FROM shopping_lists WHERE isArchived = 0 ORDER BY updatedAt DESC")
|
||||
fun observeActiveLists(): Flow<List<ShoppingListEntity>>
|
||||
|
||||
@Query("SELECT * FROM shopping_lists ORDER BY updatedAt DESC")
|
||||
fun observeAllLists(): Flow<List<ShoppingListEntity>>
|
||||
|
||||
@Query("SELECT * FROM shopping_lists WHERE id = :id LIMIT 1")
|
||||
suspend fun getListById(id: Long): ShoppingListEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertList(list: ShoppingListEntity): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateList(list: ShoppingListEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteList(list: ShoppingListEntity)
|
||||
|
||||
@Query("UPDATE shopping_lists SET isArchived = 1 WHERE id = :id")
|
||||
suspend fun archiveList(id: Long)
|
||||
|
||||
// ── Shopping List Items ─────────────────────────────────────────────────
|
||||
|
||||
@Query("SELECT * FROM shopping_list_items WHERE listId = :listId ORDER BY isChecked ASC, addedAt DESC")
|
||||
fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM shopping_list_items WHERE listId = :listId ORDER BY isChecked ASC, addedAt DESC")
|
||||
suspend fun getItems(listId: Long): List<ShoppingListItemEntity>
|
||||
|
||||
@Query("SELECT * FROM shopping_list_items WHERE id = :id LIMIT 1")
|
||||
suspend fun getItemById(id: Long): ShoppingListItemEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertItem(item: ShoppingListItemEntity): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateItem(item: ShoppingListItemEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteItem(item: ShoppingListItemEntity)
|
||||
|
||||
@Query("UPDATE shopping_list_items SET isChecked = :checked WHERE id = :id")
|
||||
suspend fun setItemChecked(id: Long, checked: Boolean)
|
||||
|
||||
@Query("UPDATE shopping_list_items SET isChecked = 0 WHERE listId = :listId")
|
||||
suspend fun uncheckAllItems(listId: Long)
|
||||
|
||||
@Query("DELETE FROM shopping_list_items WHERE listId = :listId")
|
||||
suspend fun deleteAllItems(listId: Long)
|
||||
|
||||
// ── Stats ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Query("SELECT COUNT(*) FROM shopping_list_items WHERE listId = :listId")
|
||||
fun observeItemCount(listId: Long): Flow<Int>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM shopping_list_items WHERE listId = :listId AND isChecked = 1")
|
||||
fun observeCheckedCount(listId: Long): Flow<Int>
|
||||
|
||||
// ── Transaction: ajouter un produit à une liste ─────────────────────────
|
||||
|
||||
@Transaction
|
||||
suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) {
|
||||
// S'assurer que le listId est correct
|
||||
val itemWithCorrectList = item.copy(listId = listId)
|
||||
insertItem(itemWithCorrectList)
|
||||
// Mettre à jour le timestamp de la liste
|
||||
val list = getListById(listId)
|
||||
if (list != null) {
|
||||
updateList(list.copy(updatedAt = System.currentTimeMillis()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,3 +60,42 @@ data class ScanHistoryEntity(
|
||||
val scannedAt: Long,
|
||||
val source: DataSource
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Shopping Lists (Phase 2 — spec UX FLOW 5)
|
||||
// =============================================================================
|
||||
|
||||
@Entity(tableName = "shopping_lists")
|
||||
data class ShoppingListEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
|
||||
val name: String,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val updatedAt: Long = System.currentTimeMillis(),
|
||||
val isArchived: Boolean = false
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "shopping_list_items",
|
||||
foreignKeys = [
|
||||
androidx.room.ForeignKey(
|
||||
entity = ShoppingListEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["listId"],
|
||||
onDelete = androidx.room.ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [androidx.room.Index("listId")]
|
||||
)
|
||||
data class ShoppingListItemEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
|
||||
val listId: Long,
|
||||
val barcode: String? = null,
|
||||
val productName: String,
|
||||
val brand: String? = null,
|
||||
val imageUrl: String? = null,
|
||||
val isChecked: Boolean = false,
|
||||
val category: String? = null, // "Frais", "Épicerie", etc.
|
||||
val safetyStatus: String? = null, // "SAFE", "WARNING", "DANGER"
|
||||
val allergenWarning: String? = null, // Allergène détecté pour alerte
|
||||
val addedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -70,4 +70,13 @@ class ProductRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun clearCache() = withContext(Dispatchers.IO) { cacheDao.clear() }
|
||||
|
||||
override suspend fun searchAlternatives(
|
||||
category: String,
|
||||
excludeAllergens: Set<String>,
|
||||
limit: Int
|
||||
): List<Product> = withContext(Dispatchers.IO) {
|
||||
// TODO: Implémenter la recherche d'alternatives via l'API OFF
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.safebite.app.data.repository
|
||||
|
||||
import com.safebite.app.data.local.database.dao.ShoppingListDao
|
||||
import com.safebite.app.data.local.database.entity.ShoppingListEntity
|
||||
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
|
||||
import com.safebite.app.domain.repository.ShoppingListRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ShoppingListRepositoryImpl @Inject constructor(
|
||||
private val dao: ShoppingListDao
|
||||
) : ShoppingListRepository {
|
||||
|
||||
override fun observeActiveLists(): Flow<List<ShoppingListEntity>> =
|
||||
dao.observeActiveLists()
|
||||
|
||||
override fun observeAllLists(): Flow<List<ShoppingListEntity>> =
|
||||
dao.observeAllLists()
|
||||
|
||||
override suspend fun getListById(id: Long): ShoppingListEntity? =
|
||||
dao.getListById(id)
|
||||
|
||||
override suspend fun createList(name: String): Long {
|
||||
val list = ShoppingListEntity(
|
||||
name = name,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
return dao.insertList(list)
|
||||
}
|
||||
|
||||
override suspend fun updateList(list: ShoppingListEntity) {
|
||||
dao.updateList(list.copy(updatedAt = System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
override suspend fun deleteList(list: ShoppingListEntity) {
|
||||
dao.deleteList(list)
|
||||
}
|
||||
|
||||
override suspend fun archiveList(id: Long) {
|
||||
dao.archiveList(id)
|
||||
}
|
||||
|
||||
override fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>> =
|
||||
dao.observeItems(listId)
|
||||
|
||||
override suspend fun getItems(listId: Long): List<ShoppingListItemEntity> =
|
||||
dao.getItems(listId)
|
||||
|
||||
override suspend fun addItem(item: ShoppingListItemEntity): Long =
|
||||
dao.insertItem(item)
|
||||
|
||||
override suspend fun updateItem(item: ShoppingListItemEntity) {
|
||||
dao.updateItem(item)
|
||||
}
|
||||
|
||||
override suspend fun deleteItem(item: ShoppingListItemEntity) {
|
||||
dao.deleteItem(item)
|
||||
}
|
||||
|
||||
override suspend fun setItemChecked(id: Long, checked: Boolean) {
|
||||
dao.setItemChecked(id, checked)
|
||||
}
|
||||
|
||||
override suspend fun uncheckAllItems(listId: Long) {
|
||||
dao.uncheckAllItems(listId)
|
||||
}
|
||||
|
||||
override suspend fun deleteAllItems(listId: Long) {
|
||||
dao.deleteAllItems(listId)
|
||||
}
|
||||
|
||||
override fun observeItemCount(listId: Long): Flow<Int> =
|
||||
dao.observeItemCount(listId)
|
||||
|
||||
override fun observeCheckedCount(listId: Long): Flow<Int> =
|
||||
dao.observeCheckedCount(listId)
|
||||
|
||||
override suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) {
|
||||
dao.addItemToList(listId, item)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import androidx.room.Room
|
||||
import com.safebite.app.data.local.database.SafeBiteDatabase
|
||||
import com.safebite.app.data.local.database.dao.ProductCacheDao
|
||||
import com.safebite.app.data.local.database.dao.ScanHistoryDao
|
||||
import com.safebite.app.data.local.database.dao.ShoppingListDao
|
||||
import com.safebite.app.data.local.database.dao.UserProfileDao
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -27,4 +28,5 @@ object DatabaseModule {
|
||||
@Provides fun provideUserProfileDao(db: SafeBiteDatabase): UserProfileDao = db.userProfileDao()
|
||||
@Provides fun provideProductCacheDao(db: SafeBiteDatabase): ProductCacheDao = db.productCacheDao()
|
||||
@Provides fun provideScanHistoryDao(db: SafeBiteDatabase): ScanHistoryDao = db.scanHistoryDao()
|
||||
@Provides fun provideShoppingListDao(db: SafeBiteDatabase): ShoppingListDao = db.shoppingListDao()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.safebite.app.di
|
||||
|
||||
import com.safebite.app.domain.engine.CategoryEngine
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object EngineModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCategoryEngine(): CategoryEngine = CategoryEngine()
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package com.safebite.app.di
|
||||
import com.safebite.app.data.repository.ProductRepositoryImpl
|
||||
import com.safebite.app.data.repository.ScanHistoryRepositoryImpl
|
||||
import com.safebite.app.data.repository.SettingsRepositoryImpl
|
||||
import com.safebite.app.data.repository.ShoppingListRepositoryImpl
|
||||
import com.safebite.app.data.repository.UserProfileRepositoryImpl
|
||||
import com.safebite.app.domain.repository.ProductRepository
|
||||
import com.safebite.app.domain.repository.ScanHistoryRepository
|
||||
import com.safebite.app.domain.repository.SettingsRepository
|
||||
import com.safebite.app.domain.repository.ShoppingListRepository
|
||||
import com.safebite.app.domain.repository.UserProfileRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
@@ -29,4 +31,7 @@ abstract class RepositoryModule {
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository
|
||||
|
||||
@Binds @Singleton
|
||||
abstract fun bindShoppingListRepository(impl: ShoppingListRepositoryImpl): ShoppingListRepository
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.safebite.app.domain.engine
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Moteur de catégorisation automatique des produits par rayon (Phase 2.4).
|
||||
*
|
||||
* Associe des mots-clés à des catégories de magasin pour organiser les listes de courses.
|
||||
*/
|
||||
@Singleton
|
||||
class CategoryEngine @Inject constructor() {
|
||||
|
||||
/**
|
||||
* Détecte le rayon d'un produit basé sur son nom et ses catégories.
|
||||
*/
|
||||
fun detectCategory(productName: String, categories: List<String> = emptyList()): String {
|
||||
val text = (listOf(productName) + categories).joinToString(" ").lowercase()
|
||||
|
||||
return when {
|
||||
text.containsAny(freshKeywords) -> "Frais"
|
||||
text.containsAny(fruitKeywords) -> "Fruits & Légumes"
|
||||
text.containsAny(bakeryKeywords) -> "Boulangerie"
|
||||
text.containsAny(meatKeywords) -> "Boucherie"
|
||||
text.containsAny(dairyKeywords) -> "Produits laitiers"
|
||||
text.containsAny(groceryKeywords) -> "Épicerie"
|
||||
text.containsAny(beverageKeywords) -> "Boissons"
|
||||
text.containsAny(frozenKeywords) -> "Surgelés"
|
||||
text.containsAny(hygieneKeywords) -> "Hygiène"
|
||||
text.containsAny(babyKeywords) -> "Bébé"
|
||||
text.containsAny(petKeywords) -> "Animaux"
|
||||
text.containsAny(cleaningKeywords) -> "Entretien"
|
||||
else -> "Autre"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Catégorise une liste de produits et retourne un map catégorie -> produits.
|
||||
*/
|
||||
fun categorizeProducts(products: List<ProductInfo>): Map<String, List<ProductInfo>> {
|
||||
return products
|
||||
.groupBy { detectCategory(it.name, it.categories) }
|
||||
.toSortedMap(compareBy { it })
|
||||
}
|
||||
|
||||
data class ProductInfo(
|
||||
val name: String,
|
||||
val categories: List<String> = emptyList()
|
||||
)
|
||||
|
||||
private fun String.containsAny(keywords: List<String>): Boolean =
|
||||
keywords.any { this.contains(it) }
|
||||
|
||||
// ── Mots-clés par catégorie ─────────────────────────────────────────────
|
||||
|
||||
private val freshKeywords = listOf("frais", "fraise", "framboise", "myrtille", "salade", "tomate", "concombre")
|
||||
|
||||
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")
|
||||
|
||||
private val bakeryKeywords = listOf("pain", "baguette", "biscotte", "croissant", "brioche", "boulang", "patisserie", "gateau", "tarte")
|
||||
|
||||
private val meatKeywords = listOf("poulet", "boeuf", "porc", "agneau", "dinde", "veau", "jambon", "saucisse", "steak", "viande", "merguez", "lard", "bacon")
|
||||
|
||||
private val dairyKeywords = listOf("lait", "yaourt", "fromage", "beurre", "creme", "oeuf", "laitier", "camembert", "emmental", "brie", "chevre", "mozzarella", "parmesan")
|
||||
|
||||
private val groceryKeywords = listOf("riz", "pates", "spaghetti", "semoule", "quinoa", "lentille", "pois chiche", "haricot sec", "farine", "sucre", "sel", "huile", "vinaigre", "moutarde", "ketchup", "sauce", "epice", "herbe", "conserv", "soupe", "biscuit", "chocolat", "confiture", "miel", "cereale", "muesli")
|
||||
|
||||
private val beverageKeywords = listOf("eau", "jus", "soda", "cola", "the", "cafe", "vin", "biere", "cidre", "champagne", "sirop", "nectar", "limonade", "boisson")
|
||||
|
||||
private val frozenKeywords = listOf("surgel", "congel", "glace", "sorbet", "pizza", "frite", "legume surgel", "fruit surgel")
|
||||
|
||||
private val hygieneKeywords = listOf("savon", "shampoing", "dentifrice", "gel douche", "deodorant", "papier toilette", "mouchoir", "lingette", "creme solaire", "maquillage")
|
||||
|
||||
private val babyKeywords = listOf("bebe", "couche", "biberon", "lait bebe", "compote bebe", "petit suisse")
|
||||
|
||||
private val petKeywords = listOf("chien", "chat", "croquette", "patee", "oiseau", "poisson", "animal")
|
||||
|
||||
private val cleaningKeywords = listOf("lessive", "adoucissant", "liquide vaisselle", "eponge", "chiffon", "javel", "nettoyant", "desinfectant", "aspirateur")
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.safebite.app.domain.repository
|
||||
|
||||
import com.safebite.app.data.local.database.entity.ShoppingListEntity
|
||||
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
|
||||
import com.safebite.app.domain.model.AppLanguage
|
||||
import com.safebite.app.domain.model.DetectionLanguage
|
||||
import com.safebite.app.domain.model.HealthStrictness
|
||||
@@ -21,6 +23,12 @@ interface ProductRepository {
|
||||
suspend fun cacheProduct(product: Product)
|
||||
suspend fun getCachedProduct(barcode: String): Product?
|
||||
suspend fun clearCache()
|
||||
/** Search for products in the same category without specific allergens. */
|
||||
suspend fun searchAlternatives(
|
||||
category: String,
|
||||
excludeAllergens: Set<String>,
|
||||
limit: Int = 5
|
||||
): List<Product>
|
||||
}
|
||||
|
||||
interface UserProfileRepository {
|
||||
@@ -58,3 +66,35 @@ interface SettingsRepository {
|
||||
suspend fun setOnboardingCompleted(value: Boolean)
|
||||
suspend fun setHealthStrictness(value: HealthStrictness)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Shopping Lists Repository (Phase 2)
|
||||
// =============================================================================
|
||||
|
||||
interface ShoppingListRepository {
|
||||
// Lists
|
||||
fun observeActiveLists(): Flow<List<ShoppingListEntity>>
|
||||
fun observeAllLists(): Flow<List<ShoppingListEntity>>
|
||||
suspend fun getListById(id: Long): ShoppingListEntity?
|
||||
suspend fun createList(name: String): Long
|
||||
suspend fun updateList(list: ShoppingListEntity)
|
||||
suspend fun deleteList(list: ShoppingListEntity)
|
||||
suspend fun archiveList(id: Long)
|
||||
|
||||
// Items
|
||||
fun observeItems(listId: Long): Flow<List<ShoppingListItemEntity>>
|
||||
suspend fun getItems(listId: Long): List<ShoppingListItemEntity>
|
||||
suspend fun addItem(item: ShoppingListItemEntity): Long
|
||||
suspend fun updateItem(item: ShoppingListItemEntity)
|
||||
suspend fun deleteItem(item: ShoppingListItemEntity)
|
||||
suspend fun setItemChecked(id: Long, checked: Boolean)
|
||||
suspend fun uncheckAllItems(listId: Long)
|
||||
suspend fun deleteAllItems(listId: Long)
|
||||
|
||||
// Stats
|
||||
fun observeItemCount(listId: Long): Flow<Int>
|
||||
fun observeCheckedCount(listId: Long): Flow<Int>
|
||||
|
||||
// Helpers
|
||||
suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.safebite.app.domain.usecase
|
||||
|
||||
import com.safebite.app.domain.model.Product
|
||||
import com.safebite.app.domain.repository.ProductRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* UseCase pour trouver des produits alternatifs dans la même catégorie
|
||||
* sans les allergènes problématiques.
|
||||
*/
|
||||
class GetAlternativesUseCase @Inject constructor(
|
||||
private val productRepository: ProductRepository
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
category: String,
|
||||
excludeAllergenTags: Set<String>,
|
||||
limit: Int = 5
|
||||
): List<Product> {
|
||||
if (category.isBlank()) return emptyList()
|
||||
return productRepository.searchAlternatives(category, excludeAllergenTags, limit)
|
||||
}
|
||||
}
|
||||
@@ -87,3 +87,34 @@ class SaveScanUseCase @Inject constructor(
|
||||
) {
|
||||
suspend operator fun invoke(result: ScanResult): Long = repo.save(result)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Shopping List UseCases (Phase 2)
|
||||
// =============================================================================
|
||||
|
||||
class GetShoppingListsUseCase @Inject constructor(
|
||||
private val repo: com.safebite.app.domain.repository.ShoppingListRepository
|
||||
) {
|
||||
fun observeActive() = repo.observeActiveLists()
|
||||
fun observeAll() = repo.observeAllLists()
|
||||
suspend fun getList(id: Long) = repo.getListById(id)
|
||||
suspend fun createList(name: String) = repo.createList(name)
|
||||
suspend fun updateList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.updateList(list)
|
||||
suspend fun deleteList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.deleteList(list)
|
||||
fun observeItemCount(listId: Long) = repo.observeItemCount(listId)
|
||||
fun observeCheckedCount(listId: Long) = repo.observeCheckedCount(listId)
|
||||
}
|
||||
|
||||
class ManageShoppingListUseCase @Inject constructor(
|
||||
private val repo: com.safebite.app.domain.repository.ShoppingListRepository
|
||||
) {
|
||||
fun observeItems(listId: Long) = repo.observeItems(listId)
|
||||
suspend fun getItems(listId: Long) = repo.getItems(listId)
|
||||
suspend fun addItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.addItem(item)
|
||||
suspend fun updateItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.updateItem(item)
|
||||
suspend fun deleteItem(item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.deleteItem(item)
|
||||
suspend fun setItemChecked(id: Long, checked: Boolean) = repo.setItemChecked(id, checked)
|
||||
suspend fun uncheckAllItems(listId: Long) = repo.uncheckAllItems(listId)
|
||||
suspend fun deleteAllItems(listId: Long) = repo.deleteAllItems(listId)
|
||||
suspend fun addItemToList(listId: Long, item: com.safebite.app.data.local.database.entity.ShoppingListItemEntity) = repo.addItemToList(listId, item)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.safebite.app.presentation.common.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.safebite.app.domain.model.AllergenType
|
||||
import com.safebite.app.presentation.theme.LocalDimens
|
||||
|
||||
/**
|
||||
* Niveau d'allergie pour un allergène.
|
||||
*/
|
||||
enum class AllergenLevel(val label: String, val emoji: String) {
|
||||
NONE("Aucun", ""),
|
||||
TRACE("Traces", "⚠️"),
|
||||
SEVERE("Sévère", "❌")
|
||||
}
|
||||
|
||||
/**
|
||||
* Couleurs de fond par état d'allergie (spec UX §4.2).
|
||||
*/
|
||||
fun AllergenLevel.backgroundColor(): Color = when (this) {
|
||||
AllergenLevel.NONE -> Color.Transparent
|
||||
AllergenLevel.TRACE -> Color(0xFFFEF5E7) // Orange clair
|
||||
AllergenLevel.SEVERE -> Color(0xFFFDEDEC) // Rouge clair
|
||||
}
|
||||
|
||||
/**
|
||||
* Couleur de bordure par état.
|
||||
*/
|
||||
@Composable
|
||||
fun AllergenLevel.borderColor(): Color = when (this) {
|
||||
AllergenLevel.NONE -> MaterialTheme.colorScheme.outlineVariant
|
||||
AllergenLevel.TRACE -> Color(0xFFF39C12)
|
||||
AllergenLevel.SEVERE -> Color(0xFFE74C3C)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grille de sélection d'allergènes avec 3 états par tap.
|
||||
*
|
||||
* Cycle : NONE → TRACE → SEVERE → NONE
|
||||
*
|
||||
* @param selectedAllergens Map allergène → niveau sélectionné
|
||||
* @param onLevelChanged Callback quand le niveau d'un allergène change
|
||||
* @param modifier Modifier
|
||||
*/
|
||||
@Composable
|
||||
fun AllergenSelectionGrid(
|
||||
selectedAllergens: Map<AllergenType, AllergenLevel>,
|
||||
onLevelChanged: (AllergenType, AllergenLevel) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
|
||||
) {
|
||||
items(AllergenType.entries.toList()) { allergen ->
|
||||
val currentLevel = selectedAllergens[allergen] ?: AllergenLevel.NONE
|
||||
AllergenSelectionChip(
|
||||
allergen = allergen,
|
||||
level = currentLevel,
|
||||
onClick = {
|
||||
val nextLevel = when (currentLevel) {
|
||||
AllergenLevel.NONE -> AllergenLevel.TRACE
|
||||
AllergenLevel.TRACE -> AllergenLevel.SEVERE
|
||||
AllergenLevel.SEVERE -> AllergenLevel.NONE
|
||||
}
|
||||
onLevelChanged(allergen, nextLevel)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chip individuel pour un allergène avec 3 états visuels.
|
||||
*/
|
||||
@Composable
|
||||
fun AllergenSelectionChip(
|
||||
allergen: AllergenType,
|
||||
level: AllergenLevel,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(dimens.radiusMd),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = level.backgroundColor()
|
||||
),
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
width = 2.dp,
|
||||
color = level.borderColor()
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = dimens.spacingSm, horizontal = dimens.spacingXs),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Emoji allergène
|
||||
Text(
|
||||
text = allergen.icon,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
// Nom court
|
||||
Text(
|
||||
text = allergen.displayNameFr,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
// Indicateur d'état
|
||||
if (level != AllergenLevel.NONE) {
|
||||
Text(
|
||||
text = level.emoji,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grille d'affichage d'allergènes (lecture seule).
|
||||
* Utilisée pour afficher les allergies d'un profil.
|
||||
*/
|
||||
@Composable
|
||||
fun AllergenDisplayGrid(
|
||||
severeAllergens: Set<AllergenType>,
|
||||
moderateIntolerances: Set<AllergenType>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
if (severeAllergens.isEmpty() && moderateIntolerances.isEmpty()) {
|
||||
Text(
|
||||
text = "Aucune allergie détectée ✅",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = modifier.padding(dimens.spacingSm)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
|
||||
) {
|
||||
// Allergènes sévères
|
||||
if (severeAllergens.isNotEmpty()) {
|
||||
Text(
|
||||
text = "❌ Allergies sévères :",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFFE74C3C)
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
severeAllergens.forEach { allergen ->
|
||||
AllergenBadge(
|
||||
allergen = allergen,
|
||||
level = AllergenLevel.SEVERE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Intolérances modérées
|
||||
if (moderateIntolerances.isNotEmpty()) {
|
||||
Text(
|
||||
text = "⚠️ Intolérances :",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFFF39C12)
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
moderateIntolerances.forEach { allergen ->
|
||||
AllergenBadge(
|
||||
allergen = allergen,
|
||||
level = AllergenLevel.TRACE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge individuel pour un allergène.
|
||||
*/
|
||||
@Composable
|
||||
fun AllergenBadge(
|
||||
allergen: AllergenType,
|
||||
level: AllergenLevel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(
|
||||
color = level.backgroundColor(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = level.borderColor(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(text = allergen.icon, style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
text = allergen.displayNameFr,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package com.safebite.app.presentation.common.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
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 com.safebite.app.presentation.theme.LocalDimens
|
||||
import com.safebite.app.presentation.theme.LocalStatusColors
|
||||
|
||||
/**
|
||||
* Graphique en anneau (donut chart) pour afficher une progression circulaire.
|
||||
*
|
||||
* @param progress Valeur entre 0 et 1 (ex: 0.78 pour 78%)
|
||||
* @param size Taille du composant
|
||||
* @param strokeWidth Épaisseur du trait
|
||||
* @param trackColor Couleur du fond
|
||||
* @param progressColor Couleur de la progression
|
||||
* @param centerText Texte affiché au centre
|
||||
* @param centerSubText Sous-texte affiché sous le texte central
|
||||
*/
|
||||
@Composable
|
||||
fun DonutChart(
|
||||
progress: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 160.dp,
|
||||
strokeWidth: Dp = 16.dp,
|
||||
trackColor: Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
progressColor: Color = LocalStatusColors.current.safe,
|
||||
centerText: String = "${(progress * 100).toInt()}%",
|
||||
centerSubText: String? = null,
|
||||
animated: Boolean = true
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = progress.coerceIn(0f, 1f),
|
||||
animationSpec = tween(durationMillis = 800),
|
||||
label = "donutProgress"
|
||||
)
|
||||
val displayProgress = if (animated) animatedProgress else progress
|
||||
|
||||
Box(
|
||||
modifier = modifier.size(size),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val sweepAngle = 360f * displayProgress
|
||||
val stroke = strokeWidth.toPx()
|
||||
|
||||
// Cercle de fond (track)
|
||||
drawArc(
|
||||
color = trackColor,
|
||||
startAngle = -90f,
|
||||
sweepAngle = 360f,
|
||||
useCenter = false,
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round)
|
||||
)
|
||||
|
||||
// Arc de progression
|
||||
if (sweepAngle > 0f) {
|
||||
drawArc(
|
||||
color = progressColor,
|
||||
startAngle = -90f,
|
||||
sweepAngle = sweepAngle,
|
||||
useCenter = false,
|
||||
style = Stroke(width = stroke, cap = StrokeCap.Round)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Texte central
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = centerText,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (centerSubText != null) {
|
||||
Text(
|
||||
text = centerSubText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Données pour un graphique sparkline (mini graphique d'évolution).
|
||||
*/
|
||||
data class SparklineData(
|
||||
val values: List<Float>,
|
||||
val labels: List<String> = emptyList()
|
||||
) {
|
||||
fun max(): Float = values.maxOrNull() ?: 0f
|
||||
fun min(): Float = values.minOrNull() ?: 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* Mini graphique d'évolution (sparkline).
|
||||
*
|
||||
* @param data Données à afficher
|
||||
* @param lineColor Couleur de la ligne
|
||||
* @param fillColor Couleur de remplissage sous la ligne
|
||||
* @param height Hauteur du graphique
|
||||
*/
|
||||
@Composable
|
||||
fun Sparkline(
|
||||
data: SparklineData,
|
||||
modifier: Modifier = Modifier,
|
||||
lineColor: Color = LocalStatusColors.current.safe,
|
||||
fillColor: Color = LocalStatusColors.current.safe.copy(alpha = 0.15f),
|
||||
height: Dp = 80.dp,
|
||||
showDots: Boolean = true
|
||||
) {
|
||||
if (data.values.isEmpty()) return
|
||||
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = 600),
|
||||
label = "sparklineProgress"
|
||||
)
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val padding = 8.dp.toPx()
|
||||
val maxVal = data.max().takeIf { it > 0 } ?: 1f
|
||||
val minVal = data.min()
|
||||
|
||||
val range = maxVal - minVal
|
||||
val stepX = (width - 2 * padding) / (data.values.size - 1).coerceAtLeast(1)
|
||||
|
||||
// Calcul des points
|
||||
val points = data.values.mapIndexed { index, value ->
|
||||
val x = padding + index * stepX
|
||||
val normalizedValue = if (range > 0) (value - minVal) / range else 0.5f
|
||||
val y = height - padding - normalizedValue * (height - 2 * padding)
|
||||
Offset(x, y)
|
||||
}
|
||||
|
||||
// Zone de remplissage
|
||||
if (points.size > 1) {
|
||||
val fillPath = androidx.compose.ui.graphics.Path().apply {
|
||||
moveTo(points.first().x, height)
|
||||
lineTo(points.first().x, points.first().y)
|
||||
points.forEach { point ->
|
||||
lineTo(point.x, point.y)
|
||||
}
|
||||
lineTo(points.last().x, height)
|
||||
close()
|
||||
}
|
||||
drawPath(fillPath, color = fillColor)
|
||||
}
|
||||
|
||||
// Ligne
|
||||
if (points.size > 1) {
|
||||
val linePath = androidx.compose.ui.graphics.Path().apply {
|
||||
moveTo(points.first().x, points.first().y)
|
||||
points.forEach { point ->
|
||||
lineTo(point.x, point.y)
|
||||
}
|
||||
}
|
||||
drawPath(
|
||||
path = linePath,
|
||||
color = lineColor,
|
||||
style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round)
|
||||
)
|
||||
}
|
||||
|
||||
// Points
|
||||
if (showDots) {
|
||||
points.forEach { point ->
|
||||
drawCircle(
|
||||
color = lineColor,
|
||||
radius = 4.dp.toPx(),
|
||||
center = point
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = 2.dp.toPx(),
|
||||
center = point
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Données pour un graphique à barres.
|
||||
*/
|
||||
data class BarChartData(
|
||||
val items: List<BarChartItem>
|
||||
)
|
||||
|
||||
data class BarChartItem(
|
||||
val label: String,
|
||||
val value: Int,
|
||||
val color: Color = Color.Unspecified
|
||||
)
|
||||
|
||||
/**
|
||||
* Graphique à barres horizontales pour afficher des statistiques.
|
||||
*
|
||||
* @param data Données à afficher
|
||||
* @param maxValue Valeur maximale pour l'échelle (auto si null)
|
||||
* @param height Hauteur de chaque barre
|
||||
* @param spacing Espacement entre les barres
|
||||
*/
|
||||
@Composable
|
||||
fun HorizontalBarChart(
|
||||
data: BarChartData,
|
||||
modifier: Modifier = Modifier,
|
||||
maxValue: Int? = null,
|
||||
height: Dp = 32.dp,
|
||||
spacing: Dp = LocalDimens.current.spacingSm
|
||||
) {
|
||||
if (data.items.isEmpty()) return
|
||||
|
||||
val max = maxValue ?: data.items.maxOfOrNull { it.value } ?: 0
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
data.items.forEach { item ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd)
|
||||
) {
|
||||
// Label
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(0.4f),
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
// Barre
|
||||
val progress = if (max > 0) item.value.toFloat() / max else 0f
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = progress,
|
||||
animationSpec = tween(durationMillis = 500),
|
||||
label = "barProgress"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(0.4f)
|
||||
.height(height)
|
||||
) {
|
||||
// Fond
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val cornerRadius = 8.dp.toPx()
|
||||
drawRoundRect(
|
||||
color = androidx.compose.ui.graphics.Color(0xFFE3E2EC),
|
||||
size = Size(size.width, size.height),
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius)
|
||||
)
|
||||
|
||||
// Progression
|
||||
if (animatedProgress > 0f) {
|
||||
drawRoundRect(
|
||||
color = item.color,
|
||||
size = Size(size.width * animatedProgress, size.height),
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Valeur
|
||||
Text(
|
||||
text = "${item.value}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte statistique avec icône, valeur et label.
|
||||
*/
|
||||
@Composable
|
||||
fun StatCard(
|
||||
icon: String,
|
||||
value: String,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
valueColor: Color = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(dimens.spacingSm),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = icon,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = valueColor
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre temporel pour les statistiques.
|
||||
*/
|
||||
enum class TimeFilter(val label: String) {
|
||||
WEEK("Semaine"),
|
||||
MONTH("Mois"),
|
||||
YEAR("Année"),
|
||||
ALL("Tout")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimeFilterRow(
|
||||
selected: TimeFilter,
|
||||
onFilterChanged: (TimeFilter) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm)
|
||||
) {
|
||||
TimeFilter.values().forEach { filter ->
|
||||
androidx.compose.material3.FilterChip(
|
||||
selected = selected == filter,
|
||||
onClick = { onFilterChanged(filter) },
|
||||
label = { Text(filter.label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.safebite.app.presentation.common.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -21,7 +24,14 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -64,8 +74,14 @@ fun AllergenChip(
|
||||
val dimens = LocalDimens.current
|
||||
val bg = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface
|
||||
val fg = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||
val stateDesc = if (selected) "Sélectionné" else "Non sélectionné"
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
modifier = modifier
|
||||
.semantics {
|
||||
contentDescription = "${allergen.displayNameFr} - $stateDesc"
|
||||
role = Role.Checkbox
|
||||
stateDescription = stateDesc
|
||||
},
|
||||
shape = RoundedCornerShape(dimens.radiusPill),
|
||||
color = bg,
|
||||
border = if (selected) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
@@ -75,7 +91,11 @@ fun AllergenChip(
|
||||
modifier = Modifier.padding(horizontal = dimens.spacingMd, vertical = dimens.spacingSm),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = allergen.icon, color = fg)
|
||||
Text(
|
||||
text = allergen.icon,
|
||||
color = fg,
|
||||
modifier = Modifier.semantics { contentDescription = "" }
|
||||
)
|
||||
Spacer(Modifier.width(dimens.spacingXs + 2.dp))
|
||||
Text(
|
||||
text = allergen.displayNameFr,
|
||||
@@ -86,42 +106,207 @@ fun AllergenChip(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VerdictBanner amélioré (spec UX §5.2) — 3 variantes avec formes daltonien.
|
||||
*
|
||||
* Système daltonien : forme + couleur + icône, jamais couleur seule.
|
||||
* - SAFE : cercle ⭕ + ✅ + vert #2ECC71
|
||||
* - WARNING : triangle 🔺 + ⚠️ + orange #E67E22
|
||||
* - DANGER : losange 🔷 + ❌ + rouge #E74C3C
|
||||
*/
|
||||
@Composable
|
||||
fun SafetyStatusBanner(status: SafetyStatus, modifier: Modifier = Modifier) {
|
||||
val (text, icon) = when (status) {
|
||||
SafetyStatus.SAFE -> R.string.result_safe_headline to "✅"
|
||||
SafetyStatus.WARNING -> R.string.result_warning_headline to "⚠️"
|
||||
SafetyStatus.DANGER -> R.string.result_danger_headline to "⛔"
|
||||
}
|
||||
fun SafetyStatusBanner(
|
||||
status: SafetyStatus,
|
||||
modifier: Modifier = Modifier,
|
||||
profileName: String? = null,
|
||||
allergenName: String? = null,
|
||||
severity: String? = null
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = statusColor(status),
|
||||
contentColor = onStatusColor(status)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(dimens.spacingXl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(text = icon, style = MaterialTheme.typography.displaySmall)
|
||||
Spacer(Modifier.height(dimens.spacingSm))
|
||||
Text(
|
||||
text = stringResource(text),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
val colors = LocalStatusColors.current
|
||||
|
||||
val a11yDescription = when (status) {
|
||||
SafetyStatus.SAFE -> stringResource(R.string.a11y_verdict_safe)
|
||||
SafetyStatus.WARNING -> stringResource(R.string.a11y_verdict_warning)
|
||||
SafetyStatus.DANGER -> if (profileName != null)
|
||||
stringResource(R.string.a11y_verdict_danger, profileName)
|
||||
else
|
||||
stringResource(R.string.a11y_danger_status, "")
|
||||
}
|
||||
|
||||
val (titleRes, icon, shapeIcon, containerColor, onContainerColor) = when (status) {
|
||||
SafetyStatus.SAFE -> {
|
||||
VerdictBannerData(
|
||||
titleRes = R.string.result_safe_headline,
|
||||
icon = "✅",
|
||||
shapeIcon = "⭕",
|
||||
containerColor = colors.safe,
|
||||
onContainerColor = colors.onSafe
|
||||
)
|
||||
}
|
||||
SafetyStatus.WARNING -> {
|
||||
VerdictBannerData(
|
||||
titleRes = R.string.result_warning_headline,
|
||||
icon = "⚠️",
|
||||
shapeIcon = "🔺",
|
||||
containerColor = colors.warning,
|
||||
onContainerColor = colors.onWarning
|
||||
)
|
||||
}
|
||||
SafetyStatus.DANGER -> {
|
||||
VerdictBannerData(
|
||||
titleRes = R.string.result_danger_headline,
|
||||
icon = "❌",
|
||||
shapeIcon = "🔷",
|
||||
containerColor = colors.danger,
|
||||
onContainerColor = colors.onDanger
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.semantics {
|
||||
contentDescription = a11yDescription
|
||||
},
|
||||
color = containerColor,
|
||||
contentColor = onContainerColor
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(dimens.spacingLg)
|
||||
) {
|
||||
// Ligne supérieure : forme daltonienne + icône + titre
|
||||
// Système daltonien : forme géométrique + couleur + icône
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
// Forme daltonienne (jamais couleur seule)
|
||||
DaltonianShape(
|
||||
status = status,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Spacer(Modifier.width(dimens.spacingXs))
|
||||
Text(
|
||||
text = icon,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.semantics { contentDescription = "" }
|
||||
)
|
||||
Spacer(Modifier.width(dimens.spacingSm))
|
||||
Text(
|
||||
text = stringResource(titleRes),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
// Sous-titre contextuel (si allergène et profil)
|
||||
if (allergenName != null && profileName != null) {
|
||||
Spacer(Modifier.height(dimens.spacingXs))
|
||||
val subtitle = when (status) {
|
||||
SafetyStatus.WARNING -> "⚠️ Attention pour $profileName : $allergenName"
|
||||
SafetyStatus.DANGER -> "❌ Interdit pour $profileName : $allergenName${if (severity == "anaphylaxis") " (anaphylaxie)" else ""}"
|
||||
else -> ""
|
||||
}
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Message supplémentaire pour danger
|
||||
if (status == SafetyStatus.DANGER) {
|
||||
Spacer(Modifier.height(dimens.spacingXs))
|
||||
Text(
|
||||
text = "Ne pas consommer",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forme géométrique pour le système daltonien.
|
||||
* - SAFE : cercle vert
|
||||
* - WARNING : triangle orange
|
||||
* - DANGER : losange rouge
|
||||
* Jamais la couleur seule pour indiquer le statut.
|
||||
*/
|
||||
@Composable
|
||||
fun DaltonianShape(
|
||||
status: SafetyStatus,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = LocalStatusColors.current
|
||||
val color = when (status) {
|
||||
SafetyStatus.SAFE -> colors.safe
|
||||
SafetyStatus.WARNING -> colors.warning
|
||||
SafetyStatus.DANGER -> colors.danger
|
||||
}
|
||||
|
||||
when (status) {
|
||||
SafetyStatus.SAFE -> {
|
||||
// Cercle pour SAFE
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(color, CircleShape)
|
||||
.semantics { contentDescription = "" }
|
||||
)
|
||||
}
|
||||
SafetyStatus.WARNING -> {
|
||||
// Triangle pour WARNING (dessiné avec Canvas)
|
||||
Canvas(modifier = modifier) {
|
||||
val path = androidx.compose.ui.graphics.Path().apply {
|
||||
moveTo(size.width / 2, 0f)
|
||||
lineTo(size.width, size.height)
|
||||
lineTo(0f, size.height)
|
||||
close()
|
||||
}
|
||||
drawPath(path, color = color)
|
||||
}
|
||||
}
|
||||
SafetyStatus.DANGER -> {
|
||||
// Losange pour DANGER
|
||||
Canvas(modifier = modifier) {
|
||||
val path = androidx.compose.ui.graphics.Path().apply {
|
||||
moveTo(size.width / 2, 0f)
|
||||
lineTo(size.width, size.height / 2)
|
||||
lineTo(size.width / 2, size.height)
|
||||
lineTo(0f, size.height / 2)
|
||||
close()
|
||||
}
|
||||
drawPath(path, color = color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Données internes pour le VerdictBanner. */
|
||||
private data class VerdictBannerData(
|
||||
val titleRes: Int,
|
||||
val icon: String,
|
||||
val shapeIcon: String,
|
||||
val containerColor: androidx.compose.ui.graphics.Color,
|
||||
val onContainerColor: androidx.compose.ui.graphics.Color
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ProductCard(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
imageUrl: String?,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
imageContentDescription: String? = null
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
val imgDesc = imageContentDescription ?: "Image du produit"
|
||||
StandardCard(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
variant = CardVariant.Elevated,
|
||||
@@ -131,7 +316,7 @@ fun ProductCard(
|
||||
if (!imageUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentDescription = imageContentDescription,
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.background(
|
||||
@@ -148,7 +333,12 @@ fun ProductCard(
|
||||
RoundedCornerShape(dimens.radiusMd)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) { Text("🛒") }
|
||||
) {
|
||||
Text(
|
||||
text = "🛒",
|
||||
modifier = Modifier.semantics { contentDescription = imgDesc }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(dimens.spacingMd))
|
||||
Column(Modifier.weight(1f)) {
|
||||
|
||||
@@ -92,6 +92,75 @@ fun ShimmerListItem(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton pour la fiche produit (spec UX §5.2 - loading).
|
||||
*
|
||||
* Layout :
|
||||
* ┌──────────────────────────────┐
|
||||
* │ ████████████ (nom produit) │
|
||||
* │ ██████ (marque) │
|
||||
* │ ░░░░░░░░░░░░ (verdict) │
|
||||
* │ │
|
||||
* │ ████████████ │
|
||||
* │ ██████████ │
|
||||
* └──────────────────────────────┘
|
||||
*/
|
||||
@Composable
|
||||
fun ProductSkeleton(modifier: Modifier = Modifier) {
|
||||
val dimens = LocalDimens.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
||||
) {
|
||||
// Image produit
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
cornerRadius = dimens.radiusMd
|
||||
)
|
||||
|
||||
// Nom produit
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.8f)
|
||||
.height(20.dp)
|
||||
)
|
||||
|
||||
// Marque
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.5f)
|
||||
.height(14.dp)
|
||||
)
|
||||
|
||||
// Verdict banner (zone colorée)
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
cornerRadius = dimens.radiusMd
|
||||
)
|
||||
|
||||
// Actions
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
cornerRadius = dimens.radiusPill
|
||||
)
|
||||
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
cornerRadius = dimens.radiusPill
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** État vide standardisé. */
|
||||
@Composable
|
||||
fun EmptyState(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.safebite.app.presentation.navigation
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -11,8 +12,10 @@ import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.safebite.app.presentation.screen.history.HistoryScreen
|
||||
import com.safebite.app.presentation.screen.home.HomeScreen
|
||||
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.main.MainScreen
|
||||
import com.safebite.app.presentation.screen.ocr.OcrCaptureScreen
|
||||
import com.safebite.app.presentation.screen.ocr.OcrReviewScreen
|
||||
import com.safebite.app.presentation.screen.onboarding.OnboardingScreen
|
||||
@@ -22,10 +25,18 @@ import com.safebite.app.presentation.screen.result.ResultScreen
|
||||
import com.safebite.app.presentation.screen.scanner.ScannerScreen
|
||||
import com.safebite.app.presentation.screen.settings.SettingsScreen
|
||||
|
||||
/**
|
||||
* Graph de navigation principal de l'application SafeBite.
|
||||
*
|
||||
* Structure (spec UX §3.1) :
|
||||
* - Onboarding → MainScreen (Bottom Navigation + FAB)
|
||||
* - MainScreen contient 4 onglets : Dashboard, Listes, Suivi, Famille
|
||||
* - Écrans de navigation : Scanner, Result, OCR, Settings, etc.
|
||||
*/
|
||||
@Composable
|
||||
fun SafeBiteNavGraph(onboardingCompleted: Boolean) {
|
||||
val navController = rememberNavController()
|
||||
val startDestination = if (onboardingCompleted) Screen.Home.route else Screen.Onboarding.route
|
||||
val startDestination = if (onboardingCompleted) Screen.Dashboard.route else Screen.Onboarding.route
|
||||
|
||||
val enterAnim = fadeIn(animationSpec = tween(250)) +
|
||||
slideInHorizontally(animationSpec = tween(250)) { it / 24 }
|
||||
@@ -42,40 +53,47 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean) {
|
||||
popEnterTransition = { popEnterAnim },
|
||||
popExitTransition = { popExitAnim },
|
||||
) {
|
||||
// ── Onboarding ──
|
||||
composable(Screen.Onboarding.route) {
|
||||
OnboardingScreen(onFinished = {
|
||||
navController.navigate(Screen.Home.route) {
|
||||
navController.navigate(Screen.Dashboard.route) {
|
||||
popUpTo(Screen.Onboarding.route) { inclusive = true }
|
||||
}
|
||||
})
|
||||
}
|
||||
composable(Screen.Home.route) {
|
||||
HomeScreen(
|
||||
onScan = { navController.navigate(Screen.Scanner.route) },
|
||||
onOcr = { navController.navigate(Screen.OcrCapture.route) },
|
||||
onProfiles = { navController.navigate(Screen.ProfileList.route) },
|
||||
onCreateProfile = { navController.navigate(Screen.ProfileEdit.new()) },
|
||||
onHistory = { navController.navigate(Screen.History.route) },
|
||||
onSettings = { navController.navigate(Screen.Settings.route) },
|
||||
|
||||
// ── Main Screen (Bottom Navigation + FAB) ──
|
||||
composable(Screen.Dashboard.route) {
|
||||
MainScreen(
|
||||
onOpenScanner = { navController.navigate(Screen.Scanner.route) },
|
||||
onOpenSettings = { navController.navigate(Screen.Settings.route) },
|
||||
onOpenProfile = { id -> navController.navigate(Screen.ProfileEdit.edit(id)) },
|
||||
onOpenListDetail = { id, name -> navController.navigate(Screen.ListDetail.build(id, name)) },
|
||||
onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Scanner (plein écran) ──
|
||||
composable(Screen.Scanner.route) {
|
||||
ScannerScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onBarcode = { code ->
|
||||
navController.navigate(Screen.Result.fromBarcode(code)) {
|
||||
popUpTo(Screen.Home.route)
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ── OCR Capture ──
|
||||
composable(Screen.OcrCapture.route) {
|
||||
OcrCaptureScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onCaptured = { text -> navController.navigate(Screen.OcrReview.build(text)) }
|
||||
)
|
||||
}
|
||||
|
||||
// ── OCR Review ──
|
||||
composable(
|
||||
route = Screen.OcrReview.route,
|
||||
arguments = listOf(navArgument("text") { type = NavType.StringType })
|
||||
@@ -86,11 +104,13 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean) {
|
||||
onBack = { navController.popBackStack() },
|
||||
onAnalyze = { edited ->
|
||||
navController.navigate(Screen.Result.fromOcr(edited)) {
|
||||
popUpTo(Screen.Home.route)
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ── Result ──
|
||||
composable(
|
||||
route = Screen.Result.route,
|
||||
arguments = listOf(
|
||||
@@ -109,16 +129,18 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean) {
|
||||
onBack = { navController.popBackStack() },
|
||||
onScanAgain = {
|
||||
navController.navigate(Screen.Scanner.route) {
|
||||
popUpTo(Screen.Home.route)
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
}
|
||||
},
|
||||
onOcr = {
|
||||
navController.navigate(Screen.OcrCapture.route) {
|
||||
popUpTo(Screen.Home.route)
|
||||
popUpTo(Screen.Dashboard.route)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ── Legacy screens (compatibilité) ──
|
||||
composable(Screen.ProfileList.route) {
|
||||
ProfileListScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
@@ -128,7 +150,7 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean) {
|
||||
}
|
||||
composable(
|
||||
route = Screen.ProfileEdit.route,
|
||||
arguments = listOf(navArgument("id") { type = NavType.LongType; defaultValue = 0L })
|
||||
arguments = listOf(navArgument("id") { type = NavType.LongType })
|
||||
) { entry ->
|
||||
val id = entry.arguments?.getLong("id") ?: 0L
|
||||
ProfileEditScreen(
|
||||
@@ -137,14 +159,47 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean) {
|
||||
onSaved = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(Screen.History.route) {
|
||||
HistoryScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpen = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) }
|
||||
composable(Screen.Tracking.route) {
|
||||
TrackingScreen(
|
||||
onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) },
|
||||
onOpenScanner = { navController.navigate(Screen.Scanner.route) }
|
||||
)
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
// ── List Detail (Phase 2) ──
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
composable(
|
||||
route = Screen.ListDetail.route,
|
||||
arguments = listOf(
|
||||
navArgument("id") { type = NavType.LongType },
|
||||
navArgument("name") { type = NavType.StringType; defaultValue = "Ma liste" }
|
||||
)
|
||||
) { entry ->
|
||||
val listId = entry.arguments?.getLong("id") ?: 0L
|
||||
val listName = entry.arguments?.getString("name") ?: "Ma liste"
|
||||
ListDetailScreen(
|
||||
listId = listId,
|
||||
listName = listName,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenScanner = { navController.navigate(Screen.Scanner.route) },
|
||||
onOpenProduct = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Product Detail (Phase 5) ──
|
||||
composable(
|
||||
route = Screen.ProductDetail.route,
|
||||
arguments = listOf(navArgument("barcode") { type = NavType.StringType })
|
||||
) { entry ->
|
||||
val barcode = entry.arguments?.getString("barcode").orEmpty()
|
||||
ProductDetailScreen(
|
||||
barcode = barcode,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenProduct = { b -> navController.navigate(Screen.ProductDetail.build(b)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
package com.safebite.app.presentation.navigation
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.List
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.ShowChart
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.List
|
||||
import androidx.compose.material.icons.outlined.People
|
||||
import androidx.compose.material.icons.outlined.ShowChart
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/**
|
||||
* Routes de l'application.
|
||||
*
|
||||
* Architecture de navigation (spec UX §3.1) :
|
||||
* - 4 onglets Bottom Navigation : Dashboard, Lists, Tracking, Family
|
||||
* - FAB Scanner : accessible depuis tous les onglets
|
||||
* - Écrans de navigation : Scanner, Result, ProductDetail, etc.
|
||||
*/
|
||||
sealed class Screen(val route: String) {
|
||||
data object Onboarding : Screen("onboarding")
|
||||
data object Home : Screen("home")
|
||||
// ── Onglets principaux (Bottom Navigation) ──
|
||||
data object Dashboard : Screen("dashboard")
|
||||
data object Lists : Screen("lists")
|
||||
data object Tracking : Screen("tracking")
|
||||
data object Family : Screen("family")
|
||||
|
||||
// ── Écrans de navigation (non dans bottom nav) ──
|
||||
data object Scanner : Screen("scanner")
|
||||
data object OcrCapture : Screen("ocr/capture")
|
||||
data object OcrReview : Screen("ocr/review/{text}") {
|
||||
@@ -13,11 +37,66 @@ sealed class Screen(val route: String) {
|
||||
fun fromOcr(text: String) = "result/ocr?fromOcr=true&ocrText=${android.net.Uri.encode(text)}"
|
||||
fun fromHistory(barcode: String) = "result/$barcode?fromOcr=false&ocrText="
|
||||
}
|
||||
data object ProfileList : Screen("profiles")
|
||||
data object ProfileEdit : Screen("profile/edit?id={id}") {
|
||||
fun new() = "profile/edit?id=0"
|
||||
fun edit(id: Long) = "profile/edit?id=$id"
|
||||
}
|
||||
data object History : Screen("history")
|
||||
data object Onboarding : Screen("onboarding")
|
||||
data object Settings : Screen("settings")
|
||||
|
||||
// ── Sous-écrans ──
|
||||
data object ProfileList : Screen("profiles")
|
||||
data object ProfileEdit : Screen("profile/edit/{id}") {
|
||||
fun new() = "profile/edit/0"
|
||||
fun edit(id: Long) = "profile/edit/$id"
|
||||
}
|
||||
data object ProductDetail : Screen("product/{barcode}") {
|
||||
fun build(barcode: String) = "product/$barcode"
|
||||
}
|
||||
data object ListDetail : Screen("list/{id}?name={name}") {
|
||||
fun build(id: Long, name: String = "Ma liste") = "list/$id?name=${android.net.Uri.encode(name)}"
|
||||
}
|
||||
data object ListEdit : Screen("list/edit?id={id}") {
|
||||
fun new() = "list/edit?id=0"
|
||||
fun edit(id: Long) = "list/edit?id=$id"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Éléments de la Bottom Navigation (spec UX §3.2).
|
||||
*/
|
||||
data class BottomNavItem(
|
||||
val screen: Screen,
|
||||
val iconSelected: ImageVector,
|
||||
val iconUnselected: ImageVector,
|
||||
val label: String,
|
||||
val contentDescription: String,
|
||||
val badgeCount: Int = 0
|
||||
)
|
||||
|
||||
val bottomNavItems = listOf(
|
||||
BottomNavItem(
|
||||
screen = Screen.Dashboard,
|
||||
iconSelected = Icons.Filled.Home,
|
||||
iconUnselected = Icons.Outlined.Home,
|
||||
label = "Accueil",
|
||||
contentDescription = "Tableau de bord"
|
||||
),
|
||||
BottomNavItem(
|
||||
screen = Screen.Lists,
|
||||
iconSelected = Icons.Filled.List,
|
||||
iconUnselected = Icons.Outlined.List,
|
||||
label = "Listes",
|
||||
contentDescription = "Mes listes de courses"
|
||||
),
|
||||
BottomNavItem(
|
||||
screen = Screen.Tracking,
|
||||
iconSelected = Icons.Filled.ShowChart,
|
||||
iconUnselected = Icons.Outlined.ShowChart,
|
||||
label = "Suivi",
|
||||
contentDescription = "Statistiques et historique"
|
||||
),
|
||||
BottomNavItem(
|
||||
screen = Screen.Family,
|
||||
iconSelected = Icons.Filled.People,
|
||||
iconUnselected = Icons.Outlined.People,
|
||||
label = "Famille",
|
||||
contentDescription = "Profils et réglages"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.safebite.app.presentation.screen.dashboard
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.safebite.app.R
|
||||
import com.safebite.app.presentation.common.components.OutlinedActionButton
|
||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
||||
|
||||
/**
|
||||
* Dashboard contextuel (spec UX §5.3).
|
||||
*
|
||||
* Trois modes :
|
||||
* - first_time : aucun scan dans l'historique
|
||||
* - store_mode : détecté via géolocalisation/heure
|
||||
* - home_mode : mode par défaut
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
onScan: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenList: (Long, String) -> Unit,
|
||||
onOpenHistoryItem: (String) -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.app_name)) },
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(Icons.Filled.Settings, stringResource(R.string.nav_settings))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Greeting
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_greeting, "Sophie"),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
// Quick actions
|
||||
PrimaryButton(
|
||||
text = stringResource(R.string.dashboard_scan_button),
|
||||
onClick = onScan,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedActionButton(
|
||||
text = stringResource(R.string.dashboard_lists_button),
|
||||
onClick = { onOpenList(0, "Ma liste") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Weekly stats placeholder
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_weekly_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "78% produits OK",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Recent scans
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_recent_scans),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_no_scans),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.safebite.app.presentation.screen.family
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.safebite.app.R
|
||||
import com.safebite.app.domain.model.UserProfile
|
||||
import com.safebite.app.presentation.common.components.AllergenDisplayGrid
|
||||
import com.safebite.app.presentation.common.components.EmptyState
|
||||
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
||||
import com.safebite.app.presentation.theme.LocalDimens
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FamilyScreen(
|
||||
onOpenProfile: (Long) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
viewModel: FamilyViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val activeProfileIds by viewModel.activeProfileIds.collectAsStateWithLifecycle()
|
||||
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
SafeBiteTopAppBar(
|
||||
title = stringResource(R.string.family_title),
|
||||
onBack = null,
|
||||
backContentDescription = null
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
val addContentDesc = stringResource(R.string.a11y_add)
|
||||
FloatingActionButton(
|
||||
onClick = { onOpenProfile(0L) },
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = addContentDesc
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Add,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
if (uiState.profiles.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.family_no_profiles),
|
||||
message = stringResource(R.string.family_no_profiles_body),
|
||||
emoji = "👨👩👧👦"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = LocalDimens.current.spacingMd),
|
||||
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd),
|
||||
verticalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingMd)
|
||||
) {
|
||||
items(uiState.profiles, key = { it.id }) { profile ->
|
||||
val isActive = profile.id in activeProfileIds
|
||||
ProfileCard(
|
||||
profile = profile,
|
||||
isActive = isActive,
|
||||
onToggleActive = { viewModel.toggleProfileActive(profile.id) },
|
||||
onEdit = { onOpenProfile(profile.id) },
|
||||
onDelete = { showDeleteDialog = profile.id },
|
||||
onSetDefault = { viewModel.setDefaultProfile(profile.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialog de confirmation de suppression
|
||||
showDeleteDialog?.let { profileId ->
|
||||
val profile = uiState.profiles.find { it.id == profileId }
|
||||
if (profile != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = null },
|
||||
title = { Text("Supprimer le profil") },
|
||||
text = { Text("Voulez-vous vraiment supprimer le profil de ${profile.name} ?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.deleteProfile(profile)
|
||||
showDeleteDialog = null
|
||||
}
|
||||
) {
|
||||
Text("Supprimer", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = null }) {
|
||||
Text("Annuler")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte de profil pour la grille Family.
|
||||
*/
|
||||
@Composable
|
||||
fun ProfileCard(
|
||||
profile: UserProfile,
|
||||
isActive: Boolean,
|
||||
onToggleActive: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onSetDefault: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.clickable(onClick = onEdit),
|
||||
shape = RoundedCornerShape(dimens.radiusMd),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd)
|
||||
) {
|
||||
// En-tête avec avatar et nom
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// Avatar
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isActive) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = profile.avatar,
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
}
|
||||
|
||||
// Nom et badge
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).padding(horizontal = dimens.spacingSm)
|
||||
) {
|
||||
Text(
|
||||
text = profile.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
if (profile.isDefault) {
|
||||
Text(
|
||||
text = stringResource(R.string.profile_default_badge),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bouton actif
|
||||
val a11yDesc = if (isActive)
|
||||
stringResource(R.string.a11y_profile_inactive, profile.name)
|
||||
else
|
||||
stringResource(R.string.a11y_profile_active, profile.name)
|
||||
IconButton(
|
||||
onClick = onToggleActive,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = a11yDesc
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isActive) Icons.Filled.Star else Icons.Filled.StarBorder,
|
||||
contentDescription = null,
|
||||
tint = if (isActive) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
||||
|
||||
// Allergies
|
||||
AllergenDisplayGrid(
|
||||
severeAllergens = profile.severeAllergens,
|
||||
moderateIntolerances = profile.moderateIntolerances,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
||||
|
||||
// Restrictions alimentaires
|
||||
if (profile.dietaryRestrictions.isNotEmpty()) {
|
||||
Text(
|
||||
text = profile.dietaryRestrictions.joinToString(", ") { it.displayFr },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.safebite.app.presentation.screen.family
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.safebite.app.domain.model.UserProfile
|
||||
import com.safebite.app.domain.usecase.ManageProfileUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* State UI pour l'écran Family.
|
||||
*/
|
||||
data class FamilyUiState(
|
||||
val profiles: List<UserProfile> = emptyList(),
|
||||
val activeProfileIds: Set<Long> = emptySet(),
|
||||
val isLoading: Boolean = true
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class FamilyViewModel @Inject constructor(
|
||||
private val manageProfileUseCase: ManageProfileUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<FamilyUiState> = manageProfileUseCase.observe()
|
||||
.map { profiles ->
|
||||
FamilyUiState(
|
||||
profiles = profiles,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
FamilyUiState()
|
||||
)
|
||||
|
||||
val activeProfileIds: StateFlow<Set<Long>> = manageProfileUseCase.observeActiveIds()
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
emptySet()
|
||||
)
|
||||
|
||||
fun toggleProfileActive(id: Long) = viewModelScope.launch {
|
||||
val current = manageProfileUseCase.observeActiveIds().first()
|
||||
val newIds = if (id in current) current - id else current + id
|
||||
manageProfileUseCase.setActive(newIds)
|
||||
}
|
||||
|
||||
fun deleteProfile(profile: UserProfile) = viewModelScope.launch {
|
||||
manageProfileUseCase.delete(profile)
|
||||
}
|
||||
|
||||
fun setDefaultProfile(id: Long) = viewModelScope.launch {
|
||||
manageProfileUseCase.setDefault(id)
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package com.safebite.app.presentation.screen.history
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.safebite.app.R
|
||||
import com.safebite.app.domain.model.SafetyStatus
|
||||
import com.safebite.app.presentation.common.components.CardVariant
|
||||
import com.safebite.app.presentation.common.components.EmptyState
|
||||
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
||||
import com.safebite.app.presentation.common.components.StandardCard
|
||||
import com.safebite.app.presentation.common.components.StandardTextField
|
||||
import com.safebite.app.presentation.common.components.statusColor
|
||||
import com.safebite.app.presentation.theme.LocalDimens
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HistoryScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpen: (String) -> Unit,
|
||||
viewModel: HistoryViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||
val filter by viewModel.filter.collectAsStateWithLifecycle()
|
||||
|
||||
val dimens = LocalDimens.current
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
SafeBiteTopAppBar(
|
||||
title = stringResource(R.string.history_title),
|
||||
onBack = onBack,
|
||||
backContentDescription = stringResource(R.string.action_back),
|
||||
actions = {
|
||||
IconButton(onClick = viewModel::clearAll) {
|
||||
Icon(
|
||||
Icons.Filled.DeleteSweep,
|
||||
contentDescription = stringResource(R.string.history_clear_all)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = dimens.spacingLg, vertical = dimens.spacingLg),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
||||
) {
|
||||
StandardTextField(
|
||||
value = query,
|
||||
onValueChange = viewModel::setQuery,
|
||||
placeholder = stringResource(R.string.history_search),
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
|
||||
FilterChip(selected = filter == null, onClick = { viewModel.setFilter(null) }, label = { Text(stringResource(R.string.history_filter_all)) })
|
||||
FilterChip(selected = filter == SafetyStatus.DANGER, onClick = { viewModel.setFilter(SafetyStatus.DANGER) }, label = { Text(stringResource(R.string.history_filter_danger)) })
|
||||
FilterChip(selected = filter == SafetyStatus.WARNING, onClick = { viewModel.setFilter(SafetyStatus.WARNING) }, label = { Text(stringResource(R.string.history_filter_warning)) })
|
||||
FilterChip(selected = filter == SafetyStatus.SAFE, onClick = { viewModel.setFilter(SafetyStatus.SAFE) }, label = { Text(stringResource(R.string.history_filter_safe)) })
|
||||
}
|
||||
|
||||
if (state.items.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.history_empty),
|
||||
emoji = "📂",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)) {
|
||||
items(state.items, key = { it.id }) { item ->
|
||||
StandardCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
variant = CardVariant.Elevated,
|
||||
onClick = { onOpen(item.barcode) },
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(dimens.spacingMd),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(12.dp)
|
||||
.background(statusColor(item.safetyStatus), CircleShape)
|
||||
)
|
||||
Spacer(Modifier.size(dimens.spacingMd))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
item.productName ?: item.barcode,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(item.scannedAt)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (item.profileNames.isNotEmpty()) {
|
||||
Text(
|
||||
item.profileNames.joinToString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { viewModel.delete(item.id) }) {
|
||||
Icon(
|
||||
Icons.Filled.Delete,
|
||||
contentDescription = stringResource(R.string.action_delete),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package com.safebite.app.presentation.screen.history
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.safebite.app.domain.model.SafetyStatus
|
||||
import com.safebite.app.domain.model.ScanHistoryItem
|
||||
import com.safebite.app.domain.usecase.GetScanHistoryUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class HistoryUi(
|
||||
val items: List<ScanHistoryItem> = emptyList(),
|
||||
val filter: SafetyStatus? = null,
|
||||
val query: String = ""
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class HistoryViewModel @Inject constructor(
|
||||
private val useCase: GetScanHistoryUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
private val _filter = MutableStateFlow<SafetyStatus?>(null)
|
||||
private val _query = MutableStateFlow("")
|
||||
val filter: StateFlow<SafetyStatus?> = _filter.asStateFlow()
|
||||
val query: StateFlow<String> = _query.asStateFlow()
|
||||
|
||||
val state: StateFlow<HistoryUi> = combine(useCase.observe(), _filter, _query) { items, f, q ->
|
||||
val filtered = items
|
||||
.filter { f == null || it.safetyStatus == f }
|
||||
.filter { q.isBlank() || (it.productName?.contains(q, ignoreCase = true) == true) || it.barcode.contains(q) }
|
||||
HistoryUi(items = filtered, filter = f, query = q)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HistoryUi())
|
||||
|
||||
fun setFilter(status: SafetyStatus?) { _filter.value = status }
|
||||
fun setQuery(q: String) { _query.value = q }
|
||||
fun delete(id: Long) = viewModelScope.launch { useCase.delete(id) }
|
||||
fun clearAll() = viewModelScope.launch { useCase.clear() }
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
package com.safebite.app.presentation.screen.lists
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.automirrored.filled.MergeType
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.ShoppingCart
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import com.safebite.app.R
|
||||
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
|
||||
import com.safebite.app.presentation.common.components.EmptyState
|
||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||
import com.safebite.app.presentation.theme.LocalDimens
|
||||
import com.safebite.app.presentation.theme.LocalStatusColors
|
||||
|
||||
/**
|
||||
* Écran détail d'une liste de courses (Phase 2 — spec UX FLOW 5).
|
||||
*
|
||||
* Fonctionnalités :
|
||||
* - Affichage des produits avec verdicts (✅/⚠️/)
|
||||
* - Chips filtres par rayon
|
||||
* - Swipe right : cocher/décocher
|
||||
* - Swipe left : supprimer (avec undo)
|
||||
* - Champ de recherche "J'ai besoin ..." en bas
|
||||
* - Section "Recently Used" avec produits récemment scannés
|
||||
* - Catégories de produits cliquables
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ListDetailScreen(
|
||||
listId: Long,
|
||||
listName: String,
|
||||
onBack: () -> Unit,
|
||||
onOpenScanner: () -> Unit,
|
||||
onOpenProduct: (String) -> Unit,
|
||||
viewModel: ListDetailViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||
val showSearch by viewModel.showSearch.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
var selectedCategory by remember { mutableStateOf("Tous") }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showAddManualDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(listId, listName) {
|
||||
viewModel.initList(listId, listName)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(listName) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
"Retour"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.toggleSearch() }) {
|
||||
Icon(
|
||||
if (showSearch) Icons.Filled.Close else Icons.Filled.Search,
|
||||
if (showSearch) "Fermer la recherche" else "Rechercher"
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(Icons.Filled.MoreVert, "Menu")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Partager la liste") },
|
||||
leadingIcon = { Icon(Icons.Filled.Share, null) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
if (state is ListDetailViewModel.UiState.Success) {
|
||||
val s = state as ListDetailViewModel.UiState.Success
|
||||
val shareText = viewModel.shareList(s.listName, s.items)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, shareText)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "Partager via"))
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Fusionner avec une autre liste") },
|
||||
leadingIcon = { Icon(Icons.AutoMirrored.Filled.MergeType, null) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
// TODO: Implémenter la fusion
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
when (val s = state) {
|
||||
is ListDetailViewModel.UiState.Loading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
is ListDetailViewModel.UiState.Empty -> {
|
||||
ListEmptyContent(
|
||||
listName = s.listName,
|
||||
recentlyUsed = s.recentlyUsed,
|
||||
selectedCategory = selectedCategory,
|
||||
onCategorySelected = { selectedCategory = it },
|
||||
onProductClick = { product ->
|
||||
viewModel.addProductFromHistory(product)
|
||||
},
|
||||
onOpenScanner = onOpenScanner,
|
||||
onAddManual = { showAddManualDialog = true }
|
||||
)
|
||||
}
|
||||
is ListDetailViewModel.UiState.Success -> {
|
||||
ListContent(
|
||||
items = s.items,
|
||||
categories = s.categories,
|
||||
recentlyUsed = s.recentlyUsed,
|
||||
selectedCategory = selectedCategory,
|
||||
searchQuery = searchQuery,
|
||||
showSearch = showSearch,
|
||||
onCategorySelected = { selectedCategory = it },
|
||||
onToggleCheck = { item ->
|
||||
viewModel.toggleItemChecked(item.id, !item.isChecked)
|
||||
},
|
||||
onDelete = { item ->
|
||||
viewModel.deleteItem(
|
||||
ShoppingListItemEntity(
|
||||
id = item.id,
|
||||
listId = s.listId,
|
||||
productName = item.productName
|
||||
)
|
||||
)
|
||||
},
|
||||
onProductClick = { item ->
|
||||
item.barcode?.let { onOpenProduct(it) }
|
||||
},
|
||||
onProductFromHistoryClick = { product ->
|
||||
viewModel.addProductFromHistory(product)
|
||||
},
|
||||
onOpenScanner = onOpenScanner,
|
||||
onAddManual = { showAddManualDialog = true },
|
||||
onUncheckAll = { viewModel.uncheckAllItems() }
|
||||
)
|
||||
}
|
||||
is ListDetailViewModel.UiState.Error -> {
|
||||
EmptyState(
|
||||
title = "Erreur",
|
||||
message = s.message,
|
||||
emoji = "❌"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddManualDialog) {
|
||||
AddProductDialog(
|
||||
onDismiss = { showAddManualDialog = false },
|
||||
onAdd = { name ->
|
||||
viewModel.addItemToList(
|
||||
ShoppingListItemEntity(
|
||||
listId = listId,
|
||||
productName = name
|
||||
)
|
||||
)
|
||||
showAddManualDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListEmptyContent(
|
||||
listName: String,
|
||||
recentlyUsed: List<ListDetailViewModel.RecentlyUsedProduct>,
|
||||
selectedCategory: String,
|
||||
onCategorySelected: (String) -> Unit,
|
||||
onProductClick: (ListDetailViewModel.RecentlyUsedProduct) -> Unit,
|
||||
onOpenScanner: () -> Unit,
|
||||
onAddManual: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Message vide
|
||||
EmptyState(
|
||||
title = "Liste vide",
|
||||
message = "Ajoutez des produits à votre liste",
|
||||
emoji = "🛒",
|
||||
action = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
PrimaryButton(
|
||||
text = "Scanner un produit",
|
||||
onClick = onOpenScanner,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
PrimaryButton(
|
||||
text = "Ajouter manuellement",
|
||||
onClick = onAddManual,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Section Recently Used
|
||||
if (recentlyUsed.isNotEmpty()) {
|
||||
RecentlyUsedSection(
|
||||
products = recentlyUsed,
|
||||
selectedCategory = selectedCategory,
|
||||
onCategorySelected = onCategorySelected,
|
||||
onProductClick = onProductClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListContent(
|
||||
items: List<ListDetailViewModel.ShoppingListItemUi>,
|
||||
categories: List<String>,
|
||||
recentlyUsed: List<ListDetailViewModel.RecentlyUsedProduct>,
|
||||
selectedCategory: String,
|
||||
searchQuery: String,
|
||||
showSearch: Boolean,
|
||||
onCategorySelected: (String) -> Unit,
|
||||
onToggleCheck: (ListDetailViewModel.ShoppingListItemUi) -> Unit,
|
||||
onDelete: (ListDetailViewModel.ShoppingListItemUi) -> Unit,
|
||||
onProductClick: (ListDetailViewModel.ShoppingListItemUi) -> Unit,
|
||||
onProductFromHistoryClick: (ListDetailViewModel.RecentlyUsedProduct) -> Unit,
|
||||
onOpenScanner: () -> Unit,
|
||||
onAddManual: () -> Unit,
|
||||
onUncheckAll: () -> Unit
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Chips filtres par rayon
|
||||
if (categories.size > 1) {
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(categories) { category ->
|
||||
FilterChip(
|
||||
selected = selectedCategory == category,
|
||||
onClick = { onCategorySelected(category) },
|
||||
label = { Text(category) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Liste des produits
|
||||
val filteredItems = if (selectedCategory == "Tous") {
|
||||
items
|
||||
} else {
|
||||
items.filter { it.category == selectedCategory }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(
|
||||
filteredItems,
|
||||
key = { it.id }
|
||||
) { item ->
|
||||
SwipeableListItem(
|
||||
item = item,
|
||||
onToggleCheck = { onToggleCheck(item) },
|
||||
onDelete = { onDelete(item) },
|
||||
onProductClick = { onProductClick(item) }
|
||||
)
|
||||
}
|
||||
|
||||
// Section Recently Used en bas de la liste
|
||||
if (recentlyUsed.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
RecentlyUsedSection(
|
||||
products = recentlyUsed,
|
||||
selectedCategory = selectedCategory,
|
||||
onCategorySelected = onCategorySelected,
|
||||
onProductClick = onProductFromHistoryClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom bar
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onUncheckAll,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Tout décocher")
|
||||
}
|
||||
PrimaryButton(
|
||||
text = "Ajouter",
|
||||
onClick = onAddManual,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecentlyUsedSection(
|
||||
products: List<ListDetailViewModel.RecentlyUsedProduct>,
|
||||
selectedCategory: String,
|
||||
onCategorySelected: (String) -> Unit,
|
||||
onProductClick: (ListDetailViewModel.RecentlyUsedProduct) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// Header Recently Used
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Recently Used",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (expanded) "Réduire" else "Développer"
|
||||
)
|
||||
}
|
||||
|
||||
// Contenu expandable
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = fadeIn() + slideInVertically(),
|
||||
exit = fadeOut() + slideOutVertically()
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// Grille de produits
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(bottom = 16.dp)
|
||||
) {
|
||||
items(products) { product ->
|
||||
RecentlyUsedProductCard(
|
||||
product = product,
|
||||
onClick = { onProductClick(product) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecentlyUsedProductCard(
|
||||
product: ListDetailViewModel.RecentlyUsedProduct,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
val statusColors = LocalStatusColors.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingSm),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Image ou icône
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (!product.imageUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = product.imageUrl,
|
||||
contentDescription = product.productName,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = getCategoryEmoji(product.category),
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Nom du produit
|
||||
Text(
|
||||
text = product.productName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Badge statut
|
||||
if (!product.safetyStatus.isNullOrBlank()) {
|
||||
val (icon, color) = when (product.safetyStatus) {
|
||||
"SAFE" -> "✅" to statusColors.safe
|
||||
"WARNING" -> "⚠️" to statusColors.warning
|
||||
"DANGER" -> "❌" to statusColors.danger
|
||||
else -> "" to Color.Gray
|
||||
}
|
||||
Text(
|
||||
text = icon,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SwipeableListItem(
|
||||
item: ListDetailViewModel.ShoppingListItemUi,
|
||||
onToggleCheck: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onProductClick: () -> Unit
|
||||
) {
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { value ->
|
||||
when (value) {
|
||||
SwipeToDismissBoxValue.EndToStart -> {
|
||||
onDelete()
|
||||
true
|
||||
}
|
||||
SwipeToDismissBoxValue.StartToEnd -> {
|
||||
onToggleCheck()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
backgroundContent = {
|
||||
val color = when (dismissState.targetValue) {
|
||||
SwipeToDismissBoxValue.StartToEnd -> LocalStatusColors.current.safe
|
||||
SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.error
|
||||
else -> Color.Transparent
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White)
|
||||
Icon(Icons.Filled.Delete, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
},
|
||||
content = {
|
||||
ShoppingListItemRow(
|
||||
item = item,
|
||||
onToggleCheck = onToggleCheck,
|
||||
onProductClick = onProductClick
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ShoppingListItemRow(
|
||||
item: ListDetailViewModel.ShoppingListItemUi,
|
||||
onToggleCheck: () -> Unit,
|
||||
onProductClick: () -> Unit
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
val backgroundColor = if (item.isChecked) {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onProductClick,
|
||||
onLongClick = onToggleCheck
|
||||
),
|
||||
colors = CardDefaults.cardColors(containerColor = backgroundColor)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Checkbox
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.background(
|
||||
color = if (item.isChecked) MaterialTheme.colorScheme.primary else Color.Transparent,
|
||||
shape = MaterialTheme.shapes.small
|
||||
)
|
||||
.combinedClickable(onClick = onToggleCheck),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (item.isChecked) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(dimens.spacingSm))
|
||||
|
||||
// Image du produit
|
||||
if (!item.imageUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = item.imageUrl,
|
||||
contentDescription = item.productName,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
)
|
||||
Spacer(Modifier.width(dimens.spacingSm))
|
||||
}
|
||||
|
||||
// Product info
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.productName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textDecoration = if (item.isChecked) TextDecoration.LineThrough else null,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!item.brand.isNullOrBlank()) {
|
||||
Text(
|
||||
text = item.brand,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Safety status indicator
|
||||
val statusColors = LocalStatusColors.current
|
||||
if (!item.safetyStatus.isNullOrBlank()) {
|
||||
val (icon, color) = when (item.safetyStatus) {
|
||||
"SAFE" -> "✅" to statusColors.safe
|
||||
"WARNING" -> "⚠️" to statusColors.warning
|
||||
"DANGER" -> "❌" to statusColors.danger
|
||||
else -> "" to Color.Gray
|
||||
}
|
||||
Text(
|
||||
text = icon,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
|
||||
// Allergen warning
|
||||
if (!item.allergenWarning.isNullOrBlank()) {
|
||||
Icon(
|
||||
Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = LocalStatusColors.current.warning,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddProductDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (String) -> Unit
|
||||
) {
|
||||
var productName by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Ajouter un produit") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = productName,
|
||||
onValueChange = { productName = it },
|
||||
label = { Text("Nom du produit") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { if (productName.isNotBlank()) onAdd(productName) },
|
||||
enabled = productName.isNotBlank()
|
||||
) {
|
||||
Text("Ajouter")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Annuler")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne un emoji pour une catégorie donnée.
|
||||
*/
|
||||
private fun getCategoryEmoji(category: String?): String {
|
||||
return when (category) {
|
||||
"Frais" -> "🥬"
|
||||
"Fruits & Légumes" -> "🍎"
|
||||
"Boulangerie" -> "🥖"
|
||||
"Boucherie" -> "🥩"
|
||||
"Produits laitiers" -> "🥛"
|
||||
"Épicerie" -> "🛒"
|
||||
"Boissons" -> "🥤"
|
||||
"Surgelés" -> "🧊"
|
||||
"Hygiène" -> "🧴"
|
||||
"Bébé" -> "👶"
|
||||
"Animaux" -> "🐾"
|
||||
"Entretien" -> "🧹"
|
||||
else -> "📦"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.safebite.app.presentation.screen.lists
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
|
||||
import com.safebite.app.domain.engine.CategoryEngine
|
||||
import com.safebite.app.domain.model.ScanHistoryItem
|
||||
import com.safebite.app.domain.usecase.GetScanHistoryUseCase
|
||||
import com.safebite.app.domain.usecase.ManageShoppingListUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModel pour l'écran détail d'une liste (Phase 2).
|
||||
*
|
||||
* Gère:
|
||||
* - L'affichage des produits dans la liste
|
||||
* - L'ajout de produits (manuel ou depuis l'historique)
|
||||
* - La recherche de produits
|
||||
* - Les catégories de produits
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ListDetailViewModel @Inject constructor(
|
||||
private val manageListUseCase: ManageShoppingListUseCase,
|
||||
private val getScanHistoryUseCase: GetScanHistoryUseCase,
|
||||
private val categoryEngine: CategoryEngine
|
||||
) : ViewModel() {
|
||||
|
||||
sealed class UiState {
|
||||
object Loading : UiState()
|
||||
data class Success(
|
||||
val listId: Long,
|
||||
val listName: String,
|
||||
val items: List<ShoppingListItemUi>,
|
||||
val categories: List<String>,
|
||||
val recentlyUsed: List<RecentlyUsedProduct>
|
||||
) : UiState()
|
||||
data class Empty(val listId: Long, val listName: String, val recentlyUsed: List<RecentlyUsedProduct>) : UiState()
|
||||
data class Error(val message: String) : UiState()
|
||||
}
|
||||
|
||||
data class ShoppingListItemUi(
|
||||
val id: Long,
|
||||
val barcode: String?,
|
||||
val productName: String,
|
||||
val brand: String?,
|
||||
val imageUrl: String?,
|
||||
val isChecked: Boolean,
|
||||
val category: String?,
|
||||
val safetyStatus: String?,
|
||||
val allergenWarning: String?
|
||||
)
|
||||
|
||||
data class RecentlyUsedProduct(
|
||||
val barcode: String,
|
||||
val productName: String,
|
||||
val brand: String?,
|
||||
val imageUrl: String?,
|
||||
val safetyStatus: String?,
|
||||
val category: String?
|
||||
)
|
||||
|
||||
private val _listIdFlow = MutableStateFlow(0L)
|
||||
private var _listName: String = ""
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery
|
||||
|
||||
private val _showSearch = MutableStateFlow(false)
|
||||
val showSearch: StateFlow<Boolean> = _showSearch
|
||||
|
||||
fun initList(listId: Long, listName: String) {
|
||||
_listIdFlow.value = listId
|
||||
_listName = listName
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val state: StateFlow<UiState> = _listIdFlow.flatMapLatest { listId ->
|
||||
combine(
|
||||
manageListUseCase.observeItems(listId),
|
||||
getScanHistoryUseCase.observe(),
|
||||
_searchQuery
|
||||
) { items, history, query ->
|
||||
// Convertir l'historique en produits récemment utilisés
|
||||
val recentlyUsed = history.take(20).map { it.toRecentlyUsed() }
|
||||
|
||||
// Filtrer les items selon la recherche
|
||||
val filteredItems = if (query.isBlank()) {
|
||||
items
|
||||
} else {
|
||||
items.filter {
|
||||
it.productName.contains(query, ignoreCase = true) ||
|
||||
it.brand?.contains(query, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
UiState.Empty(listId, _listName, recentlyUsed)
|
||||
} else {
|
||||
val categories = items.mapNotNull { it.category }.distinct().sorted()
|
||||
UiState.Success(
|
||||
listId = listId,
|
||||
listName = _listName,
|
||||
items = filteredItems.map { it.toUi() },
|
||||
categories = listOf("Tous") + categories,
|
||||
recentlyUsed = recentlyUsed
|
||||
)
|
||||
}
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = UiState.Loading
|
||||
)
|
||||
|
||||
fun toggleItemChecked(id: Long, checked: Boolean) {
|
||||
viewModelScope.launch {
|
||||
manageListUseCase.setItemChecked(id, checked)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteItem(item: ShoppingListItemEntity) {
|
||||
viewModelScope.launch {
|
||||
manageListUseCase.deleteItem(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun uncheckAllItems() {
|
||||
viewModelScope.launch {
|
||||
manageListUseCase.uncheckAllItems(_listIdFlow.value)
|
||||
}
|
||||
}
|
||||
|
||||
fun addItemToList(item: ShoppingListItemEntity) {
|
||||
viewModelScope.launch {
|
||||
val listId = _listIdFlow.value
|
||||
// Auto-categorisation
|
||||
val category = categoryEngine.detectCategory(item.productName)
|
||||
val itemWithCategory = item.copy(
|
||||
listId = listId,
|
||||
category = category
|
||||
)
|
||||
manageListUseCase.addItemToList(listId, itemWithCategory)
|
||||
}
|
||||
}
|
||||
|
||||
fun addProductFromHistory(product: RecentlyUsedProduct) {
|
||||
viewModelScope.launch {
|
||||
val listId = _listIdFlow.value
|
||||
val category = categoryEngine.detectCategory(product.productName)
|
||||
val item = ShoppingListItemEntity(
|
||||
listId = listId,
|
||||
barcode = product.barcode,
|
||||
productName = product.productName,
|
||||
brand = product.brand,
|
||||
imageUrl = product.imageUrl,
|
||||
category = category,
|
||||
safetyStatus = product.safetyStatus
|
||||
)
|
||||
manageListUseCase.addItemToList(listId, item)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSearch() {
|
||||
_showSearch.value = !_showSearch.value
|
||||
if (!_showSearch.value) {
|
||||
_searchQuery.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun mergeWithList(otherListId: Long, otherListName: String) {
|
||||
viewModelScope.launch {
|
||||
val listId = _listIdFlow.value
|
||||
val items = manageListUseCase.getItems(otherListId)
|
||||
items.forEach { item ->
|
||||
val newItem = item.copy(id = 0L, listId = listId)
|
||||
manageListUseCase.addItem(newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shareList(listName: String, items: List<ShoppingListItemUi>): String {
|
||||
val checkedCount = items.count { it.isChecked }
|
||||
val totalCount = items.size
|
||||
val uncheckedItems = items.filterNot { it.isChecked }
|
||||
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("📋 $listName")
|
||||
sb.appendLine("━━━━━━━━━━━━━━━━━━━━")
|
||||
sb.appendLine("$checkedCount/$totalCount produits achetés")
|
||||
sb.appendLine()
|
||||
|
||||
val byCategory = uncheckedItems.groupBy { it.category ?: "Autre" }
|
||||
byCategory.forEach { (category, categoryItems) ->
|
||||
sb.appendLine("📂 $category")
|
||||
categoryItems.forEach { item ->
|
||||
val status = when (item.safetyStatus) {
|
||||
"SAFE" -> "✅"
|
||||
"WARNING" -> "⚠️"
|
||||
"DANGER" -> "❌"
|
||||
else -> "☐"
|
||||
}
|
||||
val warning = if (!item.allergenWarning.isNullOrBlank()) " ⚠️${item.allergenWarning}" else ""
|
||||
sb.appendLine(" $status ${item.productName}${warning}")
|
||||
}
|
||||
sb.appendLine()
|
||||
}
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun ScanHistoryItem.toRecentlyUsed() = RecentlyUsedProduct(
|
||||
barcode = barcode,
|
||||
productName = productName ?: "Produit inconnu",
|
||||
brand = brand,
|
||||
imageUrl = imageUrl,
|
||||
safetyStatus = safetyStatus.name,
|
||||
category = categoryEngine.detectCategory(productName ?: "")
|
||||
)
|
||||
|
||||
private fun ShoppingListItemEntity.toUi() = ShoppingListItemUi(
|
||||
id = id,
|
||||
barcode = barcode,
|
||||
productName = productName,
|
||||
brand = brand,
|
||||
imageUrl = imageUrl,
|
||||
isChecked = isChecked,
|
||||
category = category,
|
||||
safetyStatus = safetyStatus,
|
||||
allergenWarning = allergenWarning
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package com.safebite.app.presentation.screen.lists
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.MergeType
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.ShoppingCart
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.safebite.app.R
|
||||
import com.safebite.app.data.local.database.entity.ShoppingListEntity
|
||||
import com.safebite.app.presentation.common.components.EmptyState
|
||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||
import com.safebite.app.presentation.theme.LocalDimens
|
||||
|
||||
/**
|
||||
* Écran Listes (spec UX §5.5 - Flow 5).
|
||||
*
|
||||
* Affiche la liste des courses avec progression et alertes allergies.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ListsScreen(
|
||||
onOpenList: (Long, String) -> Unit,
|
||||
onOpenScanner: () -> Unit,
|
||||
viewModel: ListsViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.lists_title)) },
|
||||
actions = {
|
||||
val addContentDesc = stringResource(R.string.a11y_add)
|
||||
IconButton(
|
||||
onClick = { showCreateDialog = true },
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = addContentDesc
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
when (val s = state) {
|
||||
is ListsViewModel.UiState.Loading -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
is ListsViewModel.UiState.Empty -> {
|
||||
EmptyState(
|
||||
title = "Aucune liste",
|
||||
message = s.message,
|
||||
emoji = "📋",
|
||||
action = {
|
||||
PrimaryButton(
|
||||
text = "Créer une liste",
|
||||
onClick = { showCreateDialog = true }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
is ListsViewModel.UiState.Success -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(s.lists, key = { it.list.id }) { item ->
|
||||
ShoppingListCard(
|
||||
item = item,
|
||||
onClick = { onOpenList(item.list.id, item.list.name) },
|
||||
onDelete = { viewModel.deleteList(item.list) },
|
||||
onMerge = { /* TODO: Ouvrir dialog fusion */ }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is ListsViewModel.UiState.Error -> {
|
||||
EmptyState(
|
||||
title = "Erreur",
|
||||
message = s.message,
|
||||
emoji = "❌"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateDialog) {
|
||||
CreateListDialog(
|
||||
onDismiss = { showCreateDialog = false },
|
||||
onCreate = { name ->
|
||||
viewModel.createList(name)
|
||||
showCreateDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShoppingListCard(
|
||||
item: ListsViewModel.ShoppingListWithStats,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onMerge: () -> Unit
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
val progress = if (item.itemCount > 0) item.checkedCount.toFloat() / item.itemCount else 0f
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(dimens.spacingMd)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ShoppingCart,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(Modifier.padding(start = dimens.spacingSm))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.list.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "${item.itemCount} produits • ${item.checkedCount} achetés",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
// Menu actions
|
||||
Box {
|
||||
val moreOptionsDesc = stringResource(R.string.a11y_more_options)
|
||||
IconButton(
|
||||
onClick = { showMenu = true },
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = moreOptionsDesc
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = null)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Fusionner avec...") },
|
||||
leadingIcon = { Icon(Icons.Filled.MergeType, contentDescription = null) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onMerge()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Supprimer") },
|
||||
leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onDelete()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(dimens.spacingSm))
|
||||
|
||||
// Barre de progression
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(MaterialTheme.shapes.small),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateListDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (String) -> Unit
|
||||
) {
|
||||
var listName by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Nouvelle liste") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = listName,
|
||||
onValueChange = { listName = it },
|
||||
label = { Text("Nom de la liste") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { if (listName.isNotBlank()) onCreate(listName) },
|
||||
enabled = listName.isNotBlank()
|
||||
) {
|
||||
Text("Créer")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Annuler")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.safebite.app.presentation.screen.lists
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.safebite.app.data.local.database.entity.ShoppingListEntity
|
||||
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* ViewModel pour l'écran Listes (Phase 2).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ListsViewModel @Inject constructor(
|
||||
private val getShoppingListsUseCase: GetShoppingListsUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
sealed class UiState {
|
||||
object Loading : UiState()
|
||||
data class Success(
|
||||
val lists: List<ShoppingListWithStats>
|
||||
) : UiState()
|
||||
data class Empty(val message: String = "") : UiState()
|
||||
data class Error(val message: String) : UiState()
|
||||
}
|
||||
|
||||
data class ShoppingListWithStats(
|
||||
val list: ShoppingListEntity,
|
||||
val itemCount: Int,
|
||||
val checkedCount: Int
|
||||
)
|
||||
|
||||
val state: StateFlow<UiState> = getShoppingListsUseCase.observeActive()
|
||||
.map { lists ->
|
||||
if (lists.isEmpty()) {
|
||||
UiState.Empty("Aucune liste de courses. Créez votre première liste !")
|
||||
} else {
|
||||
// Pour chaque liste, on récupère les stats
|
||||
// Note: Dans une implémentation complète, on utiliserait combine
|
||||
// pour observer les stats en temps réel
|
||||
UiState.Success(
|
||||
lists.map { list ->
|
||||
ShoppingListWithStats(
|
||||
list = list,
|
||||
itemCount = 0, // Sera mis à jour par le Flow
|
||||
checkedCount = 0
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = UiState.Loading
|
||||
)
|
||||
|
||||
fun createList(name: String) {
|
||||
viewModelScope.launch {
|
||||
getShoppingListsUseCase.createList(name)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteList(list: ShoppingListEntity) {
|
||||
viewModelScope.launch {
|
||||
getShoppingListsUseCase.deleteList(list)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.safebite.app.presentation.screen.main
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FabPosition
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.safebite.app.R
|
||||
import com.safebite.app.presentation.navigation.BottomNavItem
|
||||
import com.safebite.app.presentation.navigation.Screen
|
||||
import com.safebite.app.presentation.navigation.bottomNavItems
|
||||
import com.safebite.app.presentation.screen.dashboard.DashboardScreen
|
||||
import com.safebite.app.presentation.screen.family.FamilyScreen
|
||||
import com.safebite.app.presentation.screen.lists.ListsScreen
|
||||
import com.safebite.app.presentation.screen.tracking.TrackingScreen
|
||||
|
||||
/**
|
||||
* Écran principal de l'application avec Bottom Navigation et FAB Scanner.
|
||||
*
|
||||
* Architecture (spec UX §3.1) :
|
||||
* - 4 onglets : Dashboard, Listes, Suivi, Famille
|
||||
* - FAB Scanner central (56dp, chevauchant la bottom bar)
|
||||
* - Le FAB disparaît pendant le scan actif
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
onOpenScanner: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenProfile: (Long) -> Unit,
|
||||
onOpenListDetail: (Long, String) -> Unit,
|
||||
onOpenHistoryItem: (String) -> Unit,
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = currentBackStackEntry?.destination
|
||||
|
||||
// Déterminer si le FAB doit être visible
|
||||
val fabVisible = currentDestination?.route in listOf(
|
||||
Screen.Dashboard.route,
|
||||
Screen.Lists.route,
|
||||
Screen.Tracking.route,
|
||||
Screen.Family.route
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.app_name)) }
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
SafeBiteBottomNavigation(
|
||||
navController = navController,
|
||||
currentDestination = currentDestination,
|
||||
items = bottomNavItems
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
SafeBiteFab(
|
||||
visible = fabVisible,
|
||||
onClick = onOpenScanner
|
||||
)
|
||||
},
|
||||
floatingActionButtonPosition = FabPosition.Center
|
||||
) { paddingValues ->
|
||||
// NavHost pour les 4 onglets principaux
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Dashboard.route,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
composable(Screen.Dashboard.route) {
|
||||
DashboardScreen(
|
||||
onScan = onOpenScanner,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenList = onOpenListDetail,
|
||||
onOpenHistoryItem = onOpenHistoryItem
|
||||
)
|
||||
}
|
||||
composable(Screen.Lists.route) {
|
||||
ListsScreen(
|
||||
onOpenList = { id, name -> onOpenListDetail(id, name) },
|
||||
onOpenScanner = onOpenScanner
|
||||
)
|
||||
}
|
||||
composable(Screen.Tracking.route) {
|
||||
TrackingScreen(
|
||||
onOpenHistoryItem = onOpenHistoryItem,
|
||||
onOpenScanner = onOpenScanner
|
||||
)
|
||||
}
|
||||
composable(Screen.Family.route) {
|
||||
FamilyScreen(
|
||||
onOpenProfile = onOpenProfile,
|
||||
onOpenSettings = onOpenSettings
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom Navigation Bar SafeBite (spec UX §3.2).
|
||||
*
|
||||
* - 4 items avec icônes selected/unselected
|
||||
* - Badge pour notifications non lues
|
||||
* - Labels toujours visibles
|
||||
*/
|
||||
@Composable
|
||||
private fun SafeBiteBottomNavigation(
|
||||
navController: NavHostController,
|
||||
currentDestination: NavDestination?,
|
||||
items: List<BottomNavItem>
|
||||
) {
|
||||
NavigationBar(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 2.dp
|
||||
) {
|
||||
items.forEach { item ->
|
||||
val selected = currentDestination?.hierarchy?.any { it.route == item.screen.route } == true
|
||||
NavigationBarItem(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
navController.navigate(item.screen.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
val icon = if (selected) item.iconSelected else item.iconUnselected
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
},
|
||||
alwaysShowLabel = true,
|
||||
colors = androidx.compose.material3.NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.onSurface,
|
||||
selectedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
indicatorColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FAB Scanner SafeBite (spec UX §3.3).
|
||||
*
|
||||
* - 56dp, centré, chevauchant la bottom bar
|
||||
* - Animation scale + fade pour apparition/disparition
|
||||
* - Haptic feedback au tap
|
||||
*/
|
||||
@Composable
|
||||
private fun SafeBiteFab(
|
||||
visible: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(animationSpec = tween(200)) +
|
||||
scaleIn(initialScale = 0.8f, animationSpec = tween(200)) +
|
||||
slideInVertically(
|
||||
initialOffsetY = { it },
|
||||
animationSpec = tween(200)
|
||||
),
|
||||
exit = fadeOut(animationSpec = tween(200)) +
|
||||
scaleOut(targetScale = 0.8f, animationSpec = tween(200)) +
|
||||
slideOutVertically(
|
||||
targetOffsetY = { it },
|
||||
animationSpec = tween(200)
|
||||
)
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = onClick,
|
||||
containerColor = MaterialTheme.colorScheme.onSurface,
|
||||
contentColor = MaterialTheme.colorScheme.surface,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
elevation = androidx.compose.material3.FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = 6.dp
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.QrCodeScanner,
|
||||
contentDescription = stringResource(R.string.fab_scan),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+498
@@ -0,0 +1,498 @@
|
||||
package com.safebite.app.presentation.screen.product
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRowDefaults
|
||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import com.safebite.app.R
|
||||
import com.safebite.app.domain.model.AllergenType
|
||||
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.Nutriments
|
||||
import com.safebite.app.domain.model.Product
|
||||
import com.safebite.app.domain.model.SafetyStatus
|
||||
import com.safebite.app.domain.model.ScanResult
|
||||
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
||||
import com.safebite.app.presentation.theme.LocalDimens
|
||||
|
||||
/**
|
||||
* Écran fiche produit détaillée avec tabs.
|
||||
*
|
||||
* Tabs :
|
||||
* 1. Résumé : verdict, nutri-score, calories, jauges
|
||||
* 2. Allergènes : liste 14 allergènes avec statut
|
||||
* 3. Additifs : liste additifs code E
|
||||
* 4. Alternatives : produits similaires
|
||||
*/
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProductDetailScreen(
|
||||
barcode: String,
|
||||
onBack: () -> Unit,
|
||||
onOpenProduct: (String) -> Unit,
|
||||
viewModel: ProductDetailViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(barcode) {
|
||||
viewModel.loadProduct(barcode)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
SafeBiteTopAppBar(
|
||||
title = stringResource(R.string.result_ingredients),
|
||||
onBack = onBack,
|
||||
backContentDescription = stringResource(R.string.a11y_back)
|
||||
)
|
||||
|
||||
when (val state = uiState) {
|
||||
is ProductDetailUiState.Loading -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is ProductDetailUiState.Error -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("❌", style = MaterialTheme.typography.displayMedium)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(state.message, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
is ProductDetailUiState.Success -> {
|
||||
ProductDetailContent(
|
||||
product = state.product,
|
||||
scanResult = state.scanResult,
|
||||
onOpenProduct = onOpenProduct
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProductDetailContent(
|
||||
product: Product,
|
||||
scanResult: ScanResult?,
|
||||
onOpenProduct: (String) -> Unit
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
val tabTitles = listOf("Résumé", "Allergènes", "Additifs", "Alternatives")
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header produit
|
||||
ProductHeader(product = product, scanResult = scanResult)
|
||||
|
||||
// Tabs
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
edgePadding = dimens.spacingMd,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.SecondaryIndicator(
|
||||
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTab])
|
||||
)
|
||||
}
|
||||
) {
|
||||
tabTitles.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(title, style = MaterialTheme.typography.labelLarge) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Contenu des tabs
|
||||
when (selectedTab) {
|
||||
0 -> SummaryTab(product = product, scanResult = scanResult)
|
||||
1 -> AllergensTab(scanResult = scanResult)
|
||||
2 -> AdditivesTab(product = product)
|
||||
3 -> AlternativesTab(onOpenProduct = onOpenProduct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProductHeader(product: Product, scanResult: ScanResult?) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(dimens.spacingMd),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Image produit
|
||||
AsyncImage(
|
||||
model = product.imageUrl,
|
||||
contentDescription = stringResource(R.string.a11y_product_image),
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.medium)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(dimens.spacingSm))
|
||||
|
||||
// Nom et marque
|
||||
Text(
|
||||
text = product.name ?: "Produit inconnu",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (!product.brand.isNullOrBlank()) {
|
||||
Text(
|
||||
text = product.brand,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Verdict si disponible
|
||||
if (scanResult != null) {
|
||||
Spacer(Modifier.height(dimens.spacingSm))
|
||||
VerdictBadge(status = scanResult.safetyStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerdictBadge(status: SafetyStatus) {
|
||||
val (text, color, a11yDesc) = when (status) {
|
||||
SafetyStatus.SAFE -> Triple("✅ Sûr", Color(0xFF2ECC71), "Produit sûr")
|
||||
SafetyStatus.WARNING -> Triple("⚠️ Attention", Color(0xFFF39C12), "Attention : traces d'allergènes")
|
||||
SafetyStatus.DANGER -> Triple("❌ Danger", Color(0xFFE74C3C), "Danger : allergènes détectés")
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(color.copy(alpha = 0.15f), MaterialTheme.shapes.medium)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.semantics { contentDescription = a11yDesc }
|
||||
) {
|
||||
Text(text = text, color = color, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab Résumé ──
|
||||
|
||||
@Composable
|
||||
private fun SummaryTab(product: Product, scanResult: ScanResult?) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(dimens.spacingMd),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
||||
) {
|
||||
// Nutri-Score
|
||||
if (scanResult?.health?.nutriScore != null) {
|
||||
item {
|
||||
NutriScoreCard(grade = scanResult.health.nutriScore)
|
||||
}
|
||||
}
|
||||
|
||||
// Calories
|
||||
if (product.nutriments.energyKcal100g != null) {
|
||||
item {
|
||||
CaloriesCard(kcal = product.nutriments.energyKcal100g)
|
||||
}
|
||||
}
|
||||
|
||||
// Jauges nutritionnelles
|
||||
item {
|
||||
NutritionGauges(nutriments = product.nutriments)
|
||||
}
|
||||
|
||||
// Verdict santé
|
||||
if (scanResult?.health != null) {
|
||||
item {
|
||||
HealthVerdictCard(health = scanResult.health)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NutriScoreCard(grade: String) {
|
||||
val dimens = LocalDimens.current
|
||||
val color = when (grade.uppercase()) {
|
||||
"A" -> Color(0xFF1E8E3E)
|
||||
"B" -> Color(0xFF7CB342)
|
||||
"C" -> Color(0xFFFBC02D)
|
||||
"D" -> Color(0xFFEF6C00)
|
||||
"E" -> Color(0xFFC62828)
|
||||
else -> Color.Gray
|
||||
}
|
||||
|
||||
val a11yDesc = stringResource(R.string.a11y_nutri_score, grade.uppercase())
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(dimens.spacingMd),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Nutri-Score", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(color, MaterialTheme.shapes.medium)
|
||||
.semantics {
|
||||
contentDescription = a11yDesc
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(grade.uppercase(), color = Color.White, fontWeight = FontWeight.Black, style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CaloriesCard(kcal: Double) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(dimens.spacingMd),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("🔥", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.width(dimens.spacingMd))
|
||||
Column {
|
||||
Text("Calories", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("${kcal.toInt()} kcal / 100g", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NutritionGauges(nutriments: Nutriments) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(dimens.spacingMd)) {
|
||||
Text("Nutriments", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(dimens.spacingSm))
|
||||
|
||||
nutriments.fat100g?.let { GaugeRow("Matières grasses", it, 100.0, Color(0xFFE74C3C)) }
|
||||
nutriments.sugars100g?.let { GaugeRow("Sucres", it, 50.0, Color(0xFFF39C12)) }
|
||||
nutriments.salt100g?.let { GaugeRow("Sel", it, 6.0, Color(0xFF3498DB)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GaugeRow(label: String, value: Double, max: Double, color: Color) {
|
||||
val dimens = LocalDimens.current
|
||||
val progress = (value / max).toFloat().coerceIn(0f, 1f)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
Text("${String.format("%.1f", value)}g", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.small)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(progress)
|
||||
.height(8.dp)
|
||||
.background(color, MaterialTheme.shapes.small)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(dimens.spacingSm))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HealthVerdictCard(health: HealthAssessment) {
|
||||
val dimens = LocalDimens.current
|
||||
val (emoji, text, color) = when (health.rating) {
|
||||
HealthRating.HEALTHY -> Triple("💪", "Plutôt sain", Color(0xFF2E7D32))
|
||||
HealthRating.MODERATE -> Triple("🙂", "Modération", Color(0xFFF57C00))
|
||||
HealthRating.UNHEALTHY -> Triple("🚫", "Peu recommandable", Color(0xFFC62828))
|
||||
HealthRating.UNKNOWN -> Triple("❔", "Inconnu", Color.Gray)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(dimens.spacingMd),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(emoji, style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.width(dimens.spacingMd))
|
||||
Column {
|
||||
Text("Verdict santé", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(text, style = MaterialTheme.typography.titleMedium, color = color, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab Allergènes ──
|
||||
|
||||
@Composable
|
||||
private fun AllergensTab(scanResult: ScanResult?) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(dimens.spacingMd),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
|
||||
) {
|
||||
item {
|
||||
Text("14 allergènes réglementaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(dimens.spacingSm))
|
||||
}
|
||||
|
||||
if (scanResult == null) {
|
||||
item {
|
||||
Text("Aucune analyse disponible", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
items(AllergenType.entries.toList()) { allergen ->
|
||||
val detected = scanResult.detectedAllergens.find { it.allergenType == allergen }
|
||||
AllergenStatusRow(allergen = allergen, detected = detected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllergenStatusRow(allergen: AllergenType, detected: com.safebite.app.domain.model.DetectedAllergen?) {
|
||||
val dimens = LocalDimens.current
|
||||
val a11yAbsent = stringResource(R.string.a11y_allergen_absent)
|
||||
val a11yTrace = stringResource(R.string.a11y_allergen_trace)
|
||||
val a11yPresent = stringResource(R.string.a11y_allergen_present)
|
||||
val (status, emoji, bgColor, a11yDesc) = when {
|
||||
detected == null -> Quad("Absent", "✅", Color(0xFFE8F8F5), a11yAbsent)
|
||||
detected.detectionLevel == DetectionLevel.TRACE -> Quad("Traces", "⚠️", Color(0xFFFEF5E7), a11yTrace)
|
||||
else -> Quad("Présent", "❌", Color(0xFFFDEDEC), a11yPresent)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(bgColor, MaterialTheme.shapes.small)
|
||||
.padding(dimens.spacingSm)
|
||||
.semantics { contentDescription = "${allergen.displayNameFr}: $a11yDesc" },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(allergen.icon, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.width(dimens.spacingSm))
|
||||
Text(allergen.displayNameFr, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Text("$emoji $status", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
||||
|
||||
// ── Tab Additifs ──
|
||||
|
||||
@Composable
|
||||
private fun AdditivesTab(product: Product) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(dimens.spacingMd)
|
||||
) {
|
||||
item {
|
||||
Text("Additifs alimentaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(dimens.spacingSm))
|
||||
}
|
||||
|
||||
item {
|
||||
val ingredients = product.ingredientsText ?: "Ingrédients non disponibles"
|
||||
Text(ingredients, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab Alternatives ──
|
||||
|
||||
@Composable
|
||||
private fun AlternativesTab(onOpenProduct: (String) -> Unit) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(dimens.spacingMd)
|
||||
) {
|
||||
Text("Produits similaires", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(dimens.spacingMd))
|
||||
|
||||
// Placeholder - les alternatives nécessitent une recherche API
|
||||
Text(
|
||||
"Fonctionnalité à venir : recherche de produits similaires sans allergènes.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.safebite.app.presentation.screen.product
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.safebite.app.domain.model.Product
|
||||
import com.safebite.app.domain.model.ScanResult
|
||||
import com.safebite.app.domain.repository.ProductFetchResult
|
||||
import com.safebite.app.domain.usecase.AnalyzeProductUseCase
|
||||
import com.safebite.app.domain.usecase.FetchProductUseCase
|
||||
import com.safebite.app.domain.usecase.ManageProfileUseCase
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* State UI pour la fiche produit détaillée.
|
||||
*/
|
||||
sealed class ProductDetailUiState {
|
||||
data object Loading : ProductDetailUiState()
|
||||
data class Success(
|
||||
val product: Product,
|
||||
val scanResult: ScanResult?
|
||||
) : ProductDetailUiState()
|
||||
data class Error(val message: String) : ProductDetailUiState()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class ProductDetailViewModel @Inject constructor(
|
||||
private val fetchProduct: FetchProductUseCase,
|
||||
private val analyzeProduct: AnalyzeProductUseCase,
|
||||
private val manageProfile: ManageProfileUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<ProductDetailUiState>(ProductDetailUiState.Loading)
|
||||
val uiState: StateFlow<ProductDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadProduct(barcode: String) = viewModelScope.launch {
|
||||
_uiState.value = ProductDetailUiState.Loading
|
||||
|
||||
when (val result = fetchProduct(barcode)) {
|
||||
is ProductFetchResult.Found -> {
|
||||
val profiles = resolveProfiles()
|
||||
val scanResult = if (profiles.isNotEmpty()) {
|
||||
analyzeProduct(result.product, profiles, com.safebite.app.domain.model.DataSource.API)
|
||||
} else null
|
||||
_uiState.value = ProductDetailUiState.Success(result.product, scanResult)
|
||||
}
|
||||
is ProductFetchResult.NotFound -> {
|
||||
_uiState.value = ProductDetailUiState.Error("Produit non trouvé")
|
||||
}
|
||||
is ProductFetchResult.Error -> {
|
||||
_uiState.value = ProductDetailUiState.Error(result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveProfiles() = run {
|
||||
val all = manageProfile.observe().first()
|
||||
val activeIds = manageProfile.observeActiveIds().first()
|
||||
when {
|
||||
activeIds.isNotEmpty() -> all.filter { it.id in activeIds }
|
||||
else -> all.filter { it.isDefault }.ifEmpty { all.take(1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-11
@@ -25,23 +25,20 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.safebite.app.R
|
||||
import com.safebite.app.presentation.common.components.AllergenLevel
|
||||
import com.safebite.app.presentation.common.components.AllergenSelectionGrid
|
||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
||||
import com.safebite.app.presentation.common.components.StandardTextField
|
||||
import com.safebite.app.presentation.theme.LocalDimens
|
||||
import com.safebite.app.domain.model.AllergenType
|
||||
import com.safebite.app.domain.model.CustomDietItem
|
||||
import com.safebite.app.domain.model.CustomItemTag
|
||||
import com.safebite.app.domain.model.DietaryRestriction
|
||||
@@ -112,15 +109,14 @@ fun ProfileEditScreen(
|
||||
|
||||
item {
|
||||
Text(stringResource(R.string.profile_allergies), style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.profile_allergies_help), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(R.string.profile_allergies_3states_help), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
item { AllergenGrid(selected = ui.severe, onToggle = viewModel::toggleSevere) }
|
||||
|
||||
item {
|
||||
Text(stringResource(R.string.profile_intolerances), style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.profile_intolerances_help), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
AllergenSelectionGrid(
|
||||
selectedAllergens = ui.allergenLevels,
|
||||
onLevelChanged = viewModel::setAllergenLevel
|
||||
)
|
||||
}
|
||||
item { AllergenGrid(selected = ui.moderate, onToggle = viewModel::toggleModerate) }
|
||||
|
||||
item {
|
||||
Text(stringResource(R.string.profile_restrictions), style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.safebite.app.domain.model.CustomItemTag
|
||||
import com.safebite.app.domain.model.DietaryRestriction
|
||||
import com.safebite.app.domain.model.UserProfile
|
||||
import com.safebite.app.domain.usecase.ManageProfileUseCase
|
||||
import com.safebite.app.presentation.common.components.AllergenLevel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -22,13 +23,19 @@ data class ProfileEditUi(
|
||||
val id: Long = 0L,
|
||||
val name: String = "",
|
||||
val avatar: String = "🙂",
|
||||
val severe: Set<AllergenType> = emptySet(),
|
||||
val moderate: Set<AllergenType> = emptySet(),
|
||||
val allergenLevels: Map<AllergenType, AllergenLevel> = emptyMap(),
|
||||
val restrictions: Set<DietaryRestriction> = emptySet(),
|
||||
val customItems: List<CustomDietItem> = emptyList(),
|
||||
val isDefault: Boolean = false,
|
||||
val loaded: Boolean = false
|
||||
)
|
||||
) {
|
||||
// Propriétés calculées pour la compatibilité
|
||||
val severe: Set<AllergenType>
|
||||
get() = allergenLevels.filterValues { it == AllergenLevel.SEVERE }.keys
|
||||
|
||||
val moderate: Set<AllergenType>
|
||||
get() = allergenLevels.filterValues { it == AllergenLevel.TRACE }.keys
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class ProfileViewModel @Inject constructor(
|
||||
@@ -47,12 +54,16 @@ class ProfileViewModel @Inject constructor(
|
||||
} else {
|
||||
val p = manage.get(id)
|
||||
if (p != null) {
|
||||
// Construire la map des niveaux d'allergènes
|
||||
val allergenLevels = mutableMapOf<AllergenType, AllergenLevel>()
|
||||
p.severeAllergens.forEach { allergenLevels[it] = AllergenLevel.SEVERE }
|
||||
p.moderateIntolerances.forEach { allergenLevels[it] = AllergenLevel.TRACE }
|
||||
|
||||
_edit.value = ProfileEditUi(
|
||||
id = p.id,
|
||||
name = p.name,
|
||||
avatar = p.avatar,
|
||||
severe = p.severeAllergens,
|
||||
moderate = p.moderateIntolerances,
|
||||
allergenLevels = allergenLevels,
|
||||
restrictions = p.dietaryRestrictions,
|
||||
customItems = p.customItems,
|
||||
isDefault = p.isDefault,
|
||||
@@ -64,12 +75,38 @@ class ProfileViewModel @Inject constructor(
|
||||
|
||||
fun setName(v: String) = _edit.update { it.copy(name = v) }
|
||||
fun setAvatar(v: String) = _edit.update { it.copy(avatar = v) }
|
||||
|
||||
/** Met à jour le niveau d'un allergène (cycle : NONE → TRACE → SEVERE → NONE) */
|
||||
fun setAllergenLevel(allergen: AllergenType, level: AllergenLevel) = _edit.update { s ->
|
||||
val newLevels = if (level == AllergenLevel.NONE) {
|
||||
s.allergenLevels - allergen
|
||||
} else {
|
||||
s.allergenLevels + (allergen to level)
|
||||
}
|
||||
s.copy(allergenLevels = newLevels)
|
||||
}
|
||||
|
||||
// Méthodes de compatibilité pour l'ancien AllergenGrid
|
||||
fun toggleSevere(a: AllergenType) = _edit.update { s ->
|
||||
s.copy(severe = if (a in s.severe) s.severe - a else s.severe + a, moderate = s.moderate - a)
|
||||
val newLevel = if (a in s.severe) AllergenLevel.NONE else AllergenLevel.SEVERE
|
||||
val newLevels = if (newLevel == AllergenLevel.NONE) {
|
||||
s.allergenLevels - a
|
||||
} else {
|
||||
s.allergenLevels + (a to newLevel)
|
||||
}
|
||||
s.copy(allergenLevels = newLevels)
|
||||
}
|
||||
|
||||
fun toggleModerate(a: AllergenType) = _edit.update { s ->
|
||||
s.copy(moderate = if (a in s.moderate) s.moderate - a else s.moderate + a, severe = s.severe - a)
|
||||
val newLevel = if (a in s.moderate) AllergenLevel.NONE else AllergenLevel.TRACE
|
||||
val newLevels = if (newLevel == AllergenLevel.NONE) {
|
||||
s.allergenLevels - a
|
||||
} else {
|
||||
s.allergenLevels + (a to newLevel)
|
||||
}
|
||||
s.copy(allergenLevels = newLevels)
|
||||
}
|
||||
|
||||
fun toggleRestriction(r: DietaryRestriction) = _edit.update { s ->
|
||||
s.copy(restrictions = if (r in s.restrictions) s.restrictions - r else s.restrictions + r)
|
||||
}
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package com.safebite.app.presentation.screen.result
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.safebite.app.R
|
||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
||||
import com.safebite.app.presentation.common.components.StandardTextField
|
||||
import com.safebite.app.presentation.theme.LocalDimens
|
||||
|
||||
/**
|
||||
* Écran "Produit non reconnu" (Phase 6 - Cas 1).
|
||||
*
|
||||
* Fonctionnalités :
|
||||
* - Message expliquant que le produit n'est pas dans la base
|
||||
* - Bouton pour prendre une photo des ingrédients (OCR)
|
||||
* - Saisie manuelle du code-barres
|
||||
* - Message de confirmation après soumission
|
||||
*/
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProductNotFoundScreen(
|
||||
barcode: String,
|
||||
onBack: () -> Unit,
|
||||
onOpenOcr: () -> Unit,
|
||||
onManualSubmit: (String) -> Unit,
|
||||
onScanAgain: () -> Unit
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
var manualBarcode by remember { mutableStateOf("") }
|
||||
var productName by remember { mutableStateOf("") }
|
||||
var submitted by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
SafeBiteTopAppBar(
|
||||
title = stringResource(R.string.result_product_not_found),
|
||||
onBack = onBack,
|
||||
backContentDescription = stringResource(R.string.a11y_back)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
if (submitted) {
|
||||
// Message de confirmation
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(dimens.spacingXl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text("✅", style = MaterialTheme.typography.displayMedium)
|
||||
Spacer(Modifier.height(dimens.spacingMd))
|
||||
Text(
|
||||
text = "Merci pour votre contribution !",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(dimens.spacingSm))
|
||||
Text(
|
||||
text = "Le produit sera analysé sous 24h. Vous recevrez une notification quand le résultat sera disponible.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(dimens.spacingLg))
|
||||
PrimaryButton(
|
||||
text = "Scanner un autre produit",
|
||||
onClick = onScanAgain,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(dimens.spacingLg),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
||||
) {
|
||||
// Message principal
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(dimens.spacingMd),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("🔍", style = MaterialTheme.typography.displayMedium)
|
||||
Spacer(Modifier.height(dimens.spacingSm))
|
||||
Text(
|
||||
text = "Produit non reconnu",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(Modifier.height(dimens.spacingSm))
|
||||
Text(
|
||||
text = "Ce produit (code: $barcode) n'est pas dans notre base de données.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Option 1 : Photo des ingrédients
|
||||
Text(
|
||||
text = "Option 1 : Photographier les ingrédients",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = onOpenOcr,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics { contentDescription = "Prendre une photo des ingrédients" }
|
||||
) {
|
||||
Icon(Icons.Filled.CameraAlt, contentDescription = null)
|
||||
Spacer(Modifier.size(dimens.spacingSm))
|
||||
Text("Prendre une photo")
|
||||
}
|
||||
|
||||
// Option 2 : Saisie manuelle
|
||||
Text(
|
||||
text = "Option 2 : Saisie manuelle",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(dimens.spacingMd)) {
|
||||
StandardTextField(
|
||||
value = productName,
|
||||
onValueChange = { productName = it },
|
||||
label = "Nom du produit (optionnel)",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(dimens.spacingSm))
|
||||
StandardTextField(
|
||||
value = manualBarcode,
|
||||
onValueChange = { manualBarcode = it },
|
||||
label = "Code-barres",
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(dimens.spacingMd))
|
||||
PrimaryButton(
|
||||
text = "Soumettre pour analyse",
|
||||
onClick = {
|
||||
if (manualBarcode.isNotBlank()) {
|
||||
onManualSubmit(manualBarcode)
|
||||
submitted = true
|
||||
}
|
||||
},
|
||||
enabled = manualBarcode.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Note
|
||||
Text(
|
||||
text = "💡 Vous pouvez aussi scanner un autre produit similaire en magasin.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -69,6 +74,7 @@ import com.safebite.app.presentation.common.components.LoadingIndicator
|
||||
import com.safebite.app.presentation.common.components.OutlinedActionButton
|
||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||
import com.safebite.app.presentation.common.components.ProductCard
|
||||
import com.safebite.app.presentation.common.components.ProductSkeleton
|
||||
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
||||
import com.safebite.app.presentation.common.components.SafetyStatusBanner
|
||||
import com.safebite.app.presentation.common.util.UiState
|
||||
@@ -100,21 +106,27 @@ fun ResultScreen(
|
||||
SafeBiteTopAppBar(
|
||||
title = stringResource(R.string.app_name),
|
||||
onBack = onBack,
|
||||
backContentDescription = stringResource(R.string.action_back),
|
||||
backContentDescription = stringResource(R.string.a11y_back),
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||
when (val s = state) {
|
||||
UiState.Idle, UiState.Loading -> LoadingIndicator()
|
||||
UiState.Idle, UiState.Loading -> ProductSkeleton()
|
||||
is UiState.Error -> {
|
||||
val msg = when {
|
||||
s.offline -> stringResource(R.string.error_no_connection)
|
||||
s.message == "not_found" -> stringResource(R.string.result_product_not_found)
|
||||
else -> stringResource(R.string.error_product_unavailable)
|
||||
}
|
||||
val errorContentDesc = stringResource(R.string.a11y_error, msg)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.semantics {
|
||||
contentDescription = errorContentDesc
|
||||
},
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ErrorView(message = msg, modifier = Modifier.weight(1f))
|
||||
@@ -155,13 +167,41 @@ private fun ResultContent(
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
SafetyStatusBanner(status = result.safetyStatus)
|
||||
// Annonce TalkBack pour le verdict
|
||||
val verdictAnnouncement = when (result.safetyStatus) {
|
||||
com.safebite.app.domain.model.SafetyStatus.SAFE -> stringResource(R.string.a11y_verdict_safe)
|
||||
com.safebite.app.domain.model.SafetyStatus.WARNING -> stringResource(R.string.a11y_verdict_warning)
|
||||
com.safebite.app.domain.model.SafetyStatus.DANGER -> {
|
||||
val profile = result.analyzedProfiles.firstOrNull()?.name ?: ""
|
||||
if (profile.isNotEmpty()) {
|
||||
stringResource(R.string.a11y_verdict_danger, profile)
|
||||
} else {
|
||||
stringResource(R.string.a11y_danger_status, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "",
|
||||
modifier = Modifier
|
||||
.semantics {
|
||||
liveRegion = LiveRegionMode.Assertive
|
||||
contentDescription = verdictAnnouncement
|
||||
}
|
||||
)
|
||||
|
||||
SafetyStatusBanner(
|
||||
status = result.safetyStatus,
|
||||
profileName = result.analyzedProfiles.firstOrNull()?.name,
|
||||
allergenName = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr,
|
||||
severity = if (result.detectedAllergens.any { it.severe }) "anaphylaxis" else null
|
||||
)
|
||||
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ProductCard(
|
||||
title = result.product.name ?: result.product.barcode,
|
||||
subtitle = result.product.brand,
|
||||
imageUrl = result.product.imageUrl
|
||||
imageUrl = result.product.imageUrl,
|
||||
imageContentDescription = stringResource(R.string.a11y_product_image)
|
||||
)
|
||||
|
||||
// Open on OFF (only when we have a real barcode, not an OCR synthetic one).
|
||||
@@ -220,7 +260,10 @@ private fun ResultContent(
|
||||
IconButton(onClick = { ingredientsExpanded = !ingredientsExpanded }) {
|
||||
Icon(
|
||||
if (ingredientsExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
null
|
||||
contentDescription = if (ingredientsExpanded)
|
||||
stringResource(R.string.a11y_collapse)
|
||||
else
|
||||
stringResource(R.string.a11y_expand)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
@@ -77,11 +79,19 @@ fun ScannerScreen(
|
||||
SafeBiteTopAppBar(
|
||||
title = stringResource(R.string.scanner_title),
|
||||
onBack = onBack,
|
||||
backContentDescription = stringResource(R.string.action_back),
|
||||
backContentDescription = stringResource(R.string.a11y_back),
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||
val scanAreaDesc = stringResource(R.string.a11y_scan_area)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.semantics {
|
||||
contentDescription = scanAreaDesc
|
||||
}
|
||||
) {
|
||||
if (!permission.status.isGranted) {
|
||||
ErrorView(
|
||||
message = stringResource(R.string.scanner_camera_denied),
|
||||
@@ -161,11 +171,12 @@ private fun CameraView(onBarcode: (String) -> Unit) {
|
||||
onClick = {
|
||||
torch = !torch
|
||||
cameraControl?.enableTorch(torch)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
if (torch) Icons.Filled.FlashOn else Icons.Filled.FlashOff,
|
||||
contentDescription = stringResource(R.string.scanner_torch),
|
||||
contentDescription = stringResource(R.string.a11y_torch),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
package com.safebite.app.presentation.screen.tracking
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.safebite.app.R
|
||||
import com.safebite.app.domain.model.SafetyStatus
|
||||
import com.safebite.app.presentation.common.components.DonutChart
|
||||
import com.safebite.app.presentation.common.components.EmptyState
|
||||
import com.safebite.app.presentation.common.components.HorizontalBarChart
|
||||
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
||||
import com.safebite.app.presentation.common.components.ShimmerBox
|
||||
import com.safebite.app.presentation.common.components.Sparkline
|
||||
import com.safebite.app.presentation.common.components.StandardTextField
|
||||
import com.safebite.app.presentation.common.components.TimeFilterRow
|
||||
import com.safebite.app.presentation.common.components.statusColor
|
||||
import com.safebite.app.presentation.theme.LocalDimens
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TrackingScreen(
|
||||
onOpenHistoryItem: (String) -> Unit,
|
||||
onOpenScanner: () -> Unit,
|
||||
viewModel: TrackingViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val timeFilter by viewModel.timeFilter.collectAsStateWithLifecycle()
|
||||
val statusFilter by viewModel.statusFilter.collectAsStateWithLifecycle()
|
||||
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
SafeBiteTopAppBar(
|
||||
title = stringResource(R.string.tracking_title),
|
||||
onBack = null,
|
||||
backContentDescription = null,
|
||||
actions = {
|
||||
val clearAllDesc = stringResource(R.string.a11y_clear_all)
|
||||
IconButton(
|
||||
onClick = viewModel::clearAll,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = clearAllDesc
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.DeleteSweep,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
when (uiState) {
|
||||
is TrackingUiState.Loading -> {
|
||||
TrackingLoadingSkeleton(modifier = Modifier.padding(padding))
|
||||
}
|
||||
is TrackingUiState.Empty -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.tracking_empty_title),
|
||||
message = stringResource(R.string.tracking_empty_body),
|
||||
emoji = "📊"
|
||||
)
|
||||
}
|
||||
}
|
||||
is TrackingUiState.Success -> {
|
||||
val success = uiState as TrackingUiState.Success
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = dimens.spacingLg),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
||||
) {
|
||||
// Filtres temporels
|
||||
item {
|
||||
TimeFilterRow(
|
||||
selected = success.timeFilter,
|
||||
onFilterChanged = viewModel::setTimeFilter
|
||||
)
|
||||
}
|
||||
|
||||
// Section statistiques principales
|
||||
item {
|
||||
StatsSection(
|
||||
stats = success.stats,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// Graphique sparkline
|
||||
item {
|
||||
EvolutionChart(
|
||||
data = success.stats.sparklineData,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// Graphique barres
|
||||
item {
|
||||
VerdictDistribution(
|
||||
data = success.stats.barChartData,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// Top allergènes
|
||||
if (success.stats.topAllergens.isNotEmpty()) {
|
||||
item {
|
||||
TopAllergensSection(
|
||||
allergens = success.stats.topAllergens,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Filtres par statut
|
||||
item {
|
||||
StatusFilterRow(
|
||||
selected = success.statusFilter,
|
||||
onFilterChanged = viewModel::setStatusFilter
|
||||
)
|
||||
}
|
||||
|
||||
// Recherche
|
||||
item {
|
||||
StandardTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = viewModel::setSearchQuery,
|
||||
placeholder = "Rechercher un produit...",
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.a11y_search)) }
|
||||
)
|
||||
}
|
||||
|
||||
// Historique récent
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.tracking_recent_scans),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
if (success.historyItems.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.tracking_no_results),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = dimens.spacingXl)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(success.historyItems, key = { it.id }) { item ->
|
||||
HistoryItemCard(
|
||||
item = item,
|
||||
onClick = { onOpenHistoryItem(item.barcode) },
|
||||
onDelete = { viewModel.deleteItem(item.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Section statistiques principales avec donut et cartes.
|
||||
*/
|
||||
@Composable
|
||||
fun StatsSection(
|
||||
stats: TrackingStats,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.tracking_stats_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(dimens.spacingMd))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
// Donut chart
|
||||
DonutChart(
|
||||
progress = stats.safePercentage,
|
||||
size = 120.dp,
|
||||
strokeWidth = 12.dp,
|
||||
centerText = "${(stats.safePercentage * 100).toInt()}%",
|
||||
centerSubText = stringResource(R.string.tracking_safe_rate)
|
||||
)
|
||||
|
||||
// Stats cards
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingSm)
|
||||
) {
|
||||
StatCardMini(
|
||||
icon = "📊",
|
||||
value = "${stats.totalScans}",
|
||||
label = stringResource(R.string.tracking_total_scans)
|
||||
)
|
||||
StatCardMini(
|
||||
icon = "✅",
|
||||
value = "${stats.safeCount}",
|
||||
label = "Sûrs"
|
||||
)
|
||||
StatCardMini(
|
||||
icon = "⚠️",
|
||||
value = "${stats.warningCount}",
|
||||
label = "Attention"
|
||||
)
|
||||
StatCardMini(
|
||||
icon = "❌",
|
||||
value = "${stats.dangerCount}",
|
||||
label = "Danger"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatCardMini(
|
||||
icon: String,
|
||||
value: String,
|
||||
label: String
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(text = icon, style = MaterialTheme.typography.bodyLarge)
|
||||
Column {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Graphique d'évolution des scans.
|
||||
*/
|
||||
@Composable
|
||||
fun EvolutionChart(
|
||||
data: com.safebite.app.presentation.common.components.SparklineData,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
if (data.values.isEmpty() || data.values.all { it == 0f }) return
|
||||
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.tracking_evolution),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
||||
Sparkline(
|
||||
data = data,
|
||||
height = 100.dp,
|
||||
lineColor = MaterialTheme.colorScheme.primary,
|
||||
fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribution des verdicts.
|
||||
*/
|
||||
@Composable
|
||||
fun VerdictDistribution(
|
||||
data: com.safebite.app.presentation.common.components.BarChartData,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.tracking_distribution),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
||||
HorizontalBarChart(data = data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Section top allergènes.
|
||||
*/
|
||||
@Composable
|
||||
fun TopAllergensSection(
|
||||
allergens: List<AllergenCount>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.tracking_top_allergens),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(dimens.spacingSm))
|
||||
allergens.forEach { allergen ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "${allergen.emoji} ${allergen.name}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "${allergen.count}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtres par statut.
|
||||
*/
|
||||
@Composable
|
||||
fun StatusFilterRow(
|
||||
selected: SafetyStatus?,
|
||||
onFilterChanged: (SafetyStatus?) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(LocalDimens.current.spacingSm)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selected == null,
|
||||
onClick = { onFilterChanged(null) },
|
||||
label = { Text("Tous") },
|
||||
modifier = Modifier.semantics { contentDescription = "Filtrer par tous les produits" }
|
||||
)
|
||||
FilterChip(
|
||||
selected = selected == SafetyStatus.DANGER,
|
||||
onClick = { onFilterChanged(SafetyStatus.DANGER) },
|
||||
label = { Text("❌ Danger") },
|
||||
modifier = Modifier.semantics { contentDescription = "Filtrer par produits dangereux" }
|
||||
)
|
||||
FilterChip(
|
||||
selected = selected == SafetyStatus.WARNING,
|
||||
onClick = { onFilterChanged(SafetyStatus.WARNING) },
|
||||
label = { Text("⚠️ Attention") },
|
||||
modifier = Modifier.semantics { contentDescription = "Filtrer par produits avec attention" }
|
||||
)
|
||||
FilterChip(
|
||||
selected = selected == SafetyStatus.SAFE,
|
||||
onClick = { onFilterChanged(SafetyStatus.SAFE) },
|
||||
label = { Text("✅ Sûr") },
|
||||
modifier = Modifier.semantics { contentDescription = "Filtrer par produits sûrs" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte pour un item d'historique.
|
||||
*/
|
||||
@Composable
|
||||
fun HistoryItemCard(
|
||||
item: com.safebite.app.domain.model.ScanHistoryItem,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = dimens.elevationSm)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimens.spacingMd),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
||||
) {
|
||||
// Indicateur de couleur
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.background(statusColor(item.safetyStatus), CircleShape)
|
||||
)
|
||||
|
||||
// Infos produit
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.productName ?: item.barcode,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(
|
||||
text = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
|
||||
.format(Date(item.scannedAt)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (item.profileNames.isNotEmpty()) {
|
||||
Text(
|
||||
text = item.profileNames.joinToString(", "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bouton supprimer
|
||||
val deleteDesc = stringResource(R.string.a11y_delete, item.productName ?: item.barcode)
|
||||
IconButton(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = deleteDesc
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton de chargement pour l'écran Tracking.
|
||||
*/
|
||||
@Composable
|
||||
fun TrackingLoadingSkeleton(modifier: Modifier = Modifier) {
|
||||
val dimens = LocalDimens.current
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.padding(horizontal = dimens.spacingLg),
|
||||
verticalArrangement = Arrangement.spacedBy(dimens.spacingMd)
|
||||
) {
|
||||
item {
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
)
|
||||
}
|
||||
item {
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
)
|
||||
}
|
||||
item {
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
)
|
||||
}
|
||||
item {
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package com.safebite.app.presentation.screen.tracking
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.safebite.app.domain.model.SafetyStatus
|
||||
import com.safebite.app.domain.model.ScanHistoryItem
|
||||
import com.safebite.app.domain.usecase.GetScanHistoryUseCase
|
||||
import com.safebite.app.presentation.common.components.BarChartData
|
||||
import com.safebite.app.presentation.common.components.BarChartItem
|
||||
import com.safebite.app.presentation.common.components.SparklineData
|
||||
import com.safebite.app.presentation.common.components.TimeFilter
|
||||
import com.safebite.app.presentation.theme.SemanticColors
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Calendar
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Données statistiques pour l'écran de suivi.
|
||||
*/
|
||||
data class TrackingStats(
|
||||
val totalScans: Int = 0,
|
||||
val safeCount: Int = 0,
|
||||
val warningCount: Int = 0,
|
||||
val dangerCount: Int = 0,
|
||||
val safePercentage: Float = 0f,
|
||||
val topAllergens: List<AllergenCount> = emptyList(),
|
||||
val weeklyScans: Int = 0,
|
||||
val weeklySafePercentage: Float = 0f,
|
||||
val sparklineData: SparklineData = SparklineData(emptyList()),
|
||||
val barChartData: BarChartData = BarChartData(emptyList())
|
||||
)
|
||||
|
||||
/**
|
||||
* Comptage d'un allergène détecté.
|
||||
*/
|
||||
data class AllergenCount(
|
||||
val name: String,
|
||||
val count: Int,
|
||||
val emoji: String = "⚠️"
|
||||
)
|
||||
|
||||
/**
|
||||
* State UI pour l'écran Tracking.
|
||||
*/
|
||||
sealed class TrackingUiState {
|
||||
data object Loading : TrackingUiState()
|
||||
data class Success(
|
||||
val stats: TrackingStats,
|
||||
val historyItems: List<ScanHistoryItem>,
|
||||
val timeFilter: TimeFilter,
|
||||
val statusFilter: SafetyStatus? = null,
|
||||
val searchQuery: String = ""
|
||||
) : TrackingUiState()
|
||||
data object Empty : TrackingUiState()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class TrackingViewModel @Inject constructor(
|
||||
private val getScanHistoryUseCase: GetScanHistoryUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
private val _timeFilter = MutableStateFlow(TimeFilter.WEEK)
|
||||
val timeFilter: StateFlow<TimeFilter> = _timeFilter.asStateFlow()
|
||||
|
||||
private val _statusFilter = MutableStateFlow<SafetyStatus?>(null)
|
||||
val statusFilter: StateFlow<SafetyStatus?> = _statusFilter.asStateFlow()
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
val uiState: StateFlow<TrackingUiState> = combine(
|
||||
getScanHistoryUseCase.observe(),
|
||||
_timeFilter,
|
||||
_statusFilter,
|
||||
_searchQuery
|
||||
) { items, timeFilter, statusFilter, query ->
|
||||
val filteredItems = items
|
||||
.filterByTime(timeFilter)
|
||||
.filter { statusFilter == null || it.safetyStatus == statusFilter }
|
||||
.filter { query.isBlank() || matchesSearch(it, query) }
|
||||
|
||||
if (items.isEmpty()) {
|
||||
TrackingUiState.Empty
|
||||
} else {
|
||||
val stats = computeStats(items, timeFilter)
|
||||
TrackingUiState.Success(
|
||||
stats = stats,
|
||||
historyItems = filteredItems,
|
||||
timeFilter = timeFilter,
|
||||
statusFilter = statusFilter,
|
||||
searchQuery = query
|
||||
)
|
||||
}
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
TrackingUiState.Loading
|
||||
)
|
||||
|
||||
fun setTimeFilter(filter: TimeFilter) {
|
||||
_timeFilter.value = filter
|
||||
}
|
||||
|
||||
fun setStatusFilter(status: SafetyStatus?) {
|
||||
_statusFilter.value = status
|
||||
}
|
||||
|
||||
fun setSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun deleteItem(id: Long) = viewModelScope.launch {
|
||||
getScanHistoryUseCase.delete(id)
|
||||
}
|
||||
|
||||
fun clearAll() = viewModelScope.launch {
|
||||
getScanHistoryUseCase.clear()
|
||||
}
|
||||
|
||||
private fun List<ScanHistoryItem>.filterByTime(filter: TimeFilter): List<ScanHistoryItem> {
|
||||
val calendar = Calendar.getInstance()
|
||||
val cutoffTime = when (filter) {
|
||||
TimeFilter.WEEK -> {
|
||||
calendar.add(Calendar.DAY_OF_YEAR, -7)
|
||||
calendar.timeInMillis
|
||||
}
|
||||
TimeFilter.MONTH -> {
|
||||
calendar.add(Calendar.MONTH, -1)
|
||||
calendar.timeInMillis
|
||||
}
|
||||
TimeFilter.YEAR -> {
|
||||
calendar.add(Calendar.YEAR, -1)
|
||||
calendar.timeInMillis
|
||||
}
|
||||
TimeFilter.ALL -> 0L
|
||||
}
|
||||
return this.filter { it.scannedAt >= cutoffTime }
|
||||
}
|
||||
|
||||
private fun matchesSearch(item: ScanHistoryItem, query: String): Boolean {
|
||||
return item.productName?.contains(query, ignoreCase = true) == true ||
|
||||
item.brand?.contains(query, ignoreCase = true) == true ||
|
||||
item.barcode.contains(query)
|
||||
}
|
||||
|
||||
private fun computeStats(allItems: List<ScanHistoryItem>, timeFilter: TimeFilter): TrackingStats {
|
||||
val items = allItems.filterByTime(timeFilter)
|
||||
val total = items.size
|
||||
val safeCount = items.count { it.safetyStatus == SafetyStatus.SAFE }
|
||||
val warningCount = items.count { it.safetyStatus == SafetyStatus.WARNING }
|
||||
val dangerCount = items.count { it.safetyStatus == SafetyStatus.DANGER }
|
||||
val safePercentage = if (total > 0) safeCount.toFloat() / total else 0f
|
||||
|
||||
// Calcul des allergènes top (simulé à partir des noms de produits)
|
||||
val topAllergens = computeTopAllergens(items)
|
||||
|
||||
// Données sparkline (scans par jour sur la période)
|
||||
val sparklineData = computeSparklineData(items, timeFilter)
|
||||
|
||||
// Données bar chart (répartition par statut)
|
||||
val barChartData = BarChartData(
|
||||
items = listOf(
|
||||
BarChartItem("Sûr", safeCount, SemanticColors.Safe),
|
||||
BarChartItem("Attention", warningCount, SemanticColors.Warning),
|
||||
BarChartItem("Danger", dangerCount, SemanticColors.Danger)
|
||||
)
|
||||
)
|
||||
|
||||
return TrackingStats(
|
||||
totalScans = total,
|
||||
safeCount = safeCount,
|
||||
warningCount = warningCount,
|
||||
dangerCount = dangerCount,
|
||||
safePercentage = safePercentage,
|
||||
topAllergens = topAllergens,
|
||||
weeklyScans = items.size,
|
||||
weeklySafePercentage = safePercentage,
|
||||
sparklineData = sparklineData,
|
||||
barChartData = barChartData
|
||||
)
|
||||
}
|
||||
|
||||
private fun computeTopAllergens(items: List<ScanHistoryItem>): List<AllergenCount> {
|
||||
// Simulation : on compte les profils associés aux scans danger/warning
|
||||
val allergenCounts = mutableMapOf<String, Int>()
|
||||
items.filter { it.safetyStatus != SafetyStatus.SAFE }.forEach { item ->
|
||||
item.profileNames.forEach { profileName ->
|
||||
allergenCounts[profileName] = allergenCounts.getOrDefault(profileName, 0) + 1
|
||||
}
|
||||
}
|
||||
return allergenCounts.entries
|
||||
.sortedByDescending { it.value }
|
||||
.take(5)
|
||||
.map { AllergenCount(it.key, it.value) }
|
||||
}
|
||||
|
||||
private fun computeSparklineData(items: List<ScanHistoryItem>, timeFilter: TimeFilter): SparklineData {
|
||||
val calendar = Calendar.getInstance()
|
||||
val days = when (timeFilter) {
|
||||
TimeFilter.WEEK -> 7
|
||||
TimeFilter.MONTH -> 30
|
||||
TimeFilter.YEAR -> 12
|
||||
TimeFilter.ALL -> {
|
||||
val oldest = items.minOfOrNull { it.scannedAt } ?: 0L
|
||||
val daysDiff = ((System.currentTimeMillis() - oldest) / (1000 * 60 * 60 * 24)).toInt()
|
||||
daysDiff.coerceAtMost(365)
|
||||
}
|
||||
}
|
||||
|
||||
val labels = mutableListOf<String>()
|
||||
val values = mutableListOf<Float>()
|
||||
|
||||
if (timeFilter == TimeFilter.YEAR) {
|
||||
// Par mois
|
||||
for (i in 11 downTo 0) {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MONTH, -i)
|
||||
val monthStart = cal.timeInMillis
|
||||
cal.add(Calendar.MONTH, 1)
|
||||
val monthEnd = cal.timeInMillis
|
||||
val count = items.count { it.scannedAt in monthStart..<monthEnd }
|
||||
values.add(count.toFloat())
|
||||
labels.add(cal.getDisplayName(Calendar.MONTH, Calendar.SHORT, java.util.Locale.getDefault()))
|
||||
}
|
||||
} else {
|
||||
// Par jour
|
||||
for (i in days - 1 downTo 0) {
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.DAY_OF_YEAR, -i)
|
||||
val dayStart = cal.timeInMillis
|
||||
cal.set(Calendar.HOUR_OF_DAY, 23)
|
||||
cal.set(Calendar.MINUTE, 59)
|
||||
cal.set(Calendar.SECOND, 59)
|
||||
val dayEnd = cal.timeInMillis
|
||||
val count = items.count { it.scannedAt in dayStart..dayEnd }
|
||||
values.add(count.toFloat())
|
||||
if (timeFilter == TimeFilter.WEEK || i % 7 == 0) {
|
||||
labels.add(cal.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT, java.util.Locale.getDefault()))
|
||||
} else {
|
||||
labels.add("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SparklineData(values, labels)
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,47 @@ import androidx.compose.ui.graphics.Color
|
||||
|
||||
// =============================================================================
|
||||
// SafeBite palette — Material 3 design tokens (Light + Dark)
|
||||
// Les couleurs 'status' (Safe / Warning / Danger) restent hors M3 et sont
|
||||
// utilisées par les badges et bannières de sécurité.
|
||||
// Aligné sur la spec UX flux-UX.md §2.1 (feu tricolore)
|
||||
// =============================================================================
|
||||
|
||||
// ---- Brand anchors --------------------------------------------------------
|
||||
// ---- COULEURS SÉMANTIQUES (Feu tricolore — spec UX §2.1) -------------------
|
||||
// Ces couleurs sont indépendantes du thème M3 pour cohérence marque.
|
||||
object SemanticColors {
|
||||
// Light mode
|
||||
val Safe = Color(0xFF2ECC71) // Vert sécurité
|
||||
val SafeContainer = Color(0xFFE8F8F5) // Fond très clair
|
||||
val OnSafe = Color(0xFFFFFFFF)
|
||||
val OnSafeContainer = Color(0xFF1A3A2A)
|
||||
|
||||
val Warning = Color(0xFFE67E22) // Orange attention
|
||||
val WarningContainer = Color(0xFFFEF5E7)
|
||||
val OnWarning = Color(0xFFFFFFFF)
|
||||
val OnWarningContainer = Color(0xFF3A2A1A)
|
||||
|
||||
val Danger = Color(0xFFE74C3C) // Rouge danger
|
||||
val DangerContainer = Color(0xFFFDEDEC)
|
||||
val OnDanger = Color(0xFFFFFFFF)
|
||||
val OnDangerContainer = Color(0xFF3A1A1A)
|
||||
|
||||
// Dark mode (mêmes couleurs sémantiques, containers adaptés)
|
||||
val SafeDark = Color(0xFF2ECC71)
|
||||
val SafeContainerDark = Color(0xFF1A3A2A)
|
||||
val WarningDark = Color(0xFFE67E22)
|
||||
val WarningContainerDark = Color(0xFF3A2A1A)
|
||||
val DangerDark = Color(0xFFE74C3C)
|
||||
val DangerContainerDark = Color(0xFF3A1A1A)
|
||||
}
|
||||
|
||||
// ---- NEUTRES (spec UX §2.1) ------------------------------------------------
|
||||
object NeutralColors {
|
||||
val Background = Color(0xFFF5F5F0) // Gris chaud (réduit fatigue oculaire)
|
||||
val Surface = Color(0xFFFFFFFF) // Blanc pur pour cartes
|
||||
val TextPrimary = Color(0xFF2D3436) // Noir doux (pas #000)
|
||||
val TextSecondary = Color(0xFF636E72) // Gris moyen
|
||||
val Separator = Color(0xFFDFE6E9) // Gris clair
|
||||
}
|
||||
|
||||
// ---- Brand anchors (Material 3) --------------------------------------------
|
||||
val BrandIndigo = Color(0xFF1A237E)
|
||||
val BrandIndigoLight = Color(0xFFBAC3FF)
|
||||
val BrandTeal = Color(0xFF00897B)
|
||||
@@ -35,15 +71,15 @@ val LightOnError = Color(0xFFFFFFFF)
|
||||
val LightErrorContainer = Color(0xFFFFDAD6)
|
||||
val LightOnErrorContainer = Color(0xFF410002)
|
||||
|
||||
val LightBackground = Color(0xFFFAFAFA)
|
||||
val LightOnBackground = Color(0xFF1A1A1A)
|
||||
val LightSurface = Color(0xFFFFFFFF)
|
||||
val LightOnSurface = Color(0xFF1A1A1A)
|
||||
val LightBackground = NeutralColors.Background // #F5F5F0
|
||||
val LightOnBackground = NeutralColors.TextPrimary // #2D3436
|
||||
val LightSurface = NeutralColors.Surface // #FFFFFF
|
||||
val LightOnSurface = NeutralColors.TextPrimary // #2D3436
|
||||
val LightSurfaceVariant = Color(0xFFE3E2EC)
|
||||
val LightOnSurfaceVariant = Color(0xFF46464F)
|
||||
val LightOnSurfaceVariant = NeutralColors.TextSecondary
|
||||
val LightSurfaceTint = LightPrimary
|
||||
|
||||
val LightOutline = Color(0xFF767680)
|
||||
val LightOutline = NeutralColors.Separator
|
||||
val LightOutlineVariant = Color(0xFFC7C6D0)
|
||||
|
||||
val LightInverseSurface = Color(0xFF2F3033)
|
||||
@@ -75,7 +111,7 @@ val DarkOnErrorContainer = Color(0xFFFFDAD6)
|
||||
|
||||
val DarkBackground = Color(0xFF121212)
|
||||
val DarkOnBackground = Color(0xFFE6E1E5)
|
||||
val DarkSurface = Color(0xFF121212)
|
||||
val DarkSurface = Color(0xFF1E1E1E)
|
||||
val DarkOnSurface = Color(0xFFE6E1E5)
|
||||
val DarkSurfaceVariant = Color(0xFF46464F)
|
||||
val DarkOnSurfaceVariant = Color(0xFFC7C6D0)
|
||||
@@ -90,22 +126,37 @@ val DarkInversePrimary = Color(0xFF1A237E)
|
||||
|
||||
val DarkScrim = Color(0xFF000000)
|
||||
|
||||
// ---- Status / safety (food domain, hors M3) -------------------------------
|
||||
val StatusSafe = Color(0xFF2E7D32)
|
||||
val StatusSafeContainer = Color(0xFFC8E6C9)
|
||||
val OnStatusSafe = Color(0xFFFFFFFF)
|
||||
// ---- Legacy aliases (backward compat pour code existant) -------------------
|
||||
@Deprecated("Use SemanticColors.Safe", ReplaceWith("SemanticColors.Safe"))
|
||||
val StatusSafe get() = SemanticColors.Safe
|
||||
@Deprecated("Use SemanticColors.SafeContainer", ReplaceWith("SemanticColors.SafeContainer"))
|
||||
val StatusSafeContainer get() = SemanticColors.SafeContainer
|
||||
@Deprecated("Use SemanticColors.OnSafe", ReplaceWith("SemanticColors.OnSafe"))
|
||||
val OnStatusSafe get() = SemanticColors.OnSafe
|
||||
|
||||
val StatusWarning = Color(0xFFF57C00)
|
||||
val StatusWarningContainer = Color(0xFFFFE0B2)
|
||||
val OnStatusWarning = Color(0xFFFFFFFF)
|
||||
@Deprecated("Use SemanticColors.Warning", ReplaceWith("SemanticColors.Warning"))
|
||||
val StatusWarning get() = SemanticColors.Warning
|
||||
@Deprecated("Use SemanticColors.WarningContainer", ReplaceWith("SemanticColors.WarningContainer"))
|
||||
val StatusWarningContainer get() = SemanticColors.WarningContainer
|
||||
@Deprecated("Use SemanticColors.OnWarning", ReplaceWith("SemanticColors.OnWarning"))
|
||||
val OnStatusWarning get() = SemanticColors.OnWarning
|
||||
|
||||
val StatusDanger = Color(0xFFC62828)
|
||||
val StatusDangerContainer = Color(0xFFFFCDD2)
|
||||
val OnStatusDanger = Color(0xFFFFFFFF)
|
||||
@Deprecated("Use SemanticColors.Danger", ReplaceWith("SemanticColors.Danger"))
|
||||
val StatusDanger get() = SemanticColors.Danger
|
||||
@Deprecated("Use SemanticColors.DangerContainer", ReplaceWith("SemanticColors.DangerContainer"))
|
||||
val StatusDangerContainer get() = SemanticColors.DangerContainer
|
||||
@Deprecated("Use SemanticColors.OnDanger", ReplaceWith("SemanticColors.OnDanger"))
|
||||
val OnStatusDanger get() = SemanticColors.OnDanger
|
||||
|
||||
val StatusSafeDark = Color(0xFF81C784)
|
||||
val StatusSafeContainerDark = Color(0xFF1B5E20)
|
||||
val StatusWarningDark = Color(0xFFFFB74D)
|
||||
val StatusWarningContainerDark = Color(0xFF8A4B00)
|
||||
val StatusDangerDark = Color(0xFFEF9A9A)
|
||||
val StatusDangerContainerDark = Color(0xFF7F1D1D)
|
||||
@Deprecated("Use SemanticColors.SafeDark", ReplaceWith("SemanticColors.SafeDark"))
|
||||
val StatusSafeDark get() = SemanticColors.SafeDark
|
||||
@Deprecated("Use SemanticColors.SafeContainerDark", ReplaceWith("SemanticColors.SafeContainerDark"))
|
||||
val StatusSafeContainerDark get() = SemanticColors.SafeContainerDark
|
||||
@Deprecated("Use SemanticColors.WarningDark", ReplaceWith("SemanticColors.WarningDark"))
|
||||
val StatusWarningDark get() = SemanticColors.WarningDark
|
||||
@Deprecated("Use SemanticColors.WarningContainerDark", ReplaceWith("SemanticColors.WarningContainerDark"))
|
||||
val StatusWarningContainerDark get() = SemanticColors.WarningContainerDark
|
||||
@Deprecated("Use SemanticColors.DangerDark", ReplaceWith("SemanticColors.DangerDark"))
|
||||
val StatusDangerDark get() = SemanticColors.DangerDark
|
||||
@Deprecated("Use SemanticColors.DangerContainerDark", ReplaceWith("SemanticColors.DangerContainerDark"))
|
||||
val StatusDangerContainerDark get() = SemanticColors.DangerContainerDark
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.Color
|
||||
/**
|
||||
* Palette de statuts de sécurité alimentaire (safe / warning / danger), adaptée
|
||||
* au thème courant. Injectée dans la hiérarchie via [LocalStatusColors].
|
||||
* Aligné sur la spec UX flux-UX.md §2.1 (feu tricolore).
|
||||
*/
|
||||
@Immutable
|
||||
data class StatusColors(
|
||||
@@ -27,32 +28,32 @@ data class StatusColors(
|
||||
)
|
||||
|
||||
val LightStatusColors = StatusColors(
|
||||
safe = StatusSafe,
|
||||
onSafe = OnStatusSafe,
|
||||
safeContainer = StatusSafeContainer,
|
||||
onSafeContainer = Color(0xFF0F3A13),
|
||||
warning = StatusWarning,
|
||||
onWarning = OnStatusWarning,
|
||||
warningContainer = StatusWarningContainer,
|
||||
onWarningContainer = Color(0xFF4A2800),
|
||||
danger = StatusDanger,
|
||||
onDanger = OnStatusDanger,
|
||||
dangerContainer = StatusDangerContainer,
|
||||
onDangerContainer = Color(0xFF5C0B0B),
|
||||
safe = SemanticColors.Safe,
|
||||
onSafe = SemanticColors.OnSafe,
|
||||
safeContainer = SemanticColors.SafeContainer,
|
||||
onSafeContainer = SemanticColors.OnSafeContainer,
|
||||
warning = SemanticColors.Warning,
|
||||
onWarning = SemanticColors.OnWarning,
|
||||
warningContainer = SemanticColors.WarningContainer,
|
||||
onWarningContainer = SemanticColors.OnWarningContainer,
|
||||
danger = SemanticColors.Danger,
|
||||
onDanger = SemanticColors.OnDanger,
|
||||
dangerContainer = SemanticColors.DangerContainer,
|
||||
onDangerContainer = SemanticColors.OnDangerContainer,
|
||||
)
|
||||
|
||||
val DarkStatusColors = StatusColors(
|
||||
safe = StatusSafeDark,
|
||||
safe = SemanticColors.SafeDark,
|
||||
onSafe = Color(0xFF0F3A13),
|
||||
safeContainer = StatusSafeContainerDark,
|
||||
safeContainer = SemanticColors.SafeContainerDark,
|
||||
onSafeContainer = Color(0xFFC8E6C9),
|
||||
warning = StatusWarningDark,
|
||||
warning = SemanticColors.WarningDark,
|
||||
onWarning = Color(0xFF4A2800),
|
||||
warningContainer = StatusWarningContainerDark,
|
||||
warningContainer = SemanticColors.WarningContainerDark,
|
||||
onWarningContainer = Color(0xFFFFE0B2),
|
||||
danger = StatusDangerDark,
|
||||
danger = SemanticColors.DangerDark,
|
||||
onDanger = Color(0xFF5C0B0B),
|
||||
dangerContainer = StatusDangerContainerDark,
|
||||
dangerContainer = SemanticColors.DangerContainerDark,
|
||||
onDangerContainer = Color(0xFFFFCDD2),
|
||||
)
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
<string name="onboarding_how_step3">3. Obtenez un verdict instantané</string>
|
||||
<string name="onboarding_profile_title">Créez votre premier profil</string>
|
||||
<string name="onboarding_permission_title">Autorisation caméra</string>
|
||||
<string name="onboarding_permission_body">SafeBite a besoin de la caméra pour scanner les codes-barres et lire les étiquettes. Aucune image n\'est envoyée sur Internet.</string>
|
||||
<string name="onboarding_permission_body">SafeBite a besoin de la caméra pour scanner les
|
||||
codes-barres et lire les étiquettes. Aucune image n\'est envoyée sur Internet.</string>
|
||||
<string name="onboarding_permission_grant">Autoriser la caméra</string>
|
||||
<string name="onboarding_ready_title">Vous êtes prêt !</string>
|
||||
<string name="onboarding_ready_body">Scannez votre premier produit dès maintenant.</string>
|
||||
@@ -46,6 +47,59 @@
|
||||
<string name="nav_profiles">Profils</string>
|
||||
<string name="nav_settings">Paramètres</string>
|
||||
|
||||
<!-- Main / Navigation -->
|
||||
<string name="fab_scan">Scanner un produit</string>
|
||||
<string name="nav_dashboard">Accueil</string>
|
||||
<string name="nav_lists">Listes</string>
|
||||
<string name="nav_tracking">Suivi</string>
|
||||
<string name="nav_family">Famille</string>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<string name="dashboard_greeting">Bonjour, %1$s</string>
|
||||
<string name="dashboard_scan_button">Scanner un produit</string>
|
||||
<string name="dashboard_lists_button">Mes listes</string>
|
||||
<string name="dashboard_weekly_title">Cette semaine</string>
|
||||
<string name="dashboard_recent_scans">Derniers scans</string>
|
||||
<string name="dashboard_no_scans">Aucun scan récent</string>
|
||||
<string name="dashboard_first_time_title">Prêt à commencer !</string>
|
||||
<string name="dashboard_first_time_body">Scannez votre premier produit</string>
|
||||
<string name="dashboard_first_time_cta">Commencer</string>
|
||||
<string name="dashboard_store_mode_title">Vous êtes en magasin ?</string>
|
||||
<string name="dashboard_current_list">Votre liste en cours</string>
|
||||
<string name="dashboard_remaining">%1$d produits restants</string>
|
||||
|
||||
<!-- Lists -->
|
||||
<string name="lists_title">Mes listes</string>
|
||||
<string name="lists_new">Nouvelle liste</string>
|
||||
<string name="lists_empty">Aucune liste</string>
|
||||
<string name="lists_products_count">%1$d produits</string>
|
||||
<string name="lists_bought_count">%1$d achetés</string>
|
||||
|
||||
<!-- Tracking -->
|
||||
<string name="tracking_title">Suivi</string>
|
||||
<string name="tracking_empty_title">Aucune statistique</string>
|
||||
<string name="tracking_empty_body">Scannez vos premiers produits pour voir vos statistiques ici.</string>
|
||||
<string name="tracking_weekly_ok">produits OK cette semaine</string>
|
||||
<string name="tracking_top_allergens">Allergènes détectés</string>
|
||||
<string name="tracking_stats_title">Statistiques</string>
|
||||
<string name="tracking_total_scans">Total scans</string>
|
||||
<string name="tracking_safe_rate">Produits sûrs</string>
|
||||
<string name="tracking_evolution">Évolution des scans</string>
|
||||
<string name="tracking_distribution">Répartition par verdict</string>
|
||||
<string name="tracking_recent_scans">Scans récents</string>
|
||||
<string name="tracking_no_results">Aucun résultat</string>
|
||||
<string name="tracking_clear_all">Tout effacer</string>
|
||||
|
||||
<!-- Family -->
|
||||
<string name="family_title">Ma famille</string>
|
||||
<string name="family_add_member">Ajouter un membre</string>
|
||||
<string name="family_activate_profile">Activer pour les scans</string>
|
||||
<string name="family_deactivate_profile">Désactiver</string>
|
||||
<string name="family_delete_confirm">Supprimer ce profil ?</string>
|
||||
<string name="family_set_default">Définir par défaut</string>
|
||||
<string name="family_no_profiles">Aucun profil configuré</string>
|
||||
<string name="family_no_profiles_body">Créez un profil pour commencer</string>
|
||||
|
||||
<!-- Scanner -->
|
||||
<string name="scanner_title">Scanner un code-barres</string>
|
||||
<string name="scanner_hint">Placez le code-barres dans le cadre</string>
|
||||
@@ -72,7 +126,9 @@
|
||||
<string name="result_level_confirmed">Confirmé</string>
|
||||
<string name="result_level_trace">Traces</string>
|
||||
<string name="result_level_suspected">Suspecté</string>
|
||||
<string name="result_disclaimer">⚠️ Cette application est un outil d\'aide. Elle ne remplace pas la lecture attentive de l\'étiquette. Les données peuvent être incomplètes ou inexactes. En cas de doute, ne consommez pas le produit. En cas de réaction allergique, appelez le 911.</string>
|
||||
<string name="result_disclaimer">⚠️ Cette application est un outil d\'aide. Elle ne remplace pas
|
||||
la lecture attentive de l\'étiquette. Les données peuvent être incomplètes ou inexactes. En
|
||||
cas de doute, ne consommez pas le produit. En cas de réaction allergique, appelez le 911.</string>
|
||||
<string name="result_product_not_found">Produit introuvable dans Open Food Facts</string>
|
||||
<string name="result_try_ocr">Voulez-vous prendre une photo des ingrédients ?</string>
|
||||
|
||||
@@ -90,10 +146,13 @@
|
||||
<string name="profile_edit_title">Modifier le profil</string>
|
||||
<string name="profile_name">Nom du profil</string>
|
||||
<string name="profile_avatar">Avatar</string>
|
||||
<string name="profile_allergies">Allergies (sévères)</string>
|
||||
<string name="profile_allergies_help">Déclenchent un DANGER</string>
|
||||
<string name="profile_intolerances">Intolérances (modérées)</string>
|
||||
<string name="profile_intolerances_help">Déclenchent un AVERTISSEMENT</string>
|
||||
<string name="profile_allergies">Allergies et intolérances</string>
|
||||
<string name="profile_allergies_help">Tapez pour changer le niveau : Aucun → Traces ⚠️ → Sévère
|
||||
❌</string>
|
||||
<string name="profile_allergies_3states_help">Tapez pour changer : Aucun → Traces ⚠️ → Sévère ❌</string>
|
||||
<string name="profile_intolerances">Intolérances modérées</string>
|
||||
<string name="profile_intolerances_help">Tapez pour changer le niveau : Aucun → Traces ⚠️ →
|
||||
Sévère ❌</string>
|
||||
<string name="profile_restrictions">Restrictions alimentaires</string>
|
||||
<string name="profile_restriction_vegan">Végane</string>
|
||||
<string name="profile_restriction_vegetarian">Végétarien</string>
|
||||
@@ -142,7 +201,8 @@
|
||||
|
||||
<!-- Custom diet items -->
|
||||
<string name="profile_custom_items">Éléments personnalisés</string>
|
||||
<string name="profile_custom_items_help">Ajoutez vos propres ingrédients à surveiller et attribuez-leur un tag.</string>
|
||||
<string name="profile_custom_items_help">Ajoutez vos propres ingrédients à surveiller et
|
||||
attribuez-leur un tag.</string>
|
||||
<string name="profile_custom_add">Ajouter un élément</string>
|
||||
<string name="profile_custom_name">Nom (ex. huile de palme)</string>
|
||||
<string name="profile_custom_tag">Tag</string>
|
||||
@@ -172,7 +232,8 @@
|
||||
<string name="result_nutriscore">Nutri-Score</string>
|
||||
<string name="result_nutriscore_details">Qualité nutritionnelle (A = meilleure, E = à éviter).</string>
|
||||
<string name="result_nova">NOVA</string>
|
||||
<string name="result_nova_details">Degré de transformation (1 = non transformé, 4 = ultra-transformé).</string>
|
||||
<string name="result_nova_details">Degré de transformation (1 = non transformé, 4 =
|
||||
ultra-transformé).</string>
|
||||
<string name="result_nova_1">Aliments non transformés ou peu transformés</string>
|
||||
<string name="result_nova_2">Ingrédients culinaires transformés</string>
|
||||
<string name="result_nova_3">Aliments transformés</string>
|
||||
@@ -206,4 +267,43 @@
|
||||
<string name="allergen_lupin">Lupin</string>
|
||||
<string name="allergen_molluscs">Mollusques</string>
|
||||
<string name="allergen_celery">Céleri</string>
|
||||
</resources>
|
||||
|
||||
<!-- Accessibilité (Phase 7) -->
|
||||
<string name="a11y_safe_status">Produit sûr : aucun allergène détecté</string>
|
||||
<string name="a11y_warning_status">Attention : peut contenir des traces d\'allergènes</string>
|
||||
<string name="a11y_danger_status">Danger : contient des allergènes pour %1$s</string>
|
||||
<string name="a11y_product_image">Image du produit</string>
|
||||
<string name="a11y_avatar">Avatar de %1$s</string>
|
||||
<string name="a11y_delete">Supprimer %1$s</string>
|
||||
<string name="a11y_edit">Modifier %1$s</string>
|
||||
<string name="a11y_toggle">Basculer</string>
|
||||
<string name="a11y_expand">Développer</string>
|
||||
<string name="a11y_collapse">Réduire</string>
|
||||
<string name="a11y_loading">Chargement en cours</string>
|
||||
<string name="a11y_error">Erreur : %1$s</string>
|
||||
<string name="a11y_offline">Mode hors ligne</string>
|
||||
<string name="a11y_torch">Activer ou désactiver la lampe</string>
|
||||
<string name="a11y_scan_area">Zone de scan du code-barres</string>
|
||||
<string name="a11y_nutri_score">Nutri-Score : %1$s</string>
|
||||
<string name="a11y_nova_group">Groupe NOVA : %1$s sur 4</string>
|
||||
<string name="a11y_eco_score">Éco-Score : %1$s</string>
|
||||
<string name="a11y_allergen_present">Allergène présent</string>
|
||||
<string name="a11y_allergen_trace">Traces d\'allergène</string>
|
||||
<string name="a11y_allergen_absent">Allergène absent</string>
|
||||
<string name="a11y_profile_active">Profil %1$s activé</string>
|
||||
<string name="a11y_profile_inactive">Profil %1$s désactivé</string>
|
||||
<string name="a11y_add">Ajouter</string>
|
||||
<string name="a11y_more_options">Plus d\'options</string>
|
||||
<string name="a11y_merge">Fusionner</string>
|
||||
<string name="a11y_clear_all">Tout effacer</string>
|
||||
<string name="a11y_search">Rechercher</string>
|
||||
<string name="a11y_filter">Filtrer</string>
|
||||
<string name="a11y_settings">Paramètres</string>
|
||||
<string name="a11y_back">Revenir en arrière</string>
|
||||
<string name="a11y_close">Fermer</string>
|
||||
<string name="a11y_confirm">Confirmer</string>
|
||||
<string name="a11y_cancel">Annuler</string>
|
||||
<string name="a11y_verdict_safe">Verdict : produit sûr pour tous les profils</string>
|
||||
<string name="a11y_verdict_warning">Verdict : attention, traces d\'allergènes possibles</string>
|
||||
<string name="a11y_verdict_danger">Verdict : danger, ne pas consommer pour %1$s</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.safebite.app.data.repository
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.safebite.app.data.local.database.entity.ProductCacheEntity
|
||||
import com.safebite.app.data.local.database.dao.ProductCacheDao
|
||||
import com.safebite.app.data.remote.api.OpenFoodFactsApi
|
||||
import com.safebite.app.data.remote.dto.OpenFoodFactsResponse
|
||||
import com.safebite.app.data.remote.dto.ProductDto
|
||||
import com.safebite.app.domain.model.Product
|
||||
import com.safebite.app.domain.repository.ProductFetchResult
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Tests unitaires pour ProductRepositoryImpl.
|
||||
*/
|
||||
class ProductRepositoryImplTest {
|
||||
|
||||
private lateinit var api: OpenFoodFactsApi
|
||||
private lateinit var cacheDao: ProductCacheDao
|
||||
private lateinit var repository: ProductRepositoryImpl
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
api = mockk()
|
||||
cacheDao = mockk()
|
||||
repository = ProductRepositoryImpl(api, cacheDao)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetchProduct returns Found from API when product exists`() = runTest {
|
||||
val barcode = "123456789"
|
||||
val productDto = ProductDto(
|
||||
code = barcode,
|
||||
productName = "Test Product",
|
||||
brands = "Test Brand"
|
||||
)
|
||||
val apiResponse = OpenFoodFactsResponse(
|
||||
status = 1,
|
||||
product = productDto
|
||||
)
|
||||
|
||||
coEvery { api.getProduct(barcode) } returns apiResponse
|
||||
coEvery { cacheDao.insert(any()) } returns Unit
|
||||
|
||||
val result = repository.fetchProduct(barcode)
|
||||
|
||||
assertThat(result).isInstanceOf(ProductFetchResult.Found::class.java)
|
||||
val found = result as ProductFetchResult.Found
|
||||
assertThat(found.fromCache).isFalse()
|
||||
assertThat(found.product.barcode).isEqualTo(barcode)
|
||||
coVerify { cacheDao.insert(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetchProduct returns NotFound when API returns status 0`() = runTest {
|
||||
val barcode = "999999"
|
||||
val apiResponse = OpenFoodFactsResponse(status = 0, product = null)
|
||||
|
||||
coEvery { api.getProduct(barcode) } returns apiResponse
|
||||
|
||||
val result = repository.fetchProduct(barcode)
|
||||
|
||||
assertThat(result).isInstanceOf(ProductFetchResult.NotFound::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetchProduct returns Error on IOException`() = runTest {
|
||||
val barcode = "123456789"
|
||||
coEvery { api.getProduct(barcode) } throws IOException("Network error")
|
||||
|
||||
val result = repository.fetchProduct(barcode)
|
||||
|
||||
assertThat(result).isInstanceOf(ProductFetchResult.Error::class.java)
|
||||
val error = result as ProductFetchResult.Error
|
||||
assertThat(error.offline).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetchProduct returns Error on HttpException`() = runTest {
|
||||
val barcode = "123456789"
|
||||
coEvery { api.getProduct(barcode) } throws HttpException(Response.error<String>(500, mockk()))
|
||||
|
||||
val result = repository.fetchProduct(barcode)
|
||||
|
||||
assertThat(result).isInstanceOf(ProductFetchResult.Error::class.java)
|
||||
val error = result as ProductFetchResult.Error
|
||||
assertThat(error.offline).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCachedProduct returns product when exists in cache`() = runTest {
|
||||
val barcode = "123456789"
|
||||
val cachedEntity = ProductCacheEntity(
|
||||
barcode = barcode,
|
||||
name = "Cached Product",
|
||||
brand = "Cached Brand",
|
||||
imageUrl = null,
|
||||
ingredientsText = null,
|
||||
allergensTags = emptyList(),
|
||||
tracesTags = emptyList(),
|
||||
nutriScore = null,
|
||||
novaGroup = null,
|
||||
ecoScore = null,
|
||||
labels = emptyList(),
|
||||
categories = emptyList(),
|
||||
cachedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
coEvery { cacheDao.getProduct(barcode) } returns cachedEntity
|
||||
|
||||
val result = repository.getCachedProduct(barcode)
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result?.barcode).isEqualTo(barcode)
|
||||
assertThat(result?.name).isEqualTo("Cached Product")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCachedProduct returns null when not in cache`() = runTest {
|
||||
val barcode = "123456789"
|
||||
coEvery { cacheDao.getProduct(barcode) } returns null
|
||||
|
||||
val result = repository.getCachedProduct(barcode)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cacheProduct inserts into cache`() = runTest {
|
||||
val product = Product(
|
||||
barcode = "123456789",
|
||||
name = "Test Product",
|
||||
brand = "Test Brand"
|
||||
)
|
||||
|
||||
coEvery { cacheDao.insert(any()) } returns Unit
|
||||
|
||||
repository.cacheProduct(product)
|
||||
|
||||
coVerify { cacheDao.insert(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearCache clears all cached products`() = runTest {
|
||||
coEvery { cacheDao.clearCache() } returns Unit
|
||||
|
||||
repository.clearCache()
|
||||
|
||||
coVerify { cacheDao.clearCache() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.safebite.app.domain.engine
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.safebite.app.domain.model.HealthRating
|
||||
import com.safebite.app.domain.model.HealthStrictness
|
||||
import com.safebite.app.domain.model.Product
|
||||
import org.junit.Test
|
||||
|
||||
class HealthClassifierTest {
|
||||
|
||||
@Test
|
||||
fun `Nutri-Score A with Nova 1 is HEALTHY on NORMAL`() {
|
||||
val product = Product(
|
||||
barcode = "1", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null, nutriScore = "a", novaGroup = 1
|
||||
)
|
||||
val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL)
|
||||
assertThat(assessment.rating).isEqualTo(HealthRating.HEALTHY)
|
||||
assertThat(assessment.reasons).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Nutri-Score B with Nova 2 is HEALTHY on NORMAL`() {
|
||||
val product = Product(
|
||||
barcode = "1", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null, nutriScore = "b", novaGroup = 2
|
||||
)
|
||||
val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL)
|
||||
assertThat(assessment.rating).isEqualTo(HealthRating.HEALTHY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Nutri-Score C with Nova 3 is MODERATE on NORMAL`() {
|
||||
val product = Product(
|
||||
barcode = "1", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null, nutriScore = "c", novaGroup = 3
|
||||
)
|
||||
val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL)
|
||||
assertThat(assessment.rating).isEqualTo(HealthRating.MODERATE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Nutri-Score D or E with Nova 4 is UNHEALTHY on NORMAL`() {
|
||||
val productD = Product(
|
||||
barcode = "1", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null, nutriScore = "d", novaGroup = 4
|
||||
)
|
||||
val assessmentD = HealthClassifier.classify(productD, emptyList(), HealthStrictness.NORMAL)
|
||||
assertThat(assessmentD.rating).isEqualTo(HealthRating.UNHEALTHY)
|
||||
|
||||
val productE = Product(
|
||||
barcode = "2", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null, nutriScore = "e", novaGroup = 4
|
||||
)
|
||||
val assessmentE = HealthClassifier.classify(productE, emptyList(), HealthStrictness.NORMAL)
|
||||
assertThat(assessmentE.rating).isEqualTo(HealthRating.UNHEALTHY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `STRICT mode - Nutri-Score A is HEALTHY`() {
|
||||
val product = Product(
|
||||
barcode = "1", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null, nutriScore = "a", novaGroup = 1
|
||||
)
|
||||
val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.STRICT)
|
||||
assertThat(assessment.rating).isEqualTo(HealthRating.HEALTHY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `STRICT mode - Nutri-Score B is MODERATE`() {
|
||||
val product = Product(
|
||||
barcode = "1", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null, nutriScore = "b", novaGroup = 2
|
||||
)
|
||||
val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.STRICT)
|
||||
assertThat(assessment.rating).isEqualTo(HealthRating.MODERATE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `STRICT mode - Nutri-Score C is UNHEALTHY`() {
|
||||
val product = Product(
|
||||
barcode = "1", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null, nutriScore = "c", novaGroup = 3
|
||||
)
|
||||
val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.STRICT)
|
||||
assertThat(assessment.rating).isEqualTo(HealthRating.UNHEALTHY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LENIENT mode - Nutri-Score C is HEALTHY`() {
|
||||
val product = Product(
|
||||
barcode = "1", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null, nutriScore = "c", novaGroup = 3
|
||||
)
|
||||
val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.LENIENT)
|
||||
assertThat(assessment.rating).isEqualTo(HealthRating.HEALTHY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LENIENT mode - Nutri-Score D is MODERATE`() {
|
||||
val product = Product(
|
||||
barcode = "1", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null, nutriScore = "d", novaGroup = 4
|
||||
)
|
||||
val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.LENIENT)
|
||||
assertThat(assessment.rating).isEqualTo(HealthRating.MODERATE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `No nutriScore and no novaGroup is UNKNOWN`() {
|
||||
val product = Product(
|
||||
barcode = "1", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null
|
||||
)
|
||||
val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL)
|
||||
assertThat(assessment.rating).isEqualTo(HealthRating.UNKNOWN)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Only nutriScore without novaGroup uses nutriScore only`() {
|
||||
val product = Product(
|
||||
barcode = "1", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null, nutriScore = "b"
|
||||
)
|
||||
val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL)
|
||||
assertThat(assessment.rating).isEqualTo(HealthRating.HEALTHY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Only novaGroup without nutriScore uses novaGroup only`() {
|
||||
val product = Product(
|
||||
barcode = "1", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null, novaGroup = 4
|
||||
)
|
||||
val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL)
|
||||
assertThat(assessment.rating).isEqualTo(HealthRating.UNHEALTHY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Reasons list is populated for HEALTHY`() {
|
||||
val product = Product(
|
||||
barcode = "1", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null, nutriScore = "a", novaGroup = 1
|
||||
)
|
||||
val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL)
|
||||
assertThat(assessment.reasons).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Reasons list is populated for UNHEALTHY`() {
|
||||
val product = Product(
|
||||
barcode = "1", name = null, brand = null, imageUrl = null,
|
||||
ingredientsText = null, nutriScore = "e", novaGroup = 4
|
||||
)
|
||||
val assessment = HealthClassifier.classify(product, emptyList(), HealthStrictness.NORMAL)
|
||||
assertThat(assessment.reasons).isNotEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.safebite.app.domain.usecase
|
||||
|
||||
import com.safebite.app.domain.model.Product
|
||||
import com.safebite.app.domain.repository.ProductFetchResult
|
||||
import com.safebite.app.domain.repository.ProductRepository
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests unitaires pour GetAlternativesUseCase.
|
||||
*/
|
||||
class GetAlternativesUseCaseTest {
|
||||
|
||||
private lateinit var productRepository: ProductRepository
|
||||
private lateinit var useCase: GetAlternativesUseCase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
productRepository = mockk()
|
||||
useCase = GetAlternativesUseCase(productRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke returns alternatives when category is valid`() = runTest {
|
||||
val category = "breakfasts"
|
||||
val excludeAllergens = setOf("en:milk", "en:peanuts")
|
||||
val expectedProducts = listOf(
|
||||
Product(barcode = "111", name = "Product A", category = category),
|
||||
Product(barcode = "222", name = "Product B", category = category)
|
||||
)
|
||||
|
||||
coEvery { productRepository.searchAlternatives(category, excludeAllergens, 5) } returns expectedProducts
|
||||
|
||||
val result = useCase(category, excludeAllergens)
|
||||
|
||||
assertEquals(expectedProducts, result)
|
||||
coVerify { productRepository.searchAlternatives(category, excludeAllergens, 5) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke returns empty list when category is blank`() = runTest {
|
||||
val result = useCase("", setOf("en:milk"))
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke returns empty list when category is whitespace`() = runTest {
|
||||
val result = useCase(" ", setOf("en:milk"))
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke uses default limit of 5`() = runTest {
|
||||
val category = "snacks"
|
||||
val excludeAllergens = setOf("en:gluten")
|
||||
|
||||
coEvery { productRepository.searchAlternatives(category, excludeAllergens, 5) } returns emptyList()
|
||||
|
||||
useCase(category, excludeAllergens)
|
||||
|
||||
coVerify { productRepository.searchAlternatives(category, excludeAllergens, 5) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke passes custom limit`() = runTest {
|
||||
val category = "beverages"
|
||||
val excludeAllergens = setOf("en:soya")
|
||||
val customLimit = 10
|
||||
|
||||
coEvery { productRepository.searchAlternatives(category, excludeAllergens, customLimit) } returns emptyList()
|
||||
|
||||
useCase(category, excludeAllergens, customLimit)
|
||||
|
||||
coVerify { productRepository.searchAlternatives(category, excludeAllergens, customLimit) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.safebite.app.presentation.screen.result
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.safebite.app.domain.model.DataSource
|
||||
import com.safebite.app.domain.model.Product
|
||||
import com.safebite.app.domain.model.ScanResult
|
||||
import com.safebite.app.domain.model.SafetyStatus
|
||||
import com.safebite.app.domain.model.UserProfile
|
||||
import com.safebite.app.domain.repository.ProductFetchResult
|
||||
import com.safebite.app.domain.usecase.AnalyzeIngredientsTextUseCase
|
||||
import com.safebite.app.domain.usecase.AnalyzeProductUseCase
|
||||
import com.safebite.app.domain.usecase.FetchProductUseCase
|
||||
import com.safebite.app.domain.usecase.ManageProfileUseCase
|
||||
import com.safebite.app.domain.usecase.SaveScanUseCase
|
||||
import com.safebite.app.presentation.common.util.UiState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests unitaires pour ResultViewModel.
|
||||
*/
|
||||
class ResultViewModelTest {
|
||||
|
||||
private val fetchProduct: FetchProductUseCase = mockk()
|
||||
private val analyzeProduct: AnalyzeProductUseCase = mockk()
|
||||
private val analyzeText: AnalyzeIngredientsTextUseCase = mockk()
|
||||
private val manageProfile: ManageProfileUseCase = mockk()
|
||||
private val saveScan: SaveScanUseCase = mockk()
|
||||
|
||||
private lateinit var viewModel: ResultViewModel
|
||||
|
||||
private val testProfile = UserProfile(
|
||||
id = 1,
|
||||
name = "Test User",
|
||||
isDefault = true,
|
||||
isActive = true
|
||||
)
|
||||
|
||||
private val testProduct = Product(
|
||||
barcode = "123456789",
|
||||
name = "Test Product"
|
||||
)
|
||||
|
||||
private val testScanResult = ScanResult(
|
||||
product = testProduct,
|
||||
safetyStatus = SafetyStatus.SAFE,
|
||||
dataSource = DataSource.API,
|
||||
matchedProfiles = listOf(testProfile)
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
viewModel = ResultViewModel(
|
||||
fetchProduct, analyzeProduct, analyzeText, manageProfile, saveScan
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analyzeBarcode returns Success when product found and analyzed`() = runTest {
|
||||
coEvery { manageProfile.observe() } returns flowOf(listOf(testProfile))
|
||||
coEvery { manageProfile.observeActiveIds() } returns flowOf(setOf(1L))
|
||||
coEvery { fetchProduct("123456789") } returns ProductFetchResult.Found(testProduct, fromCache = false)
|
||||
coEvery { analyzeProduct(testProduct, listOf(testProfile), DataSource.API) } returns testScanResult
|
||||
coEvery { saveScan(testScanResult) } returns 1L
|
||||
|
||||
viewModel.analyzeBarcode("123456789")
|
||||
|
||||
viewModel.state.test {
|
||||
val state = awaitItem()
|
||||
assertTrue(state is UiState.Success)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analyzeBarcode returns Error when no profiles configured`() = runTest {
|
||||
coEvery { manageProfile.observe() } returns flowOf(emptyList())
|
||||
coEvery { manageProfile.observeActiveIds() } returns flowOf(emptySet())
|
||||
|
||||
viewModel.analyzeBarcode("123456789")
|
||||
|
||||
viewModel.state.test {
|
||||
val state = awaitItem()
|
||||
assertTrue(state is UiState.Error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analyzeBarcode returns Error when product not found`() = runTest {
|
||||
coEvery { manageProfile.observe() } returns flowOf(listOf(testProfile))
|
||||
coEvery { manageProfile.observeActiveIds() } returns flowOf(setOf(1L))
|
||||
coEvery { fetchProduct("999999") } returns ProductFetchResult.NotFound
|
||||
|
||||
viewModel.analyzeBarcode("999999")
|
||||
|
||||
viewModel.state.test {
|
||||
val state = awaitItem()
|
||||
assertTrue(state is UiState.Error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analyzeBarcode returns Error with offline flag when network error`() = runTest {
|
||||
coEvery { manageProfile.observe() } returns flowOf(listOf(testProfile))
|
||||
coEvery { manageProfile.observeActiveIds() } returns flowOf(setOf(1L))
|
||||
coEvery { fetchProduct("123456789") } returns ProductFetchResult.Error("Network error", offline = true)
|
||||
|
||||
viewModel.analyzeBarcode("123456789")
|
||||
|
||||
viewModel.state.test {
|
||||
val state = awaitItem()
|
||||
assertTrue(state is UiState.Error)
|
||||
assertTrue((state as UiState.Error).offline)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analyzeOcrText returns Success when analysis succeeds`() = runTest {
|
||||
coEvery { manageProfile.observe() } returns flowOf(listOf(testProfile))
|
||||
coEvery { manageProfile.observeActiveIds() } returns flowOf(setOf(1L))
|
||||
coEvery { analyzeText("ingredients text", listOf(testProfile), null, null) } returns testScanResult
|
||||
coEvery { saveScan(testScanResult) } returns 1L
|
||||
|
||||
viewModel.analyzeOcrText("ingredients text")
|
||||
|
||||
viewModel.state.test {
|
||||
val state = awaitItem()
|
||||
assertTrue(state is UiState.Success)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analyzeOcrText returns Error when no profiles`() = runTest {
|
||||
coEvery { manageProfile.observe() } returns flowOf(emptyList())
|
||||
coEvery { manageProfile.observeActiveIds() } returns flowOf(emptySet())
|
||||
|
||||
viewModel.analyzeOcrText("ingredients text")
|
||||
|
||||
viewModel.state.test {
|
||||
val state = awaitItem()
|
||||
assertTrue(state is UiState.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user