Compare commits

...
6 Commits
22 changed files with 1335 additions and 93 deletions
+61
View File
@@ -0,0 +1,61 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>
+5
View File
@@ -4,14 +4,19 @@ 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.
* **Easy Family Sync:** Invita a miembros de tu familia compartiendo un código directamente desde los ajustes.
* **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.
* **Inventario por Zonas (Smart Zoned Inventory):** Organiza tus productos por ubicación (Despensa, Nevera, Congelador, Baño, etc.) para un control más preciso.
* **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.
## 🛠 Funcionalidades Principales ## 🛠 Funcionalidades Principales
### 🔐 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.
+2
View File
@@ -49,6 +49,8 @@ dependencies {
implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view) implementation(libs.androidx.camera.view)
implementation(libs.barcode.scanning) implementation(libs.barcode.scanning)
implementation(libs.text.recognition)
implementation(libs.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?
)
@@ -7,7 +7,11 @@ data class Product(
val minQuantity: Int, val minQuantity: Int,
val unit: String = "unidad", val unit: String = "unidad",
val category: String = "General", val category: String = "General",
val location: String = "Despensa",
val price: Double = 0.0, val price: Double = 0.0,
val lastSupermarket: String? = null,
val bestPrice: Double? = null,
val bestPriceSupermarket: 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,26 @@ 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 = """
SELECT p.*, h.precio as mejor_precio, h.supermercado as mejor_supermercado
FROM productos p
LEFT JOIN historial_precios h ON h.id = (
SELECT id FROM historial_precios
WHERE id_producto = p.id
ORDER BY precio ASC, fecha DESC
LIMIT 1
)
WHERE p.visible = 1 ${if (familyCode != null) "AND p.codigo_familia = ?" else ""}
""".trimIndent()
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>()
@@ -64,7 +79,11 @@ class PantryRepository {
minQuantity = resultSet.getInt("cantidad_minima"), minQuantity = resultSet.getInt("cantidad_minima"),
unit = resultSet.getString("unidad") ?: "unidad", unit = resultSet.getString("unidad") ?: "unidad",
category = resultSet.getString("categoria"), category = resultSet.getString("categoria"),
location = resultSet.getString("ubicacion") ?: "Despensa",
price = resultSet.getDouble("precio"), price = resultSet.getDouble("precio"),
lastSupermarket = resultSet.getString("ultimo_supermercado"),
bestPrice = if (resultSet.getObject("mejor_precio") != null) resultSet.getDouble("mejor_precio") else null,
bestPriceSupermarket = resultSet.getString("mejor_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,6 +118,31 @@ class PantryRepository {
} }
} }
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 = ?, ultimo_supermercado = ?, modificado_por = ? WHERE id = ?"
val statement = connection.prepareStatement(sql)
statement.setDouble(1, newPrice)
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) {
Result.failure(e)
}
}
}
suspend fun updateProductFull( suspend fun updateProductFull(
productId: Int, productId: Int,
nombre: String, nombre: String,
@@ -106,7 +150,9 @@ class PantryRepository {
minCantidad: Int, minCantidad: Int,
unidad: String, unidad: String,
category: String, category: String,
location: String,
price: Double, price: Double,
supermarket: String? = null,
fechaCaducidad: String?, fechaCaducidad: String?,
imageUrl: String? = null, imageUrl: String? = null,
userName: String userName: String
@@ -114,20 +160,27 @@ 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 = ?, ubicacion = ?, 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)
statement.setInt(3, minCantidad) statement.setInt(3, minCantidad)
statement.setString(4, unidad) statement.setString(4, unidad)
statement.setString(5, category) statement.setString(5, category)
statement.setDouble(6, price) statement.setString(6, location)
statement.setString(7, fechaCaducidad) statement.setDouble(7, price)
statement.setString(8, imageUrl) statement.setString(8, supermarket)
statement.setString(9, userName) statement.setString(9, fechaCaducidad)
statement.setInt(10, productId) statement.setString(10, imageUrl)
statement.setString(11, userName)
statement.setInt(12, 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) {
@@ -143,7 +196,9 @@ class PantryRepository {
minCantidad: Int, minCantidad: Int,
unidad: String, unidad: String,
categoria: String, categoria: String,
location: String,
price: Double, price: Double,
supermarket: String? = null,
imageUrl: String?, imageUrl: String?,
fechaCaducidad: String?, fechaCaducidad: String?,
userName: String userName: String
@@ -152,34 +207,59 @@ 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, ubicacion, 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),
cantidad_minima = VALUES(cantidad_minima), cantidad_minima = VALUES(cantidad_minima),
unidad = VALUES(unidad), unidad = VALUES(unidad),
categoria = VALUES(categoria), categoria = VALUES(categoria),
ubicacion = VALUES(ubicacion),
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)
statement.setInt(4, minCantidad) statement.setInt(4, minCantidad)
statement.setString(5, unidad) statement.setString(5, unidad)
statement.setString(6, categoria) statement.setString(6, categoria)
statement.setDouble(7, price) statement.setString(7, location)
statement.setString(8, imageUrl) statement.setDouble(8, price)
statement.setString(9, fechaCaducidad) statement.setString(9, supermarket)
statement.setString(10, userName) statement.setString(10, imageUrl)
statement.setString(11, fechaCaducidad)
statement.setString(12, 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) {
@@ -210,7 +290,17 @@ class PantryRepository {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val connection = getConnection() val connection = getConnection()
val sql = "SELECT * FROM productos WHERE id = ?" val sql = """
SELECT p.*, h.precio as mejor_precio, h.supermercado as mejor_supermercado
FROM productos p
LEFT JOIN historial_precios h ON h.id = (
SELECT id FROM historial_precios
WHERE id_producto = p.id
ORDER BY precio ASC, fecha DESC
LIMIT 1
)
WHERE p.id = ?
""".trimIndent()
val statement = connection.prepareStatement(sql) val statement = connection.prepareStatement(sql)
statement.setInt(1, productId) statement.setInt(1, productId)
val resultSet = statement.executeQuery() val resultSet = statement.executeQuery()
@@ -224,7 +314,11 @@ class PantryRepository {
minQuantity = resultSet.getInt("cantidad_minima"), minQuantity = resultSet.getInt("cantidad_minima"),
unit = resultSet.getString("unidad") ?: "unidad", unit = resultSet.getString("unidad") ?: "unidad",
category = resultSet.getString("categoria"), category = resultSet.getString("categoria"),
location = resultSet.getString("ubicacion") ?: "Despensa",
price = resultSet.getDouble("precio"), price = resultSet.getDouble("precio"),
lastSupermarket = resultSet.getString("ultimo_supermercado"),
bestPrice = if (resultSet.getObject("mejor_precio") != null) resultSet.getDouble("mejor_precio") else null,
bestPriceSupermarket = resultSet.getString("mejor_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"),
@@ -243,7 +337,17 @@ class PantryRepository {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val connection = getConnection() val connection = getConnection()
val sql = "SELECT * FROM productos WHERE barcode = ?" val sql = """
SELECT p.*, h.precio as mejor_precio, h.supermercado as mejor_supermercado
FROM productos p
LEFT JOIN historial_precios h ON h.id = (
SELECT id FROM historial_precios
WHERE id_producto = p.id
ORDER BY precio ASC, fecha DESC
LIMIT 1
)
WHERE p.barcode = ?
""".trimIndent()
val statement = connection.prepareStatement(sql) val statement = connection.prepareStatement(sql)
statement.setString(1, barcode) statement.setString(1, barcode)
val resultSet = statement.executeQuery() val resultSet = statement.executeQuery()
@@ -257,7 +361,11 @@ class PantryRepository {
minQuantity = resultSet.getInt("cantidad_minima"), minQuantity = resultSet.getInt("cantidad_minima"),
unit = resultSet.getString("unidad") ?: "unidad", unit = resultSet.getString("unidad") ?: "unidad",
category = resultSet.getString("categoria"), category = resultSet.getString("categoria"),
location = resultSet.getString("ubicacion") ?: "Despensa",
price = resultSet.getDouble("precio"), price = resultSet.getDouble("precio"),
lastSupermarket = resultSet.getString("ultimo_supermercado"),
bestPrice = if (resultSet.getObject("mejor_precio") != null) resultSet.getDouble("mejor_precio") else null,
bestPriceSupermarket = resultSet.getString("mejor_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"),
@@ -271,4 +379,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,116 @@
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_costco),
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
)
}
}
}
@@ -1,17 +1,17 @@
package com.example.despensapp.ui.navigation package com.example.despensapp.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.example.despensapp.ui.screens.LoginScreen
import com.example.despensapp.ui.screens.PantryScreen
import com.example.despensapp.ui.screens.RegisterScreen
import com.example.despensapp.ui.screens.AddProductScreen
import com.example.despensapp.ui.screens.ProductDetailScreen
import com.example.despensapp.ui.screens.SettingsScreen
import androidx.navigation.NavType
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.example.despensapp.data.repository.PantryRepository
import com.example.despensapp.data.session.UserSession
import com.example.despensapp.ui.screens.*
import kotlinx.coroutines.launch
sealed class Screen(val route: String) { sealed class Screen(val route: String) {
object Login : Screen("login") object Login : Screen("login")
@@ -19,6 +19,9 @@ sealed class Screen(val route: String) {
object Pantry : Screen("pantry") object Pantry : Screen("pantry")
object AddProduct : Screen("add_product") object AddProduct : Screen("add_product")
object Settings : Screen("settings") object Settings : Screen("settings")
object PriceScanner : Screen("price_scanner/{productId}") {
fun createRoute(productId: Int) = "price_scanner/$productId"
}
object ProductDetail : Screen("product_detail/{productId}") { object ProductDetail : Screen("product_detail/{productId}") {
fun createRoute(productId: Int) = "product_detail/$productId" fun createRoute(productId: Int) = "product_detail/$productId"
} }
@@ -65,6 +68,9 @@ fun NavGraph(navController: NavHostController) {
onNavigateToSettings = { onNavigateToSettings = {
navController.navigate(Screen.Settings.route) navController.navigate(Screen.Settings.route)
}, },
onNavigateToPriceScanner = { productId ->
navController.navigate(Screen.PriceScanner.createRoute(productId))
},
onLogout = { onLogout = {
navController.navigate(Screen.Login.route) { navController.navigate(Screen.Login.route) {
popUpTo(Screen.Pantry.route) { inclusive = true } popUpTo(Screen.Pantry.route) { inclusive = true }
@@ -82,6 +88,27 @@ fun NavGraph(navController: NavHostController) {
navController.popBackStack() navController.popBackStack()
}) })
} }
composable(
route = Screen.PriceScanner.route,
arguments = listOf(navArgument("productId") { type = NavType.IntType })
) { backStackEntry ->
val productId = backStackEntry.arguments?.getInt("productId") ?: 0
val repository = PantryRepository()
val scope = rememberCoroutineScope()
val context = LocalContext.current
PriceScannerScreen(
onPriceScanned = { price, supermarket ->
scope.launch {
val userName = UserSession.getUserName(context)
repository.updateProductPrice(productId, price, userName, supermarket).onSuccess {
navController.popBackStack()
}
}
},
onBack = { navController.popBackStack() }
)
}
composable( composable(
route = Screen.ProductDetail.route, route = Screen.ProductDetail.route,
arguments = listOf(navArgument("productId") { type = NavType.IntType }) arguments = listOf(navArgument("productId") { type = NavType.IntType })
@@ -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
@@ -76,9 +77,11 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
var productPrice by remember { mutableStateOf(0.0) } var productPrice by remember { mutableStateOf(0.0) }
var productImageUrl by remember { mutableStateOf<String?>(null) } var productImageUrl by remember { mutableStateOf<String?>(null) }
var expirationDate by remember { mutableStateOf<String?>(null) } var expirationDate by remember { mutableStateOf<String?>(null) }
var selectedLocation by remember { mutableStateOf("Despensa") }
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 +257,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 +317,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(
@@ -336,6 +349,49 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Selector de Ubicación
val locationMap = listOf(
"Despensa" to stringResource(R.string.location_despensa),
"Nevera" to stringResource(R.string.location_nevera),
"Congelador" to stringResource(R.string.location_congelador),
"Baño/Aseo" to stringResource(R.string.location_bano),
"Otros" to stringResource(R.string.location_otro)
)
var locationExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = locationExpanded,
onExpandedChange = { locationExpanded = !locationExpanded },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = locationMap.find { it.first == selectedLocation }?.second ?: selectedLocation,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.pantry_location_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = locationExpanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
leadingIcon = { Icon(Icons.Default.Place, contentDescription = null) }
)
ExposedDropdownMenu(
expanded = locationExpanded,
onDismissRequest = { locationExpanded = false }
) {
locationMap.forEach { (key, label) ->
DropdownMenuItem(
text = { Text(label) },
onClick = {
selectedLocation = key
locationExpanded = false
}
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// Selector de Unidad // Selector de Unidad
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = unitsExpanded, expanded = unitsExpanded,
@@ -406,7 +462,9 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
minCantidad = minQuantity, minCantidad = minQuantity,
unidad = selectedUnit, unidad = selectedUnit,
categoria = productCategory, categoria = productCategory,
location = selectedLocation,
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
)
}
}
} }
} }
@@ -42,6 +42,7 @@ fun PantryScreen(
onNavigateToAdd: () -> Unit, onNavigateToAdd: () -> Unit,
onNavigateToDetail: (Int) -> Unit, onNavigateToDetail: (Int) -> Unit,
onNavigateToSettings: () -> Unit, onNavigateToSettings: () -> Unit,
onNavigateToPriceScanner: (Int) -> Unit,
onLogout: () -> Unit onLogout: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -50,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) }
@@ -57,12 +60,13 @@ fun PantryScreen(
// Filters and Search // Filters and Search
var searchQuery by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf<String?>(null) } var selectedCategory by remember { mutableStateOf<String?>(null) }
var selectedLocation by remember { mutableStateOf<String?>(null) }
var showOnlyNearExpiry by remember { mutableStateOf(false) } var showOnlyNearExpiry by remember { mutableStateOf(false) }
var showShoppingList by remember { mutableStateOf(false) } var showShoppingList by remember { mutableStateOf(false) }
// 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
@@ -79,10 +83,11 @@ fun PantryScreen(
val filteredProducts = allProducts.filter { product -> val filteredProducts = allProducts.filter { product ->
val matchesSearch = product.name.contains(searchQuery, ignoreCase = true) val matchesSearch = product.name.contains(searchQuery, ignoreCase = true)
val matchesCategory = selectedCategory == null || product.category == selectedCategory val matchesCategory = selectedCategory == null || product.category == selectedCategory
val matchesLocation = selectedLocation == null || product.location == selectedLocation
val matchesExpiry = !showOnlyNearExpiry || isNearExpiry(product.expirationDate) val matchesExpiry = !showOnlyNearExpiry || isNearExpiry(product.expirationDate)
val matchesLowStock = !showShoppingList || product.isLowStock val matchesLowStock = !showShoppingList || product.isLowStock
matchesSearch && matchesCategory && matchesExpiry && matchesLowStock matchesSearch && matchesCategory && matchesLocation && matchesExpiry && matchesLowStock
} }
val lowStockCount = allProducts.count { it.isLowStock } val lowStockCount = allProducts.count { it.isLowStock }
@@ -111,7 +116,20 @@ fun PantryScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(if (showShoppingList) stringResource(R.string.shopping_list_title) else stringResource(R.string.pantry_title)) }, title = {
Column {
Text(
if (showShoppingList) stringResource(R.string.shopping_list_title)
else stringResource(R.string.pantry_title),
style = MaterialTheme.typography.titleLarge
)
Text(
stringResource(R.string.pantry_subtitle, userName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
},
actions = { actions = {
if (showShoppingList) { if (showShoppingList) {
IconButton(onClick = { shareShoppingList() }) { IconButton(onClick = { shareShoppingList() }) {
@@ -245,6 +263,36 @@ fun PantryScreen(
) )
} }
// Filtros de Ubicación
val locations = listOf(
"Despensa" to stringResource(R.string.location_despensa),
"Nevera" to stringResource(R.string.location_nevera),
"Congelador" to stringResource(R.string.location_congelador),
"Baño/Aseo" to stringResource(R.string.location_bano),
"Otros" to stringResource(R.string.location_otro)
)
ScrollableTabRow(
selectedTabIndex = if (selectedLocation == null) 0 else locations.indexOfFirst { it.first == selectedLocation } + 1,
edgePadding = 16.dp,
divider = {},
containerColor = Color.Transparent,
indicator = {}
) {
Tab(
selected = selectedLocation == null,
onClick = { selectedLocation = null },
text = { Text(stringResource(R.string.pantry_filter_all)) }
)
locations.forEach { (key, label) ->
Tab(
selected = selectedLocation == key,
onClick = { selectedLocation = key },
text = { Text(label) }
)
}
}
// Categorías Scrollable // Categorías Scrollable
ScrollableTabRow( ScrollableTabRow(
selectedTabIndex = if (selectedCategory == null) 0 else categories.indexOf(selectedCategory) + 1, selectedTabIndex = if (selectedCategory == null) 0 else categories.indexOf(selectedCategory) + 1,
@@ -310,6 +358,7 @@ fun PantryScreen(
ProductItem( ProductItem(
product = product, product = product,
onItemClick = { onNavigateToDetail(product.id) }, onItemClick = { onNavigateToDetail(product.id) },
onNavigateToPriceScanner = onNavigateToPriceScanner,
onQuantityChange = { newQty -> onQuantityChange = { newQty ->
scope.launch { scope.launch {
val success = repository.updateProductQuantity(product.id, newQty, userName) val success = repository.updateProductQuantity(product.id, newQty, userName)
@@ -384,7 +433,12 @@ fun isNearExpiry(dateStr: String?): Boolean {
} }
@Composable @Composable
fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (Int) -> Unit) { fun ProductItem(
product: Product,
onItemClick: () -> Unit,
onNavigateToPriceScanner: (Int) -> Unit = {},
onQuantityChange: (Int) -> Unit
) {
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -442,17 +496,76 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Row(
text = product.name, modifier = Modifier.fillMaxWidth(),
fontSize = 18.sp, horizontalArrangement = Arrangement.SpaceBetween,
fontWeight = FontWeight.Bold, verticalAlignment = Alignment.Top
maxLines = 1 // Evitamos que nombres muy largos rompan el diseño ) {
) Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = product.category, text = product.name,
fontSize = 14.sp, fontSize = 18.sp,
color = Color.Gray fontWeight = FontWeight.Bold,
) maxLines = 1 // Evitamos que nombres muy largos rompan el diseño
)
Text(
text = product.category,
fontSize = 14.sp,
color = Color.Gray
)
// Ubicación Badge
Surface(
modifier = Modifier.padding(top = 2.dp),
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
) {
Text(
text = product.location,
fontSize = 10.sp,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
fontWeight = FontWeight.Bold
)
}
}
// Botones de acción arriba a la derecha en columna (+ arriba, - abajo)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
FilledIconButton(
onClick = { onQuantityChange(product.quantity + 1) },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_product_more), modifier = Modifier.size(18.dp))
}
Spacer(modifier = Modifier.height(4.dp))
// Nuevo Botón: Registrar solo Precio (Historial) - Ahora más visible
FilledTonalIconButton(
onClick = { onNavigateToPriceScanner(product.id) },
modifier = Modifier.size(36.dp),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Icon(
Icons.Default.Sell,
contentDescription = stringResource(R.string.pantry_update_price),
modifier = Modifier.size(18.dp)
)
}
Spacer(modifier = Modifier.height(4.dp))
FilledTonalIconButton(
onClick = { if (product.quantity > 0) onQuantityChange(product.quantity - 1) },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Remove, contentDescription = stringResource(R.string.add_product_less), modifier = Modifier.size(18.dp))
}
}
}
// Información de Fechas y Usuario (organizada en columna para evitar solapamientos) // Información de Fechas y Usuario (organizada en columna para evitar solapamientos)
Column(modifier = Modifier.padding(top = 4.dp)) { Column(modifier = Modifier.padding(top = 4.dp)) {
@@ -480,6 +593,45 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
} }
} }
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
)
}
}
// Mejor Precio Histórico (con Fallback al precio actual si no hay historial)
val displayBestPrice = product.bestPrice ?: if (product.price > 0) product.price else null
val displayBestSupermarket = product.bestPriceSupermarket ?: product.lastSupermarket
if (displayBestPrice != null && displayBestSupermarket != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
Icon(
Icons.Default.Stars,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = Color(0xFF4CAF50) // Verde éxito
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.pantry_best_price, displayBestPrice, displayBestSupermarket),
fontSize = 11.sp,
color = Color(0xFF388E3C),
fontWeight = FontWeight.Bold
)
}
}
if (product.createdAt != null) { if (product.createdAt != null) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
@@ -520,10 +672,11 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.SpaceBetween
) { ) {
// Recuadro de Unidades y Precio con peso flexible
Surface( Surface(
modifier = Modifier.weight(1f, fill = false),
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
color = if (product.isLowStock) MaterialTheme.colorScheme.errorContainer color = if (product.isLowStock) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.secondaryContainer, else MaterialTheme.colorScheme.secondaryContainer,
@@ -547,22 +700,6 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
} }
} }
} }
Row {
FilledTonalIconButton(
onClick = { if (product.quantity > 0) onQuantityChange(product.quantity - 1) },
modifier = Modifier.size(32.dp)
) {
Icon(Icons.Default.Remove, contentDescription = stringResource(R.string.add_product_less), modifier = Modifier.size(18.dp))
}
Spacer(modifier = Modifier.width(8.dp))
FilledIconButton(
onClick = { onQuantityChange(product.quantity + 1) },
modifier = Modifier.size(32.dp)
) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_product_more), modifier = Modifier.size(18.dp))
}
}
} }
} }
} }
@@ -574,6 +711,6 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
@Composable @Composable
fun PantryScreenPreview() { fun PantryScreenPreview() {
DespensappTheme { DespensappTheme {
PantryScreen(onNavigateToAdd = {}, onNavigateToDetail = {}, onNavigateToSettings = {}, onLogout = {}) PantryScreen(onNavigateToAdd = {}, onNavigateToDetail = {}, onNavigateToSettings = {}, onNavigateToPriceScanner = {}, onLogout = {})
} }
} }
@@ -0,0 +1,180 @@
package com.example.despensapp.ui.screens
import android.util.Log
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.FlashOff
import androidx.compose.material.icons.filled.FlashOn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.example.despensapp.R
import com.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, String) -> Unit, onBack: () -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
var torchEnabled by remember { mutableStateOf(false) }
var detectedText by remember { mutableStateOf("") }
var detectedPrice by remember { mutableStateOf<Double?>(null) }
var supermarketName by remember { mutableStateOf("") }
val recognizer = remember { TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.pantry_scan_price)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = {
IconButton(onClick = { torchEnabled = !torchEnabled }) {
Icon(
if (torchEnabled) Icons.Default.FlashOn else Icons.Default.FlashOff,
contentDescription = null,
tint = if (torchEnabled) Color.Yellow else LocalContentColor.current
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
) {
Box(modifier = Modifier.weight(1f)) {
AndroidView(
factory = { ctx ->
val previewView = PreviewView(ctx)
val executor = ContextCompat.getMainExecutor(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
imageAnalysis.setAnalyzer(executor) { imageProxy ->
@androidx.camera.core.ExperimentalGetImage
val mediaImage = imageProxy.image
if (mediaImage != null) {
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
recognizer.process(image)
.addOnSuccessListener { visionText ->
val fullText = visionText.text
// Lógica simple para extraer precio: buscar algo como X,XX o X.XX
val priceRegex = """(\d+[,.]\d{2})""".toRegex()
val match = priceRegex.find(fullText)
if (match != null) {
val priceStr = match.value.replace(",", ".")
detectedPrice = priceStr.toDoubleOrNull()
detectedText = match.value
}
}
.addOnCompleteListener { imageProxy.close() }
} else {
imageProxy.close()
}
}
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
cameraProvider.unbindAll()
val camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
camera.cameraControl.enableTorch(torchEnabled)
} catch (e: Exception) {
Log.e("PriceScanner", "Binding failed", e)
}
}, executor)
previewView
},
modifier = Modifier.fillMaxSize()
)
// Marco de enfoque
Box(
modifier = Modifier
.size(250.dp, 100.dp)
.border(2.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(12.dp))
.align(Alignment.Center)
)
Text(
text = stringResource(R.string.pantry_ocr_instruction),
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp)
.background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
// Panel inferior con el precio detectado y entrada de supermercado
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (detectedText.isNotEmpty()) "Precio detectado: $detectedText" else "Buscando precio...",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(8.dp))
SupermarketDropdown(
selectedSupermarket = supermarketName,
onSupermarketChange = { supermarketName = it },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Button(
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 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) }
@@ -55,7 +58,9 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
var editMinQuantity by remember { mutableStateOf(0) } var editMinQuantity by remember { mutableStateOf(0) }
var editUnit by remember { mutableStateOf("") } var editUnit by remember { mutableStateOf("") }
var editCategory by remember { mutableStateOf("") } var editCategory by remember { mutableStateOf("") }
var editLocation by remember { mutableStateOf("Despensa") }
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 +184,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 {
@@ -190,11 +203,14 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
editMinQuantity = p.minQuantity editMinQuantity = p.minQuantity
editUnit = p.unit editUnit = p.unit
editCategory = p.category editCategory = p.category
editLocation = p.location
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 ?: "")
@@ -242,7 +258,9 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
minCantidad = editMinQuantity, minCantidad = editMinQuantity,
unidad = editUnit, unidad = editUnit,
category = editCategory, category = editCategory,
location = editLocation,
price = editPrice, price = editPrice,
supermarket = editSupermarket.ifEmpty { null },
fechaCaducidad = editExpirationDate, fechaCaducidad = editExpirationDate,
imageUrl = editImageUrl, imageUrl = editImageUrl,
userName = userName userName = userName
@@ -331,7 +349,13 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
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.Place, label = stringResource(R.string.pantry_location_label), value = p.location)
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 +370,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 +461,57 @@ 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))
// Selector de Ubicación
val locationMap = listOf(
"Despensa" to stringResource(R.string.location_despensa),
"Nevera" to stringResource(R.string.location_nevera),
"Congelador" to stringResource(R.string.location_congelador),
"Baño/Aseo" to stringResource(R.string.location_bano),
"Otros" to stringResource(R.string.location_otro)
)
var locationExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = locationExpanded,
onExpandedChange = { locationExpanded = !locationExpanded },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = locationMap.find { it.first == editLocation }?.second ?: editLocation,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.pantry_location_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = locationExpanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
leadingIcon = { Icon(Icons.Default.Place, contentDescription = null) }
)
ExposedDropdownMenu(
expanded = locationExpanded,
onDismissRequest = { locationExpanded = false }
) {
locationMap.forEach { (key, label) ->
DropdownMenuItem(
text = { Text(label) },
onClick = {
editLocation = key
locationExpanded = false
}
)
}
}
}
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 ?: "") }
@@ -1,10 +1,12 @@
package com.example.despensapp.ui.screens package com.example.despensapp.ui.screens
import android.content.Intent
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -12,8 +14,11 @@ import androidx.compose.ui.Modifier
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.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
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 +28,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 +97,83 @@ 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)
}
)
}
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.settings_family_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
val familyCode = remember { UserSession.getFamilyCode(context) ?: "DEFAULT" }
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.settings_family_code_label),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = familyCode,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 8.dp)
)
Button(
onClick = {
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, context.getString(R.string.settings_family_invite_message, familyCode))
}
context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.share)))
},
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Share, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.settings_family_invite_button))
}
}
}
} }
} }
} }
@@ -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)
} }
} }
+44
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>
@@ -38,6 +39,7 @@
<!-- Pantry Screen --> <!-- Pantry Screen -->
<string name="pantry_title">Mi Despensa</string> <string name="pantry_title">Mi Despensa</string>
<string name="pantry_subtitle">Usuario: %1$s</string>
<string name="shopping_list_title">Lista de la Compra</string> <string name="shopping_list_title">Lista de la Compra</string>
<string name="pantry_search_placeholder">Buscar productos...</string> <string name="pantry_search_placeholder">Buscar productos...</string>
<string name="pantry_search_clear">Limpiar</string> <string name="pantry_search_clear">Limpiar</string>
@@ -60,6 +62,37 @@
<string name="pantry_expiry_date">Caduca: %1$s</string> <string name="pantry_expiry_date">Caduca: %1$s</string>
<string name="pantry_added_date">Añadido: %1$s</string> <string name="pantry_added_date">Añadido: %1$s</string>
<string name="pantry_last_modified_by">Por: %1$s</string> <string name="pantry_last_modified_by">Por: %1$s</string>
<string name="pantry_update_price">Actualizar Precio</string>
<string name="pantry_scan_price">Escanear Precio</string>
<string name="pantry_ocr_instruction">Apunta al precio de la etiqueta</string>
<string name="pantry_price_updated">Precio actualizado a %1$.2f €</string>
<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>
<string name="pantry_best_price">Mejor: %1$.2f € (%2$s)</string>
<string name="pantry_location_label">Ubicación</string>
<string name="location_despensa">Despensa</string>
<string name="location_nevera">Nevera</string>
<string name="location_congelador">Congelador</string>
<string name="location_bano">Baño/Aseo</string>
<string name="location_otro">Otros</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_costco">COSTCO</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>
@@ -123,4 +156,15 @@
<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="settings_family_title">Mi Familia</string>
<string name="settings_family_code_label">Tu Código de Familia:</string>
<string name="settings_family_invite_button">Invitar Familiar</string>
<string name="settings_family_invite_message">¡Hola! Únete a nuestra despensa compartida en Despensapp. \n\nUsa este código al registrarte: *%1$s* \n\n¡Así sabremos qué falta en casa en tiempo real! 🥫🛒</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>
+4
View File
@@ -14,9 +14,11 @@ okhttp = "4.12.0"
mariadbJavaClient = "3.5.10" mariadbJavaClient = "3.5.10"
camerax = "1.4.1" camerax = "1.4.1"
barcodeScanning = "17.3.0" barcodeScanning = "17.3.0"
textRecognition = "16.0.1"
coil = "2.7.0" coil = "2.7.0"
workManager = "2.10.0" workManager = "2.10.0"
room = "2.6.1" room = "2.6.1"
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" }
@@ -44,11 +46,13 @@ androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2",
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "barcodeScanning" } barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "barcodeScanning" }
text-recognition = { group = "com.google.mlkit", name = "text-recognition", version.ref = "textRecognition" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
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]