From 8a19d469495c60ceca4ab1372d1ade0da389aaa6 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 26 Apr 2026 15:23:26 -0400 Subject: [PATCH] feat: add tag system to shopping list items, implement dashboard quick access cards, and enhance item detail sheet with photo picker - Add `tag` field to `ShoppingListItemEntity` for visual tags (urgent, offre, whenever) - Increment database version to 5 - Implement dashboard quick access cards showing shopping lists with remaining item counts - Add tag selection buttons in item detail sheet with toggle functionality - Display tag badges on item tiles with color-coded styling (danger for urgent, safe --- .../data/local/database/SafeBiteDatabase.kt | 2 +- .../data/local/database/entity/Entities.kt | 1 + .../screen/dashboard/DashboardScreen.kt | 83 ++++++++++++------- .../screen/dashboard/DashboardViewModel.kt | 80 ++++++++++++++++++ .../screen/lists/ListDetailScreen.kt | 82 +++++++++++++++--- .../screen/lists/ListDetailViewModel.kt | 24 +++++- .../screen/lists/ListsViewModel.kt | 31 +++---- .../presentation/screen/main/MainScreen.kt | 13 ++- version.properties | 4 +- 9 files changed, 259 insertions(+), 61 deletions(-) create mode 100644 app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardViewModel.kt diff --git a/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt b/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt index 4e45865..c2b85fe 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt @@ -21,7 +21,7 @@ import com.safebite.app.data.local.database.entity.UserProfileEntity ShoppingListEntity::class, ShoppingListItemEntity::class ], - version = 4, + version = 5, exportSchema = false ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt b/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt index 8a04947..2eb5fa9 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt @@ -99,5 +99,6 @@ data class ShoppingListItemEntity( val allergenWarning: String? = null, // Allergène détecté pour alerte val note: String? = null, // Quantité / description libre (ex: "2 kg") val customEmoji: String? = null, // Emoji personnalisé choisi par l'utilisateur + val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever" val addedAt: Long = System.currentTimeMillis() ) diff --git a/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardScreen.kt index ff1791e..a79918c 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardScreen.kt @@ -4,33 +4,32 @@ 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.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.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.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.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.presentation.common.components.OutlinedActionButton import com.safebite.app.presentation.common.components.PrimaryButton -import com.safebite.app.presentation.common.components.SafeBiteTopAppBar +import com.safebite.app.presentation.common.components.StandardCard +import com.safebite.app.presentation.common.components.CardVariant /** * Dashboard contextuel (spec UX §5.3). @@ -40,26 +39,17 @@ import com.safebite.app.presentation.common.components.SafeBiteTopAppBar * - 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 + onOpenHistoryItem: (String) -> Unit, + viewModel: DashboardViewModel = hiltViewModel() ) { + val state by viewModel.state.collectAsStateWithLifecycle() + 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)) - } - } - ) - } + containerColor = MaterialTheme.colorScheme.background ) { padding -> Column( modifier = Modifier @@ -71,7 +61,7 @@ fun DashboardScreen( ) { // Greeting Text( - text = stringResource(R.string.dashboard_greeting, "Sophie"), + text = stringResource(R.string.dashboard_greeting, state.greetingName), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.SemiBold ) @@ -82,11 +72,48 @@ fun DashboardScreen( onClick = onScan, modifier = Modifier.fillMaxWidth() ) - OutlinedActionButton( - text = stringResource(R.string.dashboard_lists_button), - onClick = { onOpenList(0, "Ma liste") }, - modifier = Modifier.fillMaxWidth() - ) + + // Shopping lists quick access + if (state.lists.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + state.lists.forEach { list -> + StandardCard( + modifier = Modifier + .weight(1f) + .height(72.dp), + variant = CardVariant.Filled, + onClick = { onOpenList(list.id, list.name) }, + contentPadding = PaddingValues(8.dp) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = list.name, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource( + R.string.dashboard_remaining, + list.remaining + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } // Weekly stats placeholder Card( diff --git a/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..0ba3839 --- /dev/null +++ b/app/src/main/java/com/safebite/app/presentation/screen/dashboard/DashboardViewModel.kt @@ -0,0 +1,80 @@ +package com.safebite.app.presentation.screen.dashboard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.safebite.app.data.local.database.entity.ShoppingListEntity +import com.safebite.app.domain.model.UserProfile +import com.safebite.app.domain.usecase.GetShoppingListsUseCase +import com.safebite.app.domain.usecase.ManageProfileUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +data class DashboardUiState( + val greetingName: String = "", + val lists: List = emptyList() +) + +data class ListSummary( + val id: Long, + val name: String, + val remaining: Int +) + +@HiltViewModel +class DashboardViewModel @Inject constructor( + private val manageProfile: ManageProfileUseCase, + private val getShoppingLists: GetShoppingListsUseCase +) : ViewModel() { + + @OptIn(ExperimentalCoroutinesApi::class) + val state: StateFlow = combine( + manageProfile.observe(), + manageProfile.observeActiveIds() + ) { profiles, activeIds -> + profiles to activeIds + }.flatMapLatest { (profiles, activeIds) -> + val greetingName = resolveGreetingName(profiles, activeIds) + observeListsWithStats(greetingName) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = DashboardUiState() + ) + + @OptIn(ExperimentalCoroutinesApi::class) + private fun observeListsWithStats(greetingName: String): Flow { + return getShoppingLists.observeActive().flatMapLatest { lists -> + val sortedLists = lists.sortedBy { it.createdAt }.take(4) + if (sortedLists.isEmpty()) { + flowOf(DashboardUiState(greetingName = greetingName, lists = emptyList())) + } else { + val listFlows = sortedLists.map { list -> + combine( + getShoppingLists.observeItemCount(list.id), + getShoppingLists.observeCheckedCount(list.id) + ) { total, checked -> + ListSummary(list.id, list.name, total - checked) + } + } + combine(listFlows) { array -> + DashboardUiState(greetingName, array.toList()) + } + } + } + } + + private fun resolveGreetingName(profiles: List, activeIds: Set): String { + return when { + activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }.firstOrNull()?.name + else -> profiles.filter { it.isDefault }.firstOrNull()?.name ?: profiles.firstOrNull()?.name + } ?: "" + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt index 2045668..1cf143b 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt @@ -76,6 +76,9 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -261,6 +264,8 @@ fun ListDetailScreen( onUpdateNote = { note -> viewModel.updateItemNote(selected.id, note) }, onUpdateCategory = { cat -> viewModel.updateItemCategory(selected.id, cat) }, onUpdateEmoji = { emoji -> viewModel.updateItemEmoji(selected.id, emoji) }, + onUpdateTag = { tag -> viewModel.updateItemTag(selected.id, tag) }, + onUpdateImage = { imageUrl -> viewModel.updateItemImageUrl(selected.id, imageUrl) }, onMoveTo = { targetListId -> viewModel.moveItemToList(selected.id, targetListId) }, onDelete = { viewModel.deleteItem(selected.id) }, onOpenProduct = selected.barcode?.let { bc -> { onOpenProduct(bc) } } @@ -289,6 +294,15 @@ private fun ListDetailContent( ) { val dimens = LocalDimens.current + val filteredCatalogCounts = remember(ready.activeItems, ready.recentlyUsed) { + catalog.categories.associateWith { category -> + catalog.itemsForCategory(category).count { catItem -> + ready.activeItems.none { it.productName.equals(catItem.name, ignoreCase = true) } && + ready.recentlyUsed.none { it.productName.equals(catItem.name, ignoreCase = true) } + } + } + } + LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( @@ -315,7 +329,8 @@ private fun ListDetailContent( emoji = it.emoji, imageUrl = it.imageUrl, tone = TileTone.Active, - badgeWarning = !it.allergenWarning.isNullOrBlank() + badgeWarning = !it.allergenWarning.isNullOrBlank(), + tag = it.tag ) }, onTap = onTapActive, @@ -345,7 +360,8 @@ private fun ListDetailContent( emoji = it.emoji, imageUrl = it.imageUrl, tone = TileTone.Recent, - badgeWarning = false + badgeWarning = false, + tag = it.tag ) }, onTap = onTapRecent, @@ -373,14 +389,17 @@ private fun ListDetailContent( item(key = "header-$category") { CollapsibleHeader( title = category, - count = catalog.itemsForCategory(category).size, + count = filteredCatalogCounts[category] ?: 0, expanded = expanded, onToggle = { onToggleCategory(category) } ) } if (expanded) { item(key = "grid-$category") { - val items = catalog.itemsForCategory(category) + val items = catalog.itemsForCategory(category).filter { catItem -> + ready.activeItems.none { it.productName.equals(catItem.name, ignoreCase = true) } && + ready.recentlyUsed.none { it.productName.equals(catItem.name, ignoreCase = true) } + } TileGrid( items = items.map { TileData( @@ -418,7 +437,8 @@ private data class TileData( val emoji: String, val imageUrl: String?, val tone: TileTone, - val badgeWarning: Boolean + val badgeWarning: Boolean, + val tag: String? = null ) @OptIn(ExperimentalFoundationApi::class) @@ -528,6 +548,23 @@ private fun Tile( .size(16.dp) ) } + if (!data.tag.isNullOrBlank()) { + val tagColor = when (data.tag.lowercase()) { + "urgent" -> statusColors.danger + "offre" -> statusColors.safe + else -> MaterialTheme.colorScheme.tertiary + } + Text( + text = data.tag.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + fontWeight = FontWeight.Bold, + modifier = Modifier + .align(Alignment.TopStart) + .background(tagColor, RoundedCornerShape(4.dp)) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) + } } } } @@ -776,17 +813,26 @@ private fun ItemDetailSheet( onUpdateNote: (String) -> Unit, onUpdateCategory: (String) -> Unit, onUpdateEmoji: (String?) -> Unit, + onUpdateTag: (String?) -> Unit, + onUpdateImage: (String?) -> Unit, onMoveTo: (Long) -> Unit, onDelete: () -> Unit, onOpenProduct: (() -> Unit)? ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var note by remember(item.id) { mutableStateOf(item.note.orEmpty()) } + var currentTag by remember(item.id) { mutableStateOf(item.tag) } var showCategoryPicker by remember { mutableStateOf(false) } var showIconPicker by remember { mutableStateOf(false) } var showMovePicker by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current + val photoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { onUpdateImage(it.toString()) } + } + ModalBottomSheet( onDismissRequest = { // Persiste la note avant fermeture si elle a changé. @@ -853,22 +899,34 @@ private fun ItemDetailSheet( DetailTagButton( icon = Icons.Filled.AutoAwesome, label = "Urgent", - selected = false, - onClick = { /* TODO */ }, + selected = currentTag == "urgent", + onClick = { + val newTag = if (currentTag == "urgent") null else "urgent" + currentTag = newTag + onUpdateTag(newTag) + }, modifier = Modifier.weight(1f) ) DetailTagButton( icon = Icons.Filled.Done, label = "Offre", - selected = false, - onClick = { /* TODO */ }, + selected = currentTag == "offre", + onClick = { + val newTag = if (currentTag == "offre") null else "offre" + currentTag = newTag + onUpdateTag(newTag) + }, modifier = Modifier.weight(1f) ) DetailTagButton( icon = Icons.Filled.History, label = "Quand cela convient", - selected = false, - onClick = { /* TODO */ }, + selected = currentTag == "whenever", + onClick = { + val newTag = if (currentTag == "whenever") null else "whenever" + currentTag = newTag + onUpdateTag(newTag) + }, modifier = Modifier.weight(1f) ) } @@ -894,7 +952,7 @@ private fun ItemDetailSheet( ParameterButton( icon = Icons.Filled.Camera, label = "Ajouter une photo", - onClick = { /* TODO: Photo picker */ }, + onClick = { photoPickerLauncher.launch("image/*") }, modifier = Modifier.weight(1f) ) } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt index 6794ebd..4d8cd97 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt @@ -64,7 +64,8 @@ class ListDetailViewModel @Inject constructor( val safetyStatus: String?, val allergenWarning: String?, val note: String?, - val emoji: String + val emoji: String, + val tag: String? ) /** @@ -322,6 +323,24 @@ class ListDetailViewModel @Inject constructor( } } + /** Change le tag visuel d'un article. */ + fun updateItemTag(id: Long, tag: String?) { + viewModelScope.launch { + val listId = _listIdFlow.value + val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch + manageListUseCase.updateItem(item.copy(tag = tag)) + } + } + + /** Change l'image (URL/URI) d'un article. */ + fun updateItemImageUrl(id: Long, imageUrl: String?) { + viewModelScope.launch { + val listId = _listIdFlow.value + val item = manageListUseCase.getItems(listId).firstOrNull { it.id == id } ?: return@launch + manageListUseCase.updateItem(item.copy(imageUrl = imageUrl)) + } + } + /** Change l'emoji personnalisé d'un article. */ fun updateItemEmoji(id: Long, emoji: String?) { viewModelScope.launch { @@ -402,7 +421,8 @@ class ListDetailViewModel @Inject constructor( safetyStatus = safetyStatus, allergenWarning = allergenWarning, note = note, - emoji = customEmoji ?: catalog.emojiFor(productName, category) + emoji = customEmoji ?: catalog.emojiFor(productName, category), + tag = tag ) companion object { diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt index 54112c4..f682791 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt @@ -5,9 +5,12 @@ 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.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -35,23 +38,23 @@ class ListsViewModel @Inject constructor( val checkedCount: Int ) + @OptIn(ExperimentalCoroutinesApi::class) val state: StateFlow = getShoppingListsUseCase.observeActive() - .map { lists -> + .flatMapLatest { lists -> if (lists.isEmpty()) { - UiState.Empty("Aucune liste de courses. Créez votre première liste !") + flowOf(UiState.Empty("Aucune liste de courses. Créez votre première liste !")) } else { - // 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 - ) + val statsFlows = lists.map { list -> + combine( + getShoppingListsUseCase.observeItemCount(list.id), + getShoppingListsUseCase.observeCheckedCount(list.id) + ) { itemCount, checkedCount -> + ShoppingListWithStats(list, itemCount, checkedCount) } - ) + } + combine(statsFlows) { array -> + UiState.Success(array.toList()) + } } } .stateIn( diff --git a/app/src/main/java/com/safebite/app/presentation/screen/main/MainScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/main/MainScreen.kt index d9065ea..037647c 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/main/MainScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/main/MainScreen.kt @@ -13,10 +13,12 @@ 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.material.icons.filled.Settings import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FabPosition import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem @@ -79,7 +81,15 @@ fun MainScreen( containerColor = MaterialTheme.colorScheme.background, topBar = { TopAppBar( - title = { Text(stringResource(R.string.app_name)) } + title = { Text(stringResource(R.string.app_name)) }, + actions = { + IconButton(onClick = onOpenSettings) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = stringResource(R.string.nav_settings) + ) + } + } ) }, bottomBar = { @@ -108,7 +118,6 @@ fun MainScreen( composable(Screen.Dashboard.route) { DashboardScreen( onScan = onOpenScanner, - onOpenSettings = onOpenSettings, onOpenList = onOpenListDetail, onOpenHistoryItem = onOpenHistoryItem ) diff --git a/version.properties b/version.properties index b27d052..f489880 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ MAJOR=1 MINOR=7 -PATCH=0 -CODE=8 +PATCH=1 +CODE=9