Añadir la opcion de registrar el historico de un producto en un supermercado con su precio y manejo de notificaciones de stock bajo

This commit is contained in:
2026-08-10 09:10:46 +02:00
parent 57b545c243
commit 9554190f54
21 changed files with 715 additions and 60 deletions
+3
View File
@@ -4,6 +4,8 @@ Aplicación Android moderna y colaborativa diseñada para gestionar el inventari
## 🚀 Novedades Recientes ## 🚀 Novedades Recientes
* **Seguimiento de Cambios:** Ahora puedes ver qué miembro de la familia realizó la última modificación en cada producto. * **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). * **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. * **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. * **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 ### 🔐 Autenticación y Familia
* **Acceso Multiusuario:** Registro e inicio de sesión conectado a MariaDB externa. * **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. * **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. * **Sesiones Seguras:** Gestión de sesión de usuario local para acceso rápido.
+1
View File
@@ -50,6 +50,7 @@ dependencies {
implementation(libs.androidx.camera.view) implementation(libs.androidx.camera.view)
implementation(libs.barcode.scanning) implementation(libs.barcode.scanning)
implementation(libs.text.recognition) implementation(libs.text.recognition)
implementation(libs.androidx.biometric)
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)
@@ -1,19 +1,33 @@
package com.example.despensapp package com.example.despensapp
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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 androidx.navigation.compose.rememberNavController
import com.example.despensapp.ui.navigation.NavGraph import com.example.despensapp.ui.navigation.NavGraph
import com.example.despensapp.ui.theme.DespensappTheme import com.example.despensapp.ui.theme.DespensappTheme
import com.example.despensapp.util.NotificationScheduler 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
checkNotificationPermission()
// Programar revisión de inventario usando la frecuencia guardada // Programar revisión de inventario usando la frecuencia guardada
NotificationScheduler.schedule(this, NotificationScheduler.getSavedFrequency(this)) 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)
}
}
}
} }
@@ -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?
)
@@ -8,6 +8,7 @@ data class Product(
val unit: String = "unidad", val unit: String = "unidad",
val category: String = "General", val category: String = "General",
val price: Double = 0.0, val price: Double = 0.0,
val lastSupermarket: String? = null,
val imageUrl: String? = null, val imageUrl: String? = null,
val expirationDate: String? = null, val expirationDate: String? = null,
val createdAt: String? = null, val createdAt: String? = null,
@@ -1,6 +1,7 @@
package com.example.despensapp.data.repository package com.example.despensapp.data.repository
import com.example.despensapp.data.api.OpenFoodFactsApi 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.Product
import com.example.despensapp.data.model.off.OFFProduct import com.example.despensapp.data.model.off.OFFProduct
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -46,12 +47,19 @@ class PantryRepository {
} }
} }
suspend fun getAllProductsFromDb(): Result<List<Product>> { suspend fun getAllProductsFromDb(familyCode: String? = null): Result<List<Product>> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val connection = getConnection() 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) val statement = connection.prepareStatement(sql)
if (familyCode != null) {
statement.setString(1, familyCode)
}
val resultSet = statement.executeQuery() val resultSet = statement.executeQuery()
val products = mutableListOf<Product>() val products = mutableListOf<Product>()
@@ -65,6 +73,7 @@ class PantryRepository {
unit = resultSet.getString("unidad") ?: "unidad", unit = resultSet.getString("unidad") ?: "unidad",
category = resultSet.getString("categoria"), category = resultSet.getString("categoria"),
price = resultSet.getDouble("precio"), price = resultSet.getDouble("precio"),
lastSupermarket = resultSet.getString("ultimo_supermercado"),
imageUrl = resultSet.getString("imagen_url"), imageUrl = resultSet.getString("imagen_url"),
expirationDate = resultSet.getString("fecha_caducidad"), expirationDate = resultSet.getString("fecha_caducidad"),
createdAt = resultSet.getString("fecha_alta"), createdAt = resultSet.getString("fecha_alta"),
@@ -99,17 +108,23 @@ class PantryRepository {
} }
} }
suspend fun updateProductPrice(productId: Int, newPrice: Double, userName: String): Result<Boolean> { suspend fun updateProductPrice(productId: Int, newPrice: Double, userName: String, supermarket: String? = null): Result<Boolean> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val connection = getConnection() 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) val statement = connection.prepareStatement(sql)
statement.setDouble(1, newPrice) statement.setDouble(1, newPrice)
statement.setString(2, userName) statement.setString(2, supermarket)
statement.setInt(3, productId) statement.setString(3, userName)
statement.setInt(4, productId)
val rowsUpdated = statement.executeUpdate() val rowsUpdated = statement.executeUpdate()
if (rowsUpdated > 0) {
addPriceHistory(productId, supermarket ?: "Desconocido", newPrice, userName)
}
connection.close() connection.close()
Result.success(rowsUpdated > 0) Result.success(rowsUpdated > 0)
} catch (e: Exception) { } catch (e: Exception) {
@@ -126,6 +141,7 @@ class PantryRepository {
unidad: String, unidad: String,
category: String, category: String,
price: Double, price: Double,
supermarket: String? = null,
fechaCaducidad: String?, fechaCaducidad: String?,
imageUrl: String? = null, imageUrl: String? = null,
userName: String userName: String
@@ -133,7 +149,7 @@ class PantryRepository {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val connection = getConnection() 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) val statement = connection.prepareStatement(sql)
statement.setString(1, nombre) statement.setString(1, nombre)
statement.setInt(2, cantidad) statement.setInt(2, cantidad)
@@ -141,12 +157,18 @@ class PantryRepository {
statement.setString(4, unidad) statement.setString(4, unidad)
statement.setString(5, category) statement.setString(5, category)
statement.setDouble(6, price) statement.setDouble(6, price)
statement.setString(7, fechaCaducidad) statement.setString(7, supermarket)
statement.setString(8, imageUrl) statement.setString(8, fechaCaducidad)
statement.setString(9, userName) statement.setString(9, imageUrl)
statement.setInt(10, productId) statement.setString(10, userName)
statement.setInt(11, productId)
val rowsUpdated = statement.executeUpdate() val rowsUpdated = statement.executeUpdate()
if (rowsUpdated > 0 && price > 0) {
addPriceHistory(productId, supermarket ?: "Desconocido", price, userName)
}
connection.close() connection.close()
Result.success(rowsUpdated > 0) Result.success(rowsUpdated > 0)
} catch (e: Exception) { } catch (e: Exception) {
@@ -163,6 +185,7 @@ class PantryRepository {
unidad: String, unidad: String,
categoria: String, categoria: String,
price: Double, price: Double,
supermarket: String? = null,
imageUrl: String?, imageUrl: String?,
fechaCaducidad: String?, fechaCaducidad: String?,
userName: String userName: String
@@ -171,8 +194,8 @@ class PantryRepository {
try { try {
val connection = getConnection() val connection = getConnection()
val sql = """ val sql = """
INSERT INTO productos (barcode, nombre, cantidad, cantidad_minima, unidad, categoria, precio, imagen_url, fecha_caducidad, visible, modificado_por) INSERT INTO productos (barcode, nombre, cantidad, cantidad_minima, unidad, categoria, precio, ultimo_supermercado, imagen_url, fecha_caducidad, visible, modificado_por)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
nombre = VALUES(nombre), nombre = VALUES(nombre),
cantidad = VALUES(cantidad), cantidad = VALUES(cantidad),
@@ -180,13 +203,14 @@ class PantryRepository {
unidad = VALUES(unidad), unidad = VALUES(unidad),
categoria = VALUES(categoria), categoria = VALUES(categoria),
precio = VALUES(precio), precio = VALUES(precio),
ultimo_supermercado = VALUES(ultimo_supermercado),
imagen_url = IF(VALUES(imagen_url) IS NULL, imagen_url, VALUES(imagen_url)), imagen_url = IF(VALUES(imagen_url) IS NULL, imagen_url, VALUES(imagen_url)),
fecha_caducidad = VALUES(fecha_caducidad), fecha_caducidad = VALUES(fecha_caducidad),
visible = 1, visible = 1,
modificado_por = VALUES(modificado_por) modificado_por = VALUES(modificado_por)
""".trimIndent() """.trimIndent()
val statement = connection.prepareStatement(sql) val statement = connection.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS)
statement.setString(1, barcode) statement.setString(1, barcode)
statement.setString(2, nombre) statement.setString(2, nombre)
statement.setInt(3, cantidad) statement.setInt(3, cantidad)
@@ -194,11 +218,33 @@ class PantryRepository {
statement.setString(5, unidad) statement.setString(5, unidad)
statement.setString(6, categoria) statement.setString(6, categoria)
statement.setDouble(7, price) statement.setDouble(7, price)
statement.setString(8, imageUrl) statement.setString(8, supermarket)
statement.setString(9, fechaCaducidad) statement.setString(9, imageUrl)
statement.setString(10, userName) statement.setString(10, fechaCaducidad)
statement.setString(11, userName)
val rowsInserted = statement.executeUpdate() 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() connection.close()
Result.success(rowsInserted > 0) Result.success(rowsInserted > 0)
} catch (e: Exception) { } catch (e: Exception) {
@@ -244,6 +290,7 @@ class PantryRepository {
unit = resultSet.getString("unidad") ?: "unidad", unit = resultSet.getString("unidad") ?: "unidad",
category = resultSet.getString("categoria"), category = resultSet.getString("categoria"),
price = resultSet.getDouble("precio"), price = resultSet.getDouble("precio"),
lastSupermarket = resultSet.getString("ultimo_supermercado"),
imageUrl = resultSet.getString("imagen_url"), imageUrl = resultSet.getString("imagen_url"),
expirationDate = resultSet.getString("fecha_caducidad"), expirationDate = resultSet.getString("fecha_caducidad"),
createdAt = resultSet.getString("fecha_alta"), createdAt = resultSet.getString("fecha_alta"),
@@ -277,6 +324,7 @@ class PantryRepository {
unit = resultSet.getString("unidad") ?: "unidad", unit = resultSet.getString("unidad") ?: "unidad",
category = resultSet.getString("categoria"), category = resultSet.getString("categoria"),
price = resultSet.getDouble("precio"), price = resultSet.getDouble("precio"),
lastSupermarket = resultSet.getString("ultimo_supermercado"),
imageUrl = resultSet.getString("imagen_url"), imageUrl = resultSet.getString("imagen_url"),
expirationDate = resultSet.getString("fecha_caducidad"), expirationDate = resultSet.getString("fecha_caducidad"),
createdAt = resultSet.getString("fecha_alta"), createdAt = resultSet.getString("fecha_alta"),
@@ -290,4 +338,54 @@ class PantryRepository {
} }
} }
} }
suspend fun addPriceHistory(productId: Int, supermarket: String, price: Double, userName: String?): Result<Boolean> {
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<List<PriceHistory>> {
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<PriceHistory>()
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)
}
}
}
} }
@@ -24,15 +24,16 @@ class UserRepository {
} }
} }
suspend fun registerUser(nombre: String, email: String, contrasena: String): Result<String> { suspend fun registerUser(nombre: String, email: String, contrasena: String, familyCode: String?): Result<String> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val connection = getConnection() 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) val statement = connection.prepareStatement(sql)
statement.setString(1, nombre) statement.setString(1, nombre)
statement.setString(2, email) statement.setString(2, email)
statement.setString(3, contrasena) statement.setString(3, contrasena)
statement.setString(4, familyCode)
val rowsInserted = statement.executeUpdate() val rowsInserted = statement.executeUpdate()
connection.close() connection.close()
@@ -48,11 +49,11 @@ class UserRepository {
} }
} }
suspend fun loginUser(email: String, contrasena: String): Result<String> { suspend fun loginUser(email: String, contrasena: String): Result<Pair<String, String?>> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val connection = getConnection() 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) val statement = connection.prepareStatement(sql)
statement.setString(1, email) statement.setString(1, email)
statement.setString(2, contrasena) statement.setString(2, contrasena)
@@ -60,8 +61,9 @@ class UserRepository {
val resultSet = statement.executeQuery() val resultSet = statement.executeQuery()
if (resultSet.next()) { if (resultSet.next()) {
val nombre = resultSet.getString("nombre_completo") val nombre = resultSet.getString("nombre_completo")
val familyCode = resultSet.getString("codigo_familia")
connection.close() connection.close()
Result.success(nombre) Result.success(Pair(nombre, familyCode))
} else { } else {
connection.close() connection.close()
Result.failure(Exception("Credenciales incorrectas")) Result.failure(Exception("Credenciales incorrectas"))
@@ -5,6 +5,10 @@ import android.content.Context
object UserSession { object UserSession {
private const val PREFS_NAME = "user_session" private const val PREFS_NAME = "user_session"
private const val KEY_USER_NAME = "user_name" 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) { fun saveUserName(context: Context, name: String) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@@ -16,8 +20,65 @@ object UserSession {
return prefs.getString(KEY_USER_NAME, "Familiar") ?: "Familiar" 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) { fun clear(context: Context) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) 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() 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()
} }
} }
@@ -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
)
}
}
}
@@ -98,10 +98,10 @@ fun NavGraph(navController: NavHostController) {
val context = LocalContext.current val context = LocalContext.current
PriceScannerScreen( PriceScannerScreen(
onPriceScanned = { price -> onPriceScanned = { price, supermarket ->
scope.launch { scope.launch {
val userName = UserSession.getUserName(context) val userName = UserSession.getUserName(context)
repository.updateProductPrice(productId, price, userName).onSuccess { repository.updateProductPrice(productId, price, userName, supermarket).onSuccess {
navController.popBackStack() navController.popBackStack()
} }
} }
@@ -38,6 +38,7 @@ import coil.compose.AsyncImage
import com.example.despensapp.R import com.example.despensapp.R
import com.example.despensapp.data.repository.PantryRepository import com.example.despensapp.data.repository.PantryRepository
import com.example.despensapp.data.session.UserSession 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.DateUtils
import com.example.despensapp.util.ImageUtils import com.example.despensapp.util.ImageUtils
import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScanning
@@ -79,6 +80,7 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
var quantity by remember { mutableStateOf(1) } var quantity by remember { mutableStateOf(1) }
var minQuantity by remember { mutableStateOf(1) } var minQuantity by remember { mutableStateOf(1) }
var selectedUnit by remember { mutableStateOf("unidad") } var selectedUnit by remember { mutableStateOf("unidad") }
var selectedSupermarket by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
// Nuevo: Estado para saber si la imagen se está procesando // Nuevo: Estado para saber si la imagen se está procesando
@@ -254,12 +256,14 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
productImageUrl = dbProduct.imageUrl productImageUrl = dbProduct.imageUrl
imagePreviewModel = dbProduct.imageUrl imagePreviewModel = dbProduct.imageUrl
selectedUnit = dbProduct.unit selectedUnit = dbProduct.unit
selectedSupermarket = dbProduct.lastSupermarket ?: ""
} else { } else {
productName = "" productName = ""
productCategory = "General" productCategory = "General"
productPrice = 0.0 productPrice = 0.0
productImageUrl = null productImageUrl = null
imagePreviewModel = null imagePreviewModel = null
selectedSupermarket = ""
} }
isLoading = false isLoading = false
}.onFailure { }.onFailure {
@@ -312,6 +316,14 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
SupermarketDropdown(
selectedSupermarket = selectedSupermarket,
onSupermarketChange = { selectedSupermarket = it },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Selector de Imagen // Selector de Imagen
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Button( Button(
@@ -407,6 +419,7 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
unidad = selectedUnit, unidad = selectedUnit,
categoria = productCategory, categoria = productCategory,
price = productPrice, price = productPrice,
supermarket = selectedSupermarket.ifEmpty { null },
imageUrl = productImageUrl, imageUrl = productImageUrl,
fechaCaducidad = expirationDate, fechaCaducidad = expirationDate,
userName = userName userName = userName
@@ -13,15 +13,20 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.R
import com.example.despensapp.data.repository.UserRepository import com.example.despensapp.data.repository.UserRepository
import com.example.despensapp.data.session.UserSession import com.example.despensapp.data.session.UserSession
import com.example.despensapp.ui.theme.DespensappTheme import com.example.despensapp.ui.theme.DespensappTheme
import com.example.despensapp.util.BiometricHelper
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun LoginScreen(onLoginSuccess: () -> Unit, onNavigateToRegister: () -> Unit) { fun LoginScreen(onLoginSuccess: () -> Unit, onNavigateToRegister: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val activity = context as? FragmentActivity
val repository = remember { UserRepository() } val repository = remember { UserRepository() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -31,6 +36,27 @@ fun LoginScreen(onLoginSuccess: () -> Unit, onNavigateToRegister: () -> Unit) {
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) } var errorMessage by remember { mutableStateOf<String?>(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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -76,27 +102,63 @@ fun LoginScreen(onLoginSuccess: () -> Unit, onNavigateToRegister: () -> Unit) {
if (isLoading) { if (isLoading) {
CircularProgressIndicator() CircularProgressIndicator()
} else { } else {
Button( Row(
onClick = { modifier = Modifier.fillMaxWidth(),
if (email.isNotEmpty() && password.isNotEmpty()) { verticalAlignment = Alignment.CenterVertically
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()
) { ) {
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
)
}
}
} }
} }
@@ -51,6 +51,8 @@ fun PantryScreen(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val userName = remember { UserSession.getUserName(context) } val userName = remember { UserSession.getUserName(context) }
val familyCode = remember { UserSession.getFamilyCode(context) }
var allProducts by remember { mutableStateOf<List<Product>>(emptyList()) } var allProducts by remember { mutableStateOf<List<Product>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) } var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) } var errorMessage by remember { mutableStateOf<String?>(null) }
@@ -63,7 +65,7 @@ fun PantryScreen(
// Cargar productos al iniciar // Cargar productos al iniciar
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val result = repository.getAllProductsFromDb() val result = repository.getAllProductsFromDb(familyCode)
result.onSuccess { result.onSuccess {
allProducts = it allProducts = it
isLoading = false isLoading = false
@@ -324,6 +326,7 @@ fun PantryScreen(
ProductItem( ProductItem(
product = product, product = product,
onItemClick = { onNavigateToDetail(product.id) }, onItemClick = { onNavigateToDetail(product.id) },
onNavigateToPriceScanner = onNavigateToPriceScanner,
showPriceUpdater = showShoppingList, showPriceUpdater = showShoppingList,
onUpdatePrice = { onNavigateToPriceScanner(product.id) }, onUpdatePrice = { onNavigateToPriceScanner(product.id) },
onQuantityChange = { newQty -> onQuantityChange = { newQty ->
@@ -403,6 +406,7 @@ fun isNearExpiry(dateStr: String?): Boolean {
fun ProductItem( fun ProductItem(
product: Product, product: Product,
onItemClick: () -> Unit, onItemClick: () -> Unit,
onNavigateToPriceScanner: (Int) -> Unit = {},
showPriceUpdater: Boolean = false, showPriceUpdater: Boolean = false,
onUpdatePrice: () -> Unit = {}, onUpdatePrice: () -> Unit = {},
onQuantityChange: (Int) -> 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)) 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( FilledTonalIconButton(
onClick = { if (product.quantity > 0) onQuantityChange(product.quantity - 1) }, onClick = { if (product.quantity > 0) onQuantityChange(product.quantity - 1) },
modifier = Modifier.size(36.dp) 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) { if (product.createdAt != null) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
@@ -24,13 +24,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.example.despensapp.R import com.example.despensapp.R
import com.example.despensapp.ui.components.SupermarketDropdown
import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.TextRecognition import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions import com.google.mlkit.vision.text.latin.TextRecognizerOptions
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PriceScannerScreen(onPriceScanned: (Double) -> Unit, onBack: () -> Unit) { fun PriceScannerScreen(onPriceScanned: (Double, String) -> Unit, onBack: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
@@ -38,6 +39,7 @@ fun PriceScannerScreen(onPriceScanned: (Double) -> Unit, onBack: () -> Unit) {
var torchEnabled by remember { mutableStateOf(false) } var torchEnabled by remember { mutableStateOf(false) }
var detectedText by remember { mutableStateOf("") } var detectedText by remember { mutableStateOf("") }
var detectedPrice by remember { mutableStateOf<Double?>(null) } var detectedPrice by remember { mutableStateOf<Double?>(null) }
var supermarketName by remember { mutableStateOf("") }
val recognizer = remember { TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) } 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( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -153,10 +155,20 @@ fun PriceScannerScreen(onPriceScanned: (Double) -> Unit, onBack: () -> Unit) {
text = if (detectedText.isNotEmpty()) "Precio detectado: $detectedText" else "Buscando precio...", text = if (detectedText.isNotEmpty()) "Precio detectado: $detectedText" else "Buscando precio...",
style = MaterialTheme.typography.headlineSmall 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)) Spacer(modifier = Modifier.height(16.dp))
Button( Button(
onClick = { detectedPrice?.let { onPriceScanned(it) } }, onClick = { detectedPrice?.let { onPriceScanned(it, supermarketName) } },
enabled = detectedPrice != null, enabled = detectedPrice != null && supermarketName.isNotEmpty(),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text(stringResource(R.string.save)) Text(stringResource(R.string.save))
@@ -27,9 +27,11 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import com.example.despensapp.R import com.example.despensapp.R
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.example.despensapp.data.model.PriceHistory
import com.example.despensapp.data.model.Product import com.example.despensapp.data.model.Product
import com.example.despensapp.data.repository.PantryRepository import com.example.despensapp.data.repository.PantryRepository
import com.example.despensapp.data.session.UserSession 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.CategoryMapper
import com.example.despensapp.util.DateUtils import com.example.despensapp.util.DateUtils
import com.example.despensapp.util.ImageUtils import com.example.despensapp.util.ImageUtils
@@ -45,6 +47,7 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
val userName = remember { UserSession.getUserName(context) } val userName = remember { UserSession.getUserName(context) }
var product by remember { mutableStateOf<Product?>(null) } var product by remember { mutableStateOf<Product?>(null) }
var priceHistory by remember { mutableStateOf<List<PriceHistory>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) } var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) } var errorMessage by remember { mutableStateOf<String?>(null) }
@@ -56,6 +59,7 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
var editUnit by remember { mutableStateOf("") } var editUnit by remember { mutableStateOf("") }
var editCategory by remember { mutableStateOf("") } var editCategory by remember { mutableStateOf("") }
var editPrice by remember { mutableStateOf(0.0) } var editPrice by remember { mutableStateOf(0.0) }
var editSupermarket by remember { mutableStateOf("") }
var editExpirationDate by remember { mutableStateOf<String?>(null) } var editExpirationDate by remember { mutableStateOf<String?>(null) }
var editImageUrl by remember { mutableStateOf<String?>(null) } var editImageUrl by remember { mutableStateOf<String?>(null) }
var isProcessingImage by remember { mutableStateOf(false) } var isProcessingImage by remember { mutableStateOf(false) }
@@ -179,6 +183,14 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
calendar.get(Calendar.DAY_OF_MONTH) calendar.get(Calendar.DAY_OF_MONTH)
) )
fun loadPriceHistory() {
scope.launch {
repository.getPriceHistory(productId).onSuccess {
priceHistory = it
}
}
}
fun loadProduct() { fun loadProduct() {
isLoading = true isLoading = true
scope.launch { scope.launch {
@@ -191,10 +203,12 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
editUnit = p.unit editUnit = p.unit
editCategory = p.category editCategory = p.category
editPrice = p.price editPrice = p.price
editSupermarket = p.lastSupermarket ?: ""
editExpirationDate = p.expirationDate editExpirationDate = p.expirationDate
editImageUrl = p.imageUrl editImageUrl = p.imageUrl
imagePreviewModel = p.imageUrl imagePreviewModel = p.imageUrl
} }
loadPriceHistory()
isLoading = false isLoading = false
}.onFailure { }.onFailure {
errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") errorMessage = context.getString(R.string.login_error_generic, it.message ?: "")
@@ -243,6 +257,7 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
unidad = editUnit, unidad = editUnit,
category = editCategory, category = editCategory,
price = editPrice, price = editPrice,
supermarket = editSupermarket.ifEmpty { null },
fechaCaducidad = editExpirationDate, fechaCaducidad = editExpirationDate,
imageUrl = editImageUrl, imageUrl = editImageUrl,
userName = userName 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.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)) 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)) 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) { 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) 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)) Spacer(modifier = Modifier.height(32.dp))
Card( Card(
@@ -394,6 +457,14 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
leadingIcon = { Icon(Icons.Default.Euro, contentDescription = null) } 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)) Spacer(modifier = Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -103,10 +103,13 @@ fun RegisterScreen(onRegisterSuccess: () -> Unit, onNavigateToLogin: () -> Unit)
isLoading = true isLoading = true
errorMessage = null errorMessage = null
scope.launch { scope.launch {
val result = repository.registerUser(name, email, password) val result = repository.registerUser(name, email, password, familyCode.ifEmpty { null })
isLoading = false isLoading = false
result.onSuccess { userName -> result.onSuccess { userName ->
UserSession.saveUserName(context, userName) UserSession.saveUserName(context, userName)
if (familyCode.isNotEmpty()) {
UserSession.saveFamilyCode(context, familyCode)
}
onRegisterSuccess() onRegisterSuccess()
} }
.onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") } .onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") }
@@ -14,6 +14,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.despensapp.R import com.example.despensapp.R
import com.example.despensapp.data.session.UserSession
import com.example.despensapp.util.BiometricHelper
import com.example.despensapp.util.NotificationScheduler import com.example.despensapp.util.NotificationScheduler
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -23,6 +25,9 @@ fun SettingsScreen(onBack: () -> Unit) {
val currentFreq = remember { NotificationScheduler.getSavedFrequency(context) } val currentFreq = remember { NotificationScheduler.getSavedFrequency(context) }
var selectedFreq by remember { mutableStateOf(currentFreq) } var selectedFreq by remember { mutableStateOf(currentFreq) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val isBiometricAvailable = remember { BiometricHelper.isBiometricAvailable(context) }
var biometricEnabled by remember { mutableStateOf(UserSession.isBiometricEnabled(context)) }
val options = listOf( val options = listOf(
12L to stringResource(R.string.settings_freq_12h), 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)
}
)
}
}
} }
} }
} }
@@ -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)
}
}
@@ -2,15 +2,17 @@ package com.example.despensapp.worker
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.example.despensapp.MainActivity
import com.example.despensapp.R import com.example.despensapp.R
import com.example.despensapp.data.repository.PantryRepository import com.example.despensapp.data.repository.PantryRepository
import java.text.SimpleDateFormat import com.example.despensapp.data.session.UserSession
import java.util.*
class InventoryCheckWorker( class InventoryCheckWorker(
context: Context, context: Context,
@@ -19,9 +21,16 @@ class InventoryCheckWorker(
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val repository = PantryRepository() 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 -> 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 } val lowStockProducts = products.filter { it.isLowStock }
if (lowStockProducts.isNotEmpty()) { if (lowStockProducts.isNotEmpty()) {
@@ -40,17 +49,37 @@ class InventoryCheckWorker(
val channelId = "inventory_alerts" val channelId = "inventory_alerts"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 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) 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) 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) .setContentTitle(title)
.setContentText(message) .setContentText(message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build() .build()
notificationManager.notify(1, notification) notificationManager.notify(1, notification)
} }
} }
+27
View File
@@ -23,6 +23,7 @@
<string name="login_email_label">Correo Electrónico</string> <string name="login_email_label">Correo Electrónico</string>
<string name="login_password_label">Contraseña</string> <string name="login_password_label">Contraseña</string>
<string name="login_button">Iniciar Sesión</string> <string name="login_button">Iniciar Sesión</string>
<string name="login_biometric_button">Usar Huella / Rostro</string>
<string name="login_no_account">¿No tienes cuenta? Regístrate aquí</string> <string name="login_no_account">¿No tienes cuenta? Regístrate aquí</string>
<string name="login_error_empty">Introduce tus credenciales</string> <string name="login_error_empty">Introduce tus credenciales</string>
<string name="login_error_generic">Error: %1$s</string> <string name="login_error_generic">Error: %1$s</string>
@@ -65,6 +66,25 @@
<string name="pantry_scan_price">Escanear 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_ocr_instruction">Apunta al precio de la etiqueta</string>
<string name="pantry_price_updated">Precio actualizado a %1$.2f €</string> <string name="pantry_price_updated">Precio actualizado a %1$.2f €</string>
<string name="pantry_supermarket_label">Supermercado</string>
<string name="pantry_history_title">Historial de Precios</string>
<string name="pantry_no_history">No hay historial de precios registrado</string>
<string name="pantry_last_supermarket">Visto en: %1$s</string>
<!-- Supermarkets -->
<string name="supermarket_mercadona">MERCADONA</string>
<string name="supermarket_lidl">LIDL</string>
<string name="supermarket_aldi">ALDI</string>
<string name="supermarket_eci">ECI</string>
<string name="supermarket_dia">DIA</string>
<string name="supermarket_carrefour">CARREFOUR</string>
<string name="supermarket_alcampo">ALCAMPO</string>
<string name="supermarket_action">ACTION</string>
<string name="supermarket_family_cash">FAMILY CASH</string>
<string name="supermarket_beltran">BELTRAN</string>
<string name="supermarket_samoy">SAMOY</string>
<string name="supermarket_saymu">SAYMU</string>
<string name="supermarket_otro">OTRO / MANUAL</string>
<!-- Add Product --> <!-- Add Product -->
<string name="add_product_title">Añadir Producto</string> <string name="add_product_title">Añadir Producto</string>
@@ -128,4 +148,11 @@
<string name="settings_freq_2d">Cada 2 días</string> <string name="settings_freq_2d">Cada 2 días</string>
<string name="settings_freq_weekly">Una vez a la semana</string> <string name="settings_freq_weekly">Una vez a la semana</string>
<string name="settings_save_success">Configuración guardada correctamente</string> <string name="settings_save_success">Configuración guardada correctamente</string>
<string name="settings_biometric_enable">Habilitar desbloqueo biométrico</string>
<string name="biometric_title">Desbloqueo de Despensapp</string>
<string name="biometric_subtitle">Usa tu huella o rostro para entrar</string>
<string name="biometric_error_not_available">Tu dispositivo no soporta biometría o no está configurada</string>
<string name="biometric_error_failed">Error en la autenticación</string>
<string name="biometric_negative_button">Cancelar</string>
<string name="biometric_success">Acceso concedido</string>
</resources> </resources>
+2
View File
@@ -18,6 +18,7 @@ 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"
biometric = "1.2.0-alpha05"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", 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-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" } material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
[plugins] [plugins]