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:
parent
8a19d46949
commit
4ac951cf6e
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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).
|
||||
*/
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -1,4 +1,4 @@
|
||||
MAJOR=1
|
||||
MINOR=7
|
||||
PATCH=1
|
||||
CODE=9
|
||||
MINOR=8
|
||||
PATCH=0
|
||||
CODE=10
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user