Compare commits
6
Commits
c55a2b5bd8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6078ab5010 | ||
|
|
1b1b455f1a | ||
|
|
9554190f54 | ||
|
|
57b545c243 | ||
|
|
84b2be8568 | ||
|
|
79f7a35640 |
+61
@@ -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>
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user