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.ArrowBack
import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Camera 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.Check
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Close 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.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import java.io.File
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.safebite.app.R
import com.safebite.app.domain.engine.CatalogProvider 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.LocalDimens
import com.safebite.app.presentation.theme.LocalStatusColors import com.safebite.app.presentation.theme.LocalStatusColors
@ -132,6 +139,37 @@ fun ListDetailScreen(
var recentlyExpanded by remember { mutableStateOf(true) } var recentlyExpanded by remember { mutableStateOf(true) }
val expandedCategories = remember { mutableStateMapOf<String, Boolean>() } 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( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
topBar = { topBar = {
@ -151,6 +189,12 @@ fun ListDetailScreen(
} }
}, },
actions = { actions = {
IconButton(onClick = { showPhotoPicker = true }) {
Icon(
Icons.Filled.CameraAlt,
contentDescription = stringResource(R.string.list_add_photo)
)
}
IconButton(onClick = onOpenScanner) { IconButton(onClick = onOpenScanner) {
Icon(Icons.Filled.Camera, contentDescription = "Scanner") Icon(Icons.Filled.Camera, contentDescription = "Scanner")
} }
@ -271,6 +315,38 @@ fun ListDetailScreen(
onOpenProduct = selected.barcode?.let { bc -> { onOpenProduct(bc) } } 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). * 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.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -34,8 +35,10 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -44,6 +47,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -92,6 +96,8 @@ fun ResultScreen(
viewModel: ResultViewModel = hiltViewModel() viewModel: ResultViewModel = hiltViewModel()
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val lists by viewModel.lists.collectAsStateWithLifecycle()
var showListPicker by remember { mutableStateOf(false) }
LaunchedEffect(barcode, fromOcr, ocrText) { LaunchedEffect(barcode, fromOcr, ocrText) {
if (fromOcr && !ocrText.isNullOrBlank()) { if (fromOcr && !ocrText.isNullOrBlank()) {
viewModel.analyzeOcrText(ocrText) viewModel.analyzeOcrText(ocrText)
@ -145,7 +151,19 @@ fun ResultScreen(
is UiState.Success -> ResultContent( is UiState.Success -> ResultContent(
result = s.data, result = s.data,
onScanAgain = onScanAgain, 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( private fun ResultContent(
result: ScanResult, result: ScanResult,
onScanAgain: () -> Unit, onScanAgain: () -> Unit,
onOcr: () -> Unit onOcr: () -> Unit,
onAddToList: () -> Unit
) { ) {
var ingredientsExpanded by remember { mutableStateOf(false) } var ingredientsExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
@ -284,6 +303,12 @@ private fun ResultContent(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
OutlinedActionButton(
text = stringResource(R.string.result_add_to_list),
onClick = onAddToList,
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedActionButton( OutlinedActionButton(
text = stringResource(R.string.action_read_ingredients), 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) 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.repository.ProductFetchResult
import com.safebite.app.domain.usecase.AnalyzeIngredientsTextUseCase import com.safebite.app.domain.usecase.AnalyzeIngredientsTextUseCase
import com.safebite.app.domain.usecase.AnalyzeProductUseCase 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.FetchProductUseCase
import com.safebite.app.domain.usecase.GetShoppingListsUseCase
import com.safebite.app.domain.usecase.ManageProfileUseCase 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.domain.usecase.SaveScanUseCase
import com.safebite.app.presentation.common.util.UiState import com.safebite.app.presentation.common.util.UiState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -26,12 +31,17 @@ class ResultViewModel @Inject constructor(
private val analyzeProduct: AnalyzeProductUseCase, private val analyzeProduct: AnalyzeProductUseCase,
private val analyzeText: AnalyzeIngredientsTextUseCase, private val analyzeText: AnalyzeIngredientsTextUseCase,
private val manageProfile: ManageProfileUseCase, private val manageProfile: ManageProfileUseCase,
private val saveScan: SaveScanUseCase private val saveScan: SaveScanUseCase,
private val getLists: GetShoppingListsUseCase,
private val manageList: ManageShoppingListUseCase
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow<UiState<ScanResult>>(UiState.Idle) private val _state = MutableStateFlow<UiState<ScanResult>>(UiState.Idle)
val state: StateFlow<UiState<ScanResult>> = _state.asStateFlow() val state: StateFlow<UiState<ScanResult>> = _state.asStateFlow()
val lists = getLists.observeActive()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun analyzeBarcode(barcode: String) = viewModelScope.launch { fun analyzeBarcode(barcode: String) = viewModelScope.launch {
_state.value = UiState.Loading _state.value = UiState.Loading
val profiles = resolveProfiles() val profiles = resolveProfiles()
@ -71,4 +81,21 @@ class ResultViewModel @Inject constructor(
else -> all.filter { it.isDefault }.ifEmpty { all.take(1) } 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_lupin">Lupin</string>
<string name="allergen_molluscs">Molluscs</string> <string name="allergen_molluscs">Molluscs</string>
<string name="allergen_celery">Celery</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> </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_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_warning">Verdict : attention, traces d\'allergènes possibles</string>
<string name="a11y_verdict_danger">Verdict : danger, ne pas consommer pour %1$s</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> </resources>

View File

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