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.).
* **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
* **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.
@@ -5,21 +5,17 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
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.theme.DespensappTheme
import com.example.despensapp.worker.InventoryCheckWorker
import java.util.concurrent.TimeUnit
import com.example.despensapp.util.NotificationScheduler
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Programar revisión de inventario cada 24 horas
val workRequest = PeriodicWorkRequestBuilder<InventoryCheckWorker>(24, TimeUnit.HOURS).build()
WorkManager.getInstance(this).enqueue(workRequest)
// Programar revisión de inventario usando la frecuencia guardada
NotificationScheduler.schedule(this, NotificationScheduler.getSavedFrequency(this))
setContent {
DespensappTheme {
@@ -5,11 +5,14 @@ data class Product(
val name: String,
val quantity: Int,
val minQuantity: Int,
val unit: String = "unidades",
val unit: String = "unidad",
val category: String = "General",
val price: Double = 0.0,
val imageUrl: 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 totalPrice: Double get() = quantity * price
}
@@ -50,7 +50,7 @@ class PantryRepository {
return withContext(Dispatchers.IO) {
try {
val connection = getConnection()
val sql = "SELECT * FROM productos"
val sql = "SELECT * FROM productos WHERE visible = 1"
val statement = connection.prepareStatement(sql)
val resultSet = statement.executeQuery()
@@ -62,11 +62,13 @@ class PantryRepository {
name = resultSet.getString("nombre"),
quantity = resultSet.getInt("cantidad"),
minQuantity = resultSet.getInt("cantidad_minima"),
unit = resultSet.getString("unidad") ?: "unidades",
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")
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) {
try {
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)
statement.setInt(1, newQuantity)
statement.setInt(2, productId)
statement.setString(2, userName)
statement.setInt(3, productId)
val rowsUpdated = statement.executeUpdate()
connection.close()
@@ -103,20 +106,26 @@ class PantryRepository {
minCantidad: Int,
unidad: String,
category: String,
fechaCaducidad: String?
price: Double,
fechaCaducidad: String?,
imageUrl: String? = null,
userName: String
): Result<Boolean> {
return withContext(Dispatchers.IO) {
try {
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)
statement.setString(1, nombre)
statement.setInt(2, cantidad)
statement.setInt(3, minCantidad)
statement.setString(4, unidad)
statement.setString(5, category)
statement.setString(6, fechaCaducidad)
statement.setInt(7, productId)
statement.setDouble(6, price)
statement.setString(7, fechaCaducidad)
statement.setString(8, imageUrl)
statement.setString(9, userName)
statement.setInt(10, productId)
val rowsUpdated = statement.executeUpdate()
connection.close()
@@ -134,13 +143,30 @@ class PantryRepository {
minCantidad: Int,
unidad: String,
categoria: String,
price: Double,
imageUrl: String?,
fechaCaducidad: String?
fechaCaducidad: String?,
userName: String
): Result<Boolean> {
return withContext(Dispatchers.IO) {
try {
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)
statement.setString(1, barcode)
statement.setString(2, nombre)
@@ -148,8 +174,10 @@ class PantryRepository {
statement.setInt(4, minCantidad)
statement.setString(5, unidad)
statement.setString(6, categoria)
statement.setString(7, imageUrl)
statement.setString(8, fechaCaducidad)
statement.setDouble(7, price)
statement.setString(8, imageUrl)
statement.setString(9, fechaCaducidad)
statement.setString(10, userName)
val rowsInserted = statement.executeUpdate()
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) {
try {
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)
statement.setInt(1, productId)
statement.setString(1, userName)
statement.setInt(2, productId)
val rowsDeleted = statement.executeUpdate()
val rowsUpdated = statement.executeUpdate()
connection.close()
Result.success(rowsDeleted > 0)
Result.success(rowsUpdated > 0)
} catch (e: Exception) {
Result.failure(e)
}
@@ -193,11 +222,46 @@ class PantryRepository {
name = resultSet.getString("nombre"),
quantity = resultSet.getInt("cantidad"),
minQuantity = resultSet.getInt("cantidad_minima"),
unit = resultSet.getString("unidad") ?: "unidades",
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")
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()
@@ -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) {
try {
val connection = getConnection()
@@ -38,7 +38,7 @@ class UserRepository {
connection.close()
if (rowsInserted > 0) {
Result.success(true)
Result.success(nombre)
} else {
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) {
try {
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)
statement.setString(1, email)
statement.setString(2, contrasena)
val resultSet = statement.executeQuery()
val exists = resultSet.next()
if (resultSet.next()) {
val nombre = resultSet.getString("nombre_completo")
connection.close()
if (exists) {
Result.success(true)
Result.success(nombre)
} else {
connection.close()
Result.failure(Exception("Credenciales incorrectas"))
}
} 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.AddProductScreen
import com.example.despensapp.ui.screens.ProductDetailScreen
import com.example.despensapp.ui.screens.SettingsScreen
import androidx.navigation.NavType
import androidx.navigation.navArgument
@@ -17,6 +18,7 @@ sealed class Screen(val route: String) {
object Register : Screen("register")
object Pantry : Screen("pantry")
object AddProduct : Screen("add_product")
object Settings : Screen("settings")
object ProductDetail : Screen("product_detail/{productId}") {
fun createRoute(productId: Int) = "product_detail/$productId"
}
@@ -60,6 +62,9 @@ fun NavGraph(navController: NavHostController) {
onNavigateToDetail = { productId ->
navController.navigate(Screen.ProductDetail.createRoute(productId))
},
onNavigateToSettings = {
navController.navigate(Screen.Settings.route)
},
onLogout = {
navController.navigate(Screen.Login.route) {
popUpTo(Screen.Pantry.route) { inclusive = true }
@@ -72,6 +77,11 @@ fun NavGraph(navController: NavHostController) {
navController.popBackStack()
})
}
composable(Screen.Settings.route) {
SettingsScreen(onBack = {
navController.popBackStack()
})
}
composable(
route = Screen.ProductDetail.route,
arguments = listOf(navArgument("productId") { type = NavType.IntType })
@@ -4,6 +4,7 @@ import android.Manifest
import android.app.DatePickerDialog
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
@@ -15,22 +16,30 @@ import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import coil.compose.AsyncImage
import com.example.despensapp.R
import com.example.despensapp.data.repository.PantryRepository
import com.example.despensapp.data.session.UserSession
import com.example.despensapp.util.DateUtils
import com.example.despensapp.util.ImageUtils
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage
import kotlinx.coroutines.launch
@@ -42,6 +51,7 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val repository = remember { PantryRepository() }
val userName = remember { UserSession.getUserName(context) }
var hasCameraPermission by remember {
mutableStateOf(
@@ -63,16 +73,126 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
var scannedBarcode by remember { mutableStateOf<String?>(null) }
var productName by remember { mutableStateOf("") }
var productCategory by remember { mutableStateOf("General") }
var productPrice by remember { mutableStateOf(0.0) }
var productImageUrl by remember { mutableStateOf<String?>(null) }
var expirationDate by remember { mutableStateOf<String?>(null) }
var quantity 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 torchEnabled by remember { mutableStateOf(false) }
// Nuevo: Estado para saber si la imagen se está procesando
var isProcessingImage by remember { mutableStateOf(false) }
val units = listOf("unidades", "kg", "gr", "l", "ml", "paquetes")
// Vista previa inmediata de la imagen
var imagePreviewModel by remember { mutableStateOf<Any?>(null) }
var torchEnabled by remember { mutableStateOf(false) }
var showPhotoSourceDialog by remember { mutableStateOf(false) }
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) }
val calendar = Calendar.getInstance()
@@ -113,13 +233,40 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
scannedBarcode = barcode
scope.launch {
isLoading = true
repository.getProductFromOFF(barcode).onSuccess { offProduct ->
productName = offProduct?.displayName ?: "Desconocido"
productCategory = offProduct?.categories?.split(",")?.firstOrNull() ?: "General"
// 1. Intentar con Open Food Facts
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
imagePreviewModel = offProduct?.imageUrl
isLoading = false
}.onFailure {
// 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))
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
ExposedDropdownMenuBox(
expanded = unitsExpanded,
@@ -210,7 +392,7 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
}
}
if (isLoading) {
if (isLoading || isProcessingImage) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
} else {
Button(
@@ -224,8 +406,10 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
minCantidad = minQuantity,
unidad = selectedUnit,
categoria = productCategory,
price = productPrice,
imageUrl = productImageUrl,
fechaCaducidad = expirationDate
fechaCaducidad = expirationDate,
userName = userName
)
isLoading = false
if (success.isSuccess) onProductAdded()
@@ -15,6 +15,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.example.despensapp.R
import com.example.despensapp.data.repository.UserRepository
import com.example.despensapp.data.session.UserSession
import com.example.despensapp.ui.theme.DespensappTheme
import kotlinx.coroutines.launch
@@ -83,7 +84,10 @@ fun LoginScreen(onLoginSuccess: () -> Unit, onNavigateToRegister: () -> Unit) {
scope.launch {
val result = repository.loginUser(email, password)
isLoading = false
result.onSuccess { onLoginSuccess() }
result.onSuccess { name ->
UserSession.saveUserName(context, name)
onLoginSuccess()
}
.onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") }
}
} else {
@@ -1,6 +1,7 @@
package com.example.despensapp.ui.screens
import android.content.Intent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import com.example.despensapp.R
import coil.compose.AsyncImage
import com.example.despensapp.ui.theme.DespensappTheme
import com.example.despensapp.data.model.Product
import com.example.despensapp.data.repository.PantryRepository
import com.example.despensapp.data.session.UserSession
import com.example.despensapp.util.CategoryMapper
import com.example.despensapp.util.DateUtils
import com.example.despensapp.util.ImageUtils
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.format.DateTimeParseException
@@ -37,12 +41,14 @@ import java.time.temporal.ChronoUnit
fun PantryScreen(
onNavigateToAdd: () -> Unit,
onNavigateToDetail: (Int) -> Unit,
onNavigateToSettings: () -> Unit,
onLogout: () -> Unit
) {
val context = LocalContext.current
val repository = remember { PantryRepository() }
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val userName = remember { UserSession.getUserName(context) }
var allProducts by remember { mutableStateOf<List<Product>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
@@ -79,6 +85,12 @@ fun PantryScreen(
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() {
val lowStockProducts = allProducts.filter { it.isLowStock }
if (lowStockProducts.isEmpty()) {
@@ -107,13 +119,29 @@ fun PantryScreen(
}
}
IconButton(onClick = { showShoppingList = !showShoppingList }) {
BadgedBox(
badge = {
if (lowStockCount > 0) {
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))
}
},
@@ -131,6 +159,40 @@ fun PantryScreen(
}
) { innerPadding ->
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
OutlinedTextField(
value = searchQuery,
@@ -235,7 +297,7 @@ fun PantryScreen(
SwipeToDeleteWrapper(
onDismiss = {
scope.launch {
val result = repository.deleteProduct(product.id)
val result = repository.deleteProduct(product.id, userName)
if (result.isSuccess) {
allProducts = allProducts.filter { it.id != product.id }
snackbarHostState.showSnackbar(context.getString(R.string.pantry_product_deleted, product.name))
@@ -250,10 +312,10 @@ fun PantryScreen(
onItemClick = { onNavigateToDetail(product.id) },
onQuantityChange = { newQty ->
scope.launch {
val success = repository.updateProductQuantity(product.id, newQty)
val success = repository.updateProductQuantity(product.id, newQty, userName)
if (success.isSuccess) {
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,21 +389,38 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
modifier = Modifier
.fillMaxWidth()
.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(
modifier = Modifier.height(IntrinsicSize.Min), // Para que la barra lateral ocupe todo el alto
verticalAlignment = Alignment.CenterVertically
) {
// Barra lateral indicadora de Stock Bajo
if (product.isLowStock) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(6.dp)
.background(MaterialTheme.colorScheme.error)
)
}
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.size(64.dp)) {
AsyncImage(
model = product.imageUrl,
model = ImageUtils.getCoilModel(product.imageUrl),
contentDescription = product.name,
modifier = Modifier
.fillMaxSize()
.background(Color.White, MaterialTheme.shapes.small)
.padding(4.dp),
error = null
error = rememberVectorPainter(Icons.Default.Inventory2),
placeholder = rememberVectorPainter(Icons.Default.Inventory2)
)
// Icono de Categoría Superpuesto
@@ -362,17 +441,12 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = product.name,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
maxLines = 1 // Evitamos que nombres muy largos rompan el diseño
)
Text(
text = product.category,
@@ -380,8 +454,8 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
color = Color.Gray
)
// Información de Fechas
Column {
// Información de Fechas y Usuario (organizada en columna para evitar solapamientos)
Column(modifier = Modifier.padding(top = 4.dp)) {
if (product.expirationDate != null) {
val expiryDate = try { LocalDate.parse(product.expirationDate) } catch (e: Exception) { null }
val today = LocalDate.now()
@@ -422,20 +496,27 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
)
}
}
}
}
if (product.isLowStock) {
if (product.lastModifiedBy != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = stringResource(R.string.pantry_low_stock_warning),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp)
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
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
@@ -449,11 +530,22 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
contentColor = if (product.isLowStock) MaterialTheme.colorScheme.onErrorContainer
else MaterialTheme.colorScheme.onSecondaryContainer
) {
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
)
}
}
}
Row {
@@ -476,11 +568,12 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
}
}
}
}
@Preview(showBackground = true)
@Composable
fun PantryScreenPreview() {
DespensappTheme {
PantryScreen(onNavigateToAdd = {}, onNavigateToDetail = {}, onLogout = {})
PantryScreen(onNavigateToAdd = {}, onNavigateToDetail = {}, onNavigateToSettings = {}, onLogout = {})
}
}
@@ -1,6 +1,8 @@
package com.example.despensapp.ui.screens
import android.app.DatePickerDialog
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
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.platform.LocalContext
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 coil.compose.AsyncImage
import com.example.despensapp.data.model.Product
import com.example.despensapp.data.repository.PantryRepository
import com.example.despensapp.data.session.UserSession
import com.example.despensapp.util.CategoryMapper
import com.example.despensapp.util.DateUtils
import com.example.despensapp.util.ImageUtils
import kotlinx.coroutines.launch
import java.util.*
@@ -35,6 +42,8 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val repository = remember { PantryRepository() }
val userName = remember { UserSession.getUserName(context) }
var product by remember { mutableStateOf<Product?>(null) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
@@ -46,9 +55,117 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
var editMinQuantity by remember { mutableStateOf(0) }
var editUnit by remember { mutableStateOf("") }
var editCategory by remember { mutableStateOf("") }
var editPrice by remember { mutableStateOf(0.0) }
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) }
val calendar = Calendar.getInstance()
@@ -73,7 +190,10 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
editMinQuantity = p.minQuantity
editUnit = p.unit
editCategory = p.category
editPrice = p.price
editExpirationDate = p.expirationDate
editImageUrl = p.imageUrl
imagePreviewModel = p.imageUrl
}
isLoading = false
}.onFailure {
@@ -103,7 +223,16 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit))
}
} else {
IconButton(onClick = {
if (isProcessingImage) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).padding(4.dp),
color = MaterialTheme.colorScheme.onPrimaryContainer,
strokeWidth = 2.dp
)
}
IconButton(
enabled = !isProcessingImage,
onClick = {
scope.launch {
isLoading = true
val result = repository.updateProductFull(
@@ -112,8 +241,11 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
cantidad = editQuantity,
minCantidad = editMinQuantity,
unidad = editUnit,
categoria = editCategory,
fechaCaducidad = editExpirationDate
category = editCategory,
price = editPrice,
fechaCaducidad = editExpirationDate,
imageUrl = editImageUrl,
userName = userName
)
if (result.isSuccess) {
isEditing = false
@@ -123,7 +255,8 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
isLoading = false
}
}
}) {
}
) {
Icon(Icons.Default.Save, contentDescription = stringResource(R.string.save))
}
}
@@ -147,18 +280,39 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
if (!isEditing) {
// Modo Vista
AsyncImage(
model = p.imageUrl,
contentDescription = p.name,
// Imagen del producto (común a ambos modos)
Box(
modifier = Modifier
.fillMaxWidth()
.height(250.dp)
.background(Color.White),
contentScale = ContentScale.Fit
.background(Color.White)
) {
AsyncImage(
model = ImageUtils.getCoilModel(if (isEditing) imagePreviewModel else product?.imageUrl),
contentDescription = product?.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
error = rememberVectorPainter(Icons.Default.Inventory2),
placeholder = rememberVectorPainter(Icons.Default.Inventory2)
)
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)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
@@ -177,6 +331,7 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
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.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))
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)!!)
}
if (p.lastModifiedBy != null) {
DetailRow(icon = Icons.Default.Person, label = "Modificado por", value = p.lastModifiedBy)
}
Spacer(modifier = Modifier.height(32.dp))
Card(
@@ -224,6 +383,17 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
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))
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -9,11 +9,12 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import com.example.despensapp.R
import com.example.despensapp.data.repository.UserRepository
import com.example.despensapp.data.session.UserSession
import kotlinx.coroutines.launch
@Composable
@@ -104,7 +105,10 @@ fun RegisterScreen(onRegisterSuccess: () -> Unit, onNavigateToLogin: () -> Unit)
scope.launch {
val result = repository.registerUser(name, email, password)
isLoading = false
result.onSuccess { onRegisterSuccess() }
result.onSuccess { userName ->
UserSession.saveUserName(context, userName)
onRegisterSuccess()
}
.onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") }
}
} 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_low_stock_warning">Stock Bajo</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_date">Caduca: %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 -->
<string name="add_product_title">Añadir Producto</string>
@@ -68,8 +72,29 @@
<string name="add_product_less">Menos</string>
<string name="add_product_more">Más</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_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 -->
<string name="detail_title">Detalles del Producto</string>
@@ -89,4 +114,13 @@
<string name="notification_title">¡Revisa tu despensa!</string>
<string name="notification_message">Tienes %1$d productos con stock bajo.</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>