Mejoras varias

This commit is contained in:
2026-08-06 13:19:58 +02:00
parent e0b554deb6
commit 1a4beeeb3c
16 changed files with 1030 additions and 219 deletions
+6
View File
@@ -28,6 +28,12 @@ Aplicación Android diseñada para gestionar el inventario de una despensa de fo
* **Iconografía Dinámica:** Asignación inteligente de iconos de Material Design basados en la categoría del producto (Lácteos, Carnicería, Fresco, etc.). * **Iconografía Dinámica:** Asignación inteligente de iconos de Material Design basados en la categoría del producto (Lácteos, Carnicería, Fresco, etc.).
* **Cierre de Sesión:** Opción para cerrar la sesión actual y volver de forma segura a la pantalla de bienvenida. * **Cierre de Sesión:** Opción para cerrar la sesión actual y volver de forma segura a la pantalla de bienvenida.
### 💰 Control de Precios y Valor Financiero
* **Gestión de Precios:** Seguimiento del precio unitario de cada producto para una mejor planificación económica.
* **Valoración de la Despensa:** Cálculo automático del valor total de todo el inventario acumulado.
* **Presupuesto de Compra:** Cálculo del coste estimado necesario para reponer los productos en stock bajo.
* **Visualización Detallada:** Visualización del precio junto a las unidades en la lista principal y detalles.
### 🎨 Identidad Visual ### 🎨 Identidad Visual
* **Icono Personalizado:** Diseño de icono adaptativo único que representa un mueble de despensa lleno de productos. * **Icono Personalizado:** Diseño de icono adaptativo único que representa un mueble de despensa lleno de productos.
* **Interfaz Material 3:** Uso de los últimos estándares de diseño de Google para una experiencia fluida y moderna. * **Interfaz Material 3:** Uso de los últimos estándares de diseño de Google para una experiencia fluida y moderna.
@@ -5,21 +5,17 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
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.worker.InventoryCheckWorker import com.example.despensapp.util.NotificationScheduler
import java.util.concurrent.TimeUnit
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
// Programar revisión de inventario cada 24 horas // Programar revisión de inventario usando la frecuencia guardada
val workRequest = PeriodicWorkRequestBuilder<InventoryCheckWorker>(24, TimeUnit.HOURS).build() NotificationScheduler.schedule(this, NotificationScheduler.getSavedFrequency(this))
WorkManager.getInstance(this).enqueue(workRequest)
setContent { setContent {
DespensappTheme { DespensappTheme {
@@ -5,11 +5,14 @@ data class Product(
val name: String, val name: String,
val quantity: Int, val quantity: Int,
val minQuantity: Int, val minQuantity: Int,
val unit: String = "unidades", val unit: String = "unidad",
val category: String = "General", val category: String = "General",
val price: Double = 0.0,
val imageUrl: String? = null, val imageUrl: String? = null,
val expirationDate: String? = null, val expirationDate: String? = null,
val createdAt: String? = null val createdAt: String? = null,
val lastModifiedBy: String? = null
) { ) {
val isLowStock: Boolean get() = quantity <= minQuantity val isLowStock: Boolean get() = quantity <= minQuantity
} val totalPrice: Double get() = quantity * price
}
@@ -50,7 +50,7 @@ class PantryRepository {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val connection = getConnection() val connection = getConnection()
val sql = "SELECT * FROM productos" val sql = "SELECT * FROM productos WHERE visible = 1"
val statement = connection.prepareStatement(sql) val statement = connection.prepareStatement(sql)
val resultSet = statement.executeQuery() val resultSet = statement.executeQuery()
@@ -62,11 +62,13 @@ class PantryRepository {
name = resultSet.getString("nombre"), name = resultSet.getString("nombre"),
quantity = resultSet.getInt("cantidad"), quantity = resultSet.getInt("cantidad"),
minQuantity = resultSet.getInt("cantidad_minima"), minQuantity = resultSet.getInt("cantidad_minima"),
unit = resultSet.getString("unidad") ?: "unidades", unit = resultSet.getString("unidad") ?: "unidad",
category = resultSet.getString("categoria"), category = resultSet.getString("categoria"),
price = resultSet.getDouble("precio"),
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"),
lastModifiedBy = resultSet.getString("modificado_por")
) )
) )
} }
@@ -78,14 +80,15 @@ class PantryRepository {
} }
} }
suspend fun updateProductQuantity(productId: Int, newQuantity: Int): Result<Boolean> { suspend fun updateProductQuantity(productId: Int, newQuantity: Int, userName: String): Result<Boolean> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val connection = getConnection() val connection = getConnection()
val sql = "UPDATE productos SET cantidad = ? WHERE id = ?" val sql = "UPDATE productos SET cantidad = ?, modificado_por = ? WHERE id = ?"
val statement = connection.prepareStatement(sql) val statement = connection.prepareStatement(sql)
statement.setInt(1, newQuantity) statement.setInt(1, newQuantity)
statement.setInt(2, productId) statement.setString(2, userName)
statement.setInt(3, productId)
val rowsUpdated = statement.executeUpdate() val rowsUpdated = statement.executeUpdate()
connection.close() connection.close()
@@ -103,20 +106,26 @@ class PantryRepository {
minCantidad: Int, minCantidad: Int,
unidad: String, unidad: String,
category: String, category: String,
fechaCaducidad: String? price: Double,
fechaCaducidad: String?,
imageUrl: String? = null,
userName: String
): Result<Boolean> { ): Result<Boolean> {
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 = ?, fecha_caducidad = ? WHERE id = ?" val sql = "UPDATE productos SET nombre = ?, cantidad = ?, cantidad_minima = ?, unidad = ?, categoria = ?, precio = ?, 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.setString(6, fechaCaducidad) statement.setDouble(6, price)
statement.setInt(7, productId) statement.setString(7, fechaCaducidad)
statement.setString(8, imageUrl)
statement.setString(9, userName)
statement.setInt(10, productId)
val rowsUpdated = statement.executeUpdate() val rowsUpdated = statement.executeUpdate()
connection.close() connection.close()
@@ -133,14 +142,31 @@ class PantryRepository {
cantidad: Int, cantidad: Int,
minCantidad: Int, minCantidad: Int,
unidad: String, unidad: String,
categoria: String, categoria: String,
price: Double,
imageUrl: String?, imageUrl: String?,
fechaCaducidad: String? fechaCaducidad: String?,
userName: String
): Result<Boolean> { ): Result<Boolean> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val connection = getConnection() val connection = getConnection()
val sql = "INSERT INTO productos (barcode, nombre, cantidad, cantidad_minima, unidad, categoria, imagen_url, fecha_caducidad) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" val sql = """
INSERT INTO productos (barcode, nombre, cantidad, cantidad_minima, unidad, categoria, precio, imagen_url, fecha_caducidad, visible, modificado_por)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
ON DUPLICATE KEY UPDATE
nombre = VALUES(nombre),
cantidad = VALUES(cantidad),
cantidad_minima = VALUES(cantidad_minima),
unidad = VALUES(unidad),
categoria = VALUES(categoria),
precio = VALUES(precio),
imagen_url = IF(VALUES(imagen_url) IS NULL, imagen_url, VALUES(imagen_url)),
fecha_caducidad = VALUES(fecha_caducidad),
visible = 1,
modificado_por = VALUES(modificado_por)
""".trimIndent()
val statement = connection.prepareStatement(sql) val statement = connection.prepareStatement(sql)
statement.setString(1, barcode) statement.setString(1, barcode)
statement.setString(2, nombre) statement.setString(2, nombre)
@@ -148,8 +174,10 @@ class PantryRepository {
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.setString(7, imageUrl) statement.setDouble(7, price)
statement.setString(8, fechaCaducidad) statement.setString(8, imageUrl)
statement.setString(9, fechaCaducidad)
statement.setString(10, userName)
val rowsInserted = statement.executeUpdate() val rowsInserted = statement.executeUpdate()
connection.close() connection.close()
@@ -160,17 +188,18 @@ class PantryRepository {
} }
} }
suspend fun deleteProduct(productId: Int): Result<Boolean> { suspend fun deleteProduct(productId: Int, userName: String): Result<Boolean> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val connection = getConnection() val connection = getConnection()
val sql = "DELETE FROM productos WHERE id = ?" val sql = "UPDATE productos SET visible = 0, cantidad = 0, modificado_por = ? WHERE id = ?"
val statement = connection.prepareStatement(sql) val statement = connection.prepareStatement(sql)
statement.setInt(1, productId) statement.setString(1, userName)
statement.setInt(2, productId)
val rowsDeleted = statement.executeUpdate() val rowsUpdated = statement.executeUpdate()
connection.close() connection.close()
Result.success(rowsDeleted > 0) Result.success(rowsUpdated > 0)
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
} }
@@ -193,11 +222,46 @@ class PantryRepository {
name = resultSet.getString("nombre"), name = resultSet.getString("nombre"),
quantity = resultSet.getInt("cantidad"), quantity = resultSet.getInt("cantidad"),
minQuantity = resultSet.getInt("cantidad_minima"), minQuantity = resultSet.getInt("cantidad_minima"),
unit = resultSet.getString("unidad") ?: "unidades", unit = resultSet.getString("unidad") ?: "unidad",
category = resultSet.getString("categoria"), category = resultSet.getString("categoria"),
price = resultSet.getDouble("precio"),
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"),
lastModifiedBy = resultSet.getString("modificado_por")
)
}
connection.close()
Result.success(product)
} catch (e: Exception) {
Result.failure(e)
}
}
}
suspend fun getProductByBarcode(barcode: String): Result<Product?> {
return withContext(Dispatchers.IO) {
try {
val connection = getConnection()
val sql = "SELECT * FROM productos WHERE barcode = ?"
val statement = connection.prepareStatement(sql)
statement.setString(1, barcode)
val resultSet = statement.executeQuery()
var product: Product? = null
if (resultSet.next()) {
product = Product(
id = resultSet.getInt("id"),
name = resultSet.getString("nombre"),
quantity = resultSet.getInt("cantidad"),
minQuantity = resultSet.getInt("cantidad_minima"),
unit = resultSet.getString("unidad") ?: "unidad",
category = resultSet.getString("categoria"),
price = resultSet.getDouble("precio"),
imageUrl = resultSet.getString("imagen_url"),
expirationDate = resultSet.getString("fecha_caducidad"),
createdAt = resultSet.getString("fecha_alta"),
lastModifiedBy = resultSet.getString("modificado_por")
) )
} }
connection.close() connection.close()
@@ -24,7 +24,7 @@ class UserRepository {
} }
} }
suspend fun registerUser(nombre: String, email: String, contrasena: String): Result<Boolean> { suspend fun registerUser(nombre: String, email: String, contrasena: String): Result<String> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val connection = getConnection() val connection = getConnection()
@@ -38,7 +38,7 @@ class UserRepository {
connection.close() connection.close()
if (rowsInserted > 0) { if (rowsInserted > 0) {
Result.success(true) Result.success(nombre)
} else { } else {
Result.failure(Exception("No se pudo insertar el usuario")) Result.failure(Exception("No se pudo insertar el usuario"))
} }
@@ -48,22 +48,22 @@ class UserRepository {
} }
} }
suspend fun loginUser(email: String, contrasena: String): Result<Boolean> { suspend fun loginUser(email: String, contrasena: String): Result<String> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val connection = getConnection() val connection = getConnection()
val sql = "SELECT * FROM usuarios WHERE email = ? AND contrasena = ?" val sql = "SELECT nombre_completo 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)
val resultSet = statement.executeQuery() val resultSet = statement.executeQuery()
val exists = resultSet.next() if (resultSet.next()) {
connection.close() val nombre = resultSet.getString("nombre_completo")
connection.close()
if (exists) { Result.success(nombre)
Result.success(true)
} else { } else {
connection.close()
Result.failure(Exception("Credenciales incorrectas")) Result.failure(Exception("Credenciales incorrectas"))
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -0,0 +1,23 @@
package com.example.despensapp.data.session
import android.content.Context
object UserSession {
private const val PREFS_NAME = "user_session"
private const val KEY_USER_NAME = "user_name"
fun saveUserName(context: Context, name: String) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(KEY_USER_NAME, name).apply()
}
fun getUserName(context: Context): String {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(KEY_USER_NAME, "Familiar") ?: "Familiar"
}
fun clear(context: Context) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().clear().apply()
}
}
@@ -9,6 +9,7 @@ import com.example.despensapp.ui.screens.PantryScreen
import com.example.despensapp.ui.screens.RegisterScreen import com.example.despensapp.ui.screens.RegisterScreen
import com.example.despensapp.ui.screens.AddProductScreen import com.example.despensapp.ui.screens.AddProductScreen
import com.example.despensapp.ui.screens.ProductDetailScreen import com.example.despensapp.ui.screens.ProductDetailScreen
import com.example.despensapp.ui.screens.SettingsScreen
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.navArgument import androidx.navigation.navArgument
@@ -17,6 +18,7 @@ sealed class Screen(val route: String) {
object Register : Screen("register") object Register : Screen("register")
object Pantry : Screen("pantry") object Pantry : Screen("pantry")
object AddProduct : Screen("add_product") object AddProduct : Screen("add_product")
object Settings : Screen("settings")
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"
} }
@@ -60,6 +62,9 @@ fun NavGraph(navController: NavHostController) {
onNavigateToDetail = { productId -> onNavigateToDetail = { productId ->
navController.navigate(Screen.ProductDetail.createRoute(productId)) navController.navigate(Screen.ProductDetail.createRoute(productId))
}, },
onNavigateToSettings = {
navController.navigate(Screen.Settings.route)
},
onLogout = { onLogout = {
navController.navigate(Screen.Login.route) { navController.navigate(Screen.Login.route) {
popUpTo(Screen.Pantry.route) { inclusive = true } popUpTo(Screen.Pantry.route) { inclusive = true }
@@ -72,6 +77,11 @@ fun NavGraph(navController: NavHostController) {
navController.popBackStack() navController.popBackStack()
}) })
} }
composable(Screen.Settings.route) {
SettingsScreen(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 })
@@ -4,6 +4,7 @@ import android.Manifest
import android.app.DatePickerDialog import android.app.DatePickerDialog
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.VibrationEffect import android.os.VibrationEffect
import android.os.Vibrator import android.os.Vibrator
@@ -15,22 +16,30 @@ import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
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
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import 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.util.DateUtils import com.example.despensapp.util.DateUtils
import com.example.despensapp.util.ImageUtils
import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.common.InputImage
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -42,6 +51,7 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val repository = remember { PantryRepository() } val repository = remember { PantryRepository() }
val userName = remember { UserSession.getUserName(context) }
var hasCameraPermission by remember { var hasCameraPermission by remember {
mutableStateOf( mutableStateOf(
@@ -63,16 +73,126 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
var scannedBarcode by remember { mutableStateOf<String?>(null) } var scannedBarcode by remember { mutableStateOf<String?>(null) }
var productName by remember { mutableStateOf("") } var productName by remember { mutableStateOf("") }
var productCategory by remember { mutableStateOf("General") } var productCategory by remember { mutableStateOf("General") }
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 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("unidades") } var selectedUnit by remember { mutableStateOf("unidad") }
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
// Nuevo: Estado para saber si la imagen se está procesando
var isProcessingImage by remember { mutableStateOf(false) }
// Vista previa inmediata de la imagen
var imagePreviewModel by remember { mutableStateOf<Any?>(null) }
var torchEnabled by remember { mutableStateOf(false) } var torchEnabled by remember { mutableStateOf(false) }
var showPhotoSourceDialog by remember { mutableStateOf(false) }
val units = listOf("unidades", "kg", "gr", "l", "ml", "paquetes") val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let {
imagePreviewModel = it
isProcessingImage = true
scope.launch {
val base64 = ImageUtils.uriToBase64(context, it)
if (base64 != null) {
productImageUrl = base64
}
isProcessingImage = false
}
}
}
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicturePreview()
) { bitmap ->
bitmap?.let {
imagePreviewModel = it
isProcessingImage = true
scope.launch {
val base64 = ImageUtils.bitmapToBase64(it)
if (base64 != null) {
productImageUrl = base64
}
isProcessingImage = false
}
}
}
if (showPhotoSourceDialog) {
AlertDialog(
onDismissRequest = { showPhotoSourceDialog = false },
title = { Text(stringResource(R.string.photo_source_title)) },
text = {
Column {
TextButton(
onClick = {
showPhotoSourceDialog = false
cameraLauncher.launch(null)
},
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.PhotoCamera, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.photo_source_camera))
}
}
TextButton(
onClick = {
showPhotoSourceDialog = false
galleryLauncher.launch("image/*")
},
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.PhotoLibrary, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.photo_source_gallery))
}
}
TextButton(
onClick = {
showPhotoSourceDialog = false
productImageUrl = null
imagePreviewModel = null
},
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.NoPhotography, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.photo_source_none))
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { showPhotoSourceDialog = false }) {
Text(stringResource(R.string.cancel))
}
}
)
}
val units = listOf(
stringResource(R.string.unit_unidad),
stringResource(R.string.unit_kg),
stringResource(R.string.unit_gr),
stringResource(R.string.unit_l),
stringResource(R.string.unit_ml),
stringResource(R.string.unit_paquete),
stringResource(R.string.unit_botella),
stringResource(R.string.unit_sobre),
stringResource(R.string.unit_lata),
stringResource(R.string.unit_bote),
stringResource(R.string.unit_docena),
stringResource(R.string.unit_caja)
)
var unitsExpanded by remember { mutableStateOf(false) } var unitsExpanded by remember { mutableStateOf(false) }
val calendar = Calendar.getInstance() val calendar = Calendar.getInstance()
@@ -113,13 +233,40 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
scannedBarcode = barcode scannedBarcode = barcode
scope.launch { scope.launch {
isLoading = true isLoading = true
repository.getProductFromOFF(barcode).onSuccess { offProduct ->
productName = offProduct?.displayName ?: "Desconocido" // 1. Intentar con Open Food Facts
productCategory = offProduct?.categories?.split(",")?.firstOrNull() ?: "General" val offResult = repository.getProductFromOFF(barcode)
offResult.onSuccess { offProduct ->
productName = offProduct?.displayName ?: ""
productCategory = offProduct?.categories?.split(",")?.firstOrNull()?.trim() ?: "General"
productPrice = 0.0 // OFF usually doesn't provide price easily
productImageUrl = offProduct?.imageUrl productImageUrl = offProduct?.imageUrl
imagePreviewModel = offProduct?.imageUrl
isLoading = false isLoading = false
}.onFailure { }.onFailure {
isLoading = false // 2. Si falla OFF o no hay internet, intentar con MariaDB Local
repository.getProductByBarcode(barcode).onSuccess { dbProduct ->
if (dbProduct != null) {
productName = dbProduct.name
productCategory = dbProduct.category
productPrice = dbProduct.price
productImageUrl = dbProduct.imageUrl
imagePreviewModel = dbProduct.imageUrl
selectedUnit = dbProduct.unit
} else {
productName = ""
productCategory = "General"
productPrice = 0.0
productImageUrl = null
imagePreviewModel = null
}
isLoading = false
}.onFailure {
productName = ""
productPrice = 0.0
isLoading = false
}
} }
} }
} }
@@ -154,6 +301,41 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = if (productPrice == 0.0) "" else productPrice.toString(),
onValueChange = { productPrice = it.toDoubleOrNull() ?: 0.0 },
label = { Text("Precio (€)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
leadingIcon = { Icon(Icons.Default.Euro, contentDescription = null) }
)
Spacer(modifier = Modifier.height(8.dp))
// Selector de Imagen
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = { showPhotoSourceDialog = true },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.PhotoCamera, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(if (productImageUrl == null) R.string.add_product_add_photo else R.string.add_product_change_photo))
}
if (imagePreviewModel != null) {
Spacer(modifier = Modifier.width(8.dp))
AsyncImage(
model = imagePreviewModel,
contentDescription = "Preview",
modifier = Modifier.size(64.dp).clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Selector de Unidad // Selector de Unidad
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = unitsExpanded, expanded = unitsExpanded,
@@ -210,7 +392,7 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
} }
} }
if (isLoading) { if (isLoading || isProcessingImage) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
} else { } else {
Button( Button(
@@ -224,8 +406,10 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
minCantidad = minQuantity, minCantidad = minQuantity,
unidad = selectedUnit, unidad = selectedUnit,
categoria = productCategory, categoria = productCategory,
price = productPrice,
imageUrl = productImageUrl, imageUrl = productImageUrl,
fechaCaducidad = expirationDate fechaCaducidad = expirationDate,
userName = userName
) )
isLoading = false isLoading = false
if (success.isSuccess) onProductAdded() if (success.isSuccess) onProductAdded()
@@ -305,4 +489,4 @@ fun BarcodeScannerView(torchEnabled: Boolean, onBarcodeScanned: (String) -> Unit
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
@@ -15,6 +15,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
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.ui.theme.DespensappTheme import com.example.despensapp.ui.theme.DespensappTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -83,7 +84,10 @@ fun LoginScreen(onLoginSuccess: () -> Unit, onNavigateToRegister: () -> Unit) {
scope.launch { scope.launch {
val result = repository.loginUser(email, password) val result = repository.loginUser(email, password)
isLoading = false isLoading = false
result.onSuccess { onLoginSuccess() } result.onSuccess { name ->
UserSession.saveUserName(context, name)
onLoginSuccess()
}
.onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") } .onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") }
} }
} else { } else {
@@ -108,4 +112,4 @@ fun LoginPreview() {
DespensappTheme { DespensappTheme {
LoginScreen(onLoginSuccess = {}, onNavigateToRegister = {}) LoginScreen(onLoginSuccess = {}, onNavigateToRegister = {})
} }
} }
@@ -1,6 +1,7 @@
package com.example.despensapp.ui.screens package com.example.despensapp.ui.screens
import android.content.Intent import android.content.Intent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -20,13 +21,16 @@ import androidx.compose.ui.unit.sp
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.compose.ui.graphics.vector.rememberVectorPainter
import com.example.despensapp.R import com.example.despensapp.R
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.example.despensapp.ui.theme.DespensappTheme import com.example.despensapp.ui.theme.DespensappTheme
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.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 kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeParseException import java.time.format.DateTimeParseException
@@ -37,12 +41,14 @@ import java.time.temporal.ChronoUnit
fun PantryScreen( fun PantryScreen(
onNavigateToAdd: () -> Unit, onNavigateToAdd: () -> Unit,
onNavigateToDetail: (Int) -> Unit, onNavigateToDetail: (Int) -> Unit,
onNavigateToSettings: () -> Unit,
onLogout: () -> Unit onLogout: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val repository = remember { PantryRepository() } val repository = remember { PantryRepository() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val userName = remember { UserSession.getUserName(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) }
@@ -79,6 +85,12 @@ fun PantryScreen(
matchesSearch && matchesCategory && matchesExpiry && matchesLowStock matchesSearch && matchesCategory && matchesExpiry && matchesLowStock
} }
val lowStockCount = allProducts.count { it.isLowStock }
val totalPantryValue = allProducts.sumOf { it.quantity * it.price }
val estimatedShoppingCost = allProducts.filter { it.isLowStock }
.sumOf { (it.minQuantity - it.quantity).coerceAtLeast(0) * it.price }
fun shareShoppingList() { fun shareShoppingList() {
val lowStockProducts = allProducts.filter { it.isLowStock } val lowStockProducts = allProducts.filter { it.isLowStock }
if (lowStockProducts.isEmpty()) { if (lowStockProducts.isEmpty()) {
@@ -107,13 +119,29 @@ fun PantryScreen(
} }
} }
IconButton(onClick = { showShoppingList = !showShoppingList }) { IconButton(onClick = { showShoppingList = !showShoppingList }) {
Icon( BadgedBox(
if (showShoppingList) Icons.Default.Inventory else Icons.Default.ShoppingCart, badge = {
contentDescription = "Modo Lista Compra", if (lowStockCount > 0) {
tint = if (showShoppingList) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface Badge {
) Text(lowStockCount.toString())
}
}
}
) {
Icon(
if (showShoppingList) Icons.Default.Inventory else Icons.Default.ShoppingCart,
contentDescription = "Modo Lista Compra",
tint = if (showShoppingList) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
}
} }
IconButton(onClick = onLogout) { IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings_title))
}
IconButton(onClick = {
UserSession.clear(context)
onLogout()
}) {
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.logout)) Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.logout))
} }
}, },
@@ -131,6 +159,40 @@ fun PantryScreen(
} }
) { innerPadding -> ) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) { Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
// Summary Card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = if (showShoppingList) MaterialTheme.colorScheme.tertiaryContainer
else MaterialTheme.colorScheme.secondaryContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (showShoppingList) Icons.Default.ReceiptLong else Icons.Default.AccountBalanceWallet,
contentDescription = null,
tint = if (showShoppingList) MaterialTheme.colorScheme.onTertiaryContainer
else MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = if (showShoppingList) stringResource(R.string.shopping_list_total, estimatedShoppingCost)
else stringResource(R.string.pantry_total_value, totalPantryValue),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (showShoppingList) MaterialTheme.colorScheme.onTertiaryContainer
else MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
// Search Bar // Search Bar
OutlinedTextField( OutlinedTextField(
value = searchQuery, value = searchQuery,
@@ -235,7 +297,7 @@ fun PantryScreen(
SwipeToDeleteWrapper( SwipeToDeleteWrapper(
onDismiss = { onDismiss = {
scope.launch { scope.launch {
val result = repository.deleteProduct(product.id) val result = repository.deleteProduct(product.id, userName)
if (result.isSuccess) { if (result.isSuccess) {
allProducts = allProducts.filter { it.id != product.id } allProducts = allProducts.filter { it.id != product.id }
snackbarHostState.showSnackbar(context.getString(R.string.pantry_product_deleted, product.name)) snackbarHostState.showSnackbar(context.getString(R.string.pantry_product_deleted, product.name))
@@ -250,10 +312,10 @@ fun PantryScreen(
onItemClick = { onNavigateToDetail(product.id) }, onItemClick = { onNavigateToDetail(product.id) },
onQuantityChange = { newQty -> onQuantityChange = { newQty ->
scope.launch { scope.launch {
val success = repository.updateProductQuantity(product.id, newQty) val success = repository.updateProductQuantity(product.id, newQty, userName)
if (success.isSuccess) { if (success.isSuccess) {
allProducts = allProducts.map { allProducts = allProducts.map {
if (it.id == product.id) it.copy(quantity = newQty) else it if (it.id == product.id) it.copy(quantity = newQty, lastModifiedBy = userName) else it
} }
} }
} }
@@ -327,148 +389,179 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onItemClick), .clickable(onClick = onItemClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
// Añadimos un borde rojo sutil si el stock es bajo
border = if (product.isLowStock) BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)) else null
) { ) {
Row( Row(
modifier = Modifier.padding(16.dp), modifier = Modifier.height(IntrinsicSize.Min), // Para que la barra lateral ocupe todo el alto
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box(modifier = Modifier.size(64.dp)) { // Barra lateral indicadora de Stock Bajo
AsyncImage( if (product.isLowStock) {
model = product.imageUrl, Box(
contentDescription = product.name,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxHeight()
.background(Color.White, MaterialTheme.shapes.small) .width(6.dp)
.padding(4.dp), .background(MaterialTheme.colorScheme.error)
error = null
) )
// Icono de Categoría Superpuesto
Surface(
modifier = Modifier.align(Alignment.BottomEnd).offset(x = 4.dp, y = 4.dp),
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.primaryContainer,
tonalElevation = 4.dp
) {
Icon(
CategoryMapper.getIconForCategory(product.category),
contentDescription = null,
modifier = Modifier.size(16.dp).padding(2.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
} }
Spacer(modifier = Modifier.width(16.dp)) Row(
modifier = Modifier.padding(16.dp),
Column(modifier = Modifier.weight(1f)) { verticalAlignment = Alignment.CenterVertically
Row( ) {
modifier = Modifier.fillMaxWidth(), Box(modifier = Modifier.size(64.dp)) {
verticalAlignment = Alignment.CenterVertically, AsyncImage(
horizontalArrangement = Arrangement.SpaceBetween model = ImageUtils.getCoilModel(product.imageUrl),
) { contentDescription = product.name,
Column(modifier = Modifier.weight(1f)) { modifier = Modifier
Text( .fillMaxSize()
text = product.name, .background(Color.White, MaterialTheme.shapes.small)
fontSize = 18.sp, .padding(4.dp),
fontWeight = FontWeight.Bold error = rememberVectorPainter(Icons.Default.Inventory2),
) placeholder = rememberVectorPainter(Icons.Default.Inventory2)
Text( )
text = product.category,
fontSize = 14.sp, // Icono de Categoría Superpuesto
color = Color.Gray Surface(
) modifier = Modifier.align(Alignment.BottomEnd).offset(x = 4.dp, y = 4.dp),
shape = MaterialTheme.shapes.extraSmall,
// Información de Fechas color = MaterialTheme.colorScheme.primaryContainer,
Column { tonalElevation = 4.dp
if (product.expirationDate != null) { ) {
val expiryDate = try { LocalDate.parse(product.expirationDate) } catch (e: Exception) { null }
val today = LocalDate.now()
val isExpired = expiryDate?.isBefore(today) == true
val nearExpiry = isNearExpiry(product.expirationDate)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.CalendarToday,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = if (nearExpiry) MaterialTheme.colorScheme.error else Color.Gray
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (isExpired) stringResource(R.string.pantry_expiry_expired, DateUtils.formatToDisplay(product.expirationDate) ?: "")
else stringResource(R.string.pantry_expiry_date, DateUtils.formatToDisplay(product.expirationDate) ?: ""),
fontSize = 12.sp,
color = if (nearExpiry) MaterialTheme.colorScheme.error else Color.Gray,
fontWeight = if (nearExpiry) FontWeight.Bold else FontWeight.Normal
)
}
}
if (product.createdAt != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.History,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = Color.Gray
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.pantry_added_date, DateUtils.formatToDisplay(product.createdAt) ?: ""),
fontSize = 11.sp,
color = Color.Gray
)
}
}
}
}
if (product.isLowStock) {
Icon( Icon(
imageVector = Icons.Default.Warning, CategoryMapper.getIconForCategory(product.category),
contentDescription = stringResource(R.string.pantry_low_stock_warning), contentDescription = null,
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(16.dp).padding(2.dp),
modifier = Modifier.size(24.dp) tint = MaterialTheme.colorScheme.onPrimaryContainer
) )
} }
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.width(16.dp))
Row( Column(modifier = Modifier.weight(1f)) {
modifier = Modifier.fillMaxWidth(), Text(
verticalAlignment = Alignment.CenterVertically, text = product.name,
horizontalArrangement = Arrangement.SpaceBetween fontSize = 18.sp,
) { fontWeight = FontWeight.Bold,
Surface( maxLines = 1 // Evitamos que nombres muy largos rompan el diseño
shape = MaterialTheme.shapes.medium, )
color = if (product.isLowStock) MaterialTheme.colorScheme.errorContainer Text(
else MaterialTheme.colorScheme.secondaryContainer, text = product.category,
contentColor = if (product.isLowStock) MaterialTheme.colorScheme.onErrorContainer fontSize = 14.sp,
else MaterialTheme.colorScheme.onSecondaryContainer color = Color.Gray
) { )
Text(
text = stringResource(R.string.pantry_units_label, product.quantity, product.unit), // Información de Fechas y Usuario (organizada en columna para evitar solapamientos)
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), Column(modifier = Modifier.padding(top = 4.dp)) {
style = MaterialTheme.typography.bodyMedium if (product.expirationDate != null) {
) val expiryDate = try { LocalDate.parse(product.expirationDate) } catch (e: Exception) { null }
val today = LocalDate.now()
val isExpired = expiryDate?.isBefore(today) == true
val nearExpiry = isNearExpiry(product.expirationDate)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.CalendarToday,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = if (nearExpiry) MaterialTheme.colorScheme.error else Color.Gray
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (isExpired) stringResource(R.string.pantry_expiry_expired, DateUtils.formatToDisplay(product.expirationDate) ?: "")
else stringResource(R.string.pantry_expiry_date, DateUtils.formatToDisplay(product.expirationDate) ?: ""),
fontSize = 12.sp,
color = if (nearExpiry) MaterialTheme.colorScheme.error else Color.Gray,
fontWeight = if (nearExpiry) FontWeight.Bold else FontWeight.Normal
)
}
}
if (product.createdAt != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.History,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = Color.Gray
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.pantry_added_date, DateUtils.formatToDisplay(product.createdAt) ?: ""),
fontSize = 11.sp,
color = Color.Gray
)
}
}
if (product.lastModifiedBy != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.pantry_last_modified_by, product.lastModifiedBy),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
}
}
} }
Row { Spacer(modifier = Modifier.height(12.dp))
FilledTonalIconButton(
onClick = { if (product.quantity > 0) onQuantityChange(product.quantity - 1) }, Row(
modifier = Modifier.size(32.dp) modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = if (product.isLowStock) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.secondaryContainer,
contentColor = if (product.isLowStock) MaterialTheme.colorScheme.onErrorContainer
else MaterialTheme.colorScheme.onSecondaryContainer
) { ) {
Icon(Icons.Default.Remove, contentDescription = stringResource(R.string.add_product_less), modifier = Modifier.size(18.dp)) Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.pantry_units_label, product.quantity, product.unit),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodyMedium
)
if (product.price > 0) {
VerticalDivider(modifier = Modifier.height(16.dp).padding(horizontal = 4.dp))
Text(
text = stringResource(R.string.pantry_price_label, product.price),
modifier = Modifier.padding(end = 8.dp),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold
)
}
}
} }
Spacer(modifier = Modifier.width(8.dp))
FilledIconButton( Row {
onClick = { onQuantityChange(product.quantity + 1) }, FilledTonalIconButton(
modifier = Modifier.size(32.dp) onClick = { if (product.quantity > 0) 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)) ) {
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))
}
} }
} }
} }
@@ -481,6 +574,6 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
@Composable @Composable
fun PantryScreenPreview() { fun PantryScreenPreview() {
DespensappTheme { DespensappTheme {
PantryScreen(onNavigateToAdd = {}, onNavigateToDetail = {}, onLogout = {}) PantryScreen(onNavigateToAdd = {}, onNavigateToDetail = {}, onNavigateToSettings = {}, onLogout = {})
} }
} }
@@ -1,6 +1,8 @@
package com.example.despensapp.ui.screens package com.example.despensapp.ui.screens
import android.app.DatePickerDialog import android.app.DatePickerDialog
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -20,12 +22,17 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
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.graphics.vector.rememberVectorPainter
import androidx.compose.foundation.text.KeyboardOptions
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.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.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 kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.util.*
@@ -35,6 +42,8 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val repository = remember { PantryRepository() } val repository = remember { PantryRepository() }
val userName = remember { UserSession.getUserName(context) }
var product by remember { mutableStateOf<Product?>(null) } var product by remember { mutableStateOf<Product?>(null) }
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) }
@@ -46,9 +55,117 @@ 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 editPrice by remember { mutableStateOf(0.0) }
var editExpirationDate by remember { mutableStateOf<String?>(null) } var editExpirationDate by remember { mutableStateOf<String?>(null) }
var editImageUrl by remember { mutableStateOf<String?>(null) }
var isProcessingImage by remember { mutableStateOf(false) }
// Nuevo estado para la vista previa inmediata (soporta Uri, Bitmap o String)
var imagePreviewModel by remember { mutableStateOf<Any?>(null) }
var showPhotoSourceDialog by remember { mutableStateOf(false) }
val units = listOf("unidades", "kg", "gr", "l", "ml", "paquetes") val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
uri?.let {
imagePreviewModel = it // Vista previa instantánea con la Uri
isProcessingImage = true
scope.launch {
val base64 = ImageUtils.uriToBase64(context, it)
if (base64 != null) {
editImageUrl = base64
}
isProcessingImage = false
}
}
}
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicturePreview()
) { bitmap ->
bitmap?.let {
imagePreviewModel = it // Vista previa instantánea con el Bitmap
isProcessingImage = true
scope.launch {
val base64 = ImageUtils.bitmapToBase64(it)
if (base64 != null) {
editImageUrl = base64
}
isProcessingImage = false
}
}
}
if (showPhotoSourceDialog) {
AlertDialog(
onDismissRequest = { showPhotoSourceDialog = false },
title = { Text(stringResource(R.string.photo_source_title)) },
text = {
Column {
TextButton(
onClick = {
showPhotoSourceDialog = false
cameraLauncher.launch(null)
},
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.PhotoCamera, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.photo_source_camera))
}
}
TextButton(
onClick = {
showPhotoSourceDialog = false
galleryLauncher.launch("image/*")
},
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.PhotoLibrary, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.photo_source_gallery))
}
}
TextButton(
onClick = {
showPhotoSourceDialog = false
editImageUrl = null
imagePreviewModel = null
},
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.NoPhotography, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.photo_source_none))
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { showPhotoSourceDialog = false }) {
Text(stringResource(R.string.cancel))
}
}
)
}
val units = listOf(
stringResource(R.string.unit_unidad),
stringResource(R.string.unit_kg),
stringResource(R.string.unit_gr),
stringResource(R.string.unit_l),
stringResource(R.string.unit_ml),
stringResource(R.string.unit_paquete),
stringResource(R.string.unit_botella),
stringResource(R.string.unit_sobre),
stringResource(R.string.unit_lata),
stringResource(R.string.unit_bote),
stringResource(R.string.unit_docena),
stringResource(R.string.unit_caja)
)
var unitsExpanded by remember { mutableStateOf(false) } var unitsExpanded by remember { mutableStateOf(false) }
val calendar = Calendar.getInstance() val calendar = Calendar.getInstance()
@@ -73,7 +190,10 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
editMinQuantity = p.minQuantity editMinQuantity = p.minQuantity
editUnit = p.unit editUnit = p.unit
editCategory = p.category editCategory = p.category
editPrice = p.price
editExpirationDate = p.expirationDate editExpirationDate = p.expirationDate
editImageUrl = p.imageUrl
imagePreviewModel = p.imageUrl
} }
isLoading = false isLoading = false
}.onFailure { }.onFailure {
@@ -103,27 +223,40 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit)) Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit))
} }
} else { } else {
IconButton(onClick = { if (isProcessingImage) {
scope.launch { CircularProgressIndicator(
isLoading = true modifier = Modifier.size(24.dp).padding(4.dp),
val result = repository.updateProductFull( color = MaterialTheme.colorScheme.onPrimaryContainer,
productId = productId, strokeWidth = 2.dp
nombre = editName, )
cantidad = editQuantity, }
minCantidad = editMinQuantity, IconButton(
unidad = editUnit, enabled = !isProcessingImage,
categoria = editCategory, onClick = {
fechaCaducidad = editExpirationDate scope.launch {
) isLoading = true
if (result.isSuccess) { val result = repository.updateProductFull(
isEditing = false productId = productId,
loadProduct() nombre = editName,
} else { cantidad = editQuantity,
errorMessage = context.getString(R.string.error_db_connection) minCantidad = editMinQuantity,
isLoading = false unidad = editUnit,
category = editCategory,
price = editPrice,
fechaCaducidad = editExpirationDate,
imageUrl = editImageUrl,
userName = userName
)
if (result.isSuccess) {
isEditing = false
loadProduct()
} else {
errorMessage = context.getString(R.string.error_db_connection)
isLoading = false
}
} }
} }
}) { ) {
Icon(Icons.Default.Save, contentDescription = stringResource(R.string.save)) Icon(Icons.Default.Save, contentDescription = stringResource(R.string.save))
} }
} }
@@ -147,18 +280,39 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
if (!isEditing) { // Imagen del producto (común a ambos modos)
// Modo Vista Box(
modifier = Modifier
.fillMaxWidth()
.height(250.dp)
.background(Color.White)
) {
AsyncImage( AsyncImage(
model = p.imageUrl, model = ImageUtils.getCoilModel(if (isEditing) imagePreviewModel else product?.imageUrl),
contentDescription = p.name, contentDescription = product?.name,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxWidth() contentScale = ContentScale.Fit,
.height(250.dp) error = rememberVectorPainter(Icons.Default.Inventory2),
.background(Color.White), placeholder = rememberVectorPainter(Icons.Default.Inventory2)
contentScale = ContentScale.Fit
) )
if (isEditing) {
FilledTonalButton(
onClick = { showPhotoSourceDialog = true },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
Icon(Icons.Default.PhotoCamera, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.add_product_change_photo))
}
}
}
if (!isEditing) {
// Modo Vista
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
@@ -177,6 +331,7 @@ 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.Payments, label = "Precio unitario", value = stringResource(R.string.pantry_price_label, p.price))
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) {
@@ -187,6 +342,10 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
DetailRow(icon = Icons.Default.AccessTime, label = stringResource(R.string.detail_added_label), value = DateUtils.formatToDisplay(p.createdAt)!!) DetailRow(icon = Icons.Default.AccessTime, label = stringResource(R.string.detail_added_label), value = DateUtils.formatToDisplay(p.createdAt)!!)
} }
if (p.lastModifiedBy != null) {
DetailRow(icon = Icons.Default.Person, label = "Modificado por", value = p.lastModifiedBy)
}
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
Card( Card(
@@ -224,6 +383,17 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = if (editPrice == 0.0) "" else editPrice.toString(),
onValueChange = { editPrice = it.toDoubleOrNull() ?: 0.0 },
label = { Text("Precio Unitario (€)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
leadingIcon = { Icon(Icons.Default.Euro, contentDescription = null) }
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -326,4 +496,4 @@ fun DetailRow(icon: ImageVector, label: String, value: String) {
Text(text = value, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) Text(text = value, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
} }
} }
} }
@@ -9,11 +9,12 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.text.font.FontWeight
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.text.font.FontWeight
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 kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@@ -104,7 +105,10 @@ fun RegisterScreen(onRegisterSuccess: () -> Unit, onNavigateToLogin: () -> Unit)
scope.launch { scope.launch {
val result = repository.registerUser(name, email, password) val result = repository.registerUser(name, email, password)
isLoading = false isLoading = false
result.onSuccess { onRegisterSuccess() } result.onSuccess { userName ->
UserSession.saveUserName(context, userName)
onRegisterSuccess()
}
.onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") } .onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") }
} }
} else { } else {
@@ -0,0 +1,94 @@
package com.example.despensapp.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.example.despensapp.R
import com.example.despensapp.util.NotificationScheduler
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onBack: () -> Unit) {
val context = LocalContext.current
val currentFreq = remember { NotificationScheduler.getSavedFrequency(context) }
var selectedFreq by remember { mutableStateOf(currentFreq) }
val snackbarHostState = remember { SnackbarHostState() }
val options = listOf(
12L to stringResource(R.string.settings_freq_12h),
24L to stringResource(R.string.settings_freq_24h),
48L to stringResource(R.string.settings_freq_2d),
168L to stringResource(R.string.settings_freq_weekly)
)
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = stringResource(R.string.settings_notification_frequency),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
Column(Modifier.selectableGroup()) {
options.forEach { (hours, label) ->
Row(
Modifier
.fillMaxWidth()
.height(56.dp)
.selectable(
selected = (selectedFreq == hours),
onClick = {
selectedFreq = hours
NotificationScheduler.schedule(context, hours)
},
role = Role.RadioButton
)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (selectedFreq == hours),
onClick = null // null because the row handles the click
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
}
}
}
@@ -0,0 +1,89 @@
package com.example.despensapp.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Base64
import java.io.ByteArrayOutputStream
import java.io.InputStream
object ImageUtils {
/**
* Convierte una URI de imagen a una cadena Base64, redimensionándola para ahorrar espacio en DB.
*/
fun uriToBase64(context: Context, uri: Uri, maxWidth: Int = 800, maxHeight: Int = 800): String? {
return try {
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
val originalBitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
if (originalBitmap == null) return null
bitmapToBase64(originalBitmap, maxWidth, maxHeight)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
/**
* Convierte un Bitmap a una cadena Base64 con el prefijo necesario para Coil.
*/
fun bitmapToBase64(bitmap: Bitmap, maxWidth: Int = 800, maxHeight: Int = 800): String? {
return try {
val scaledBitmap = scaleBitmap(bitmap, maxWidth, maxHeight)
val outputStream = ByteArrayOutputStream()
// Comprimimos un poco menos para ganar calidad
scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
val byteArray = outputStream.toByteArray()
// NO_WRAP es crucial para que la cadena sea una URL válida para Coil
val base64String = Base64.encodeToString(byteArray, Base64.NO_WRAP)
"data:image/jpeg;base64,$base64String"
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun scaleBitmap(bitmap: Bitmap, maxWidth: Int, maxHeight: Int): Bitmap {
var width = bitmap.width
var height = bitmap.height
// Evitar redimensionar si ya es pequeña (como las miniaturas de cámara)
if (width <= maxWidth && height <= maxHeight) return bitmap
val ratioBitmap = width.toFloat() / height.toFloat()
val ratioMax = maxWidth.toFloat() / maxHeight.toFloat()
var finalWidth = maxWidth
var finalHeight = maxHeight
if (ratioMax > ratioBitmap) {
finalWidth = (maxHeight.toFloat() * ratioBitmap).toInt()
} else {
finalHeight = (maxWidth.toFloat() / ratioBitmap).toInt()
}
return Bitmap.createScaledBitmap(bitmap, finalWidth, finalHeight, true)
}
/**
* Prepara el modelo para Coil. Si es Base64, lo decodifica a ByteArray para mayor estabilidad.
*/
fun getCoilModel(imageData: Any?): Any? {
if (imageData == null) return null
if (imageData !is String) return imageData // Si es Uri o Bitmap, dejarlo como está
return if (imageData.startsWith("data:image")) {
try {
val base64Data = imageData.substringAfter("base64,")
Base64.decode(base64Data, Base64.NO_WRAP)
} catch (e: Exception) {
imageData
}
} else {
imageData
}
}
}
@@ -0,0 +1,37 @@
package com.example.despensapp.util
import android.content.Context
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.example.despensapp.worker.InventoryCheckWorker
import java.util.concurrent.TimeUnit
object NotificationScheduler {
private const val WORK_NAME = "inventory_check_work"
private const val PREFS_NAME = "settings_prefs"
private const val KEY_FREQUENCY = "notification_frequency"
fun schedule(context: Context, hours: Long) {
val workRequest = PeriodicWorkRequestBuilder<InventoryCheckWorker>(hours, TimeUnit.HOURS)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
workRequest
)
saveFrequency(context, hours)
}
fun getSavedFrequency(context: Context): Long {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getLong(KEY_FREQUENCY, 24) // 24h por defecto
}
private fun saveFrequency(context: Context, hours: Long) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putLong(KEY_FREQUENCY, hours).apply()
}
}
+34
View File
@@ -53,9 +53,13 @@
<string name="pantry_delete_error">Error al eliminar de la base de datos</string> <string name="pantry_delete_error">Error al eliminar de la base de datos</string>
<string name="pantry_low_stock_warning">Stock Bajo</string> <string name="pantry_low_stock_warning">Stock Bajo</string>
<string name="pantry_units_label">%1$s %2$s</string> <string name="pantry_units_label">%1$s %2$s</string>
<string name="pantry_price_label">%1$.2f €</string>
<string name="pantry_total_value">Valor Despensa: %1$.2f €</string>
<string name="shopping_list_total">Coste estimado: %1$.2f €</string>
<string name="pantry_expiry_expired">CADUCADO: %1$s</string> <string name="pantry_expiry_expired">CADUCADO: %1$s</string>
<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>
<!-- Add Product --> <!-- Add Product -->
<string name="add_product_title">Añadir Producto</string> <string name="add_product_title">Añadir Producto</string>
@@ -68,8 +72,29 @@
<string name="add_product_less">Menos</string> <string name="add_product_less">Menos</string>
<string name="add_product_more">Más</string> <string name="add_product_more">Más</string>
<string name="add_product_save_button">Guardar en Despensa</string> <string name="add_product_save_button">Guardar en Despensa</string>
<string name="add_product_add_photo">Añadir Foto</string>
<string name="add_product_change_photo">Cambiar Foto</string>
<string name="photo_source_title">Seleccionar origen</string>
<string name="photo_source_camera">Cámara</string>
<string name="photo_source_gallery">Galería</string>
<string name="photo_source_none">Sin foto</string>
<string name="add_product_scan_another">Escanear otro</string> <string name="add_product_scan_another">Escanear otro</string>
<string name="add_product_camera_permission_required">Se necesita permiso de cámara para escanear</string> <string name="add_product_camera_permission_required">Se necesita permiso de cámara para escanear</string>
<string name="add_product_image_picker">Seleccionar Imagen</string>
<!-- Units -->
<string name="unit_unidad">unidad</string>
<string name="unit_kg">kg</string>
<string name="unit_gr">gr</string>
<string name="unit_l">l</string>
<string name="unit_ml">ml</string>
<string name="unit_paquete">paquete</string>
<string name="unit_botella">botella</string>
<string name="unit_sobre">sobre</string>
<string name="unit_lata">lata</string>
<string name="unit_bote">bote</string>
<string name="unit_docena">docena</string>
<string name="unit_caja">caja</string>
<!-- Detail Screen --> <!-- Detail Screen -->
<string name="detail_title">Detalles del Producto</string> <string name="detail_title">Detalles del Producto</string>
@@ -89,4 +114,13 @@
<string name="notification_title">¡Revisa tu despensa!</string> <string name="notification_title">¡Revisa tu despensa!</string>
<string name="notification_message">Tienes %1$d productos con stock bajo.</string> <string name="notification_message">Tienes %1$d productos con stock bajo.</string>
<string name="notification_channel_name">Alertas de Inventario</string> <string name="notification_channel_name">Alertas de Inventario</string>
<!-- Settings -->
<string name="settings_title">Configuración</string>
<string name="settings_notification_frequency">Frecuencia de Notificaciones</string>
<string name="settings_freq_12h">Cada 12 horas</string>
<string name="settings_freq_24h">Cada 24 horas (Recomendado)</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_save_success">Configuración guardada correctamente</string>
</resources> </resources>