From 9554190f545e27b5540985ce548ece3c993a0532 Mon Sep 17 00:00:00 2001 From: Pedro Javier Date: Mon, 10 Aug 2026 09:10:46 +0200 Subject: [PATCH] =?UTF-8?q?A=C3=B1adir=20la=20opcion=20de=20registrar=20el?= =?UTF-8?q?=20historico=20de=20un=20producto=20en=20un=20supermercado=20co?= =?UTF-8?q?n=20su=20precio=20y=20manejo=20de=20notificaciones=20de=20stock?= =?UTF-8?q?=20bajo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 + app/build.gradle.kts | 1 + .../com/example/despensapp/MainActivity.kt | 26 +++- .../despensapp/data/model/PriceHistory.kt | 10 ++ .../example/despensapp/data/model/Product.kt | 1 + .../data/repository/PantryRepository.kt | 132 +++++++++++++++--- .../data/repository/UserRepository.kt | 12 +- .../despensapp/data/session/UserSession.kt | 61 ++++++++ .../ui/components/SupermarketDropdown.kt | 115 +++++++++++++++ .../despensapp/ui/navigation/NavGraph.kt | 4 +- .../despensapp/ui/screens/AddProductScreen.kt | 13 ++ .../despensapp/ui/screens/LoginScreen.kt | 102 +++++++++++--- .../despensapp/ui/screens/PantryScreen.kt | 42 +++++- .../ui/screens/PriceScannerScreen.kt | 20 ++- .../ui/screens/ProductDetailScreen.kt | 71 ++++++++++ .../despensapp/ui/screens/RegisterScreen.kt | 5 +- .../despensapp/ui/screens/SettingsScreen.kt | 31 ++++ .../despensapp/util/BiometricHelper.kt | 54 +++++++ .../despensapp/worker/InventoryCheckWorker.kt | 43 +++++- app/src/main/res/values/strings.xml | 27 ++++ gradle/libs.versions.toml | 2 + 21 files changed, 715 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/com/example/despensapp/data/model/PriceHistory.kt create mode 100644 app/src/main/java/com/example/despensapp/ui/components/SupermarketDropdown.kt create mode 100644 app/src/main/java/com/example/despensapp/util/BiometricHelper.kt diff --git a/README.md b/README.md index 5243918..1572cba 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Aplicación Android moderna y colaborativa diseñada para gestionar el inventari ## 🚀 Novedades Recientes * **Seguimiento de Cambios:** Ahora puedes ver qué miembro de la familia realizó la última modificación en cada producto. +* **Historial de Precios:** Seguimiento de la evolución de precios por supermercado. +* **Comparativa de Supermercados:** Identifica dónde encontraste el producto por última vez y a qué precio. * **Configuración de Alertas:** Frecuencia de notificaciones personalizable (12h, 24h, 48h o Semanal). * **Gestión de Imágenes:** Sube tus propias fotos desde la galería o haz una foto directamente si el producto no tiene imagen en Open Food Facts. * **Valoración Financiera:** Visualiza el valor total de tu despensa y el presupuesto necesario para reponer lo que falta. @@ -12,6 +14,7 @@ Aplicación Android moderna y colaborativa diseñada para gestionar el inventari ### 🔐 Autenticación y Familia * **Acceso Multiusuario:** Registro e inicio de sesión conectado a MariaDB externa. +* **Seguridad Biométrica:** Inicio de sesión rápido mediante huella dactilar, rostro o iris. * **Códigos de Familia:** Posibilidad de unirse a despensas existentes mediante códigos compartidos. * **Sesiones Seguras:** Gestión de sesión de usuario local para acceso rápido. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4e85f99..cca3266 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { implementation(libs.androidx.camera.view) implementation(libs.barcode.scanning) implementation(libs.text.recognition) + implementation(libs.androidx.biometric) implementation(libs.coil.compose) implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.runtime) diff --git a/app/src/main/java/com/example/despensapp/MainActivity.kt b/app/src/main/java/com/example/despensapp/MainActivity.kt index dc37ce4..65d1846 100644 --- a/app/src/main/java/com/example/despensapp/MainActivity.kt +++ b/app/src/main/java/com/example/despensapp/MainActivity.kt @@ -1,19 +1,33 @@ package com.example.despensapp +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity import androidx.navigation.compose.rememberNavController import com.example.despensapp.ui.navigation.NavGraph import com.example.despensapp.ui.theme.DespensappTheme import com.example.despensapp.util.NotificationScheduler -class MainActivity : ComponentActivity() { +class MainActivity : FragmentActivity() { + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + // No necesitamos hacer nada especial aquí, el usuario verá el diálogo + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + checkNotificationPermission() + // Programar revisión de inventario usando la frecuencia guardada NotificationScheduler.schedule(this, NotificationScheduler.getSavedFrequency(this)) @@ -24,4 +38,12 @@ class MainActivity : ComponentActivity() { } } } + + private fun checkNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } } diff --git a/app/src/main/java/com/example/despensapp/data/model/PriceHistory.kt b/app/src/main/java/com/example/despensapp/data/model/PriceHistory.kt new file mode 100644 index 0000000..6519d30 --- /dev/null +++ b/app/src/main/java/com/example/despensapp/data/model/PriceHistory.kt @@ -0,0 +1,10 @@ +package com.example.despensapp.data.model + +data class PriceHistory( + val id: Int = 0, + val productId: Int, + val supermarket: String, + val price: Double, + val date: String, + val userName: String? +) diff --git a/app/src/main/java/com/example/despensapp/data/model/Product.kt b/app/src/main/java/com/example/despensapp/data/model/Product.kt index 22c38d7..8981a43 100644 --- a/app/src/main/java/com/example/despensapp/data/model/Product.kt +++ b/app/src/main/java/com/example/despensapp/data/model/Product.kt @@ -8,6 +8,7 @@ data class Product( val unit: String = "unidad", val category: String = "General", val price: Double = 0.0, + val lastSupermarket: String? = null, val imageUrl: String? = null, val expirationDate: String? = null, val createdAt: String? = null, 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 bf7240c..057a7da 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 @@ -1,6 +1,7 @@ package com.example.despensapp.data.repository import com.example.despensapp.data.api.OpenFoodFactsApi +import com.example.despensapp.data.model.PriceHistory import com.example.despensapp.data.model.Product import com.example.despensapp.data.model.off.OFFProduct import kotlinx.coroutines.Dispatchers @@ -46,12 +47,19 @@ class PantryRepository { } } - suspend fun getAllProductsFromDb(): Result> { + suspend fun getAllProductsFromDb(familyCode: String? = null): Result> { return withContext(Dispatchers.IO) { try { val connection = getConnection() - val sql = "SELECT * FROM productos WHERE visible = 1" + val sql = if (familyCode != null) { + "SELECT * FROM productos WHERE visible = 1 AND codigo_familia = ?" + } else { + "SELECT * FROM productos WHERE visible = 1" + } val statement = connection.prepareStatement(sql) + if (familyCode != null) { + statement.setString(1, familyCode) + } val resultSet = statement.executeQuery() val products = mutableListOf() @@ -65,6 +73,7 @@ class PantryRepository { unit = resultSet.getString("unidad") ?: "unidad", category = resultSet.getString("categoria"), price = resultSet.getDouble("precio"), + lastSupermarket = resultSet.getString("ultimo_supermercado"), imageUrl = resultSet.getString("imagen_url"), expirationDate = resultSet.getString("fecha_caducidad"), createdAt = resultSet.getString("fecha_alta"), @@ -99,17 +108,23 @@ class PantryRepository { } } - suspend fun updateProductPrice(productId: Int, newPrice: Double, userName: String): Result { + suspend fun updateProductPrice(productId: Int, newPrice: Double, userName: String, supermarket: String? = null): Result { return withContext(Dispatchers.IO) { try { val connection = getConnection() - val sql = "UPDATE productos SET precio = ?, modificado_por = ? WHERE id = ?" + val sql = "UPDATE productos SET precio = ?, ultimo_supermercado = ?, modificado_por = ? WHERE id = ?" val statement = connection.prepareStatement(sql) statement.setDouble(1, newPrice) - statement.setString(2, userName) - statement.setInt(3, productId) + statement.setString(2, supermarket) + statement.setString(3, userName) + statement.setInt(4, productId) val rowsUpdated = statement.executeUpdate() + + if (rowsUpdated > 0) { + addPriceHistory(productId, supermarket ?: "Desconocido", newPrice, userName) + } + connection.close() Result.success(rowsUpdated > 0) } catch (e: Exception) { @@ -126,6 +141,7 @@ class PantryRepository { unidad: String, category: String, price: Double, + supermarket: String? = null, fechaCaducidad: String?, imageUrl: String? = null, userName: String @@ -133,7 +149,7 @@ class PantryRepository { return withContext(Dispatchers.IO) { try { val connection = getConnection() - val sql = "UPDATE productos SET nombre = ?, cantidad = ?, cantidad_minima = ?, unidad = ?, categoria = ?, precio = ?, fecha_caducidad = ?, imagen_url = ?, visible = 1, modificado_por = ? WHERE id = ?" + val sql = "UPDATE productos SET nombre = ?, cantidad = ?, cantidad_minima = ?, unidad = ?, categoria = ?, precio = ?, ultimo_supermercado = ?, fecha_caducidad = ?, imagen_url = ?, visible = 1, modificado_por = ? WHERE id = ?" val statement = connection.prepareStatement(sql) statement.setString(1, nombre) statement.setInt(2, cantidad) @@ -141,12 +157,18 @@ class PantryRepository { statement.setString(4, unidad) statement.setString(5, category) statement.setDouble(6, price) - statement.setString(7, fechaCaducidad) - statement.setString(8, imageUrl) - statement.setString(9, userName) - statement.setInt(10, productId) + statement.setString(7, supermarket) + statement.setString(8, fechaCaducidad) + statement.setString(9, imageUrl) + statement.setString(10, userName) + statement.setInt(11, productId) val rowsUpdated = statement.executeUpdate() + + if (rowsUpdated > 0 && price > 0) { + addPriceHistory(productId, supermarket ?: "Desconocido", price, userName) + } + connection.close() Result.success(rowsUpdated > 0) } catch (e: Exception) { @@ -163,6 +185,7 @@ class PantryRepository { unidad: String, categoria: String, price: Double, + supermarket: String? = null, imageUrl: String?, fechaCaducidad: String?, userName: String @@ -171,8 +194,8 @@ class PantryRepository { try { val connection = getConnection() val sql = """ - INSERT INTO productos (barcode, nombre, cantidad, cantidad_minima, unidad, categoria, precio, imagen_url, fecha_caducidad, visible, modificado_por) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) + INSERT INTO productos (barcode, nombre, cantidad, cantidad_minima, unidad, categoria, precio, ultimo_supermercado, imagen_url, fecha_caducidad, visible, modificado_por) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) ON DUPLICATE KEY UPDATE nombre = VALUES(nombre), cantidad = VALUES(cantidad), @@ -180,13 +203,14 @@ class PantryRepository { unidad = VALUES(unidad), categoria = VALUES(categoria), precio = VALUES(precio), + ultimo_supermercado = VALUES(ultimo_supermercado), imagen_url = IF(VALUES(imagen_url) IS NULL, imagen_url, VALUES(imagen_url)), fecha_caducidad = VALUES(fecha_caducidad), visible = 1, modificado_por = VALUES(modificado_por) """.trimIndent() - val statement = connection.prepareStatement(sql) + val statement = connection.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS) statement.setString(1, barcode) statement.setString(2, nombre) statement.setInt(3, cantidad) @@ -194,11 +218,33 @@ class PantryRepository { statement.setString(5, unidad) statement.setString(6, categoria) statement.setDouble(7, price) - statement.setString(8, imageUrl) - statement.setString(9, fechaCaducidad) - statement.setString(10, userName) + statement.setString(8, supermarket) + statement.setString(9, imageUrl) + statement.setString(10, fechaCaducidad) + statement.setString(11, userName) val rowsInserted = statement.executeUpdate() + + // Para obtener el ID si ya existía el barcode + var finalProductId = -1 + if (rowsInserted > 0) { + val generatedKeys = statement.generatedKeys + if (generatedKeys.next()) { + finalProductId = generatedKeys.getInt(1) + } else { + // Si es un UPDATE (duplicate key), buscamos el ID por barcode + val findIdSql = "SELECT id FROM productos WHERE barcode = ?" + val findIdStmt = connection.prepareStatement(findIdSql) + findIdStmt.setString(1, barcode) + val rs = findIdStmt.executeQuery() + if (rs.next()) finalProductId = rs.getInt("id") + } + } + + if (finalProductId != -1 && price > 0) { + addPriceHistory(finalProductId, supermarket ?: "Desconocido", price, userName) + } + connection.close() Result.success(rowsInserted > 0) } catch (e: Exception) { @@ -244,6 +290,7 @@ class PantryRepository { unit = resultSet.getString("unidad") ?: "unidad", category = resultSet.getString("categoria"), price = resultSet.getDouble("precio"), + lastSupermarket = resultSet.getString("ultimo_supermercado"), imageUrl = resultSet.getString("imagen_url"), expirationDate = resultSet.getString("fecha_caducidad"), createdAt = resultSet.getString("fecha_alta"), @@ -277,6 +324,7 @@ class PantryRepository { unit = resultSet.getString("unidad") ?: "unidad", category = resultSet.getString("categoria"), price = resultSet.getDouble("precio"), + lastSupermarket = resultSet.getString("ultimo_supermercado"), imageUrl = resultSet.getString("imagen_url"), expirationDate = resultSet.getString("fecha_caducidad"), createdAt = resultSet.getString("fecha_alta"), @@ -290,4 +338,54 @@ class PantryRepository { } } } + + suspend fun addPriceHistory(productId: Int, supermarket: String, price: Double, userName: String?): Result { + return withContext(Dispatchers.IO) { + try { + val connection = getConnection() + val sql = "INSERT INTO historial_precios (id_producto, supermercado, precio, modificado_por) VALUES (?, ?, ?, ?)" + val statement = connection.prepareStatement(sql) + statement.setInt(1, productId) + statement.setString(2, supermarket) + statement.setDouble(3, price) + statement.setString(4, userName) + + val rowsInserted = statement.executeUpdate() + connection.close() + Result.success(rowsInserted > 0) + } catch (e: Exception) { + Result.failure(e) + } + } + } + + suspend fun getPriceHistory(productId: Int): Result> { + return withContext(Dispatchers.IO) { + try { + val connection = getConnection() + val sql = "SELECT * FROM historial_precios WHERE id_producto = ? ORDER BY fecha DESC" + val statement = connection.prepareStatement(sql) + statement.setInt(1, productId) + val resultSet = statement.executeQuery() + + val history = mutableListOf() + while (resultSet.next()) { + history.add( + PriceHistory( + id = resultSet.getInt("id"), + productId = resultSet.getInt("id_producto"), + supermarket = resultSet.getString("supermercado"), + price = resultSet.getDouble("precio"), + date = resultSet.getString("fecha"), + userName = resultSet.getString("modificado_por") + ) + ) + } + connection.close() + Result.success(history) + } catch (e: Exception) { + Result.failure(e) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/despensapp/data/repository/UserRepository.kt b/app/src/main/java/com/example/despensapp/data/repository/UserRepository.kt index 7acf6b8..eba10ff 100644 --- a/app/src/main/java/com/example/despensapp/data/repository/UserRepository.kt +++ b/app/src/main/java/com/example/despensapp/data/repository/UserRepository.kt @@ -24,15 +24,16 @@ class UserRepository { } } - suspend fun registerUser(nombre: String, email: String, contrasena: String): Result { + suspend fun registerUser(nombre: String, email: String, contrasena: String, familyCode: String?): Result { return withContext(Dispatchers.IO) { try { val connection = getConnection() - val sql = "INSERT INTO usuarios (nombre_completo, email, contrasena) VALUES (?, ?, ?)" + val sql = "INSERT INTO usuarios (nombre_completo, email, contrasena, codigo_familia) VALUES (?, ?, ?, ?)" val statement = connection.prepareStatement(sql) statement.setString(1, nombre) statement.setString(2, email) statement.setString(3, contrasena) + statement.setString(4, familyCode) val rowsInserted = statement.executeUpdate() connection.close() @@ -48,11 +49,11 @@ class UserRepository { } } - suspend fun loginUser(email: String, contrasena: String): Result { + suspend fun loginUser(email: String, contrasena: String): Result> { return withContext(Dispatchers.IO) { try { val connection = getConnection() - val sql = "SELECT nombre_completo FROM usuarios WHERE email = ? AND contrasena = ?" + val sql = "SELECT nombre_completo, codigo_familia FROM usuarios WHERE email = ? AND contrasena = ?" val statement = connection.prepareStatement(sql) statement.setString(1, email) statement.setString(2, contrasena) @@ -60,8 +61,9 @@ class UserRepository { val resultSet = statement.executeQuery() if (resultSet.next()) { val nombre = resultSet.getString("nombre_completo") + val familyCode = resultSet.getString("codigo_familia") connection.close() - Result.success(nombre) + Result.success(Pair(nombre, familyCode)) } else { connection.close() Result.failure(Exception("Credenciales incorrectas")) diff --git a/app/src/main/java/com/example/despensapp/data/session/UserSession.kt b/app/src/main/java/com/example/despensapp/data/session/UserSession.kt index 1b2f87c..5c9a27b 100644 --- a/app/src/main/java/com/example/despensapp/data/session/UserSession.kt +++ b/app/src/main/java/com/example/despensapp/data/session/UserSession.kt @@ -5,6 +5,10 @@ import android.content.Context object UserSession { private const val PREFS_NAME = "user_session" private const val KEY_USER_NAME = "user_name" + private const val KEY_FAMILY_CODE = "family_code" + private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled" + private const val KEY_BIOMETRIC_USER_NAME = "biometric_user_name" + private const val KEY_BIOMETRIC_FAMILY_CODE = "biometric_family_code" fun saveUserName(context: Context, name: String) { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -16,8 +20,65 @@ object UserSession { return prefs.getString(KEY_USER_NAME, "Familiar") ?: "Familiar" } + fun saveFamilyCode(context: Context, code: String) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putString(KEY_FAMILY_CODE, code).apply() + } + + fun getFamilyCode(context: Context): String? { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getString(KEY_FAMILY_CODE, null) + } + + // Vincula la biometría al usuario que está logueado actualmente + fun enableBiometricForCurrentUser(context: Context, enabled: Boolean) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + if (enabled) { + val currentName = getUserName(context) + val currentFamily = getFamilyCode(context) + prefs.edit() + .putBoolean(KEY_BIOMETRIC_ENABLED, true) + .putString(KEY_BIOMETRIC_USER_NAME, currentName) + .putString(KEY_BIOMETRIC_FAMILY_CODE, currentFamily) + .apply() + } else { + prefs.edit() + .putBoolean(KEY_BIOMETRIC_ENABLED, false) + .remove(KEY_BIOMETRIC_USER_NAME) + .remove(KEY_BIOMETRIC_FAMILY_CODE) + .apply() + } + } + + fun isBiometricEnabled(context: Context): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(KEY_BIOMETRIC_ENABLED, false) + } + + fun getBiometricUserName(context: Context): String { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getString(KEY_BIOMETRIC_USER_NAME, "Pedro") ?: "Pedro" + } + + fun getBiometricFamilyCode(context: Context): String? { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getString(KEY_BIOMETRIC_FAMILY_CODE, null) + } + fun clear(context: Context) { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + // Guardamos los datos de biometría para que no se borren al cerrar sesión normal + val bioEnabled = isBiometricEnabled(context) + val bioUser = prefs.getString(KEY_BIOMETRIC_USER_NAME, null) + val bioFamily = prefs.getString(KEY_BIOMETRIC_FAMILY_CODE, null) + prefs.edit().clear().apply() + + // Restauramos el vínculo de la huella + prefs.edit() + .putBoolean(KEY_BIOMETRIC_ENABLED, bioEnabled) + .putString(KEY_BIOMETRIC_USER_NAME, bioUser) + .putString(KEY_BIOMETRIC_FAMILY_CODE, bioFamily) + .apply() } } diff --git a/app/src/main/java/com/example/despensapp/ui/components/SupermarketDropdown.kt b/app/src/main/java/com/example/despensapp/ui/components/SupermarketDropdown.kt new file mode 100644 index 0000000..82296c4 --- /dev/null +++ b/app/src/main/java/com/example/despensapp/ui/components/SupermarketDropdown.kt @@ -0,0 +1,115 @@ +package com.example.despensapp.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Store +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.despensapp.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SupermarketDropdown( + selectedSupermarket: String, + onSupermarketChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + val options = listOf( + stringResource(R.string.supermarket_action), + stringResource(R.string.supermarket_alcampo), + stringResource(R.string.supermarket_aldi), + stringResource(R.string.supermarket_beltran), + stringResource(R.string.supermarket_carrefour), + stringResource(R.string.supermarket_dia), + stringResource(R.string.supermarket_eci), + stringResource(R.string.supermarket_family_cash), + stringResource(R.string.supermarket_lidl), + stringResource(R.string.supermarket_mercadona), + stringResource(R.string.supermarket_samoy), + stringResource(R.string.supermarket_saymu), + stringResource(R.string.supermarket_otro) + ) + + var expanded by remember { mutableStateOf(false) } + + // Determine if the current selected value is one of the predefined options + // If not, it means it's a manual entry (or it was "OTRO / MANUAL" before) + val otherLabel = stringResource(R.string.supermarket_otro) + + // We need to keep track of the manual text separately + var manualText by remember { mutableStateOf("") } + + // Logic to decide what to show in the dropdown field + val dropdownValue = when { + selectedSupermarket.isEmpty() -> "" + options.contains(selectedSupermarket) && selectedSupermarket != otherLabel -> selectedSupermarket + else -> otherLabel + } + + // Update manualText if the selected value is not in options + LaunchedEffect(selectedSupermarket) { + if (!options.contains(selectedSupermarket) && selectedSupermarket.isNotEmpty()) { + manualText = selectedSupermarket + } + } + + Column(modifier = modifier) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = dropdownValue, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.pantry_supermarket_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + leadingIcon = { Icon(Icons.Default.Store, contentDescription = null) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + if (option == otherLabel) { + onSupermarketChange(manualText) + } else { + onSupermarketChange(option) + } + expanded = false + } + ) + } + } + } + + if (dropdownValue == otherLabel) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = manualText, + onValueChange = { + manualText = it + onSupermarketChange(it) + }, + label = { Text("Nombre del Supermercado") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } + } +} 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 e3df960..df22122 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 @@ -98,10 +98,10 @@ fun NavGraph(navController: NavHostController) { val context = LocalContext.current PriceScannerScreen( - onPriceScanned = { price -> + onPriceScanned = { price, supermarket -> scope.launch { val userName = UserSession.getUserName(context) - repository.updateProductPrice(productId, price, userName).onSuccess { + repository.updateProductPrice(productId, price, userName, supermarket).onSuccess { navController.popBackStack() } } diff --git a/app/src/main/java/com/example/despensapp/ui/screens/AddProductScreen.kt b/app/src/main/java/com/example/despensapp/ui/screens/AddProductScreen.kt index 343b2a6..9ddf4fe 100644 --- a/app/src/main/java/com/example/despensapp/ui/screens/AddProductScreen.kt +++ b/app/src/main/java/com/example/despensapp/ui/screens/AddProductScreen.kt @@ -38,6 +38,7 @@ import coil.compose.AsyncImage import com.example.despensapp.R import com.example.despensapp.data.repository.PantryRepository import com.example.despensapp.data.session.UserSession +import com.example.despensapp.ui.components.SupermarketDropdown import com.example.despensapp.util.DateUtils import com.example.despensapp.util.ImageUtils import com.google.mlkit.vision.barcode.BarcodeScanning @@ -79,6 +80,7 @@ fun AddProductScreen(onProductAdded: () -> Unit) { var quantity by remember { mutableStateOf(1) } var minQuantity by remember { mutableStateOf(1) } var selectedUnit by remember { mutableStateOf("unidad") } + var selectedSupermarket by remember { mutableStateOf("") } var isLoading by remember { mutableStateOf(false) } // Nuevo: Estado para saber si la imagen se está procesando @@ -254,12 +256,14 @@ fun AddProductScreen(onProductAdded: () -> Unit) { productImageUrl = dbProduct.imageUrl imagePreviewModel = dbProduct.imageUrl selectedUnit = dbProduct.unit + selectedSupermarket = dbProduct.lastSupermarket ?: "" } else { productName = "" productCategory = "General" productPrice = 0.0 productImageUrl = null imagePreviewModel = null + selectedSupermarket = "" } isLoading = false }.onFailure { @@ -312,6 +316,14 @@ fun AddProductScreen(onProductAdded: () -> Unit) { Spacer(modifier = Modifier.height(8.dp)) + SupermarketDropdown( + selectedSupermarket = selectedSupermarket, + onSupermarketChange = { selectedSupermarket = it }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + // Selector de Imagen Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Button( @@ -407,6 +419,7 @@ fun AddProductScreen(onProductAdded: () -> Unit) { unidad = selectedUnit, categoria = productCategory, price = productPrice, + supermarket = selectedSupermarket.ifEmpty { null }, imageUrl = productImageUrl, fechaCaducidad = expirationDate, userName = userName diff --git a/app/src/main/java/com/example/despensapp/ui/screens/LoginScreen.kt b/app/src/main/java/com/example/despensapp/ui/screens/LoginScreen.kt index 8439c2d..475a981 100644 --- a/app/src/main/java/com/example/despensapp/ui/screens/LoginScreen.kt +++ b/app/src/main/java/com/example/despensapp/ui/screens/LoginScreen.kt @@ -13,15 +13,20 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.fragment.app.FragmentActivity +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Fingerprint import com.example.despensapp.R import com.example.despensapp.data.repository.UserRepository import com.example.despensapp.data.session.UserSession import com.example.despensapp.ui.theme.DespensappTheme +import com.example.despensapp.util.BiometricHelper import kotlinx.coroutines.launch @Composable fun LoginScreen(onLoginSuccess: () -> Unit, onNavigateToRegister: () -> Unit) { val context = LocalContext.current + val activity = context as? FragmentActivity val repository = remember { UserRepository() } val scope = rememberCoroutineScope() @@ -31,6 +36,27 @@ fun LoginScreen(onLoginSuccess: () -> Unit, onNavigateToRegister: () -> Unit) { var isLoading by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + if (UserSession.isBiometricEnabled(context) && BiometricHelper.isBiometricAvailable(context)) { + activity?.let { + BiometricHelper.showBiometricPrompt(it) { success, error -> + if (success) { + // Acceso directo: Usamos los datos vinculados a la huella, no el último login manual + val bioName = UserSession.getBiometricUserName(context) + val bioFamily = UserSession.getBiometricFamilyCode(context) + + UserSession.saveUserName(context, bioName) + bioFamily?.let { UserSession.saveFamilyCode(context, it) } + + onLoginSuccess() + } else if (error != null) { + errorMessage = error + } + } + } + } + } + Column( modifier = Modifier .fillMaxSize() @@ -76,27 +102,63 @@ fun LoginScreen(onLoginSuccess: () -> Unit, onNavigateToRegister: () -> Unit) { if (isLoading) { CircularProgressIndicator() } else { - Button( - onClick = { - if (email.isNotEmpty() && password.isNotEmpty()) { - isLoading = true - errorMessage = null - scope.launch { - val result = repository.loginUser(email, password) - isLoading = false - result.onSuccess { name -> - UserSession.saveUserName(context, name) - onLoginSuccess() - } - .onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") } - } - } else { - errorMessage = context.getString(R.string.login_error_empty) - } - }, - modifier = Modifier.fillMaxWidth() + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - Text(stringResource(R.string.login_button)) + Button( + onClick = { + if (email.isNotEmpty() && password.isNotEmpty()) { + isLoading = true + errorMessage = null + scope.launch { + val result = repository.loginUser(email, password) + isLoading = false + result.onSuccess { (name, familyCode) -> + UserSession.saveUserName(context, name) + familyCode?.let { UserSession.saveFamilyCode(context, it) } + onLoginSuccess() + } + .onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") } + } + } else { + errorMessage = context.getString(R.string.login_error_empty) + } + }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.login_button)) + } + + if (BiometricHelper.isBiometricAvailable(context)) { + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = { + activity?.let { + BiometricHelper.showBiometricPrompt(it) { success, error -> + if (success) { + // Usamos la identidad vinculada a la huella + val bioName = UserSession.getBiometricUserName(context) + val bioFamily = UserSession.getBiometricFamilyCode(context) + + UserSession.saveUserName(context, bioName) + bioFamily?.let { UserSession.saveFamilyCode(context, it) } + + onLoginSuccess() + } else if (error != null) { + errorMessage = error + } + } + } + } + ) { + Icon( + imageVector = Icons.Default.Fingerprint, + contentDescription = stringResource(R.string.login_biometric_button), + tint = MaterialTheme.colorScheme.primary + ) + } + } } } 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 f60f83b..f5eb908 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 @@ -51,6 +51,8 @@ fun PantryScreen( val snackbarHostState = remember { SnackbarHostState() } val userName = remember { UserSession.getUserName(context) } + val familyCode = remember { UserSession.getFamilyCode(context) } + var allProducts by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } var errorMessage by remember { mutableStateOf(null) } @@ -63,7 +65,7 @@ fun PantryScreen( // Cargar productos al iniciar LaunchedEffect(Unit) { - val result = repository.getAllProductsFromDb() + val result = repository.getAllProductsFromDb(familyCode) result.onSuccess { allProducts = it isLoading = false @@ -324,6 +326,7 @@ fun PantryScreen( ProductItem( product = product, onItemClick = { onNavigateToDetail(product.id) }, + onNavigateToPriceScanner = onNavigateToPriceScanner, showPriceUpdater = showShoppingList, onUpdatePrice = { onNavigateToPriceScanner(product.id) }, onQuantityChange = { newQty -> @@ -403,6 +406,7 @@ fun isNearExpiry(dateStr: String?): Boolean { fun ProductItem( product: Product, onItemClick: () -> Unit, + onNavigateToPriceScanner: (Int) -> Unit = {}, showPriceUpdater: Boolean = false, onUpdatePrice: () -> Unit = {}, onQuantityChange: (Int) -> Unit @@ -491,7 +495,24 @@ fun ProductItem( ) { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_product_more), modifier = Modifier.size(18.dp)) } - Spacer(modifier = Modifier.height(8.dp)) + + Spacer(modifier = Modifier.height(4.dp)) + + // Nuevo Botón: Registrar solo Precio (Historial) + IconButton( + onClick = { onNavigateToPriceScanner(product.id) }, + modifier = Modifier.size(36.dp) + ) { + Icon( + Icons.Default.LocalOffer, + contentDescription = stringResource(R.string.pantry_update_price), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + FilledTonalIconButton( onClick = { if (product.quantity > 0) onQuantityChange(product.quantity - 1) }, modifier = Modifier.size(36.dp) @@ -527,6 +548,23 @@ fun ProductItem( } } + if (product.lastSupermarket != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Store, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = Color.Gray + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.pantry_last_supermarket, product.lastSupermarket), + fontSize = 11.sp, + color = Color.Gray + ) + } + } + if (product.createdAt != null) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( 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 index 7a7a773..52beb05 100644 --- a/app/src/main/java/com/example/despensapp/ui/screens/PriceScannerScreen.kt +++ b/app/src/main/java/com/example/despensapp/ui/screens/PriceScannerScreen.kt @@ -24,13 +24,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import com.example.despensapp.R +import com.example.despensapp.ui.components.SupermarketDropdown 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) { +fun PriceScannerScreen(onPriceScanned: (Double, String) -> Unit, onBack: () -> Unit) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } @@ -38,6 +39,7 @@ fun PriceScannerScreen(onPriceScanned: (Double) -> Unit, onBack: () -> Unit) { var torchEnabled by remember { mutableStateOf(false) } var detectedText by remember { mutableStateOf("") } var detectedPrice by remember { mutableStateOf(null) } + var supermarketName by remember { mutableStateOf("") } val recognizer = remember { TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) } @@ -138,7 +140,7 @@ fun PriceScannerScreen(onPriceScanned: (Double) -> Unit, onBack: () -> Unit) { ) } - // Panel inferior con el precio detectado + // Panel inferior con el precio detectado y entrada de supermercado Card( modifier = Modifier .fillMaxWidth() @@ -153,10 +155,20 @@ fun PriceScannerScreen(onPriceScanned: (Double) -> Unit, onBack: () -> Unit) { text = if (detectedText.isNotEmpty()) "Precio detectado: $detectedText €" else "Buscando precio...", style = MaterialTheme.typography.headlineSmall ) + + Spacer(modifier = Modifier.height(8.dp)) + + SupermarketDropdown( + selectedSupermarket = supermarketName, + onSupermarketChange = { supermarketName = it }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( - onClick = { detectedPrice?.let { onPriceScanned(it) } }, - enabled = detectedPrice != null, + onClick = { detectedPrice?.let { onPriceScanned(it, supermarketName) } }, + enabled = detectedPrice != null && supermarketName.isNotEmpty(), modifier = Modifier.fillMaxWidth() ) { Text(stringResource(R.string.save)) diff --git a/app/src/main/java/com/example/despensapp/ui/screens/ProductDetailScreen.kt b/app/src/main/java/com/example/despensapp/ui/screens/ProductDetailScreen.kt index e9126e4..5d78283 100644 --- a/app/src/main/java/com/example/despensapp/ui/screens/ProductDetailScreen.kt +++ b/app/src/main/java/com/example/despensapp/ui/screens/ProductDetailScreen.kt @@ -27,9 +27,11 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.KeyboardType import com.example.despensapp.R import coil.compose.AsyncImage +import com.example.despensapp.data.model.PriceHistory import com.example.despensapp.data.model.Product import com.example.despensapp.data.repository.PantryRepository import com.example.despensapp.data.session.UserSession +import com.example.despensapp.ui.components.SupermarketDropdown import com.example.despensapp.util.CategoryMapper import com.example.despensapp.util.DateUtils import com.example.despensapp.util.ImageUtils @@ -45,6 +47,7 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) { val userName = remember { UserSession.getUserName(context) } var product by remember { mutableStateOf(null) } + var priceHistory by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } var errorMessage by remember { mutableStateOf(null) } @@ -56,6 +59,7 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) { var editUnit by remember { mutableStateOf("") } var editCategory by remember { mutableStateOf("") } var editPrice by remember { mutableStateOf(0.0) } + var editSupermarket by remember { mutableStateOf("") } var editExpirationDate by remember { mutableStateOf(null) } var editImageUrl by remember { mutableStateOf(null) } var isProcessingImage by remember { mutableStateOf(false) } @@ -179,6 +183,14 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) { calendar.get(Calendar.DAY_OF_MONTH) ) + fun loadPriceHistory() { + scope.launch { + repository.getPriceHistory(productId).onSuccess { + priceHistory = it + } + } + } + fun loadProduct() { isLoading = true scope.launch { @@ -191,10 +203,12 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) { editUnit = p.unit editCategory = p.category editPrice = p.price + editSupermarket = p.lastSupermarket ?: "" editExpirationDate = p.expirationDate editImageUrl = p.imageUrl imagePreviewModel = p.imageUrl } + loadPriceHistory() isLoading = false }.onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") @@ -243,6 +257,7 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) { unidad = editUnit, category = editCategory, price = editPrice, + supermarket = editSupermarket.ifEmpty { null }, fechaCaducidad = editExpirationDate, imageUrl = editImageUrl, userName = userName @@ -332,6 +347,11 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) { DetailRow(icon = Icons.Default.Inventory, label = stringResource(R.string.detail_stock_label), value = stringResource(R.string.pantry_units_label, p.quantity, p.unit)) DetailRow(icon = Icons.Default.Payments, label = "Precio unitario", value = stringResource(R.string.pantry_price_label, p.price)) + + if (p.lastSupermarket != null) { + DetailRow(icon = Icons.Default.Store, label = stringResource(R.string.pantry_supermarket_label), value = p.lastSupermarket) + } + DetailRow(icon = Icons.Default.NotificationsActive, label = stringResource(R.string.detail_low_stock_alert_label), value = stringResource(R.string.detail_low_stock_alert_value, p.minQuantity, p.unit)) if (p.expirationDate != null) { @@ -346,6 +366,49 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) { DetailRow(icon = Icons.Default.Person, label = "Modificado por", value = p.lastModifiedBy) } + Spacer(modifier = Modifier.height(24.dp)) + + // Historial de Precios + Text( + text = stringResource(R.string.pantry_history_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp) + ) + + if (priceHistory.isEmpty()) { + Text( + text = stringResource(R.string.pantry_no_history), + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray, + modifier = Modifier.padding(vertical = 8.dp) + ) + } else { + priceHistory.forEach { history -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(history.supermarket, fontWeight = FontWeight.Bold) + Text(DateUtils.formatToDisplay(history.date) ?: history.date, style = MaterialTheme.typography.labelSmall, color = Color.Gray) + } + Text( + text = stringResource(R.string.pantry_price_label, history.price), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + Spacer(modifier = Modifier.height(32.dp)) Card( @@ -394,6 +457,14 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) { leadingIcon = { Icon(Icons.Default.Euro, contentDescription = null) } ) + Spacer(modifier = Modifier.height(8.dp)) + + SupermarketDropdown( + selectedSupermarket = editSupermarket, + onSupermarketChange = { editSupermarket = it }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/java/com/example/despensapp/ui/screens/RegisterScreen.kt b/app/src/main/java/com/example/despensapp/ui/screens/RegisterScreen.kt index 837c470..6faa2d5 100644 --- a/app/src/main/java/com/example/despensapp/ui/screens/RegisterScreen.kt +++ b/app/src/main/java/com/example/despensapp/ui/screens/RegisterScreen.kt @@ -103,10 +103,13 @@ fun RegisterScreen(onRegisterSuccess: () -> Unit, onNavigateToLogin: () -> Unit) isLoading = true errorMessage = null scope.launch { - val result = repository.registerUser(name, email, password) + val result = repository.registerUser(name, email, password, familyCode.ifEmpty { null }) isLoading = false result.onSuccess { userName -> UserSession.saveUserName(context, userName) + if (familyCode.isNotEmpty()) { + UserSession.saveFamilyCode(context, familyCode) + } onRegisterSuccess() } .onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") } diff --git a/app/src/main/java/com/example/despensapp/ui/screens/SettingsScreen.kt b/app/src/main/java/com/example/despensapp/ui/screens/SettingsScreen.kt index a25ea49..2d56aa5 100644 --- a/app/src/main/java/com/example/despensapp/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/example/despensapp/ui/screens/SettingsScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.example.despensapp.R +import com.example.despensapp.data.session.UserSession +import com.example.despensapp.util.BiometricHelper import com.example.despensapp.util.NotificationScheduler @OptIn(ExperimentalMaterial3Api::class) @@ -23,6 +25,9 @@ fun SettingsScreen(onBack: () -> Unit) { val currentFreq = remember { NotificationScheduler.getSavedFrequency(context) } var selectedFreq by remember { mutableStateOf(currentFreq) } val snackbarHostState = remember { SnackbarHostState() } + + val isBiometricAvailable = remember { BiometricHelper.isBiometricAvailable(context) } + var biometricEnabled by remember { mutableStateOf(UserSession.isBiometricEnabled(context)) } val options = listOf( 12L to stringResource(R.string.settings_freq_12h), @@ -89,6 +94,32 @@ fun SettingsScreen(onBack: () -> Unit) { } } } + + if (isBiometricAvailable) { + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.settings_biometric_enable), + style = MaterialTheme.typography.bodyLarge + ) + Switch( + checked = biometricEnabled, + onCheckedChange = { + biometricEnabled = it + UserSession.enableBiometricForCurrentUser(context, it) + } + ) + } + } } } } diff --git a/app/src/main/java/com/example/despensapp/util/BiometricHelper.kt b/app/src/main/java/com/example/despensapp/util/BiometricHelper.kt new file mode 100644 index 0000000..fc9f8c1 --- /dev/null +++ b/app/src/main/java/com/example/despensapp/util/BiometricHelper.kt @@ -0,0 +1,54 @@ +package com.example.despensapp.util + +import android.content.Context +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import com.example.despensapp.R + +object BiometricHelper { + + fun isBiometricAvailable(context: Context): Boolean { + val biometricManager = BiometricManager.from(context) + return when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or BIOMETRIC_WEAK)) { + BiometricManager.BIOMETRIC_SUCCESS -> true + else -> false + } + } + + fun showBiometricPrompt( + activity: FragmentActivity, + onResult: (Boolean, String?) -> Unit + ) { + val executor = ContextCompat.getMainExecutor(activity) + val biometricPrompt = BiometricPrompt(activity, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + onResult(false, errString.toString()) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + onResult(true, null) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + onResult(false, activity.getString(R.string.biometric_error_failed)) + } + }) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(R.string.biometric_title)) + .setSubtitle(activity.getString(R.string.biometric_subtitle)) + .setNegativeButtonText(activity.getString(R.string.biometric_negative_button)) + .setAllowedAuthenticators(BIOMETRIC_STRONG or BIOMETRIC_WEAK) + .build() + + biometricPrompt.authenticate(promptInfo) + } +} diff --git a/app/src/main/java/com/example/despensapp/worker/InventoryCheckWorker.kt b/app/src/main/java/com/example/despensapp/worker/InventoryCheckWorker.kt index 332b1d1..7c328ac 100644 --- a/app/src/main/java/com/example/despensapp/worker/InventoryCheckWorker.kt +++ b/app/src/main/java/com/example/despensapp/worker/InventoryCheckWorker.kt @@ -2,15 +2,17 @@ package com.example.despensapp.worker import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.os.Build import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import com.example.despensapp.MainActivity import com.example.despensapp.R import com.example.despensapp.data.repository.PantryRepository -import java.text.SimpleDateFormat -import java.util.* +import com.example.despensapp.data.session.UserSession class InventoryCheckWorker( context: Context, @@ -19,9 +21,16 @@ class InventoryCheckWorker( override suspend fun doWork(): Result { val repository = PantryRepository() - val result = repository.getAllProductsFromDb() + + // Obtenemos el código de familia de la sesión guardada + val familyCode = UserSession.getFamilyCode(applicationContext) + + // Consultamos productos filtrando por la familia del usuario actual + val result = repository.getAllProductsFromDb(familyCode) result.onSuccess { products -> + // Filtramos por familia si el repositorio lo permitiera, pero como getAllProductsFromDb + // devuelve todo lo visible=1, al menos filtramos por stock bajo. val lowStockProducts = products.filter { it.isLowStock } if (lowStockProducts.isNotEmpty()) { @@ -40,17 +49,37 @@ class InventoryCheckWorker( val channelId = "inventory_alerts" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel(channelId, applicationContext.getString(R.string.notification_channel_name), NotificationManager.IMPORTANCE_DEFAULT) + val channel = NotificationChannel( + channelId, + applicationContext.getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_HIGH // IMPORTANCE_HIGH para que salte alerta visual + ).apply { + description = "Notificaciones de productos agotándose" + enableLights(true) + enableVibration(true) + } notificationManager.createNotificationChannel(channel) } + // Abrir la app al pulsar la notificación + val intent = Intent(applicationContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent = PendingIntent.getActivity( + applicationContext, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val notification = NotificationCompat.Builder(applicationContext, channelId) - .setSmallIcon(android.R.drawable.ic_dialog_info) + .setSmallIcon(R.drawable.ic_launcher_foreground) // Asegurarse de usar un icono válido .setContentTitle(title) .setContentText(message) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_ALARM) + .setAutoCancel(true) + .setContentIntent(pendingIntent) .build() notificationManager.notify(1, notification) } -} \ No newline at end of file +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e205781..f1910a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,7 @@ Correo Electrónico Contraseña Iniciar Sesión + Usar Huella / Rostro ¿No tienes cuenta? Regístrate aquí Introduce tus credenciales Error: %1$s @@ -65,6 +66,25 @@ Escanear Precio Apunta al precio de la etiqueta Precio actualizado a %1$.2f € + Supermercado + Historial de Precios + No hay historial de precios registrado + Visto en: %1$s + + + MERCADONA + LIDL + ALDI + ECI + DIA + CARREFOUR + ALCAMPO + ACTION + FAMILY CASH + BELTRAN + SAMOY + SAYMU + OTRO / MANUAL Añadir Producto @@ -128,4 +148,11 @@ Cada 2 días Una vez a la semana Configuración guardada correctamente + Habilitar desbloqueo biométrico + Desbloqueo de Despensapp + Usa tu huella o rostro para entrar + Tu dispositivo no soporta biometría o no está configurada + Error en la autenticación + Cancelar + Acceso concedido \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bcc7286..3e6221c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ textRecognition = "16.0.1" coil = "2.7.0" workManager = "2.10.0" room = "2.6.1" +biometric = "1.2.0-alpha05" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -51,6 +52,7 @@ androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", ve androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" } material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } [plugins]