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
This commit is contained in:
Bruno Charest 2026-04-26 16:31:16 -04:00
parent 8a19d46949
commit 4ac951cf6e
7 changed files with 342 additions and 6 deletions

View File

@ -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<String, Boolean>() }
var showPhotoPicker by remember { mutableStateOf(false) }
var showDescriptionDialog by remember { mutableStateOf(false) }
var selectedImageUri by remember { mutableStateOf<String?>(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))
}
}
}

View File

@ -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).
*/

View File

@ -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<com.safebite.app.data.local.database.entity.ShoppingListEntity>,
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))
}
}
}

View File

@ -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<ScanResult>>(UiState.Idle)
val state: StateFlow<UiState<ScanResult>> = _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)
}
}

View File

@ -193,4 +193,15 @@
<string name="allergen_lupin">Lupin</string>
<string name="allergen_molluscs">Molluscs</string>
<string name="allergen_celery">Celery</string>
<!-- List & Photo additions -->
<string name="result_add_to_list">Add to a list</string>
<string name="result_choose_list">Choose a list</string>
<string name="result_added_to_list">Product added to list</string>
<string name="list_add_photo">Add item</string>
<string name="list_take_photo">Take a photo</string>
<string name="list_pick_gallery">Pick from gallery</string>
<string name="list_item_name">Name</string>
<string name="list_item_description">Description</string>
<string name="list_add_item_confirm">Add</string>
</resources>

View File

@ -306,4 +306,15 @@
<string name="a11y_verdict_safe">Verdict : produit sûr pour tous les profils</string>
<string name="a11y_verdict_warning">Verdict : attention, traces d\'allergènes possibles</string>
<string name="a11y_verdict_danger">Verdict : danger, ne pas consommer pour %1$s</string>
<!-- List & Photo additions -->
<string name="result_add_to_list">Ajouter à une liste</string>
<string name="result_choose_list">Choisir une liste</string>
<string name="result_added_to_list">Produit ajouté à la liste</string>
<string name="list_add_photo">Ajouter un élément</string>
<string name="list_take_photo">Prendre une photo</string>
<string name="list_pick_gallery">Choisir dans la galerie</string>
<string name="list_item_name">Nom</string>
<string name="list_item_description">Description</string>
<string name="list_add_item_confirm">Ajouter</string>
</resources>

View File

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