feat: add shopping list member management, display order, and customization fields with drag-to-reorder UI

- Add `ShoppingListMemberEntity` table with foreign key cascade to shopping lists
- Add `backgroundResName`, `region`, `sortType`, `displayOrder`, `visibleCategories` fields to `ShoppingListEntity`
- Increment database version to 6
- Implement member CRUD operations in DAO and repository layers
- Add `observeMembers`, `addMember`, `updateMember`, `removeMember` methods across data/domain layers
- Update
This commit is contained in:
Bruno Charest 2026-04-28 07:24:59 -04:00
parent ec1c8e6940
commit 7656fba134
31 changed files with 1716 additions and 182 deletions

View File

@ -11,6 +11,7 @@ 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.ScanHistoryEntity
import com.safebite.app.data.local.database.entity.ShoppingListEntity import com.safebite.app.data.local.database.entity.ShoppingListEntity
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
import com.safebite.app.data.local.database.entity.ShoppingListMemberEntity
import com.safebite.app.data.local.database.entity.UserProfileEntity import com.safebite.app.data.local.database.entity.UserProfileEntity
@Database( @Database(
@ -19,9 +20,10 @@ import com.safebite.app.data.local.database.entity.UserProfileEntity
ProductCacheEntity::class, ProductCacheEntity::class,
ScanHistoryEntity::class, ScanHistoryEntity::class,
ShoppingListEntity::class, ShoppingListEntity::class,
ShoppingListItemEntity::class ShoppingListItemEntity::class,
ShoppingListMemberEntity::class
], ],
version = 5, version = 6,
exportSchema = false exportSchema = false
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)

View File

