From 7656fba134f46b6c82b02575b2411de22caf1086 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 28 Apr 2026 07:24:59 -0400 Subject: [PATCH] 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 --- .../data/local/database/SafeBiteDatabase.kt | 6 +- .../local/database/dao/ShoppingListDao.kt | 23 +- .../data/local/database/entity/Entities.kt | 29 +- .../repository/ShoppingListRepositoryImpl.kt | 24 +- .../app/domain/repository/Repositories.kt | 10 +- .../safebite/app/domain/usecase/UseCases.kt | 5 +- .../app/presentation/navigation/NavGraph.kt | 73 +++- .../app/presentation/navigation/Screen.kt | 18 + .../presentation/screen/lists/ListsScreen.kt | 410 +++++++++++------- .../screen/lists/ListsViewModel.kt | 41 +- .../screen/lists/create/CreateListScreen.kt | 166 +++++++ .../lists/settings/ListMembersScreen.kt | 189 ++++++++ .../lists/settings/ListNameImageScreen.kt | 224 ++++++++++ .../screen/lists/settings/ListRegionScreen.kt | 144 ++++++ .../lists/settings/ListSettingsScreen.kt | 243 +++++++++++ .../screen/lists/settings/ListSortScreen.kt | 196 +++++++++ .../screen/lists/util/ListBackgrounds.kt | 28 ++ .../presentation/screen/main/MainScreen.kt | 6 +- .../{background_list => }/bg_animaux.png | Bin .../{background_list => }/bg_baby.png | Bin .../{background_list => }/bg_epicerie.png | Bin .../{background_list => }/bg_epicerie2.png | Bin .../{background_list => }/bg_jardinage.png | Bin .../{background_list => }/bg_office.png | Bin .../{background_list => }/bg_party.png | Bin .../{background_list => }/bg_pharmacie.png | Bin .../{background_list => }/bg_plage.png | Bin .../{background_list => }/bg_renovation.png | Bin app/src/main/res/values-en/strings.xml | 33 ++ app/src/main/res/values/strings.xml | 26 ++ version.properties | 4 +- 31 files changed, 1716 insertions(+), 182 deletions(-) create mode 100644 app/src/main/java/com/safebite/app/presentation/screen/lists/create/CreateListScreen.kt create mode 100644 app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListMembersScreen.kt create mode 100644 app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListNameImageScreen.kt create mode 100644 app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListRegionScreen.kt create mode 100644 app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSettingsScreen.kt create mode 100644 app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSortScreen.kt create mode 100644 app/src/main/java/com/safebite/app/presentation/screen/lists/util/ListBackgrounds.kt rename app/src/main/res/drawable/{background_list => }/bg_animaux.png (100%) rename app/src/main/res/drawable/{background_list => }/bg_baby.png (100%) rename app/src/main/res/drawable/{background_list => }/bg_epicerie.png (100%) rename app/src/main/res/drawable/{background_list => }/bg_epicerie2.png (100%) rename app/src/main/res/drawable/{background_list => }/bg_jardinage.png (100%) rename app/src/main/res/drawable/{background_list => }/bg_office.png (100%) rename app/src/main/res/drawable/{background_list => }/bg_party.png (100%) rename app/src/main/res/drawable/{background_list => }/bg_pharmacie.png (100%) rename app/src/main/res/drawable/{background_list => }/bg_plage.png (100%) rename app/src/main/res/drawable/{background_list => }/bg_renovation.png (100%) diff --git a/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt b/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt index c2b85fe..f7850ae 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt @@ -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.ShoppingListEntity 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 @Database( @@ -19,9 +20,10 @@ import com.safebite.app.data.local.database.entity.UserProfileEntity ProductCacheEntity::class, ScanHistoryEntity::class, ShoppingListEntity::class, - ShoppingListItemEntity::class + ShoppingListItemEntity::class, + ShoppingListMemberEntity::class ], - version = 5, + version = 6, exportSchema = false ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/safebite/app/data/local/database/dao/ShoppingListDao.kt b/app/src/main/java/com/safebite/app/data/local/database/dao/ShoppingListDao.kt index a7f0a44..843848d 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/dao/ShoppingListDao.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/dao/ShoppingListDao.kt @@ -9,6 +9,7 @@ import androidx.room.Transaction import androidx.room.Update import com.safebite.app.data.local.database.entity.ShoppingListEntity import com.safebite.app.data.local.database.entity.ShoppingListItemEntity +import com.safebite.app.data.local.database.entity.ShoppingListMemberEntity import kotlinx.coroutines.flow.Flow /** @@ -19,9 +20,12 @@ interface ShoppingListDao { // ── 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> + @Query("SELECT * FROM shopping_lists WHERE isArchived = 0 ORDER BY displayOrder ASC, updatedAt DESC") + fun observeActiveListsOrdered(): Flow> + @Query("SELECT * FROM shopping_lists ORDER BY updatedAt DESC") fun observeAllLists(): Flow> @@ -69,6 +73,23 @@ interface ShoppingListDao { @Query("DELETE FROM shopping_list_items WHERE listId = :listId") suspend fun deleteAllItems(listId: Long) + // ── Members ───────────────────────────────────────────────────────────── + + @Query("SELECT * FROM shopping_list_members WHERE listId = :listId ORDER BY joinedAt ASC") + fun observeMembers(listId: Long): Flow> + + @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 ─────────────────────────────────────────────────────────────── @Query("SELECT COUNT(*) FROM shopping_list_items WHERE listId = :listId") diff --git a/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt b/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt index 2eb5fa9..0856632 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt @@ -71,7 +71,12 @@ data class ShoppingListEntity( val name: String, val createdAt: 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( @@ -102,3 +107,25 @@ data class ShoppingListItemEntity( val tag: String? = null, // Tag visuel : "urgent", "offre", "whenever" 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() +) diff --git a/app/src/main/java/com/safebite/app/data/repository/ShoppingListRepositoryImpl.kt b/app/src/main/java/com/safebite/app/data/repository/ShoppingListRepositoryImpl.kt index 4860fdf..3bc4a81 100644 --- a/app/src/main/java/com/safebite/app/data/repository/ShoppingListRepositoryImpl.kt +++ b/app/src/main/java/com/safebite/app/data/repository/ShoppingListRepositoryImpl.kt @@ -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.entity.ShoppingListEntity 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 kotlinx.coroutines.flow.Flow import javax.inject.Inject @@ -22,11 +23,12 @@ class ShoppingListRepositoryImpl @Inject constructor( override suspend fun getListById(id: Long): ShoppingListEntity? = dao.getListById(id) - override suspend fun createList(name: String): Long { + override suspend fun createList(name: String, backgroundResName: String?): Long { val list = ShoppingListEntity( name = name, createdAt = System.currentTimeMillis(), - updatedAt = System.currentTimeMillis() + updatedAt = System.currentTimeMillis(), + backgroundResName = backgroundResName ) return dao.insertList(list) } @@ -81,4 +83,22 @@ class ShoppingListRepositoryImpl @Inject constructor( override suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) { dao.addItemToList(listId, item) } + + override fun observeMembers(listId: Long): Flow> = + 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) + } } diff --git a/app/src/main/java/com/safebite/app/domain/repository/Repositories.kt b/app/src/main/java/com/safebite/app/domain/repository/Repositories.kt index ebc9be9..927c8d3 100644 --- a/app/src/main/java/com/safebite/app/domain/repository/Repositories.kt +++ b/app/src/main/java/com/safebite/app/domain/repository/Repositories.kt @@ -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.ShoppingListItemEntity +import com.safebite.app.data.local.database.entity.ShoppingListMemberEntity import com.safebite.app.domain.model.AppLanguage import com.safebite.app.domain.model.DetectionLanguage import com.safebite.app.domain.model.HealthStrictness @@ -78,7 +79,7 @@ interface ShoppingListRepository { fun observeActiveLists(): Flow> fun observeAllLists(): Flow> 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 deleteList(list: ShoppingListEntity) suspend fun archiveList(id: Long) @@ -97,6 +98,13 @@ interface ShoppingListRepository { fun observeItemCount(listId: Long): Flow fun observeCheckedCount(listId: Long): Flow + // Members + fun observeMembers(listId: Long): Flow> + suspend fun addMember(member: ShoppingListMemberEntity): Long + suspend fun updateMember(member: ShoppingListMemberEntity) + suspend fun removeMember(member: ShoppingListMemberEntity) + suspend fun deleteAllMembers(listId: Long) + // Helpers suspend fun addItemToList(listId: Long, item: ShoppingListItemEntity) } diff --git a/app/src/main/java/com/safebite/app/domain/usecase/UseCases.kt b/app/src/main/java/com/safebite/app/domain/usecase/UseCases.kt index 46fcaa0..8b87201 100644 --- a/app/src/main/java/com/safebite/app/domain/usecase/UseCases.kt +++ b/app/src/main/java/com/safebite/app/domain/usecase/UseCases.kt @@ -98,11 +98,14 @@ class GetShoppingListsUseCase @Inject constructor( fun observeActive() = repo.observeActiveLists() fun observeAll() = repo.observeAllLists() suspend fun getList(id: Long) = repo.getListById(id) - suspend fun createList(name: String) = repo.createList(name) + suspend fun 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 deleteList(list: com.safebite.app.data.local.database.entity.ShoppingListEntity) = repo.deleteList(list) fun observeItemCount(listId: Long) = repo.observeItemCount(listId) fun observeCheckedCount(listId: Long) = repo.observeCheckedCount(listId) + 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( diff --git a/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt b/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt index 90f7e1e..10e0a59 100644 --- a/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/com/safebite/app/presentation/navigation/NavGraph.kt @@ -15,6 +15,13 @@ import androidx.navigation.navArgument import com.safebite.app.presentation.screen.product.ProductDetailScreen import com.safebite.app.presentation.screen.tracking.TrackingScreen import com.safebite.app.presentation.screen.lists.ListDetailScreen +import com.safebite.app.presentation.screen.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.ocr.OcrCaptureScreen 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) }, onOpenProfile = { id -> navController.navigate(Screen.ProfileEdit.edit(id)) }, onOpenListDetail = { id, name -> navController.navigate(Screen.ListDetail.build(id, name)) }, - onOpenHistoryItem = { barcode -> navController.navigate(Screen.Result.fromBarcode(barcode)) } + 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)) } ) } + + // ── 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() } + ) + } } } diff --git a/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt b/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt index 13b0f0c..9b4937d 100644 --- a/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt +++ b/app/src/main/java/com/safebite/app/presentation/navigation/Screen.kt @@ -57,6 +57,24 @@ sealed class Screen(val route: String) { fun new() = "list/edit?id=0" 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" + } } /** diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsScreen.kt index 83f5173..8b37d47 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsScreen.kt @@ -1,79 +1,92 @@ package com.safebite.app.presentation.screen.lists +import android.content.res.Resources 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.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset 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.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.filled.Add -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.MergeType -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.ShoppingCart -import androidx.compose.material3.AlertDialog +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.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.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset 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.lifecycle.compose.collectAsStateWithLifecycle 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.PrimaryButton +import com.safebite.app.presentation.screen.lists.util.backgroundByResName 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) @Composable fun ListsScreen( onOpenList: (Long, String) -> Unit, onOpenScanner: () -> Unit, + onOpenListCreate: () -> Unit, + onOpenListSettings: (Long) -> Unit, viewModel: ListsViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() - var showCreateDialog by remember { mutableStateOf(false) } + val isEditMode by viewModel.isEditMode.collectAsStateWithLifecycle() + val dimens = LocalDimens.current Scaffold( containerColor = MaterialTheme.colorScheme.background, @@ -81,17 +94,32 @@ fun ListsScreen( TopAppBar( title = { Text(stringResource(R.string.lists_title)) }, actions = { - val addContentDesc = stringResource(R.string.a11y_add) - IconButton( - onClick = { showCreateDialog = true }, - modifier = Modifier.semantics { - contentDescription = addContentDesc + when (val s = state) { + is ListsViewModel.UiState.Success -> { + if (isEditMode) { + TextButton(onClick = { viewModel.toggleEditMode() }) { + Text(stringResource(R.string.lists_done)) + } + } else if (s.lists.isNotEmpty()) { + TextButton(onClick = { viewModel.toggleEditMode() }) { + Text(stringResource(R.string.lists_edit)) + } + } } - ) { - Icon(Icons.Filled.Add, contentDescription = null) + else -> {} } } ) + }, + 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 -> Box( @@ -101,38 +129,29 @@ fun ListsScreen( ) { when (val s = state) { is ListsViewModel.UiState.Loading -> { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center) - ) + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } is ListsViewModel.UiState.Empty -> { EmptyState( - title = "Aucune liste", + title = stringResource(R.string.lists_empty), message = s.message, emoji = "📋", action = { PrimaryButton( - text = "Créer une liste", - onClick = { showCreateDialog = true } + text = stringResource(R.string.lists_new), + onClick = onOpenListCreate ) } ) } is ListsViewModel.UiState.Success -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(s.lists, key = { it.list.id }) { item -> - ShoppingListCard( - item = item, - onClick = { onOpenList(item.list.id, item.list.name) }, - onDelete = { viewModel.deleteList(item.list) }, - onMerge = { /* TODO: Ouvrir dialog fusion */ } - ) - } - } + ReorderableList( + items = s.lists, + isEditMode = isEditMode, + onItemClick = { item -> onOpenList(item.list.id, item.list.name) }, + onSettingsClick = { item -> onOpenListSettings(item.list.id) }, + onReorder = { from, to -> viewModel.reorderLists(from, to) } + ) } is ListsViewModel.UiState.Error -> { EmptyState( @@ -144,149 +163,216 @@ fun ListsScreen( } } } +} - if (showCreateDialog) { - CreateListDialog( - onDismiss = { showCreateDialog = false }, - onCreate = { name -> - viewModel.createList(name) - showCreateDialog = false +@Composable +private fun ReorderableList( + items: List, + isEditMode: Boolean, + onItemClick: (ListsViewModel.ShoppingListWithStats) -> Unit, + onSettingsClick: (ListsViewModel.ShoppingListWithStats) -> Unit, + onReorder: (Int, Int) -> Unit +) { + var draggedIndex by remember { mutableStateOf(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 private fun ShoppingListCard( item: ListsViewModel.ShoppingListWithStats, + isEditMode: Boolean, onClick: () -> Unit, - onDelete: () -> Unit, - onMerge: () -> Unit + onSettingsClick: () -> Unit ) { val dimens = LocalDimens.current - val progress = if (item.itemCount > 0) item.checkedCount.toFloat() / item.itemCount else 0f - var showMenu by remember { mutableStateOf(false) } + val bg = backgroundByResName(item.list.backgroundResName) Card( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), + .height(160.dp) + .then(if (!isEditMode) Modifier.clickable(onClick = onClick) else Modifier), + shape = RoundedCornerShape(16.dp), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { - Column( - modifier = Modifier.padding(dimens.spacingMd) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Filled.ShoppingCart, + Box(modifier = Modifier.fillMaxSize()) { + // Background image + if (bg != null) { + Image( + painter = painterResource(id = bg.drawableRes), contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) - Spacer(Modifier.padding(start = dimens.spacingSm)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = item.list.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(Modifier.height(4.dp)) - Text( - text = "${item.itemCount} produits • ${item.checkedCount} achetés", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - // Menu actions - Box { - val moreOptionsDesc = stringResource(R.string.a11y_more_options) - IconButton( - onClick = { showMenu = true }, - modifier = Modifier.semantics { - contentDescription = moreOptionsDesc - } - ) { - Icon(Icons.Filled.MoreVert, contentDescription = null) + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)) + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primaryContainer) + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(dimens.spacingMd) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + if (isEditMode) { + Icon( + imageVector = Icons.Filled.DragHandle, + contentDescription = "Reorder", + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } else { + Spacer(modifier = Modifier.width(24.dp)) } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } + + IconButton( + onClick = onSettingsClick, + modifier = Modifier.size(32.dp) ) { - DropdownMenuItem( - text = { Text("Fusionner avec...") }, - leadingIcon = { Icon(Icons.Filled.MergeType, contentDescription = null) }, - onClick = { - showMenu = false - onMerge() - } - ) - DropdownMenuItem( - text = { Text("Supprimer") }, - leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) }, - onClick = { - showMenu = false - onDelete() - } + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = stringResource(R.string.lists_settings), + tint = Color.White, + modifier = Modifier.size(20.dp) ) } } + + 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 -private fun CreateListDialog( - onDismiss: () -> Unit, - onCreate: (String) -> Unit -) { - var listName by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Nouvelle liste") }, - text = { - OutlinedTextField( - value = listName, - onValueChange = { listName = it }, - label = { Text("Nom de la liste") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - }, - confirmButton = { - TextButton( - onClick = { if (listName.isNotBlank()) onCreate(listName) }, - enabled = listName.isNotBlank() - ) { - Text("Créer") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Annuler") - } - } - ) +private fun MemberAvatar(member: ShoppingListMemberEntity) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Text( + text = member.name.take(1).uppercase(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt index f682791..c84d46d 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt @@ -3,10 +3,12 @@ package com.safebite.app.presentation.screen.lists import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.safebite.app.data.local.database.entity.ShoppingListEntity +import com.safebite.app.data.local.database.entity.ShoppingListMemberEntity import com.safebite.app.domain.usecase.GetShoppingListsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest @@ -35,25 +37,30 @@ class ListsViewModel @Inject constructor( data class ShoppingListWithStats( val list: ShoppingListEntity, val itemCount: Int, - val checkedCount: Int + val checkedCount: Int, + val members: List = emptyList() ) + private val _isEditMode = MutableStateFlow(false) + val isEditMode: StateFlow = _isEditMode + @OptIn(ExperimentalCoroutinesApi::class) val state: StateFlow = getShoppingListsUseCase.observeActive() .flatMapLatest { lists -> if (lists.isEmpty()) { flowOf(UiState.Empty("Aucune liste de courses. Créez votre première liste !")) } else { - val statsFlows = lists.map { list -> + val statsFlows = lists.sortedBy { it.displayOrder }.map { list -> combine( getShoppingListsUseCase.observeItemCount(list.id), - getShoppingListsUseCase.observeCheckedCount(list.id) - ) { itemCount, checkedCount -> - ShoppingListWithStats(list, itemCount, checkedCount) + getShoppingListsUseCase.observeCheckedCount(list.id), + getShoppingListsUseCase.observeMembers(list.id) + ) { itemCount, checkedCount, members -> + ShoppingListWithStats(list, itemCount, checkedCount, members.take(3)) } } 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 ) - fun createList(name: String) { + fun createList(name: String, backgroundResName: String? = null) { viewModelScope.launch { - getShoppingListsUseCase.createList(name) + getShoppingListsUseCase.createList(name, backgroundResName) } } @@ -74,4 +81,22 @@ class ListsViewModel @Inject constructor( 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) + ) + } + } + } } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/create/CreateListScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/create/CreateListScreen.kt new file mode 100644 index 0000000..6ba1cb9 --- /dev/null +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/create/CreateListScreen.kt @@ -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) + ) + } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListMembersScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListMembersScreen.kt new file mode 100644 index 0000000..84bf121 --- /dev/null +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListMembersScreen.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListNameImageScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListNameImageScreen.kt new file mode 100644 index 0000000..c57a0ae --- /dev/null +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListNameImageScreen.kt @@ -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)) + } + } + } + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListRegionScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListRegionScreen.kt new file mode 100644 index 0000000..2f06d95 --- /dev/null +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListRegionScreen.kt @@ -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) + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSettingsScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSettingsScreen.kt new file mode 100644 index 0000000..5756a29 --- /dev/null +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSettingsScreen.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSortScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSortScreen.kt new file mode 100644 index 0000000..72179cd --- /dev/null +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSortScreen.kt @@ -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) + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/util/ListBackgrounds.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/util/ListBackgrounds.kt new file mode 100644 index 0000000..a12aa72 --- /dev/null +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/util/ListBackgrounds.kt @@ -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 = 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 ?: "" diff --git a/app/src/main/java/com/safebite/app/presentation/screen/main/MainScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/main/MainScreen.kt index 2a58143..418dc66 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/main/MainScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/main/MainScreen.kt @@ -70,6 +70,8 @@ fun MainScreen( onOpenProfile: (Long) -> Unit, onOpenListDetail: (Long, String) -> Unit, onOpenHistoryItem: (String) -> Unit, + onOpenListCreate: () -> Unit = {}, + onOpenListSettings: (Long) -> Unit = {}, ) { val navController = rememberNavController() val currentBackStackEntry by navController.currentBackStackEntryAsState() @@ -141,7 +143,9 @@ fun MainScreen( composable(Screen.Lists.route) { ListsScreen( onOpenList = { id, name -> onOpenListDetail(id, name) }, - onOpenScanner = onOpenScanner + onOpenScanner = onOpenScanner, + onOpenListCreate = onOpenListCreate, + onOpenListSettings = onOpenListSettings ) } composable(Screen.Tracking.route) { diff --git a/app/src/main/res/drawable/background_list/bg_animaux.png b/app/src/main/res/drawable/bg_animaux.png similarity index 100% rename from app/src/main/res/drawable/background_list/bg_animaux.png rename to app/src/main/res/drawable/bg_animaux.png diff --git a/app/src/main/res/drawable/background_list/bg_baby.png b/app/src/main/res/drawable/bg_baby.png similarity index 100% rename from app/src/main/res/drawable/background_list/bg_baby.png rename to app/src/main/res/drawable/bg_baby.png diff --git a/app/src/main/res/drawable/background_list/bg_epicerie.png b/app/src/main/res/drawable/bg_epicerie.png similarity index 100% rename from app/src/main/res/drawable/background_list/bg_epicerie.png rename to app/src/main/res/drawable/bg_epicerie.png diff --git a/app/src/main/res/drawable/background_list/bg_epicerie2.png b/app/src/main/res/drawable/bg_epicerie2.png similarity index 100% rename from app/src/main/res/drawable/background_list/bg_epicerie2.png rename to app/src/main/res/drawable/bg_epicerie2.png diff --git a/app/src/main/res/drawable/background_list/bg_jardinage.png b/app/src/main/res/drawable/bg_jardinage.png similarity index 100% rename from app/src/main/res/drawable/background_list/bg_jardinage.png rename to app/src/main/res/drawable/bg_jardinage.png diff --git a/app/src/main/res/drawable/background_list/bg_office.png b/app/src/main/res/drawable/bg_office.png similarity index 100% rename from app/src/main/res/drawable/background_list/bg_office.png rename to app/src/main/res/drawable/bg_office.png diff --git a/app/src/main/res/drawable/background_list/bg_party.png b/app/src/main/res/drawable/bg_party.png similarity index 100% rename from app/src/main/res/drawable/background_list/bg_party.png rename to app/src/main/res/drawable/bg_party.png diff --git a/app/src/main/res/drawable/background_list/bg_pharmacie.png b/app/src/main/res/drawable/bg_pharmacie.png similarity index 100% rename from app/src/main/res/drawable/background_list/bg_pharmacie.png rename to app/src/main/res/drawable/bg_pharmacie.png diff --git a/app/src/main/res/drawable/background_list/bg_plage.png b/app/src/main/res/drawable/bg_plage.png similarity index 100% rename from app/src/main/res/drawable/background_list/bg_plage.png rename to app/src/main/res/drawable/bg_plage.png diff --git a/app/src/main/res/drawable/background_list/bg_renovation.png b/app/src/main/res/drawable/bg_renovation.png similarity index 100% rename from app/src/main/res/drawable/background_list/bg_renovation.png rename to app/src/main/res/drawable/bg_renovation.png diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 66fa793..3869b37 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -206,4 +206,37 @@ Name Description Add + + + My lists + New list + No list + %1$d products + %1$d bought + EDIT + DONE + Settings + + + New list + Next + List name + Choose a background + List settings + Personalize list + Sort + Region & Language + List members + Name & Image + Leave this list + Sort + Bring! sorts items to buy by category order. Arrange them like in your usual store! + List preview + Region & Language + In which region do you shop with this list? We will adapt the assortment for you. + List members + %1$d members + Invite a new person + Remove + Edit list diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e92ad60..414758c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -75,6 +75,32 @@ Aucune liste %1$d produits %1$d achetés + ÉDITER + TERMINÉ + Paramètres + + + Nouvelle liste + Suivant + Nom de la liste + Choisir un fond + Paramètres de la liste + Personnaliser la liste + Trier + Région & Langue + Membres de la liste + Nom & Image + Quitter cette liste + Trier + Bring! range les articles à acheter selon l\'ordre des catégories. Classez-les comme dans votre magasin habituel ! + Aperçu de la liste + Région & Langue + Dans quelle région fais-tu tes achats avec cette liste ? Nous adapterons l\'assortiment pour toi. + Membres de la liste + %1$d membres + Inviter une nouvelle personne + Retirer + Éditer la liste Suivi diff --git a/version.properties b/version.properties index b2080a1..68c6e32 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ MAJOR=1 -MINOR=14 +MINOR=15 PATCH=0 -CODE=18 +CODE=19