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
This commit is contained in:
Bruno Charest 2026-04-26 15:23:26 -04:00
parent b68212b99c
commit 8a19d46949
9 changed files with 259 additions and 61 deletions

View File

@ -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)

View File

@ -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()
)

View File

@ -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(

View File

@ -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<ListSummary> = 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<DashboardUiState> = combine(
manageProfile.observe(),
manageProfile.observeActiveIds()
) { profiles, activeIds ->
profiles to activeIds
}.flatMapLatest { (profiles, activeIds) ->
val greetingName = resolveGreetingName(profiles, activeIds)
observeListsWithStats(greetingName)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = DashboardUiState()
)
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeListsWithStats(greetingName: String): Flow<DashboardUiState> {
return getShoppingLists.observeActive().flatMapLatest { lists ->
val sortedLists = lists.sortedBy { it.createdAt }.take(4)
if (sortedLists.isEmpty()) {
flowOf(DashboardUiState(greetingName = greetingName, lists = emptyList()))
} else {
val listFlows = sortedLists.map { list ->
combine(
getShoppingLists.observeItemCount(list.id),
getShoppingLists.observeCheckedCount(list.id)
) { total, checked ->
ListSummary(list.id, list.name, total - checked)
}
}
combine(listFlows) { array ->
DashboardUiState(greetingName, array.toList())
}
}
}
}
private fun resolveGreetingName(profiles: List<UserProfile>, activeIds: Set<Long>): String {
return when {
activeIds.isNotEmpty() -> profiles.filter { it.id in activeIds }.firstOrNull()?.name
else -> profiles.filter { it.isDefault }.firstOrNull()?.name ?: profiles.firstOrNull()?.name
} ?: ""
}
}

View File

@ -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)
)
}

View File

@ -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 {

View File

@ -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<UiState> = 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(

View File

@ -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
)

View File

@ -1,4 +1,4 @@
MAJOR=1
MINOR=7
PATCH=0
CODE=8
PATCH=1
CODE=9