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
+1
View File
@@ -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)
@@ -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)
}
}
}
}
@@ -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 category: String = "General",
val price: Double = 0.0,
val lastSupermarket: String? = null,
val imageUrl: String? = null,
val expirationDate: String? = null,
val createdAt: String? = null,
@@ -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<List<Product>> {
suspend fun getAllProductsFromDb(familyCode: String? = null): Result<List<Product>> {
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<Product>()
@@ -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<Boolean> {
suspend fun updateProductPrice(productId: Int, newPrice: Double, userName: String, supermarket: String? = null): Result<Boolean> {
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<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) {
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<String> {
suspend fun loginUser(email: String, contrasena: String): Result<Pair<String, String?>> {
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"))
@@ -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()
}
}
@@ -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
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()
}
}
@@ -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
@@ -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<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(
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
)
}
}
}
}
@@ -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<List<Product>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(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(
@@ -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<Double?>(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))
@@ -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<Product?>(null) }
var priceHistory by remember { mutableStateOf<List<PriceHistory>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(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<String?>(null) }
var editImageUrl by remember { mutableStateOf<String?>(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) {
@@ -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 ?: "") }
@@ -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)
}
)
}
}
}
}
}
@@ -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.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)
}
}
}
+27
View File
@@ -23,6 +23,7 @@
<string name="login_email_label">Correo Electrónico</string>
<string name="login_password_label">Contraseña</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_error_empty">Introduce tus credenciales</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_ocr_instruction">Apunta al precio de la etiqueta</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 -->
<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_weekly">Una vez a la semana</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>