Mejoras varias
This commit is contained in:
@@ -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()
|
||||
@@ -133,14 +142,31 @@ class PantryRepository {
|
||||
cantidad: Int,
|
||||
minCantidad: Int,
|
||||
unidad: String,
|
||||
categoria: 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()
|
||||
connection.close()
|
||||
|
||||
if (exists) {
|
||||
Result.success(true)
|
||||
if (resultSet.next()) {
|
||||
val nombre = resultSet.getString("nombre_completo")
|
||||
connection.close()
|
||||
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) }
|
||||
|
||||
// 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 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) }
|
||||
|
||||
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 {
|
||||
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))
|
||||
|
||||
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()
|
||||
@@ -305,4 +489,4 @@ fun BarcodeScannerView(torchEnabled: Boolean, onBarcodeScanned: (String) -> Unit
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -108,4 +112,4 @@ fun LoginPreview() {
|
||||
DespensappTheme {
|
||||
LoginScreen(onLoginSuccess = {}, onNavigateToRegister = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
Icon(
|
||||
if (showShoppingList) Icons.Default.Inventory else Icons.Default.ShoppingCart,
|
||||
contentDescription = "Modo Lista Compra",
|
||||
tint = if (showShoppingList) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
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,148 +389,179 @@ 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.padding(16.dp),
|
||||
modifier = Modifier.height(IntrinsicSize.Min), // Para que la barra lateral ocupe todo el alto
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = Modifier.size(64.dp)) {
|
||||
AsyncImage(
|
||||
model = product.imageUrl,
|
||||
contentDescription = product.name,
|
||||
// Barra lateral indicadora de Stock Bajo
|
||||
if (product.isLowStock) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White, MaterialTheme.shapes.small)
|
||||
.padding(4.dp),
|
||||
error = null
|
||||
.fillMaxHeight()
|
||||
.width(6.dp)
|
||||
.background(MaterialTheme.colorScheme.error)
|
||||
)
|
||||
|
||||
// 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))
|
||||
|
||||
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
|
||||
)
|
||||
Text(
|
||||
text = product.category,
|
||||
fontSize = 14.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
|
||||
// Información de Fechas
|
||||
Column {
|
||||
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) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = Modifier.size(64.dp)) {
|
||||
AsyncImage(
|
||||
model = ImageUtils.getCoilModel(product.imageUrl),
|
||||
contentDescription = product.name,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White, MaterialTheme.shapes.small)
|
||||
.padding(4.dp),
|
||||
error = rememberVectorPainter(Icons.Default.Inventory2),
|
||||
placeholder = rememberVectorPainter(Icons.Default.Inventory2)
|
||||
)
|
||||
|
||||
// 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(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = stringResource(R.string.pantry_low_stock_warning),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(24.dp)
|
||||
CategoryMapper.getIconForCategory(product.category),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp).padding(2.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Row(
|
||||
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
|
||||
) {
|
||||
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
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = product.name,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1 // Evitamos que nombres muy largos rompan el diseño
|
||||
)
|
||||
Text(
|
||||
text = product.category,
|
||||
fontSize = 14.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
|
||||
// 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()
|
||||
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 {
|
||||
FilledTonalIconButton(
|
||||
onClick = { if (product.quantity > 0) onQuantityChange(product.quantity - 1) },
|
||||
modifier = Modifier.size(32.dp)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
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(
|
||||
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))
|
||||
|
||||
Row {
|
||||
FilledTonalIconButton(
|
||||
onClick = { if (product.quantity > 0) onQuantityChange(product.quantity - 1) },
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Remove, contentDescription = stringResource(R.string.add_product_less), modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
FilledIconButton(
|
||||
onClick = { onQuantityChange(product.quantity + 1) },
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_product_more), modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -481,6 +574,6 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
|
||||
@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,27 +223,40 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
|
||||
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit))
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
isLoading = true
|
||||
val result = repository.updateProductFull(
|
||||
productId = productId,
|
||||
nombre = editName,
|
||||
cantidad = editQuantity,
|
||||
minCantidad = editMinQuantity,
|
||||
unidad = editUnit,
|
||||
categoria = editCategory,
|
||||
fechaCaducidad = editExpirationDate
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
isEditing = false
|
||||
loadProduct()
|
||||
} else {
|
||||
errorMessage = context.getString(R.string.error_db_connection)
|
||||
isLoading = false
|
||||
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(
|
||||
productId = productId,
|
||||
nombre = editName,
|
||||
cantidad = editQuantity,
|
||||
minCantidad = editMinQuantity,
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -147,18 +280,39 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
if (!isEditing) {
|
||||
// Modo Vista
|
||||
// Imagen del producto (común a ambos modos)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(250.dp)
|
||||
.background(Color.White)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = p.imageUrl,
|
||||
contentDescription = p.name,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(250.dp)
|
||||
.background(Color.White),
|
||||
contentScale = ContentScale.Fit
|
||||
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) {
|
||||
@@ -326,4 +496,4 @@ fun DetailRow(icon: ImageVector, label: String, value: String) {
|
||||
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.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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user