@ -9,6 +9,7 @@ import androidx.room.Transaction
import androidx.room.Update import androidx.room.Update
import com.safebite.app.data.local.database.entity.ShoppingListEntity import com.safebite.app.data.local.database.entity.ShoppingListEntity
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
import com.safebite.app.data.local.database.entity.ShoppingListMemberEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
/** /**
@ -19,9 +20,12 @@ interface ShoppingListDao {
// ── Shopping Lists ────────────────────────────────────────────────────── // ── Shopping Lists ──────────────────────────────────────────────────────
@Query("SELECT * FROM shopping_lists WHERE isArchived = 0 ORDER BY updatedAt DESC") @Query("SELECT * FROM shopping_lists WHERE isArchived = 0 ORDER BY displayOrder ASC, updatedAt DESC")
fun observeActiveLists(): Flow<List<ShoppingListEntity>> fun observeActiveLists(): Flow<List<ShoppingListEntity>>
@Query("SELECT * FROM shopping_lists WHERE isArchived = 0 ORDER BY displayOrder ASC, updatedAt DESC")
fun observeActiveListsOrdered(): Flow<List<ShoppingListEntity>>
@Query("SELECT * FROM shopping_lists ORDER BY updatedAt DESC") @Query("SELECT * FROM shopping_lists ORDER BY updatedAt DESC")
fun observeAllLists(): Flow<List<ShoppingListEntity>> fun observeAllLists(): Flow<List<ShoppingListEntity>>
@ -69,6 +73,23 @@ interface ShoppingListDao {
@Query("DELETE FROM shopping_list_items WHERE listId = :listId") @Query("DELETE FROM shopping_list_items WHERE listId = :listId")
suspend fun deleteAllItems(listId: Long) suspend fun deleteAllItems(listId: Long)
// ── Members ─────────────────────────────────────────────────────────────
@Query("SELECT * FROM shopping_list_members WHERE listId = :listId ORDER BY joinedAt ASC")
fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMember(member: ShoppingListMemberEntity): Long
@Update
suspend fun updateMember(member: ShoppingListMemberEntity)
@Delete
suspend fun deleteMember(member: ShoppingListMemberEntity)
@Query("DELETE FROM shopping_list_members WHERE listId = :listId")
suspend fun deleteAllMembers(listId: Long)
// ── Stats ─────────────────────────────────────────────────────────────── // ── Stats ───────────────────────────────────────────────────────────────
@Query("SELECT COUNT(*) FROM shopping_list_items WHERE listId = :listId") @Query("SELECT COUNT(*) FROM shopping_list_items WHERE listId = :listId")

View File

@ -71,7 +71,12 @@ data class ShoppingListEntity(
val name: String, val name: String,
val createdAt: Long = System.currentTimeMillis(), val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(), val updatedAt: Long = System.currentTimeMillis(),
val isArchived: Boolean = false val isArchived: Boolean = false,
val backgroundResName: String? = null,
val region: String? = null,
val sortType: String = "category",
val displayOrder: Int = 0,
val visibleCategories: String? = null
) )
@Entity( @Entity(
@ -102,3 +107,25 @@ data class ShoppingListItemEntity(
val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever" val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever"
val addedAt: Long = System.currentTimeMillis() val addedAt: Long = System.currentTimeMillis()
) )
@Entity(
tableName = "shopping_list_members",
foreignKeys = [
androidx.room.ForeignKey(
entity = ShoppingListEntity::class,
parentColumns = ["id"],
childColumns = ["listId"],
onDelete = androidx.room.ForeignKey.CASCADE
)
],
indices = [androidx.room.Index("listId")]
)
data class ShoppingListMemberEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
val listId: Long,
val name: String,
val email: String,
val avatarUrl: String? = null,
val role: String = "member", // "owner" | "member"
val joinedAt: Long = System.currentTimeMillis()
)

View File

@ -3,6 +3,7 @@ package com.safebite.app.data.repository
import com.safebite.app.data.local.database.dao.ShoppingListDao 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.ShoppingListEntity
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
import com.safebite.app.data.local.database.entity.ShoppingListMemberEntity
import com.safebite.app.domain.repository.ShoppingListRepository import com.safebite.app.domain.repository.ShoppingListRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
@ -22,11 +23,12 @@ class ShoppingListRepositoryImpl @Inject constructor(
override suspend fun getListById(id: Long): ShoppingListEntity? = override suspend fun getListById(id: Long): ShoppingListEntity? =
dao.getListById(id) dao.getListById(id)
override suspend fun createList(name: String): Long { override suspend fun createList(name: String, backgroundResName: String?): Long {
val list = ShoppingListEntity( val list = ShoppingListEntity(
name = name, name = name,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis() updatedAt = System.currentTimeMillis(),
backgroundResName = backgroundResName
) )
return dao.insertList(list) return dao.insertList(list)
} }
@ -81,4 +83,22 @@ class ShoppingListRepositoryImpl @Inject constructor(
override suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) { override suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) {
dao.addItemToList(listId, item) dao.addItemToList(listId, item)
} }
override fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>> =
dao.observeMembers(listId)
override suspend fun addMember(member: ShoppingListMemberEntity): Long =
dao.insertMember(member)
override suspend fun updateMember(member: ShoppingListMemberEntity) {
dao.updateMember(member)
}
override suspend fun removeMember(member: ShoppingListMemberEntity) {
dao.deleteMember(member)
}
override suspend fun deleteAllMembers(listId: Long) {
dao.deleteAllMembers(listId)
}
} }

View File

@ -2,6 +2,7 @@ package com.safebite.app.domain.repository
import com.safebite.app.data.local.database.entity.ShoppingListEntity import com.safebite.app.data.local.database.entity.ShoppingListEntity
import com.safebite.app.data.local.database.entity.ShoppingListItemEntity import com.safebite.app.data.local.database.entity.ShoppingListItemEntity
import com.safebite.app.data.local.database.entity.ShoppingListMemberEntity
import com.safebite.app.domain.model.AppLanguage import com.safebite.app.domain.model.AppLanguage
import com.safebite.app.domain.model.DetectionLanguage import com.safebite.app.domain.model.DetectionLanguage
import com.safebite.app.domain.model.HealthStrictness import com.safebite.app.domain.model.HealthStrictness
@ -78,7 +79,7 @@ interface ShoppingListRepository {
fun observeActiveLists(): Flow<List<ShoppingListEntity>> fun observeActiveLists(): Flow<List<ShoppingListEntity>>
fun observeAllLists(): Flow<List<ShoppingListEntity>> fun observeAllLists(): Flow<List<ShoppingListEntity>>
suspend fun getListById(id: Long): ShoppingListEntity? suspend fun getListById(id: Long): ShoppingListEntity?
suspend fun createList(name: String): Long suspend fun createList(name: String, backgroundResName: String? = null): Long
suspend fun updateList(list: ShoppingListEntity) suspend fun updateList(list: ShoppingListEntity)
suspend fun deleteList(list: ShoppingListEntity) suspend fun deleteList(list: ShoppingListEntity)
suspend fun archiveList(id: Long) suspend fun archiveList(id: Long)
@ -97,6 +98,13 @@ interface ShoppingListRepository {
fun observeItemCount(listId: Long): Flow<Int> fun observeItemCount(listId: Long): Flow<Int>
fun observeCheckedCount(listId: Long): Flow<Int> fun observeCheckedCount(listId: Long): Flow<Int>
// Members
fun observeMembers(listId: Long): Flow<List<ShoppingListMemberEntity>>
suspend fun addMember(member: ShoppingListMemberEntity): Long
suspend fun updateMember(member: ShoppingListMemberEntity)
suspend fun removeMember(member: ShoppingListMemberEntity)
suspend fun deleteAllMembers(listId: Long)
// Helpers // Helpers
suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity)
} }

View File

@ -98,11 +98,14 @@ class GetShoppingListsUseCase @Inject constructor(
fun observeActive() = repo.observeActiveLists() fun observeActive() = repo.observeActiveLists()
fun observeAll() = repo.observeAllLists() fun observeAll() = repo.observeAllLists()
suspend fun getList(id: Long) = repo.getListById(id) suspend fun getList(id: Long) = repo.getListById(id)
suspend fun createList(name: String) = repo.createList(name) suspend fun createList(name: String, backgroundResName: String? = null) = repo.createList(name, backgroundResName)
suspend fun updateList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.updateList(list) suspend fun updateList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.updateList(list)
suspend fun deleteList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.deleteList(list) suspend fun deleteList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.deleteList(list)
fun observeItemCount(listId: Long) = repo.observeItemCount(listId) fun observeItemCount(listId: Long) = repo.observeItemCount(listId)
fun observeCheckedCount(listId: Long) = repo.observeCheckedCount(listId) fun observeCheckedCount(listId: Long) = repo.observeCheckedCount(listId)
fun observeMembers(listId: Long) = repo.observeMembers(listId)
suspend fun addMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.addMember(member)
suspend fun removeMember(member: com.safebite.app.data.local.database.entity.ShoppingListMemberEntity) = repo.removeMember(member)
} }
class ManageShoppingListUseCase @Inject constructor( class ManageShoppingListUseCase @Inject constructor(

View File

@ -15,6 +15,13 @@ import androidx.navigation.navArgument
import com.safebite.app.presentation.screen.product.ProductDetailScreen import com.safebite.app.presentation.screen.product.ProductDetailScreen
import com.safebite.app.presentation.screen.tracking.TrackingScreen import com.safebite.app.presentation.screen.tracking.TrackingScreen
import com.safebite.app.presentation.screen.lists.ListDetailScreen import com.safebite.app.presentation.screen.lists.ListDetailScreen
import com.safebite.app.presentation.screen.lists.ListsScreen
import com.safebite.app.presentation.screen.lists.create.CreateListScreen
import com.safebite.app.presentation.screen.lists.settings.ListSettingsScreen
import com.safebite.app.presentation.screen.lists.settings.ListSortScreen
import com.safebite.app.presentation.screen.lists.settings.ListRegionScreen
import com.safebite.app.presentation.screen.lists.settings.ListNameImageScreen
import com.safebite.app.presentation.screen.lists.settings.ListMembersScreen
import com.safebite.app.presentation.screen.main.MainScreen import com.safebite.app.presentation.screen.main.MainScreen
import com.safebite.app.presentation.screen.ocr.OcrCaptureScreen import com.safebite.app.presentation.screen.ocr.OcrCaptureScreen
import com.safebite.app.presentation.screen.ocr.OcrReviewScreen import com.safebite.app.presentation.screen.ocr.OcrReviewScreen
@ -85,7 +92,9 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
onOpenSettings = { navController.navigate(Screen.Settings.route) }, onOpenSettings = { navController.navigate(Screen.Settings.route) },
onOpenProfile = { id -> navController.navigate(Screen.ProfileEdit.edit(id)) }, onOpenProfile = { id -> navController.navigate(Screen.ProfileEdit.edit(id)) },
onOpenListDetail = { id, name -> navController.navigate(Screen.ListDetail.build(id, name)) }, onOpenListDetail = { id, name -> navController.navigate(Screen.ListDetail.build(id, name)) },
onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) } onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) },
onOpenListCreate = { navController.navigate(Screen.ListCreate.route) },
onOpenListSettings = { id -> navController.navigate(Screen.ListSettings.build(id)) }
) )
} }
@ -217,5 +226,67 @@ fun SafeBiteNavGraph(onboardingCompleted: Boolean, showSplash: Boolean = false)
onOpenProduct = { b -> navController.navigate(Screen.ProductDetail.build(b)) } onOpenProduct = { b -> navController.navigate(Screen.ProductDetail.build(b)) }
) )
} }
// ── List management (refonte) ──
composable(Screen.ListCreate.route) {
CreateListScreen(
onBack = { navController.popBackStack() },
onListCreated = { navController.popBackStack() }
)
}
composable(
route = Screen.ListSettings.route,
arguments = listOf(navArgument("id") { type = NavType.LongType })
) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L
ListSettingsScreen(
listId = listId,
onBack = { navController.popBackStack() },
onOpenSort = { navController.navigate(Screen.ListSort.build(listId)) },
onOpenRegion = { navController.navigate(Screen.ListRegion.build(listId)) },
onOpenNameImage = { navController.navigate(Screen.ListNameImage.build(listId)) },
onOpenMembers = { navController.navigate(Screen.ListMembers.build(listId)) }
)
}
composable(
route = Screen.ListSort.route,
arguments = listOf(navArgument("id") { type = NavType.LongType })
) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L
ListSortScreen(
listId = listId,
onBack = { navController.popBackStack() }
)
}
composable(
route = Screen.ListRegion.route,
arguments = listOf(navArgument("id") { type = NavType.LongType })
) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L
ListRegionScreen(
listId = listId,
onBack = { navController.popBackStack() }
)
}
composable(
route = Screen.ListNameImage.route,
arguments = listOf(navArgument("id") { type = NavType.LongType })
) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L
ListNameImageScreen(
listId = listId,
onBack = { navController.popBackStack() }
)
}
composable(
route = Screen.ListMembers.route,
arguments = listOf(navArgument("id") { type = NavType.LongType })
) { entry ->
val listId = entry.arguments?.getLong("id") ?: 0L
ListMembersScreen(
listId = listId,
onBack = { navController.popBackStack() }
)
}
} }
} }

View File

@ -57,6 +57,24 @@ sealed class Screen(val route: String) {
fun new() = "list/edit?id=0" fun new() = "list/edit?id=0"
fun edit(id: Long) = "list/edit?id=$id" fun edit(id: Long) = "list/edit?id=$id"
} }
// ── List management (refonte) ──
data object ListCreate : Screen("list/create")
data object ListSettings : Screen("list/settings/{id}") {
fun build(id: Long) = "list/settings/$id"
}
data object ListSort : Screen("list/sort/{id}") {
fun build(id: Long) = "list/sort/$id"
}
data object ListRegion : Screen("list/region/{id}") {
fun build(id: Long) = "list/region/$id"
}
data object ListNameImage : Screen("list/nameimage/{id}") {
fun build(id: Long) = "list/nameimage/$id"
}
data object ListMembers : Screen("list/members/{id}") {
fun build(id: Long) = "list/members/$id"
}
} }
/** /**

View File

@ -1,79 +1,92 @@
package com.safebite.app.presentation.screen.lists package com.safebite.app.presentation.screen.lists
import android.content.res.Resources
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MergeType import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.ShoppingCart import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.safebite.app.R import com.safebite.app.R
import com.safebite.app.data.local.database.entity.ShoppingListEntity import com.safebite.app.data.local.database.entity.ShoppingListMemberEntity
import com.safebite.app.presentation.common.components.EmptyState import com.safebite.app.presentation.common.components.EmptyState
import com.safebite.app.presentation.common.components.PrimaryButton import com.safebite.app.presentation.common.components.PrimaryButton
import com.safebite.app.presentation.screen.lists.util.backgroundByResName
import com.safebite.app.presentation.theme.LocalDimens import com.safebite.app.presentation.theme.LocalDimens
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/**
* Écran Listes (spec UX §5.5 - Flow 5).
*
* Affiche la liste des courses avec progression et alertes allergies.
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ListsScreen( fun ListsScreen(
onOpenList: (Long, String) -> Unit, onOpenList: (Long, String) -> Unit,
onOpenScanner: () -> Unit, onOpenScanner: () -> Unit,
onOpenListCreate: () -> Unit,
onOpenListSettings: (Long) -> Unit,
viewModel: ListsViewModel = hiltViewModel() viewModel: ListsViewModel = hiltViewModel()
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
var showCreateDialog by remember { mutableStateOf(false) } val isEditMode by viewModel.isEditMode.collectAsStateWithLifecycle()
val dimens = LocalDimens.current
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
@ -81,17 +94,32 @@ fun ListsScreen(
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.lists_title)) }, title = { Text(stringResource(R.string.lists_title)) },
actions = { actions = {
val addContentDesc = stringResource(R.string.a11y_add) when (val s = state) {
IconButton( is ListsViewModel.UiState.Success -> {
onClick = { showCreateDialog = true }, if (isEditMode) {
modifier = Modifier.semantics { TextButton(onClick = { viewModel.toggleEditMode() }) {
contentDescription = addContentDesc Text(stringResource(R.string.lists_done))
}
} else if (s.lists.isNotEmpty()) {
TextButton(onClick = { viewModel.toggleEditMode() }) {
Text(stringResource(R.string.lists_edit))
}
}
} }
) { else -> {}
Icon(Icons.Filled.Add, contentDescription = null)
} }
} }
) )
},
floatingActionButton = {
FloatingActionButton(
onClick = onOpenListCreate,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
shape = MaterialTheme.shapes.medium
) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.lists_new))
}
} }
) { padding -> ) { padding ->
Box( Box(
@ -101,38 +129,29 @@ fun ListsScreen(
) { ) {
when (val s = state) { when (val s = state) {
is ListsViewModel.UiState.Loading -> { is ListsViewModel.UiState.Loading -> {
CircularProgressIndicator( CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
modifier = Modifier.align(Alignment.Center)
)
} }
is ListsViewModel.UiState.Empty -> { is ListsViewModel.UiState.Empty -> {
EmptyState( EmptyState(
title = "Aucune liste", title = stringResource(R.string.lists_empty),
message = s.message, message = s.message,
emoji = "📋", emoji = "📋",
action = { action = {
PrimaryButton( PrimaryButton(
text = "Créer une liste", text = stringResource(R.string.lists_new),
onClick = { showCreateDialog = true } onClick = onOpenListCreate
) )
} }
) )
} }
is ListsViewModel.UiState.Success -> { is ListsViewModel.UiState.Success -> {
LazyColumn( ReorderableList(
modifier = Modifier.fillMaxSize(), items = s.lists,
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), isEditMode = isEditMode,
verticalArrangement = Arrangement.spacedBy(12.dp) onItemClick = { item -> onOpenList(item.list.id, item.list.name) },
) { onSettingsClick = { item -> onOpenListSettings(item.list.id) },
items(s.lists, key = { it.list.id }) { item -> onReorder = { from, to -> viewModel.reorderLists(from, to) }
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 -> { is ListsViewModel.UiState.Error -> {
EmptyState( EmptyState(
@ -144,149 +163,216 @@ fun ListsScreen(
} }
} }
} }
}
if (showCreateDialog) { @Composable
CreateListDialog( private fun ReorderableList(
onDismiss = { showCreateDialog = false }, items: List<ListsViewModel.ShoppingListWithStats>,
onCreate = { name -> isEditMode: Boolean,
viewModel.createList(name) onItemClick: (ListsViewModel.ShoppingListWithStats) -> Unit,
showCreateDialog = false onSettingsClick: (ListsViewModel.ShoppingListWithStats) -> Unit,
onReorder: (Int, Int) -> Unit
) {
var draggedIndex by remember { mutableStateOf<Int?>(null) }
var dragOffsetY by remember { mutableFloatStateOf(0f) }
val scope = rememberCoroutineScope()
val itemHeight = 160.dp
val itemPx = with(LocalContext.current.resources.displayMetrics) { itemHeight.value * density }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
itemsIndexed(
items = items,
key = { _, item -> item.list.id }
) { index, item ->
val isDragged = draggedIndex == index
val zIndex = if (isDragged) 1f else 0f
val elevation = if (isDragged) 8.dp else 2.dp
val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0
Box(
modifier = Modifier
.zIndex(zIndex)
.offset { IntOffset(0, offsetY) }
.graphicsLayer {
scaleX = if (isDragged) 1.02f else 1f
scaleY = if (isDragged) 1.02f else 1f
}
.then(
if (isEditMode) {
Modifier.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { draggedIndex = index },
onDragEnd = {
draggedIndex?.let { from ->
val to = (from + (dragOffsetY / itemPx).roundToInt())
.coerceIn(0, items.size - 1)
if (from != to) onReorder(from, to)
}
draggedIndex = null
dragOffsetY = 0f
},
onDragCancel = {
draggedIndex = null
dragOffsetY = 0f
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetY += dragAmount.y
}
)
}
} else Modifier
)
) {
ShoppingListCard(
item = item,
isEditMode = isEditMode,
onClick = { onItemClick(item) },
onSettingsClick = { onSettingsClick(item) }
)
} }
) }
} }
} }
@Composable @Composable
private fun ShoppingListCard( private fun ShoppingListCard(
item: ListsViewModel.ShoppingListWithStats, item: ListsViewModel.ShoppingListWithStats,
isEditMode: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onDelete: () -> Unit, onSettingsClick: () -> Unit
onMerge: () -> Unit
) { ) {
val dimens = LocalDimens.current val dimens = LocalDimens.current
val progress = if (item.itemCount > 0) item.checkedCount.toFloat() / item.itemCount else 0f val bg = backgroundByResName(item.list.backgroundResName)
var showMenu by remember { mutableStateOf(false) }
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick), .height(160.dp)
colors = CardDefaults.cardColors( .then(if (!isEditMode) Modifier.clickable(onClick = onClick) else Modifier),
containerColor = MaterialTheme.colorScheme.surface shape = RoundedCornerShape(16.dp),
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Column( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier.padding(dimens.spacingMd) // Background image
) { if (bg != null) {
Row( Image(
modifier = Modifier.fillMaxWidth(), painter = painterResource(id = bg.drawableRes),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.ShoppingCart,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(32.dp), modifier = Modifier.fillMaxSize(),
tint = MaterialTheme.colorScheme.primary contentScale = ContentScale.Crop
) )
Spacer(Modifier.padding(start = dimens.spacingSm)) Box(
Column(modifier = Modifier.weight(1f)) { modifier = Modifier
Text( .fillMaxSize()
text = item.list.name, .background(Color.Black.copy(alpha = 0.35f))
style = MaterialTheme.typography.titleMedium, )
fontWeight = FontWeight.SemiBold, } else {
maxLines = 1, Box(
overflow = TextOverflow.Ellipsis modifier = Modifier
) .fillMaxSize()
Spacer(Modifier.height(4.dp)) .background(MaterialTheme.colorScheme.primaryContainer)
Text( )
text = "${item.itemCount} produits • ${item.checkedCount} achetés", }
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant Column(
) modifier = Modifier
} .fillMaxSize()
// Menu actions .padding(dimens.spacingMd)
Box { ) {
val moreOptionsDesc = stringResource(R.string.a11y_more_options) Row(
IconButton( modifier = Modifier.fillMaxWidth(),
onClick = { showMenu = true }, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.semantics { verticalAlignment = Alignment.Top
contentDescription = moreOptionsDesc ) {
} if (isEditMode) {
) { Icon(
Icon(Icons.Filled.MoreVert, contentDescription = null) imageVector = Icons.Filled.DragHandle,
contentDescription = "Reorder",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
} else {
Spacer(modifier = Modifier.width(24.dp))
} }
DropdownMenu(
expanded = showMenu, IconButton(
onDismissRequest = { showMenu = false } onClick = onSettingsClick,
modifier = Modifier.size(32.dp)
) { ) {
DropdownMenuItem( Icon(
text = { Text("Fusionner avec...") }, imageVector = Icons.Filled.Settings,
leadingIcon = { Icon(Icons.Filled.MergeType, contentDescription = null) }, contentDescription = stringResource(R.string.lists_settings),
onClick = { tint = Color.White,
showMenu = false modifier = Modifier.size(20.dp)
onMerge()
}
)
DropdownMenuItem(
text = { Text("Supprimer") },
leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) },
onClick = {
showMenu = false
onDelete()
}
) )
} }
} }
Spacer(modifier = Modifier.weight(1f))
// List name
Text(
text = item.list.name,
style = MaterialTheme.typography.titleLarge,
color = Color.White,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Item count badge
val remaining = item.itemCount - item.checkedCount
val badgeColor = if (remaining > 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.tertiary
Text(
text = "$remaining articles",
style = MaterialTheme.typography.labelMedium,
color = Color.White,
modifier = Modifier
.background(badgeColor.copy(alpha = 0.85f), RoundedCornerShape(12.dp))
.padding(horizontal = 8.dp, vertical = 2.dp)
)
Spacer(modifier = Modifier.width(8.dp))
// Member avatars
Row(
horizontalArrangement = Arrangement.spacedBy((-8).dp)
) {
item.members.take(3).forEach { member ->
MemberAvatar(member = member)
}
}
}
} }
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 @Composable
private fun CreateListDialog( private fun MemberAvatar(member: ShoppingListMemberEntity) {
onDismiss: () -> Unit, Box(
onCreate: (String) -> Unit modifier = Modifier
) { .size(32.dp)
var listName by remember { mutableStateOf("") } .clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
AlertDialog( contentAlignment = Alignment.Center
onDismissRequest = onDismiss, ) {
title = { Text("Nouvelle liste") }, Text(
text = { text = member.name.take(1).uppercase(),
OutlinedTextField( style = MaterialTheme.typography.labelMedium,
value = listName, fontWeight = FontWeight.Bold,
onValueChange = { listName = it }, color = MaterialTheme.colorScheme.onSurfaceVariant
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")
}
}
)
} }

View File

@ -3,10 +3,12 @@ package com.safebite.app.presentation.screen.lists
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.safebite.app.data.local.database.entity.ShoppingListEntity import com.safebite.app.data.local.database.entity.ShoppingListEntity
import com.safebite.app.data.local.database.entity.ShoppingListMemberEntity
import com.safebite.app.domain.usecase.GetShoppingListsUseCase import com.safebite.app.domain.usecase.GetShoppingListsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
@ -35,25 +37,30 @@ class ListsViewModel @Inject constructor(
data class ShoppingListWithStats( data class ShoppingListWithStats(
val list: ShoppingListEntity, val list: ShoppingListEntity,
val itemCount: Int, val itemCount: Int,
val checkedCount: Int val checkedCount: Int,
val members: List<ShoppingListMemberEntity> = emptyList()
) )
private val _isEditMode = MutableStateFlow(false)
val isEditMode: StateFlow<Boolean> = _isEditMode
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val state: StateFlow<UiState> = getShoppingListsUseCase.observeActive() val state: StateFlow<UiState> = getShoppingListsUseCase.observeActive()
.flatMapLatest { lists -> .flatMapLatest { lists ->
if (lists.isEmpty()) { if (lists.isEmpty()) {
flowOf(UiState.Empty("Aucune liste de courses. Créez votre première liste !")) flowOf(UiState.Empty("Aucune liste de courses. Créez votre première liste !"))
} else { } else {
val statsFlows = lists.map { list -> val statsFlows = lists.sortedBy { it.displayOrder }.map { list ->
combine( combine(
getShoppingListsUseCase.observeItemCount(list.id), getShoppingListsUseCase.observeItemCount(list.id),
getShoppingListsUseCase.observeCheckedCount(list.id) getShoppingListsUseCase.observeCheckedCount(list.id),
) { itemCount, checkedCount -> getShoppingListsUseCase.observeMembers(list.id)
ShoppingListWithStats(list, itemCount, checkedCount) ) { itemCount, checkedCount, members ->
ShoppingListWithStats(list, itemCount, checkedCount, members.take(3))
} }
} }
combine(statsFlows) { array -> combine(statsFlows) { array ->
UiState.Success(array.toList()) UiState.Success(array.toList().sortedBy { it.list.displayOrder })
} }
} }
} }
@ -63,9 +70,9 @@ class ListsViewModel @Inject constructor(
initialValue = UiState.Loading initialValue = UiState.Loading
) )
fun createList(name: String) { fun createList(name: String, backgroundResName: String? = null) {
viewModelScope.launch { viewModelScope.launch {
getShoppingListsUseCase.createList(name) getShoppingListsUseCase.createList(name, backgroundResName)
} }
} }
@ -74,4 +81,22 @@ class ListsViewModel @Inject constructor(
getShoppingListsUseCase.deleteList(list) getShoppingListsUseCase.deleteList(list)
} }
} }
fun toggleEditMode() {
_isEditMode.value = !_isEditMode.value
}
fun reorderLists(fromIndex: Int, toIndex: Int) {
viewModelScope.launch {
val current = state.value as? UiState.Success ?: return@launch
val mutable = current.lists.toMutableList()
val moved = mutable.removeAt(fromIndex)
mutable.add(toIndex.coerceIn(0, mutable.size), moved)
mutable.forEachIndexed { index, item ->
getShoppingListsUseCase.updateList(
item.list.copy(displayOrder = index)
)
}
}
}
} }

View File

@ -0,0 +1,166 @@
package com.safebite.app.presentation.screen.lists.create
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
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.aspectRatio
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.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
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.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.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
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 com.safebite.app.R
import com.safebite.app.presentation.screen.lists.ListsViewModel
import com.safebite.app.presentation.screen.lists.util.allListBackgrounds
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateListScreen(
onBack: () -> Unit,
onListCreated: () -> Unit,
viewModel: ListsViewModel = hiltViewModel()
) {
var listName by remember { mutableStateOf("") }
var selectedBg by remember { mutableStateOf(allListBackgrounds.firstOrNull()?.resName) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.list_create_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
TextButton(
onClick = {
if (listName.isNotBlank()) {
viewModel.createList(listName, selectedBg)
onListCreated()
}
},
enabled = listName.isNotBlank()
) {
Text(stringResource(R.string.list_create_next))
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
OutlinedTextField(
value = listName,
onValueChange = { listName = it },
label = { Text(stringResource(R.string.list_name_hint)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.list_choose_background),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
items(allListBackgrounds) { bg ->
val isSelected = selectedBg == bg.resName
Card(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.5f),
shape = RoundedCornerShape(12.dp),
border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
onClick = { selectedBg = bg.resName }
) {
Box(modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(id = bg.drawableRes),
contentDescription = bg.label,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
if (isSelected) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
contentAlignment = Alignment.TopEnd
) {
Box(
modifier = Modifier
.size(28.dp)
.background(Color.White, RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
}
}
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,189 @@
package com.safebite.app.presentation.screen.lists.settings
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.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.ArrowBack
import androidx.compose.material.icons.filled.RemoveCircleOutline
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.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.data.local.database.entity.ShoppingListMemberEntity
import com.safebite.app.presentation.screen.lists.ListsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListMembersScreen(
listId: Long,
onBack: () -> Unit,
viewModel: ListsViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
val members = listData?.members ?: emptyList()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.list_members_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
if (listData == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
Column(modifier = Modifier.fillMaxSize()) {
Text(
text = stringResource(R.string.list_members_count, members.size),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp)
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
items(members) { member ->
MemberRow(
member = member,
onRemove = {
// TODO: remove member via viewmodel/usecase
}
)
}
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = { /* TODO: invite UI placeholder */ },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.list_invite_member))
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
}
@Composable
private fun MemberRow(
member: ShoppingListMemberEntity,
onRemove: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Text(
text = member.name.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = member.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold
)
Text(
text = member.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Filled.RemoveCircleOutline,
contentDescription = stringResource(R.string.list_remove_member),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}

View File

@ -0,0 +1,224 @@
package com.safebite.app.presentation.screen.lists.settings
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
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.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
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.Text
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.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
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.screen.lists.ListsViewModel
import com.safebite.app.presentation.screen.lists.util.allListBackgrounds
import com.safebite.app.presentation.screen.lists.util.backgroundByResName
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListNameImageScreen(
listId: Long,
onBack: () -> Unit,
viewModel: ListsViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
var listName by remember(listData?.list?.name) { mutableStateOf(listData?.list?.name ?: "") }
var selectedBg by remember(listData?.list?.backgroundResName) {
mutableStateOf(listData?.list?.backgroundResName)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.list_name_image_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
if (listData == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
Column(modifier = Modifier.fillMaxSize()) {
// Preview
val bg = backgroundByResName(selectedBg)
Card(
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
shape = RoundedCornerShape(16.dp)
) {
Box(modifier = Modifier.fillMaxSize()) {
if (bg != null) {
Image(
painter = painterResource(id = bg.drawableRes),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.35f))
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer)
)
}
Text(
text = listName.ifBlank { listData.list.name },
style = MaterialTheme.typography.titleLarge,
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = listName,
onValueChange = { listName = it },
label = { Text(stringResource(R.string.list_name_hint)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.list_choose_background),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
items(allListBackgrounds) { bg ->
val isSelected = selectedBg == bg.resName
Card(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.5f),
shape = RoundedCornerShape(12.dp),
border = if (isSelected) BorderStroke(3.dp, MaterialTheme.colorScheme.primary) else null,
onClick = { selectedBg = bg.resName }
) {
Box(modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(id = bg.drawableRes),
contentDescription = bg.label,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
if (isSelected) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
contentAlignment = Alignment.TopEnd
) {
Box(
modifier = Modifier
.size(28.dp)
.background(Color.White, RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
}
}
}
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
val updated = listData.list.copy(
name = listName.ifBlank { listData.list.name },
backgroundResName = selectedBg
)
// TODO: update via viewmodel/usecase
onBack()
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Text(stringResource(R.string.action_save))
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
}

View File

@ -0,0 +1,144 @@
package com.safebite.app.presentation.screen.lists.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.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.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
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.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.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.screen.lists.ListsViewModel
private val availableRegions = listOf(
"Allemagne" to "de",
"Australie" to "au",
"Autriche" to "at",
"Canada" to "ca",
"Espagne" to "es",
"France" to "fr",
"Hongrie" to "hu",
"Italie" to "it",
"Norvège" to "no",
"Pays-Bas" to "nl",
"Pologne" to "pl",
"Portugal" to "pt",
"Royaume-Uni" to "gb",
"Russie" to "ru",
"Suisse (Allemand)" to "ch_de",
"Suisse (français)" to "ch_fr"
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListRegionScreen(
listId: Long,
onBack: () -> Unit,
viewModel: ListsViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
var selectedRegion by remember(listData?.list?.region) {
mutableStateOf(listData?.list?.region)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.list_region_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.list_region_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxWidth()
) {
items(availableRegions) { (name, code) ->
val isSelected = selectedRegion == code
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
selectedRegion = code
listData?.let {
// TODO: persist via viewmodel/usecase
}
}
.padding(vertical = 14.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
if (isSelected) {
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,243 @@
package com.safebite.app.presentation.screen.lists.settings
import androidx.compose.foundation.Image
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.People
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
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.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
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.screen.lists.ListsViewModel
import com.safebite.app.presentation.screen.lists.util.backgroundByResName
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListSettingsScreen(
listId: Long,
onBack: () -> Unit,
onOpenSort: () -> Unit,
onOpenRegion: () -> Unit,
onOpenNameImage: () -> Unit,
onOpenMembers: () -> Unit,
viewModel: ListsViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.list_settings_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
if (listData == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
Column(modifier = Modifier.fillMaxSize()) {
// Header card with list preview
val bg = backgroundByResName(listData.list.backgroundResName)
Card(
modifier = Modifier
.fillMaxWidth()
.height(140.dp),
shape = RoundedCornerShape(16.dp)
) {
Box(modifier = Modifier.fillMaxSize()) {
if (bg != null) {
Image(
painter = painterResource(id = bg.drawableRes),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.35f))
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.primaryContainer)
)
}
Text(
text = listData.list.name,
style = MaterialTheme.typography.titleLarge,
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Settings grid
Text(
text = stringResource(R.string.list_personalize),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(12.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
item {
SettingsTile(
icon = Icons.AutoMirrored.Filled.Sort,
label = stringResource(R.string.list_sort),
onClick = onOpenSort
)
}
item {
SettingsTile(
icon = Icons.Filled.Language,
label = stringResource(R.string.list_region_language),
onClick = onOpenRegion
)
}
item {
SettingsTile(
icon = Icons.Filled.People,
label = stringResource(R.string.list_members),
onClick = onOpenMembers
)
}
item {
SettingsTile(
icon = Icons.Filled.Brush,
label = stringResource(R.string.list_name_image),
onClick = onOpenNameImage
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Leave list button
Button(
onClick = {
viewModel.deleteList(listData.list)
onBack()
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Text(stringResource(R.string.list_leave))
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
}
@Composable
private fun SettingsTile(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.2f),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}

View File

@ -0,0 +1,196 @@
package com.safebite.app.presentation.screen.lists.settings
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
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.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.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.domain.engine.CatalogProvider
import com.safebite.app.presentation.screen.lists.ListsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListSortScreen(
listId: Long,
onBack: () -> Unit,
viewModel: ListsViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
val catalog = remember { CatalogProvider() }
var visibleCategories by remember(listData?.list?.visibleCategories) {
mutableStateOf(
listData?.list?.visibleCategories?.split(",")?.toSet() ?: catalog.categories.toSet()
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.list_sort_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
Text(
text = stringResource(R.string.action_save),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier
.padding(end = 16.dp)
.clickable {
listData?.let {
val updated = it.list.copy(
visibleCategories = visibleCategories.joinToString(",")
)
// TODO: update via viewmodel/usecase
}
onBack()
}
)
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.list_sort_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(R.string.list_sort_preview),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 8.dp)
) {
itemsIndexed(catalog.categories) { _, category ->
val isVisible = category in visibleCategories
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable {
visibleCategories = if (isVisible) {
visibleCategories - category
} else {
visibleCategories + category
}
}
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = category,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
IconButton(onClick = {
visibleCategories = if (isVisible) {
visibleCategories - category
} else {
visibleCategories + category
}
}) {
Icon(
imageVector = if (isVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = null,
tint = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
imageVector = Icons.Filled.DragHandle,
contentDescription = null,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
}
}
}

View File

@ -0,0 +1,28 @@
package com.safebite.app.presentation.screen.lists.util
import com.safebite.app.R
data class ListBackground(
val resName: String,
val label: String,
val drawableRes: Int
)
val allListBackgrounds: List<ListBackground> = listOf(
ListBackground("bg_animaux", "Animaux", R.drawable.bg_animaux),
ListBackground("bg_baby", "Bébé", R.drawable.bg_baby),
ListBackground("bg_epicerie", "Épicerie", R.drawable.bg_epicerie),
ListBackground("bg_epicerie2", "Épicerie 2", R.drawable.bg_epicerie2),
ListBackground("bg_jardinage", "Maison & Jardin", R.drawable.bg_jardinage),
ListBackground("bg_office", "Bureau", R.drawable.bg_office),
ListBackground("bg_party", "Fête", R.drawable.bg_party),
ListBackground("bg_pharmacie", "Pharmacie", R.drawable.bg_pharmacie),
ListBackground("bg_plage", "Plage", R.drawable.bg_plage),
ListBackground("bg_renovation", "Rénovation", R.drawable.bg_renovation)
)
fun backgroundByResName(name: String?): ListBackground? =
allListBackgrounds.firstOrNull { it.resName == name }
fun backgroundLabel(name: String?): String =
backgroundByResName(name)?.label ?: ""

View File

@ -70,6 +70,8 @@ fun MainScreen(
onOpenProfile: (Long) -> Unit, onOpenProfile: (Long) -> Unit,
onOpenListDetail: (Long, String) -> Unit, onOpenListDetail: (Long, String) -> Unit,
onOpenHistoryItem: (String) -> Unit, onOpenHistoryItem: (String) -> Unit,
onOpenListCreate: () -> Unit = {},
onOpenListSettings: (Long) -> Unit = {},
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val currentBackStackEntry by navController.currentBackStackEntryAsState() val currentBackStackEntry by navController.currentBackStackEntryAsState()
@ -141,7 +143,9 @@ fun MainScreen(
composable(Screen.Lists.route) { composable(Screen.Lists.route) {
ListsScreen( ListsScreen(
onOpenList = { id, name -> onOpenListDetail(id, name) }, onOpenList = { id, name -> onOpenListDetail(id, name) },
onOpenScanner = onOpenScanner onOpenScanner = onOpenScanner,
onOpenListCreate = onOpenListCreate,
onOpenListSettings = onOpenListSettings
) )
} }
composable(Screen.Tracking.route) { composable(Screen.Tracking.route) {

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Before

Width:  |  Height:  |  Size: 912 KiB

After

Width:  |  Height:  |  Size: 912 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -206,4 +206,37 @@
<string name="list_item_name">Name</string> <string name="list_item_name">Name</string>
<string name="list_item_description">Description</string> <string name="list_item_description">Description</string>
<string name="list_add_item_confirm">Add</string> <string name="list_add_item_confirm">Add</string>
<!-- Lists -->
<string name="lists_title">My lists</string>
<string name="lists_new">New list</string>
<string name="lists_empty">No list</string>
<string name="lists_products_count">%1$d products</string>
<string name="lists_bought_count">%1$d bought</string>
<string name="lists_edit">EDIT</string>
<string name="lists_done">DONE</string>
<string name="lists_settings">Settings</string>
<!-- List management (refonte) -->
<string name="list_create_title">New list</string>
<string name="list_create_next">Next</string>
<string name="list_name_hint">List name</string>
<string name="list_choose_background">Choose a background</string>
<string name="list_settings_title">List settings</string>
<string name="list_personalize">Personalize list</string>
<string name="list_sort">Sort</string>
<string name="list_region_language">Region &amp; Language</string>
<string name="list_members">List members</string>
<string name="list_name_image">Name &amp; Image</string>
<string name="list_leave">Leave this list</string>
<string name="list_sort_title">Sort</string>
<string name="list_sort_description">Bring! sorts items to buy by category order. Arrange them like in your usual store!</string>
<string name="list_sort_preview">List preview</string>
<string name="list_region_title">Region &amp; Language</string>
<string name="list_region_description">In which region do you shop with this list? We will adapt the assortment for you.</string>
<string name="list_members_title">List members</string>
<string name="list_members_count">%1$d members</string>
<string name="list_invite_member">Invite a new person</string>
<string name="list_remove_member">Remove</string>
<string name="list_name_image_title">Edit list</string>
</resources> </resources>

View File

@ -75,6 +75,32 @@
<string name="lists_empty">Aucune liste</string> <string name="lists_empty">Aucune liste</string>
<string name="lists_products_count">%1$d produits</string> <string name="lists_products_count">%1$d produits</string>
<string name="lists_bought_count">%1$d achetés</string> <string name="lists_bought_count">%1$d achetés</string>
<string name="lists_edit">ÉDITER</string>
<string name="lists_done">TERMINÉ</string>
<string name="lists_settings">Paramètres</string>
<!-- List management (refonte) -->
<string name="list_create_title">Nouvelle liste</string>
<string name="list_create_next">Suivant</string>
<string name="list_name_hint">Nom de la liste</string>
<string name="list_choose_background">Choisir un fond</string>
<string name="list_settings_title">Paramètres de la liste</string>
<string name="list_personalize">Personnaliser la liste</string>
<string name="list_sort">Trier</string>
<string name="list_region_language">Région &amp; Langue</string>
<string name="list_members">Membres de la liste</string>
<string name="list_name_image">Nom &amp; Image</string>
<string name="list_leave">Quitter cette liste</string>
<string name="list_sort_title">Trier</string>
<string name="list_sort_description">Bring! range les articles à acheter selon l\'ordre des catégories. Classez-les comme dans votre magasin habituel !</string>
<string name="list_sort_preview">Aperçu de la liste</string>
<string name="list_region_title">Région &amp; Langue</string>
<string name="list_region_description">Dans quelle région fais-tu tes achats avec cette liste ? Nous adapterons l\'assortiment pour toi.</string>
<string name="list_members_title">Membres de la liste</string>
<string name="list_members_count">%1$d membres</string>
<string name="list_invite_member">Inviter une nouvelle personne</string>
<string name="list_remove_member">Retirer</string>
<string name="list_name_image_title">Éditer la liste</string>
<!-- Tracking --> <!-- Tracking -->
<string name="tracking_title">Suivi</string> <string name="tracking_title">Suivi</string>

View File

@ -1,4 +1,4 @@
MAJOR=1 MAJOR=1
MINOR=14 MINOR=15
PATCH=0 PATCH=0
CODE=18 CODE=19