From 4ac951cf6e29a419d99f74f79bca2086a6148be3 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 26 Apr 2026 16:31:16 -0400 Subject: [PATCH] feat: implement photo-based item creation and product-to-list functionality with bottom sheet UI - Add camera/gallery photo capture for custom shopping list items with name and description fields - Implement `addCustomItemWithImage` in `ListDetailViewModel` to create items with attached photos - Add "Add to list" button in `ResultScreen` with list picker bottom sheet for scanned products - Extend `ResultViewModel` with `addToList` method to save scanned products to shopping lists - Add `PhotoSource --- .../screen/lists/ListDetailScreen.kt | 185 ++++++++++++++++++ .../screen/lists/ListDetailViewModel.kt | 21 ++ .../screen/result/ResultScreen.kt | 85 +++++++- .../screen/result/ResultViewModel.kt | 29 ++- app/src/main/res/values-en/strings.xml | 11 ++ app/src/main/res/values/strings.xml | 11 ++ version.properties | 6 +- 7 files changed, 342 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt index 1cf143b..21c15ca 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Close @@ -76,22 +77,28 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import android.graphics.Bitmap import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import java.io.File import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign 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.domain.engine.CatalogProvider +import com.safebite.app.presentation.common.components.PrimaryButton import com.safebite.app.presentation.theme.LocalDimens import com.safebite.app.presentation.theme.LocalStatusColors @@ -132,6 +139,37 @@ fun ListDetailScreen( var recentlyExpanded by remember { mutableStateOf(true) } val expandedCategories = remember { mutableStateMapOf() } + var showPhotoPicker by remember { mutableStateOf(false) } + var showDescriptionDialog by remember { mutableStateOf(false) } + var selectedImageUri by remember { mutableStateOf(null) } + var itemName by remember { mutableStateOf("") } + var itemDescription by remember { mutableStateOf("") } + val context = LocalContext.current + + val takePictureLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.TakePicturePreview() + ) { bitmap: Bitmap? -> + bitmap?.let { + val file = File(context.cacheDir, "sb_photo_${System.currentTimeMillis()}.jpg") + file.outputStream().use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) + } + selectedImageUri = Uri.fromFile(file).toString() + showDescriptionDialog = true + } + showPhotoPicker = false + } + + val pickImageLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { + selectedImageUri = it.toString() + showDescriptionDialog = true + } + showPhotoPicker = false + } + Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { @@ -151,6 +189,12 @@ fun ListDetailScreen( } }, actions = { + IconButton(onClick = { showPhotoPicker = true }) { + Icon( + Icons.Filled.CameraAlt, + contentDescription = stringResource(R.string.list_add_photo) + ) + } IconButton(onClick = onOpenScanner) { Icon(Icons.Filled.Camera, contentDescription = "Scanner") } @@ -271,6 +315,38 @@ fun ListDetailScreen( onOpenProduct = selected.barcode?.let { bc -> { onOpenProduct(bc) } } ) } + + // Bottom sheet choix photo / galerie + if (showPhotoPicker) { + PhotoSourceBottomSheet( + onTakePhoto = { takePictureLauncher.launch(null) }, + onPickGallery = { pickImageLauncher.launch("image/*") }, + onDismiss = { showPhotoPicker = false } + ) + } + + // Dialog saisie nom + description pour nouvel item photo + if (showDescriptionDialog) { + AddPhotoItemDialog( + name = itemName, + onNameChange = { itemName = it }, + description = itemDescription, + onDescriptionChange = { itemDescription = it }, + onConfirm = { + viewModel.addCustomItemWithImage(itemName, itemDescription, selectedImageUri) + itemName = "" + itemDescription = "" + selectedImageUri = null + showDescriptionDialog = false + }, + onDismiss = { + itemName = "" + itemDescription = "" + selectedImageUri = null + showDescriptionDialog = false + } + ) + } } // ───────────────────────────────────────────────────────────────────────────── @@ -1276,3 +1352,112 @@ private fun ParameterButton( } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PhotoSourceBottomSheet( + onTakePhoto: () -> Unit, + onPickGallery: () -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp) + ) { + Text( + text = stringResource(R.string.list_add_photo), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ParameterButton( + icon = Icons.Filled.Camera, + label = stringResource(R.string.list_take_photo), + onClick = onTakePhoto, + modifier = Modifier.weight(1f) + ) + ParameterButton( + icon = Icons.Filled.Add, + label = stringResource(R.string.list_pick_gallery), + onClick = onPickGallery, + modifier = Modifier.weight(1f) + ) + } + Spacer(Modifier.height(16.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddPhotoItemDialog( + name: String, + onNameChange: (String) -> Unit, + description: String, + onDescriptionChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp) + .navigationBarsPadding() + ) { + Text( + text = stringResource(R.string.list_add_photo), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { Text(stringResource(R.string.list_item_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = description, + onValueChange = onDescriptionChange, + label = { Text(stringResource(R.string.list_item_description)) }, + singleLine = false, + maxLines = 3, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(20.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + TextButton( + onClick = onDismiss, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.action_cancel)) + } + PrimaryButton( + text = stringResource(R.string.list_add_item_confirm), + onClick = onConfirm, + modifier = Modifier.weight(1f), + enabled = name.isNotBlank() + ) + } + Spacer(Modifier.height(8.dp)) + } + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt index 4d8cd97..1a1fe08 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt @@ -256,6 +256,27 @@ class ListDetailViewModel @Inject constructor( } } + /** Crée un item avec photo et description. */ + fun addCustomItemWithImage(name: String, note: String?, imageUri: String?) { + val trimmedName = name.trim() + if (trimmedName.isEmpty()) return + viewModelScope.launch { + val listId = _listIdFlow.value + val category = categoryEngine.detectCategory(trimmedName) + manageListUseCase.addItemToList( + listId, + ShoppingListItemEntity( + listId = listId, + productName = trimmedName, + category = category, + note = note?.trim()?.ifEmpty { null }, + imageUrl = imageUri, + isChecked = false + ) + ) + } + } + /** * Tap sur un article actif → marque comme acheté (déplace dans Recently Used). */ diff --git a/app/src/main/java/com/safebite/app/presentation/screen/result/ResultScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/result/ResultScreen.kt index cf82c84..eef03b5 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/result/ResultScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/result/ResultScreen.kt @@ -5,6 +5,7 @@ import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,8 +35,10 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -44,6 +47,7 @@ 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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -92,6 +96,8 @@ fun ResultScreen( viewModel: ResultViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() + val lists by viewModel.lists.collectAsStateWithLifecycle() + var showListPicker by remember { mutableStateOf(false) } LaunchedEffect(barcode, fromOcr, ocrText) { if (fromOcr && !ocrText.isNullOrBlank()) { viewModel.analyzeOcrText(ocrText) @@ -145,7 +151,19 @@ fun ResultScreen( is UiState.Success -> ResultContent( result = s.data, onScanAgain = onScanAgain, - onOcr = onOcr + onOcr = onOcr, + onAddToList = { showListPicker = true } + ) + } + + if (showListPicker) { + ListPickerBottomSheet( + lists = lists, + onSelect = { listId -> + viewModel.addToList(listId) + showListPicker = false + }, + onDismiss = { showListPicker = false } ) } } @@ -157,7 +175,8 @@ fun ResultScreen( private fun ResultContent( result: ScanResult, onScanAgain: () -> Unit, - onOcr: () -> Unit + onOcr: () -> Unit, + onAddToList: () -> Unit ) { var ingredientsExpanded by remember { mutableStateOf(false) } val context = LocalContext.current @@ -284,6 +303,12 @@ private fun ResultContent( color = MaterialTheme.colorScheme.onSurfaceVariant ) + OutlinedActionButton( + text = stringResource(R.string.result_add_to_list), + onClick = onAddToList, + modifier = Modifier.fillMaxWidth() + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedActionButton( text = stringResource(R.string.action_read_ingredients), @@ -618,3 +643,59 @@ private fun EcoScoreBadge(grade: String) { Text("🌿$upper", color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ListPickerBottomSheet( + lists: List, + onSelect: (Long) -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + Text( + text = stringResource(R.string.result_choose_list), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(12.dp)) + if (lists.isEmpty()) { + Text( + text = "Aucune liste disponible", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + lists.forEach { list -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onSelect(list.id) } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = list.name, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + Spacer(Modifier.height(16.dp)) + } + } +} diff --git a/app/src/main/java/com/safebite/app/presentation/screen/result/ResultViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/result/ResultViewModel.kt index b7f54a2..bba4f3d 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/result/ResultViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/result/ResultViewModel.kt @@ -8,15 +8,20 @@ import com.safebite.app.domain.model.UserProfile import com.safebite.app.domain.repository.ProductFetchResult import com.safebite.app.domain.usecase.AnalyzeIngredientsTextUseCase import com.safebite.app.domain.usecase.AnalyzeProductUseCase +import com.safebite.app.data.local.database.entity.ShoppingListItemEntity import com.safebite.app.domain.usecase.FetchProductUseCase +import com.safebite.app.domain.usecase.GetShoppingListsUseCase import com.safebite.app.domain.usecase.ManageProfileUseCase +import com.safebite.app.domain.usecase.ManageShoppingListUseCase import com.safebite.app.domain.usecase.SaveScanUseCase import com.safebite.app.presentation.common.util.UiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -26,12 +31,17 @@ class ResultViewModel @Inject constructor( private val analyzeProduct: AnalyzeProductUseCase, private val analyzeText: AnalyzeIngredientsTextUseCase, private val manageProfile: ManageProfileUseCase, - private val saveScan: SaveScanUseCase + private val saveScan: SaveScanUseCase, + private val getLists: GetShoppingListsUseCase, + private val manageList: ManageShoppingListUseCase ) : ViewModel() { private val _state = MutableStateFlow>(UiState.Idle) val state: StateFlow> = _state.asStateFlow() + val lists = getLists.observeActive() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + fun analyzeBarcode(barcode: String) = viewModelScope.launch { _state.value = UiState.Loading val profiles = resolveProfiles() @@ -71,4 +81,21 @@ class ResultViewModel @Inject constructor( else -> all.filter { it.isDefault }.ifEmpty { all.take(1) } } } + + fun addToList(listId: Long) = viewModelScope.launch { + val currentState = _state.value + if (currentState !is UiState.Success) return@launch + val result = currentState.data + val entity = ShoppingListItemEntity( + listId = listId, + barcode = result.product.barcode, + productName = result.product.name ?: result.product.barcode, + brand = result.product.brand, + imageUrl = result.product.imageUrl, + isChecked = false, + safetyStatus = result.safetyStatus.name, + allergenWarning = result.detectedAllergens.firstOrNull()?.allergenType?.displayNameFr + ) + manageList.addItemToList(listId, entity) + } } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index a26de29..2030213 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -193,4 +193,15 @@ Lupin Molluscs Celery + + + Add to a list + Choose a list + Product added to list + Add item + Take a photo + Pick from gallery + Name + Description + Add diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9202f27..859256d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -306,4 +306,15 @@ Verdict : produit sûr pour tous les profils Verdict : attention, traces d\'allergènes possibles Verdict : danger, ne pas consommer pour %1$s + + + Ajouter à une liste + Choisir une liste + Produit ajouté à la liste + Ajouter un élément + Prendre une photo + Choisir dans la galerie + Nom + Description + Ajouter \ No newline at end of file diff --git a/version.properties b/version.properties index f489880..92f92d1 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ MAJOR=1 -MINOR=7 -PATCH=1 -CODE=9 +MINOR=8 +PATCH=0 +CODE=10