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:
parent
b68212b99c
commit
8a19d46949
@ -21,7 +21,7 @@ import com.safebite.app.data.local.database.entity.UserProfileEntity
|
|||||||
ShoppingListEntity::class,
|
ShoppingListEntity::class,
|
||||||
ShoppingListItemEntity::class
|
ShoppingListItemEntity::class
|
||||||
],
|
],
|
||||||
version = 4,
|
version = 5,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
|||||||
@ -99,5 +99,6 @@ data class ShoppingListItemEntity(
|
|||||||
val allergenWarning: String? = null, // Allergène détecté pour alerte
|
val allergenWarning: String? = null, // Allergène détecté pour alerte
|
||||||
val note: String? = null, // Quantité / description libre (ex: "2 kg")
|
val note: String? = null, // Quantité / description libre (ex: "2 kg")
|
||||||
val customEmoji: String? = null, // Emoji personnalisé choisi par l'utilisateur
|
val customEmoji: String? = null, // Emoji personnalisé choisi par l'utilisateur
|
||||||
|
val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever"
|
||||||
val addedAt: Long = System.currentTimeMillis()
|
val addedAt: Long = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,33 +4,32 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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.R
|
||||||
import com.safebite.app.presentation.common.components.OutlinedActionButton
|
|
||||||
import com.safebite.app.presentation.common.components.PrimaryButton
|
import com.safebite.app.presentation.common.components.PrimaryButton
|
||||||
import com.safebite.app.presentation.common.components.SafeBiteTopAppBar
|
import com.safebite.app.presentation.common.components.StandardCard
|
||||||
|
import com.safebite.app.presentation.common.components.CardVariant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard contextuel (spec UX §5.3).
|
* 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
|
* - store_mode : détecté via géolocalisation/heure
|
||||||
* - home_mode : mode par défaut
|
* - home_mode : mode par défaut
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreen(
|
fun DashboardScreen(
|
||||||
onScan: () -> Unit,
|
onScan: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
|
||||||
onOpenList: (Long, String) -> Unit,
|
onOpenList: (Long, String) -> Unit,
|
||||||
onOpenHistoryItem: (String) -> Unit
|
onOpenHistoryItem: (String) -> Unit,
|
||||||
|
viewModel: DashboardViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
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 ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -71,7 +61,7 @@ fun DashboardScreen(
|
|||||||
) {
|
) {
|
||||||
// Greeting
|
// Greeting
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.dashboard_greeting, "Sophie"),
|
text = stringResource(R.string.dashboard_greeting, state.greetingName),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
@ -82,11 +72,48 @@ fun DashboardScreen(
|
|||||||
onClick = onScan,
|
onClick = onScan,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
OutlinedActionButton(
|
|
||||||
text = stringResource(R.string.dashboard_lists_button),
|
// Shopping lists quick access
|
||||||
onClick = { onOpenList(0, "Ma liste") },
|
if (state.lists.isNotEmpty()) {
|
||||||
modifier = Modifier.fillMaxWidth()
|
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
|
// Weekly stats placeholder
|
||||||
Card(
|
Card(
|
||||||
|
|||||||
@ -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
|
||||||
|
} ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -76,6 +76,9 @@ import androidx.compose.runtime.mutableStateMapOf
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -261,6 +264,8 @@ fun ListDetailScreen(
|
|||||||
onUpdateNote = { note -> viewModel.updateItemNote(selected.id, note) },
|
onUpdateNote = { note -> viewModel.updateItemNote(selected.id, note) },
|
||||||
onUpdateCategory = { cat -> viewModel.updateItemCategory(selected.id, cat) },
|
onUpdateCategory = { cat -> viewModel.updateItemCategory(selected.id, cat) },
|
||||||
onUpdateEmoji = { emoji -> viewModel.updateItemEmoji(selected.id, emoji) },
|
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) },
|
onMoveTo = { targetListId -> viewModel.moveItemToList(selected.id, targetListId) },
|
||||||
onDelete = { viewModel.deleteItem(selected.id) },
|
onDelete = { viewModel.deleteItem(selected.id) },
|
||||||
onOpenProduct = selected.barcode?.let { bc -> { onOpenProduct(bc) } }
|
onOpenProduct = selected.barcode?.let { bc -> { onOpenProduct(bc) } }
|
||||||
@ -289,6 +294,15 @@ private fun ListDetailContent(
|
|||||||
) {
|
) {
|
||||||
val dimens = LocalDimens.current
|
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(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
@ -315,7 +329,8 @@ private fun ListDetailContent(
|
|||||||
emoji = it.emoji,
|
emoji = it.emoji,
|
||||||
imageUrl = it.imageUrl,
|
imageUrl = it.imageUrl,
|
||||||
tone = TileTone.Active,
|
tone = TileTone.Active,
|
||||||
badgeWarning = !it.allergenWarning.isNullOrBlank()
|
badgeWarning = !it.allergenWarning.isNullOrBlank(),
|
||||||
|
tag = it.tag
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onTap = onTapActive,
|
onTap = onTapActive,
|
||||||
@ -345,7 +360,8 @@ private fun ListDetailContent(
|
|||||||
emoji = it.emoji,
|
emoji = it.emoji,
|
||||||
imageUrl = it.imageUrl,
|
imageUrl = it.imageUrl,
|
||||||
tone = TileTone.Recent,
|
tone = TileTone.Recent,
|
||||||
badgeWarning = false
|
badgeWarning = false,
|
||||||
|
tag = it.tag
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onTap = onTapRecent,
|
onTap = onTapRecent,
|
||||||
@ -373,14 +389,17 @@ private fun ListDetailContent(
|
|||||||
item(key = "header-$category") {
|
item(key = "header-$category") {
|
||||||
CollapsibleHeader(
|
CollapsibleHeader(
|
||||||
title = category,
|
title = category,
|
||||||
count = catalog.itemsForCategory(category).size,
|
count = filteredCatalogCounts[category] ?: 0,
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onToggle = { onToggleCategory(category) }
|
onToggle = { onToggleCategory(category) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
item(key = "grid-$category") {
|
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(
|
TileGrid(
|
||||||
items = items.map {
|
items = items.map {
|
||||||
TileData(
|
TileData(
|
||||||
@ -418,7 +437,8 @@ private data class TileData(
|
|||||||
val emoji: String,
|
val emoji: String,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
val tone: TileTone,
|
val tone: TileTone,
|
||||||
val badgeWarning: Boolean
|
val badgeWarning: Boolean,
|
||||||
|
val tag: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@ -528,6 +548,23 @@ private fun Tile(
|
|||||||
.size(16.dp)
|
.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,
|
onUpdateNote: (String) -> Unit,
|
||||||
onUpdateCategory: (String) -> Unit,
|
onUpdateCategory: (String) -> Unit,
|
||||||
onUpdateEmoji: (String?) -> Unit,
|
onUpdateEmoji: (String?) -> Unit,
|
||||||
|
onUpdateTag: (String?) -> Unit,
|
||||||
|
onUpdateImage: (String?) -> Unit,
|
||||||
onMoveTo: (Long) -> Unit,
|
onMoveTo: (Long) -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onOpenProduct: (() -> Unit)?
|
onOpenProduct: (() -> Unit)?
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
var note by remember(item.id) { mutableStateOf(item.note.orEmpty()) }
|
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 showCategoryPicker by remember { mutableStateOf(false) }
|
||||||
var showIconPicker by remember { mutableStateOf(false) }
|
var showIconPicker by remember { mutableStateOf(false) }
|
||||||
var showMovePicker by remember { mutableStateOf(false) }
|
var showMovePicker by remember { mutableStateOf(false) }
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
val photoPickerLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetContent()
|
||||||
|
) { uri: Uri? ->
|
||||||
|
uri?.let { onUpdateImage(it.toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
// Persiste la note avant fermeture si elle a changé.
|
// Persiste la note avant fermeture si elle a changé.
|
||||||
@ -853,22 +899,34 @@ private fun ItemDetailSheet(
|
|||||||
DetailTagButton(
|
DetailTagButton(
|
||||||
icon = Icons.Filled.AutoAwesome,
|
icon = Icons.Filled.AutoAwesome,
|
||||||
label = "Urgent",
|
label = "Urgent",
|
||||||
selected = false,
|
selected = currentTag == "urgent",
|
||||||
onClick = { /* TODO */ },
|
onClick = {
|
||||||
|
val newTag = if (currentTag == "urgent") null else "urgent"
|
||||||
|
currentTag = newTag
|
||||||
|
onUpdateTag(newTag)
|
||||||
|
},
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
DetailTagButton(
|
DetailTagButton(
|
||||||
icon = Icons.Filled.Done,
|
icon = Icons.Filled.Done,
|
||||||
label = "Offre",
|
label = "Offre",
|
||||||
selected = false,
|
selected = currentTag == "offre",
|
||||||
onClick = { /* TODO */ },
|
onClick = {
|
||||||
|
val newTag = if (currentTag == "offre") null else "offre"
|
||||||
|
currentTag = newTag
|
||||||
|
onUpdateTag(newTag)
|
||||||
|
},
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
DetailTagButton(
|
DetailTagButton(
|
||||||
icon = Icons.Filled.History,
|
icon = Icons.Filled.History,
|
||||||
label = "Quand cela convient",
|
label = "Quand cela convient",
|
||||||
selected = false,
|
selected = currentTag == "whenever",
|
||||||
onClick = { /* TODO */ },
|
onClick = {
|
||||||
|
val newTag = if (currentTag == "whenever") null else "whenever"
|
||||||
|
currentTag = newTag
|
||||||
|
onUpdateTag(newTag)
|
||||||
|
},
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -894,7 +952,7 @@ private fun ItemDetailSheet(
|
|||||||
ParameterButton(
|
ParameterButton(
|
||||||
icon = Icons.Filled.Camera,
|
icon = Icons.Filled.Camera,
|
||||||
label = "Ajouter une photo",
|
label = "Ajouter une photo",
|
||||||
onClick = { /* TODO: Photo picker */ },
|
onClick = { photoPickerLauncher.launch("image/*") },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,7 +64,8 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
val safetyStatus: String?,
|
val safetyStatus: String?,
|
||||||
val allergenWarning: String?,
|
val allergenWarning: String?,
|
||||||
val note: 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. */
|
/** Change l'emoji personnalisé d'un article. */
|
||||||
fun updateItemEmoji(id: Long, emoji: String?) {
|
fun updateItemEmoji(id: Long, emoji: String?) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@ -402,7 +421,8 @@ class ListDetailViewModel @Inject constructor(
|
|||||||
safetyStatus = safetyStatus,
|
safetyStatus = safetyStatus,
|
||||||
allergenWarning = allergenWarning,
|
allergenWarning = allergenWarning,
|
||||||
note = note,
|
note = note,
|
||||||
emoji = customEmoji ?: catalog.emojiFor(productName, category)
|
emoji = customEmoji ?: catalog.emojiFor(productName, category),
|
||||||
|
tag = tag
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@ -5,9 +5,12 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.safebite.app.data.local.database.entity.ShoppingListEntity
|
import com.safebite.app.data.local.database.entity.ShoppingListEntity
|
||||||
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
|
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@ -35,23 +38,23 @@ class ListsViewModel @Inject constructor(
|
|||||||
val checkedCount: Int
|
val checkedCount: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
val state: StateFlow<UiState> = getShoppingListsUseCase.observeActive()
|
val state: StateFlow<UiState> = getShoppingListsUseCase.observeActive()
|
||||||
.map { lists ->
|
.flatMapLatest { lists ->
|
||||||
if (lists.isEmpty()) {
|
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 {
|
} else {
|
||||||
// Pour chaque liste, on récupère les stats
|
val statsFlows = lists.map { list ->
|
||||||
// Note: Dans une implémentation complète, on utiliserait combine
|
combine(
|
||||||
// pour observer les stats en temps réel
|
getShoppingListsUseCase.observeItemCount(list.id),
|
||||||
UiState.Success(
|
getShoppingListsUseCase.observeCheckedCount(list.id)
|
||||||
lists.map { list ->
|
) { itemCount, checkedCount ->
|
||||||
ShoppingListWithStats(
|
ShoppingListWithStats(list, itemCount, checkedCount)
|
||||||
list = list,
|
|
||||||
itemCount = 0, // Sera mis à jour par le Flow
|
|
||||||
checkedCount = 0
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
combine(statsFlows) { array ->
|
||||||
|
UiState.Success(array.toList())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.stateIn(
|
.stateIn(
|
||||||
|
|||||||
@ -13,10 +13,12 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FabPosition
|
import androidx.compose.material3.FabPosition
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
@ -79,7 +81,15 @@ fun MainScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
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 = {
|
bottomBar = {
|
||||||
@ -108,7 +118,6 @@ fun MainScreen(
|
|||||||
composable(Screen.Dashboard.route) {
|
composable(Screen.Dashboard.route) {
|
||||||
DashboardScreen(
|
DashboardScreen(
|
||||||
onScan = onOpenScanner,
|
onScan = onOpenScanner,
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenList = onOpenListDetail,
|
onOpenList = onOpenListDetail,
|
||||||
onOpenHistoryItem = onOpenHistoryItem
|
onOpenHistoryItem = onOpenHistoryItem
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
MAJOR=1
|
MAJOR=1
|
||||||
MINOR=7
|
MINOR=7
|
||||||
PATCH=0
|
PATCH=1
|
||||||
CODE=8
|
CODE=9
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user