From 57b545c243c07b74169f9f8feda83bf004b2d1ab Mon Sep 17 00:00:00 2001 From: Pedro Javier Date: Fri, 7 Aug 2026 10:20:24 +0200 Subject: [PATCH] =?UTF-8?q?A=C3=B1adir=20la=20opcion=20de=20la=20lista=20d?= =?UTF-8?q?e=20la=20compra=20que=20por=20articulo=20se=20pueda=20actualiza?= =?UTF-8?q?r=20el=20precio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 + .../data/repository/PantryRepository.kt | 19 ++ .../despensapp/ui/navigation/NavGraph.kt | 41 ++++- .../despensapp/ui/screens/PantryScreen.kt | 86 ++++++--- .../ui/screens/PriceScannerScreen.kt | 168 ++++++++++++++++++ app/src/main/res/values/strings.xml | 4 + gradle/libs.versions.toml | 2 + 7 files changed, 288 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/example/despensapp/ui/screens/PriceScannerScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e20ef08..4e85f99 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.view) implementation(libs.barcode.scanning) + implementation(libs.text.recognition) implementation(libs.coil.compose) implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.runtime) diff --git a/app/src/main/java/com/example/despensapp/data/repository/PantryRepository.kt b/app/src/main/java/com/example/despensapp/data/repository/PantryRepository.kt index 96b57bc..bf7240c 100644 --- a/app/src/main/java/com/example/despensapp/data/repository/PantryRepository.kt +++ b/app/src/main/java/com/example/despensapp/data/repository/PantryRepository.kt @@ -99,6 +99,25 @@ class PantryRepository { } } + suspend fun updateProductPrice(productId: Int, newPrice: Double, userName: String): Result { + 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( productId: Int, nombre: String, diff --git a/app/src/main/java/com/example/despensapp/ui/navigation/NavGraph.kt b/app/src/main/java/com/example/despensapp/ui/navigation/NavGraph.kt index e0b74c8..e3df960 100644 --- a/app/src/main/java/com/example/despensapp/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/example/despensapp/ui/navigation/NavGraph.kt @@ -1,17 +1,17 @@ package com.example.despensapp.ui.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost 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 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) { object Login : Screen("login") @@ -19,6 +19,9 @@ sealed class Screen(val route: String) { object Pantry : Screen("pantry") object AddProduct : Screen("add_product") object Settings : Screen("settings") + object PriceScanner : Screen("price_scanner/{productId}") { + fun createRoute(productId: Int) = "price_scanner/$productId" + } object ProductDetail : Screen("product_detail/{productId}") { fun createRoute(productId: Int) = "product_detail/$productId" } @@ -65,6 +68,9 @@ fun NavGraph(navController: NavHostController) { onNavigateToSettings = { navController.navigate(Screen.Settings.route) }, + onNavigateToPriceScanner = { productId -> + navController.navigate(Screen.PriceScanner.createRoute(productId)) + }, onLogout = { navController.navigate(Screen.Login.route) { popUpTo(Screen.Pantry.route) { inclusive = true } @@ -82,6 +88,27 @@ fun NavGraph(navController: NavHostController) { 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( route = Screen.ProductDetail.route, arguments = listOf(navArgument("productId") { type = NavType.IntType }) diff --git a/app/src/main/java/com/example/despensapp/ui/screens/PantryScreen.kt b/app/src/main/java/com/example/despensapp/ui/screens/PantryScreen.kt index 99c2c39..f60f83b 100644 --- a/app/src/main/java/com/example/despensapp/ui/screens/PantryScreen.kt +++ b/app/src/main/java/com/example/despensapp/ui/screens/PantryScreen.kt @@ -42,6 +42,7 @@ fun PantryScreen( onNavigateToAdd: () -> Unit, onNavigateToDetail: (Int) -> Unit, onNavigateToSettings: () -> Unit, + onNavigateToPriceScanner: (Int) -> Unit, onLogout: () -> Unit ) { val context = LocalContext.current @@ -323,6 +324,8 @@ fun PantryScreen( ProductItem( product = product, onItemClick = { onNavigateToDetail(product.id) }, + showPriceUpdater = showShoppingList, + onUpdatePrice = { onNavigateToPriceScanner(product.id) }, onQuantityChange = { newQty -> scope.launch { val success = repository.updateProductQuantity(product.id, newQty, userName) @@ -397,7 +400,13 @@ fun isNearExpiry(dateStr: String?): Boolean { } @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( modifier = Modifier .fillMaxWidth() @@ -455,17 +464,42 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { - Text( - text = product.name, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - maxLines = 1 // Evitamos que nombres muy largos rompan el diseño - ) - Text( - text = product.category, - fontSize = 14.sp, - color = Color.Gray - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = product.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1 // Evitamos que nombres muy largos rompan el diseño + ) + Text( + text = product.category, + fontSize = 14.sp, + 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) Column(modifier = Modifier.padding(top = 4.dp)) { @@ -533,10 +567,11 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + verticalAlignment = Alignment.CenterVertically ) { + // Recuadro de Unidades y Precio con peso flexible Surface( + modifier = Modifier.weight(1f, fill = false), shape = MaterialTheme.shapes.medium, color = if (product.isLowStock) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.secondaryContainer, @@ -560,20 +595,19 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In } } } - - Row { - FilledTonalIconButton( - 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)) - } + + // Botón de OCR Precio (Solo en modo Lista Compra) + if (showPriceUpdater) { Spacer(modifier = Modifier.width(8.dp)) - FilledIconButton( - onClick = { onQuantityChange(product.quantity + 1) }, + IconButton( + onClick = onUpdatePrice, 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 fun PantryScreenPreview() { DespensappTheme { - PantryScreen(onNavigateToAdd = {}, onNavigateToDetail = {}, onNavigateToSettings = {}, onLogout = {}) + PantryScreen(onNavigateToAdd = {}, onNavigateToDetail = {}, onNavigateToSettings = {}, onNavigateToPriceScanner = {}, onLogout = {}) } } diff --git a/app/src/main/java/com/example/despensapp/ui/screens/PriceScannerScreen.kt b/app/src/main/java/com/example/despensapp/ui/screens/PriceScannerScreen.kt new file mode 100644 index 0000000..7a7a773 --- /dev/null +++ b/app/src/main/java/com/example/despensapp/ui/screens/PriceScannerScreen.kt @@ -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(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)) + } + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd6f33e..e205781 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -61,6 +61,10 @@ Caduca: %1$s Añadido: %1$s Por: %1$s + Actualizar Precio + Escanear Precio + Apunta al precio de la etiqueta + Precio actualizado a %1$.2f € Añadir Producto diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b148454..bcc7286 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ okhttp = "4.12.0" mariadbJavaClient = "3.5.10" camerax = "1.4.1" barcodeScanning = "17.3.0" +textRecognition = "16.0.1" coil = "2.7.0" workManager = "2.10.0" 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-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } 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" } 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" }