Añadir la opcion de la lista de la compra que por articulo se pueda actualizar el precio

This commit is contained in:
2026-08-07 10:20:24 +02:00
parent 84b2be8568
commit 57b545c243
7 changed files with 288 additions and 33 deletions
+1
View File
@@ -49,6 +49,7 @@ dependencies {
implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view) implementation(libs.androidx.camera.view)
implementation(libs.barcode.scanning) implementation(libs.barcode.scanning)
implementation(libs.text.recognition)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.runtime)
implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.runtime)
@@ -99,6 +99,25 @@ class PantryRepository {
} }
} }
suspend fun updateProductPrice(productId: Int, newPrice: Double, userName: String): Result<Boolean> {
return withContext(Dispatchers.IO) {
try {
val connection = getConnection()
val sql = "UPDATE productos SET precio = ?, modificado_por = ? WHERE id = ?"
val statement = connection.prepareStatement(sql)
statement.setDouble(1, newPrice)
statement.setString(2, userName)
statement.setInt(3, productId)
val rowsUpdated = statement.executeUpdate()
connection.close()
Result.success(rowsUpdated > 0)
} catch (e: Exception) {
Result.failure(e)
}
}
}
suspend fun updateProductFull( suspend fun updateProductFull(
productId: Int, productId: Int,
nombre: String, nombre: String,
@@ -1,17 +1,17 @@
package com.example.despensapp.ui.navigation package com.example.despensapp.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.example.despensapp.ui.screens.LoginScreen
import com.example.despensapp.ui.screens.PantryScreen
import com.example.despensapp.ui.screens.RegisterScreen
import com.example.despensapp.ui.screens.AddProductScreen
import com.example.despensapp.ui.screens.ProductDetailScreen
import com.example.despensapp.ui.screens.SettingsScreen
import androidx.navigation.NavType
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.example.despensapp.data.repository.PantryRepository
import com.example.despensapp.data.session.UserSession
import com.example.despensapp.ui.screens.*
import kotlinx.coroutines.launch
sealed class Screen(val route: String) { sealed class Screen(val route: String) {
object Login : Screen("login") object Login : Screen("login")
@@ -19,6 +19,9 @@ sealed class Screen(val route: String) {
object Pantry : Screen("pantry") object Pantry : Screen("pantry")
object AddProduct : Screen("add_product") object AddProduct : Screen("add_product")
object Settings : Screen("settings") object Settings : Screen("settings")
object PriceScanner : Screen("price_scanner/{productId}") {
fun createRoute(productId: Int) = "price_scanner/$productId"
}
object ProductDetail : Screen("product_detail/{productId}") { object ProductDetail : Screen("product_detail/{productId}") {
fun createRoute(productId: Int) = "product_detail/$productId" fun createRoute(productId: Int) = "product_detail/$productId"
} }
@@ -65,6 +68,9 @@ fun NavGraph(navController: NavHostController) {
onNavigateToSettings = { onNavigateToSettings = {
navController.navigate(Screen.Settings.route) navController.navigate(Screen.Settings.route)
}, },
onNavigateToPriceScanner = { productId ->
navController.navigate(Screen.PriceScanner.createRoute(productId))
},
onLogout = { onLogout = {
navController.navigate(Screen.Login.route) { navController.navigate(Screen.Login.route) {
popUpTo(Screen.Pantry.route) { inclusive = true } popUpTo(Screen.Pantry.route) { inclusive = true }
@@ -82,6 +88,27 @@ fun NavGraph(navController: NavHostController) {
navController.popBackStack() navController.popBackStack()
}) })
} }
composable(
route = Screen.PriceScanner.route,
arguments = listOf(navArgument("productId") { type = NavType.IntType })
) { backStackEntry ->
val productId = backStackEntry.arguments?.getInt("productId") ?: 0
val repository = PantryRepository()
val scope = rememberCoroutineScope()
val context = LocalContext.current
PriceScannerScreen(
onPriceScanned = { price ->
scope.launch {
val userName = UserSession.getUserName(context)
repository.updateProductPrice(productId, price, userName).onSuccess {
navController.popBackStack()
}
}
},
onBack = { navController.popBackStack() }
)
}
composable( composable(
route = Screen.ProductDetail.route, route = Screen.ProductDetail.route,
arguments = listOf(navArgument("productId") { type = NavType.IntType }) arguments = listOf(navArgument("productId") { type = NavType.IntType })
@@ -42,6 +42,7 @@ fun PantryScreen(
onNavigateToAdd: () -> Unit, onNavigateToAdd: () -> Unit,
onNavigateToDetail: (Int) -> Unit, onNavigateToDetail: (Int) -> Unit,
onNavigateToSettings: () -> Unit, onNavigateToSettings: () -> Unit,
onNavigateToPriceScanner: (Int) -> Unit,
onLogout: () -> Unit onLogout: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -323,6 +324,8 @@ fun PantryScreen(
ProductItem( ProductItem(
product = product, product = product,
onItemClick = { onNavigateToDetail(product.id) }, onItemClick = { onNavigateToDetail(product.id) },
showPriceUpdater = showShoppingList,
onUpdatePrice = { onNavigateToPriceScanner(product.id) },
onQuantityChange = { newQty -> onQuantityChange = { newQty ->
scope.launch { scope.launch {
val success = repository.updateProductQuantity(product.id, newQty, userName) val success = repository.updateProductQuantity(product.id, newQty, userName)
@@ -397,7 +400,13 @@ fun isNearExpiry(dateStr: String?): Boolean {
} }
@Composable @Composable
fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (Int) -> Unit) { fun ProductItem(
product: Product,
onItemClick: () -> Unit,
showPriceUpdater: Boolean = false,
onUpdatePrice: () -> Unit = {},
onQuantityChange: (Int) -> Unit
) {
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -454,6 +463,12 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = product.name, text = product.name,
@@ -466,6 +481,25 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
fontSize = 14.sp, fontSize = 14.sp,
color = Color.Gray color = Color.Gray
) )
}
// Botones de acción arriba a la derecha en columna (+ arriba, - abajo)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
FilledIconButton(
onClick = { onQuantityChange(product.quantity + 1) },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_product_more), modifier = Modifier.size(18.dp))
}
Spacer(modifier = Modifier.height(8.dp))
FilledTonalIconButton(
onClick = { if (product.quantity > 0) onQuantityChange(product.quantity - 1) },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Remove, contentDescription = stringResource(R.string.add_product_less), modifier = Modifier.size(18.dp))
}
}
}
// Información de Fechas y Usuario (organizada en columna para evitar solapamientos) // Información de Fechas y Usuario (organizada en columna para evitar solapamientos)
Column(modifier = Modifier.padding(top = 4.dp)) { Column(modifier = Modifier.padding(top = 4.dp)) {
@@ -533,10 +567,11 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.SpaceBetween
) { ) {
// Recuadro de Unidades y Precio con peso flexible
Surface( Surface(
modifier = Modifier.weight(1f, fill = false),
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
color = if (product.isLowStock) MaterialTheme.colorScheme.errorContainer color = if (product.isLowStock) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.secondaryContainer, else MaterialTheme.colorScheme.secondaryContainer,
@@ -561,19 +596,18 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
} }
} }
Row { // Botón de OCR Precio (Solo en modo Lista Compra)
FilledTonalIconButton( if (showPriceUpdater) {
onClick = { if (product.quantity > 0) onQuantityChange(product.quantity - 1) },
modifier = Modifier.size(32.dp)
) {
Icon(Icons.Default.Remove, contentDescription = stringResource(R.string.add_product_less), modifier = Modifier.size(18.dp))
}
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
FilledIconButton( IconButton(
onClick = { onQuantityChange(product.quantity + 1) }, onClick = onUpdatePrice,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
) { ) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_product_more), modifier = Modifier.size(18.dp)) Icon(
Icons.Default.QrCodeScanner,
contentDescription = stringResource(R.string.pantry_update_price),
tint = MaterialTheme.colorScheme.primary
)
} }
} }
} }
@@ -587,6 +621,6 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
@Composable @Composable
fun PantryScreenPreview() { fun PantryScreenPreview() {
DespensappTheme { DespensappTheme {
PantryScreen(onNavigateToAdd = {}, onNavigateToDetail = {}, onNavigateToSettings = {}, onLogout = {}) PantryScreen(onNavigateToAdd = {}, onNavigateToDetail = {}, onNavigateToSettings = {}, onNavigateToPriceScanner = {}, onLogout = {})
} }
} }
@@ -0,0 +1,168 @@
package com.example.despensapp.ui.screens
import android.util.Log
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.FlashOff
import androidx.compose.material.icons.filled.FlashOn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.example.despensapp.R
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PriceScannerScreen(onPriceScanned: (Double) -> Unit, onBack: () -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
var torchEnabled by remember { mutableStateOf(false) }
var detectedText by remember { mutableStateOf("") }
var detectedPrice by remember { mutableStateOf<Double?>(null) }
val recognizer = remember { TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.pantry_scan_price)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = {
IconButton(onClick = { torchEnabled = !torchEnabled }) {
Icon(
if (torchEnabled) Icons.Default.FlashOn else Icons.Default.FlashOff,
contentDescription = null,
tint = if (torchEnabled) Color.Yellow else LocalContentColor.current
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
) {
Box(modifier = Modifier.weight(1f)) {
AndroidView(
factory = { ctx ->
val previewView = PreviewView(ctx)
val executor = ContextCompat.getMainExecutor(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
imageAnalysis.setAnalyzer(executor) { imageProxy ->
@androidx.camera.core.ExperimentalGetImage
val mediaImage = imageProxy.image
if (mediaImage != null) {
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
recognizer.process(image)
.addOnSuccessListener { visionText ->
val fullText = visionText.text
// Lógica simple para extraer precio: buscar algo como X,XX o X.XX
val priceRegex = """(\d+[,.]\d{2})""".toRegex()
val match = priceRegex.find(fullText)
if (match != null) {
val priceStr = match.value.replace(",", ".")
detectedPrice = priceStr.toDoubleOrNull()
detectedText = match.value
}
}
.addOnCompleteListener { imageProxy.close() }
} else {
imageProxy.close()
}
}
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
cameraProvider.unbindAll()
val camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
camera.cameraControl.enableTorch(torchEnabled)
} catch (e: Exception) {
Log.e("PriceScanner", "Binding failed", e)
}
}, executor)
previewView
},
modifier = Modifier.fillMaxSize()
)
// Marco de enfoque
Box(
modifier = Modifier
.size(250.dp, 100.dp)
.border(2.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(12.dp))
.align(Alignment.Center)
)
Text(
text = stringResource(R.string.pantry_ocr_instruction),
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp)
.background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
// Panel inferior con el precio detectado
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (detectedText.isNotEmpty()) "Precio detectado: $detectedText" else "Buscando precio...",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { detectedPrice?.let { onPriceScanned(it) } },
enabled = detectedPrice != null,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.save))
}
}
}
}
}
}
+4
View File
@@ -61,6 +61,10 @@
<string name="pantry_expiry_date">Caduca: %1$s</string> <string name="pantry_expiry_date">Caduca: %1$s</string>
<string name="pantry_added_date">Añadido: %1$s</string> <string name="pantry_added_date">Añadido: %1$s</string>
<string name="pantry_last_modified_by">Por: %1$s</string> <string name="pantry_last_modified_by">Por: %1$s</string>
<string name="pantry_update_price">Actualizar Precio</string>
<string name="pantry_scan_price">Escanear Precio</string>
<string name="pantry_ocr_instruction">Apunta al precio de la etiqueta</string>
<string name="pantry_price_updated">Precio actualizado a %1$.2f €</string>
<!-- Add Product --> <!-- Add Product -->
<string name="add_product_title">Añadir Producto</string> <string name="add_product_title">Añadir Producto</string>
+2
View File
@@ -14,6 +14,7 @@ okhttp = "4.12.0"
mariadbJavaClient = "3.5.10" mariadbJavaClient = "3.5.10"
camerax = "1.4.1" camerax = "1.4.1"
barcodeScanning = "17.3.0" barcodeScanning = "17.3.0"
textRecognition = "16.0.1"
coil = "2.7.0" coil = "2.7.0"
workManager = "2.10.0" workManager = "2.10.0"
room = "2.6.1" room = "2.6.1"
@@ -44,6 +45,7 @@ androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2",
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "barcodeScanning" } barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "barcodeScanning" }
text-recognition = { group = "com.google.mlkit", name = "text-recognition", version.ref = "textRecognition" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }