Fin de aplicación

This commit is contained in:
2026-08-05 12:57:43 +02:00
parent 5df29921c9
commit 54944809d4
40 changed files with 2142 additions and 197 deletions
+51
View File
@@ -0,0 +1,51 @@
# Despensapp - Gestión de Despensa Familiar
Aplicación Android diseñada para gestionar el inventario de una despensa de forma compartida, con integración directa a MariaDB y Open Food Facts.
## Funcionalidades Actuales
### 🔐 Autenticación Multiusuario
* **Login y Registro:** Conexión directa a una base de datos MariaDB externa (`mariadb.peta9.com`).
* **Gestión de Cuentas:** Registro de usuarios con nombre completo, email y contraseña.
* **Seguridad:** Uso de hilos secundarios (Coroutines) para no bloquear la interfaz durante la conexión.
### 🍱 Gestión de Inventario
* **Visualización en Tiempo Real:** Lista de productos traída directamente desde MariaDB con soporte para unidades (kg, l, gr, etc.).
* **Imágenes de Productos:** Integración con **Coil** para mostrar las imágenes de los productos obtenidas de Open Food Facts.
* **Detalles Extendidos:** Pantalla de detalles para cada producto con información técnica de MariaDB.
* **Búsqueda y Categorización:** Filtro por nombre y categorías dinámicas para una localización rápida.
* **Swipe to Delete:** Borrado intuitivo de productos deslizando hacia la izquierda (Material 3).
* **Fecha de Caducidad:** Seguimiento de la caducidad de cada producto para evitar desperdicios.
* **Filtro Inteligente:** Filtro para ver rápidamente qué productos han caducado o están a punto de hacerlo (7 días o menos).
* **Formato de Fecha:** Visualización de fechas en formato amigable (DD/MM/YYYY) en toda la aplicación.
* **Lista de la Compra:** Modo dedicado que muestra únicamente los productos con stock bajo.
* **Historial de Alta:** Registro automático de la fecha y hora en la que se añadió cada producto.
* **Alertas de Stock Bajo:** Indicadores visuales en rojo cuando un producto está por debajo de su cantidad mínima.
* **Notificaciones Automáticas:** El sistema revisa diariamente el inventario y envía notificaciones al móvil sobre stock bajo.
* **Control Rápido:** Botones de incremento/decremento en la pantalla principal para actualizar el stock rápidamente.
* **Edición Completa:** Modo de edición total en la pantalla de detalle para modificar nombre, stock, stock mínimo, categoría y caducidad.
* **Compartir Lista:** Botón para compartir la lista de la compra directamente desde la aplicación a servicios de mensajería (WhatsApp, etc.).
* **Iconografía Dinámica:** Asignación inteligente de iconos de Material Design basados en la categoría del producto (Lácteos, Carnicería, Fresco, etc.).
* **Cierre de Sesión:** Opción para cerrar la sesión actual y volver de forma segura a la pantalla de bienvenida.
### 🎨 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.
* **Colores Amigables:** Paleta de colores personalizada para mejorar la legibilidad y la estética familiar.
### 🔍 Integración con APIs y Hardware
* **Escáner de Código de Barras:** Uso de **CameraX** y **Google ML Kit** con **Feedback Háptico** (vibración) al detectar productos.
* **Linterna:** Botón integrado en el escáner para encender y apagar el flash en entornos oscuros.
* **Open Food Facts:** Búsqueda automática de nombres, categorías e imágenes al escanear un código de barras.
* **MariaDB Directo:** Conexión JDBC para persistencia de datos compartida.
## Próximos Pasos
* [x] Gestión de fechas de caducidad.
* [x] Lista de la compra automática basada en el stock bajo.
* [ ] Compartir despensas específicas mediante códigos de familia.
* [ ] Exportar inventario a PDF/CSV.
## Requisitos Técnicos
* Android SDK 34+
* Conexión a Internet (para API y DB remota).
* Permiso de Cámara (para el escáner).
+14
View File
@@ -39,6 +39,20 @@ dependencies {
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.navigation.compose)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.mariadb.java.client)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.barcode.scanning)
implementation(libs.coil.compose)
implementation(libs.androidx.work.runtime)
implementation(libs.androidx.work.runtime)
implementation(libs.material.icons.extended)
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
+6
View File
@@ -2,6 +2,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

@@ -4,44 +4,28 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.navigation.compose.rememberNavController
import androidx.compose.foundation.layout.padding import androidx.work.PeriodicWorkRequestBuilder
import androidx.compose.material3.Scaffold import androidx.work.WorkManager
import androidx.compose.material3.Text import com.example.despensapp.ui.navigation.NavGraph
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.example.despensapp.ui.theme.DespensappTheme import com.example.despensapp.ui.theme.DespensappTheme
import com.example.despensapp.worker.InventoryCheckWorker
import java.util.concurrent.TimeUnit
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
// Programar revisión de inventario cada 24 horas
val workRequest = PeriodicWorkRequestBuilder<InventoryCheckWorker>(24, TimeUnit.HOURS).build()
WorkManager.getInstance(this).enqueue(workRequest)
setContent { setContent {
DespensappTheme { DespensappTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> val navController = rememberNavController()
Greeting( NavGraph(navController = navController)
name = "Android",
modifier = Modifier.padding(innerPadding)
)
} }
} }
} }
} }
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
DespensappTheme {
Greeting("Android")
}
}
@@ -0,0 +1,12 @@
package com.example.despensapp.data.api
import com.example.despensapp.data.model.off.OFFProductResponse
import retrofit2.http.GET
import retrofit2.http.Path
interface OpenFoodFactsApi {
@GET("api/v0/product/{barcode}.json")
suspend fun getProductByBarcode(
@Path("barcode") barcode: String
): OFFProductResponse
}
@@ -0,0 +1,15 @@
package com.example.despensapp.data.model
data class Product(
val id: Int = 0,
val name: String,
val quantity: Int,
val minQuantity: Int,
val unit: String = "unidades",
val category: String = "General",
val imageUrl: String? = null,
val expirationDate: String? = null,
val createdAt: String? = null
) {
val isLowStock: Boolean get() = quantity <= minQuantity
}
@@ -0,0 +1,19 @@
package com.example.despensapp.data.model.off
import com.google.gson.annotations.SerializedName
data class OFFProductResponse(
val status: Int,
val code: String,
val product: OFFProduct?
)
data class OFFProduct(
@SerializedName("product_name") val productName: String?,
@SerializedName("product_name_es") val productNameEs: String?,
val brands: String?,
val categories: String?,
@SerializedName("image_url") val imageUrl: String?
) {
val displayName: String get() = productNameEs ?: productName ?: "Producto desconocido"
}
@@ -0,0 +1,204 @@
package com.example.despensapp.data.repository
import com.example.despensapp.data.api.OpenFoodFactsApi
import com.example.despensapp.data.model.Product
import com.example.despensapp.data.model.off.OFFProduct
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.sql.DriverManager
import java.sql.SQLException
class PantryRepository {
private val dbUrl = "jdbc:mariadb://mariadb.peta9.com:3306/despensa_db"
private val dbUser = "despensa_dba"
private val dbPassword = "Pedro@110387"
private val offApi = Retrofit.Builder()
.baseUrl("https://es.openfoodfacts.org/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(OpenFoodFactsApi::class.java)
suspend fun getProductFromOFF(barcode: String): Result<OFFProduct?> {
return try {
val response = offApi.getProductByBarcode(barcode)
if (response.status == 1) {
Result.success(response.product)
} else {
Result.failure(Exception("Producto no encontrado en Open Food Facts"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun getAllProductsFromDb(): Result<List<Product>> {
return withContext(Dispatchers.IO) {
try {
Class.forName("org.mariadb.jdbc.Driver")
val connection = DriverManager.getConnection(dbUrl, dbUser, dbPassword)
val sql = "SELECT * FROM productos"
val statement = connection.prepareStatement(sql)
val resultSet = statement.executeQuery()
val products = mutableListOf<Product>()
while (resultSet.next()) {
products.add(
Product(
id = resultSet.getInt("id"),
name = resultSet.getString("nombre"),
quantity = resultSet.getInt("cantidad"),
minQuantity = resultSet.getInt("cantidad_minima"),
unit = resultSet.getString("unidad") ?: "unidades",
category = resultSet.getString("categoria"),
imageUrl = resultSet.getString("imagen_url"),
expirationDate = resultSet.getString("fecha_caducidad"),
createdAt = resultSet.getString("fecha_alta")
)
)
}
connection.close()
Result.success(products)
} catch (e: Exception) {
Result.failure(e)
}
}
}
suspend fun updateProductQuantity(productId: Int, newQuantity: Int): Result<Boolean> {
return withContext(Dispatchers.IO) {
try {
Class.forName("org.mariadb.jdbc.Driver")
val connection = DriverManager.getConnection(dbUrl, dbUser, dbPassword)
val sql = "UPDATE productos SET cantidad = ? WHERE id = ?"
val statement = connection.prepareStatement(sql)
statement.setInt(1, newQuantity)
statement.setInt(2, productId)
val rowsUpdated = statement.executeUpdate()
connection.close()
Result.success(rowsUpdated > 0)
} catch (e: Exception) {
Result.failure(e)
}
}
}
suspend fun updateProductFull(
productId: Int,
nombre: String,
cantidad: Int,
minCantidad: Int,
unidad: String,
categoria: String,
fechaCaducidad: String?
): Result<Boolean> {
return withContext(Dispatchers.IO) {
try {
Class.forName("org.mariadb.jdbc.Driver")
val connection = DriverManager.getConnection(dbUrl, dbUser, dbPassword)
val sql = "UPDATE productos SET nombre = ?, cantidad = ?, cantidad_minima = ?, unidad = ?, categoria = ?, fecha_caducidad = ? 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, categoria)
statement.setString(6, fechaCaducidad)
statement.setInt(7, productId)
val rowsUpdated = statement.executeUpdate()
connection.close()
Result.success(rowsUpdated > 0)
} catch (e: Exception) {
Result.failure(e)
}
}
}
suspend fun addProduct(
barcode: String,
nombre: String,
cantidad: Int,
minCantidad: Int,
unidad: String,
categoria: String,
imageUrl: String?,
fechaCaducidad: String?
): Result<Boolean> {
return withContext(Dispatchers.IO) {
try {
Class.forName("org.mariadb.jdbc.Driver")
val connection = DriverManager.getConnection(dbUrl, dbUser, dbPassword)
val sql = "INSERT INTO productos (barcode, nombre, cantidad, cantidad_minima, unidad, categoria, imagen_url, fecha_caducidad) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
val statement = connection.prepareStatement(sql)
statement.setString(1, barcode)
statement.setString(2, nombre)
statement.setInt(3, cantidad)
statement.setInt(4, minCantidad)
statement.setString(5, unidad)
statement.setString(6, categoria)
statement.setString(7, imageUrl)
statement.setString(8, fechaCaducidad)
val rowsInserted = statement.executeUpdate()
connection.close()
Result.success(rowsInserted > 0)
} catch (e: Exception) {
Result.failure(e)
}
}
}
suspend fun deleteProduct(productId: Int): Result<Boolean> {
return withContext(Dispatchers.IO) {
try {
Class.forName("org.mariadb.jdbc.Driver")
val connection = DriverManager.getConnection(dbUrl, dbUser, dbPassword)
val sql = "DELETE FROM productos WHERE id = ?"
val statement = connection.prepareStatement(sql)
statement.setInt(1, productId)
val rowsDeleted = statement.executeUpdate()
connection.close()
Result.success(rowsDeleted > 0)
} catch (e: Exception) {
Result.failure(e)
}
}
}
suspend fun getProductById(productId: Int): Result<Product?> {
return withContext(Dispatchers.IO) {
try {
Class.forName("org.mariadb.jdbc.Driver")
val connection = DriverManager.getConnection(dbUrl, dbUser, dbPassword)
val sql = "SELECT * FROM productos WHERE id = ?"
val statement = connection.prepareStatement(sql)
statement.setInt(1, productId)
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") ?: "unidades",
category = resultSet.getString("categoria"),
imageUrl = resultSet.getString("imagen_url"),
expirationDate = resultSet.getString("fecha_caducidad"),
createdAt = resultSet.getString("fecha_alta")
)
}
connection.close()
Result.success(product)
} catch (e: Exception) {
Result.failure(e)
}
}
}
}
@@ -0,0 +1,66 @@
package com.example.despensapp.data.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.sql.DriverManager
import java.sql.SQLException
class UserRepository {
private val url = "jdbc:mariadb://mariadb.peta9.com:3306/despensa_db"
private val user = "despensa_dba"
private val password = "Pedro@110387"
suspend fun registerUser(nombre: String, email: String, contrasena: String): Result<Boolean> {
return withContext(Dispatchers.IO) {
try {
// Forzar carga del driver
Class.forName("org.mariadb.jdbc.Driver")
val connection = DriverManager.getConnection(url, user, password)
val sql = "INSERT INTO usuarios (nombre_completo, email, contrasena) VALUES (?, ?, ?)"
val statement = connection.prepareStatement(sql)
statement.setString(1, nombre)
statement.setString(2, email)
statement.setString(3, contrasena)
val rowsInserted = statement.executeUpdate()
connection.close()
if (rowsInserted > 0) {
Result.success(true)
} else {
Result.failure(Exception("No se pudo insertar el usuario"))
}
} catch (e: SQLException) {
Result.failure(e)
} catch (e: Exception) {
Result.failure(e)
}
}
}
suspend fun loginUser(email: String, contrasena: String): Result<Boolean> {
return withContext(Dispatchers.IO) {
try {
Class.forName("org.mariadb.jdbc.Driver")
val connection = DriverManager.getConnection(url, user, password)
val sql = "SELECT * 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)
} else {
Result.failure(Exception("Credenciales incorrectas"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
}
@@ -0,0 +1,86 @@
package com.example.despensapp.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.example.despensapp.ui.screens.LoginScreen
import com.example.despensapp.ui.screens.PantryScreen
import com.example.despensapp.ui.screens.RegisterScreen
import com.example.despensapp.ui.screens.AddProductScreen
import com.example.despensapp.ui.screens.ProductDetailScreen
import androidx.navigation.NavType
import androidx.navigation.navArgument
sealed class Screen(val route: String) {
object Login : Screen("login")
object Register : Screen("register")
object Pantry : Screen("pantry")
object AddProduct : Screen("add_product")
object ProductDetail : Screen("product_detail/{productId}") {
fun createRoute(productId: Int) = "product_detail/$productId"
}
}
@Composable
fun NavGraph(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = Screen.Login.route
) {
composable(Screen.Login.route) {
LoginScreen(
onLoginSuccess = {
navController.navigate(Screen.Pantry.route) {
popUpTo(Screen.Login.route) { inclusive = true }
}
},
onNavigateToRegister = {
navController.navigate(Screen.Register.route)
}
)
}
composable(Screen.Register.route) {
RegisterScreen(
onRegisterSuccess = {
navController.navigate(Screen.Pantry.route) {
popUpTo(Screen.Login.route) { inclusive = true }
}
},
onNavigateToLogin = {
navController.popBackStack()
}
)
}
composable(Screen.Pantry.route) {
PantryScreen(
onNavigateToAdd = {
navController.navigate(Screen.AddProduct.route)
},
onNavigateToDetail = { productId ->
navController.navigate(Screen.ProductDetail.createRoute(productId))
},
onLogout = {
navController.navigate(Screen.Login.route) {
popUpTo(Screen.Pantry.route) { inclusive = true }
}
}
)
}
composable(Screen.AddProduct.route) {
AddProductScreen(onProductAdded = {
navController.popBackStack()
})
}
composable(
route = Screen.ProductDetail.route,
arguments = listOf(navArgument("productId") { type = NavType.IntType })
) { backStackEntry ->
val productId = backStackEntry.arguments?.getInt("productId") ?: 0
ProductDetailScreen(
productId = productId,
onBack = { navController.popBackStack() }
)
}
}
}
@@ -0,0 +1,308 @@
package com.example.despensapp.ui.screens
import android.Manifest
import android.app.DatePickerDialog
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.*
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.example.despensapp.R
import com.example.despensapp.data.repository.PantryRepository
import com.example.despensapp.util.DateUtils
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage
import kotlinx.coroutines.launch
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddProductScreen(onProductAdded: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val repository = remember { PantryRepository() }
var hasCameraPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
)
}
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted -> hasCameraPermission = granted }
)
LaunchedEffect(Unit) {
if (!hasCameraPermission) {
launcher.launch(Manifest.permission.CAMERA)
}
}
var scannedBarcode by remember { mutableStateOf<String?>(null) }
var productName by remember { mutableStateOf("") }
var productCategory by remember { mutableStateOf("General") }
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 isLoading by remember { mutableStateOf(false) }
var torchEnabled by remember { mutableStateOf(false) }
val units = listOf("unidades", "kg", "gr", "l", "ml", "paquetes")
var unitsExpanded by remember { mutableStateOf(false) }
val calendar = Calendar.getInstance()
val datePickerDialog = DatePickerDialog(
context,
{ _, year, month, dayOfMonth ->
expirationDate = "$year-${month + 1}-$dayOfMonth"
},
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH),
calendar.get(Calendar.DAY_OF_MONTH)
)
fun triggerVibration() {
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(100)
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
if (hasCameraPermission && scannedBarcode == null) {
Box(modifier = Modifier.fillMaxWidth().height(300.dp)) {
BarcodeScannerView(
torchEnabled = torchEnabled,
onBarcodeScanned = { barcode ->
triggerVibration()
scannedBarcode = barcode
scope.launch {
isLoading = true
repository.getProductFromOFF(barcode).onSuccess { offProduct ->
productName = offProduct?.displayName ?: "Desconocido"
productCategory = offProduct?.categories?.split(",")?.firstOrNull() ?: "General"
productImageUrl = offProduct?.imageUrl
isLoading = false
}.onFailure {
isLoading = false
}
}
}
)
// Botón de Linterna
IconButton(
onClick = { torchEnabled = !torchEnabled },
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
colors = IconButtonDefaults.iconButtonColors(containerColor = Color.Black.copy(alpha = 0.5f))
) {
Icon(
if (torchEnabled) Icons.Default.FlashOn else Icons.Default.FlashOff,
contentDescription = stringResource(if (torchEnabled) R.string.flash_off else R.string.flash_on),
tint = if (torchEnabled) Color.Yellow else Color.White
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
if (scannedBarcode != null) {
Text(stringResource(R.string.add_product_barcode_detected, scannedBarcode!!), style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = productName,
onValueChange = { productName = it },
label = { Text(stringResource(R.string.add_product_name_label)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Selector de Unidad
ExposedDropdownMenuBox(
expanded = unitsExpanded,
onExpandedChange = { unitsExpanded = !unitsExpanded },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = selectedUnit,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.add_product_unit_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitsExpanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors()
)
ExposedDropdownMenu(
expanded = unitsExpanded,
onDismissRequest = { unitsExpanded = false }
) {
units.forEach { unit ->
DropdownMenuItem(
text = { Text(unit) },
onClick = {
selectedUnit = unit
unitsExpanded = false
}
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// Selector de Fecha
OutlinedButton(
onClick = { datePickerDialog.show() },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.CalendarToday, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(if (expirationDate == null) stringResource(R.string.add_product_expiry_button_empty)
else stringResource(R.string.add_product_expiry_button_date, DateUtils.formatToDisplay(expirationDate) ?: ""))
}
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.add_product_quantity_label, quantity), modifier = Modifier.weight(1f))
IconButton(onClick = { if (quantity > 1) quantity-- }) {
Icon(Icons.Default.Remove, stringResource(R.string.add_product_less))
}
IconButton(onClick = { quantity++ }) {
Icon(Icons.Default.Add, stringResource(R.string.add_product_more))
}
}
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
} else {
Button(
onClick = {
scope.launch {
isLoading = true
val success = repository.addProduct(
barcode = scannedBarcode!!,
nombre = productName,
cantidad = quantity,
minCantidad = minQuantity,
unidad = selectedUnit,
categoria = productCategory,
imageUrl = productImageUrl,
fechaCaducidad = expirationDate
)
isLoading = false
if (success.isSuccess) onProductAdded()
}
},
modifier = Modifier.fillMaxWidth().padding(top = 16.dp)
) {
Text(stringResource(R.string.add_product_save_button))
}
}
TextButton(onClick = { scannedBarcode = null }, modifier = Modifier.align(Alignment.CenterHorizontally)) {
Text(stringResource(R.string.add_product_scan_another))
}
} else if (!hasCameraPermission) {
Text(stringResource(R.string.add_product_camera_permission_required))
}
}
}
@Composable
fun BarcodeScannerView(torchEnabled: Boolean, onBarcodeScanned: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
var camera by remember { mutableStateOf<Camera?>(null) }
LaunchedEffect(torchEnabled) {
camera?.cameraControl?.enableTorch(torchEnabled)
}
AndroidView(
factory = { ctx ->
val previewView = PreviewView(ctx)
val executor = ContextCompat.getMainExecutor(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
val scanner = BarcodeScanning.getClient()
@androidx.camera.core.ExperimentalGetImage
imageAnalysis.setAnalyzer(executor) { imageProxy ->
val mediaImage = imageProxy.image
if (mediaImage != null) {
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
scanner.process(image)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
barcode.rawValue?.let {
onBarcodeScanned(it)
cameraProvider.unbindAll() // Parar cámara al detectar
}
}
}
.addOnCompleteListener { imageProxy.close() }
}
}
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
cameraProvider.unbindAll()
camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
camera?.cameraControl?.enableTorch(torchEnabled)
} catch (e: Exception) {
Log.e("Camera", "Use case binding failed", e)
}
}, executor)
previewView
},
modifier = Modifier.fillMaxSize()
)
}
@@ -0,0 +1,111 @@
package com.example.despensapp.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.tooling.preview.Preview
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.ui.theme.DespensappTheme
import kotlinx.coroutines.launch
@Composable
fun LoginScreen(onLoginSuccess: () -> Unit, onNavigateToRegister: () -> Unit) {
val context = LocalContext.current
val repository = remember { UserRepository() }
val scope = rememberCoroutineScope()
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.login_welcome),
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(32.dp))
if (errorMessage != null) {
Text(text = errorMessage!!, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
}
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text(stringResource(R.string.login_email_label)) },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.login_password_label)) },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
)
Spacer(modifier = Modifier.height(24.dp))
if (isLoading) {
CircularProgressIndicator()
} else {
Button(
onClick = {
if (email.isNotEmpty() && password.isNotEmpty()) {
isLoading = true
errorMessage = null
scope.launch {
val result = repository.loginUser(email, password)
isLoading = false
result.onSuccess { onLoginSuccess() }
.onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") }
}
} else {
errorMessage = context.getString(R.string.login_error_empty)
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.login_button))
}
}
TextButton(onClick = onNavigateToRegister, enabled = !isLoading) {
Text(stringResource(R.string.login_no_account))
}
}
}
@Preview(showBackground = true)
@Composable
fun LoginPreview() {
DespensappTheme {
LoginScreen(onLoginSuccess = {}, onNavigateToRegister = {})
}
}
@@ -0,0 +1,486 @@
package com.example.despensapp.ui.screens
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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 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.util.CategoryMapper
import com.example.despensapp.util.DateUtils
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.format.DateTimeParseException
import java.time.temporal.ChronoUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PantryScreen(
onNavigateToAdd: () -> Unit,
onNavigateToDetail: (Int) -> Unit,
onLogout: () -> Unit
) {
val context = LocalContext.current
val repository = remember { PantryRepository() }
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var allProducts by remember { mutableStateOf<List<Product>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// Filters and Search
var searchQuery by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf<String?>(null) }
var showOnlyNearExpiry by remember { mutableStateOf(false) }
var showShoppingList by remember { mutableStateOf(false) }
// Cargar productos al iniciar
LaunchedEffect(Unit) {
val result = repository.getAllProductsFromDb()
result.onSuccess {
allProducts = it
isLoading = false
}.onFailure {
errorMessage = context.getString(R.string.login_error_generic, it.message ?: "")
isLoading = false
}
}
val categories = remember(allProducts) {
allProducts.map { it.category }.distinct().sorted()
}
val filteredProducts = allProducts.filter { product ->
val matchesSearch = product.name.contains(searchQuery, ignoreCase = true)
val matchesCategory = selectedCategory == null || product.category == selectedCategory
val matchesExpiry = !showOnlyNearExpiry || isNearExpiry(product.expirationDate)
val matchesLowStock = !showShoppingList || product.isLowStock
matchesSearch && matchesCategory && matchesExpiry && matchesLowStock
}
fun shareShoppingList() {
val lowStockProducts = allProducts.filter { it.isLowStock }
if (lowStockProducts.isEmpty()) {
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.pantry_empty_shopping_list)) }
return
}
val listText = lowStockProducts.joinToString("\n") { "- ${it.name} ${it.minQuantity} ${it.unit}" }
val shareText = context.getString(R.string.shopping_list_message, listText)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareText)
}
context.startActivity(Intent.createChooser(intent, context.getString(R.string.share)))
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (showShoppingList) stringResource(R.string.shopping_list_title) else stringResource(R.string.pantry_title)) },
actions = {
if (showShoppingList) {
IconButton(onClick = { shareShoppingList() }) {
Icon(Icons.Default.Share, contentDescription = stringResource(R.string.share))
}
}
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
)
}
IconButton(onClick = onLogout) {
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.logout))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
FloatingActionButton(onClick = onNavigateToAdd) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add))
}
}
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
// Search Bar
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text(stringResource(R.string.pantry_search_placeholder)) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.pantry_search_clear))
}
}
IconButton(onClick = onNavigateToAdd) {
Icon(Icons.Default.QrCodeScanner, contentDescription = stringResource(R.string.pantry_search_scan))
}
}
},
singleLine = true,
shape = MaterialTheme.shapes.medium
)
// Filtros Rápidos
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = showOnlyNearExpiry,
onClick = { showOnlyNearExpiry = !showOnlyNearExpiry },
label = { Text(stringResource(R.string.pantry_filter_expiry)) },
leadingIcon = if (showOnlyNearExpiry) {
{ Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(18.dp)) }
} else null
)
FilterChip(
selected = showShoppingList,
onClick = { showShoppingList = !showShoppingList },
label = { Text(stringResource(R.string.pantry_filter_low_stock)) },
leadingIcon = if (showShoppingList) {
{ Icon(Icons.Default.PriorityHigh, contentDescription = null, modifier = Modifier.size(18.dp)) }
} else null
)
}
// Categorías Scrollable
ScrollableTabRow(
selectedTabIndex = if (selectedCategory == null) 0 else categories.indexOf(selectedCategory) + 1,
edgePadding = 16.dp,
divider = {},
containerColor = Color.Transparent,
indicator = {}
) {
Tab(
selected = selectedCategory == null,
onClick = { selectedCategory = null },
text = { Text(stringResource(R.string.pantry_filter_all)) }
)
categories.forEach { category ->
Tab(
selected = selectedCategory == category,
onClick = { selectedCategory = category },
text = { Text(category) }
)
}
}
Box(modifier = Modifier.weight(1f)) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (errorMessage != null) {
Text(
text = errorMessage!!,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp).align(Alignment.Center)
)
} else if (filteredProducts.isEmpty()) {
val emptyText = when {
showShoppingList -> stringResource(R.string.pantry_empty_shopping_list)
showOnlyNearExpiry -> stringResource(R.string.pantry_empty_expiry)
searchQuery.isNotEmpty() -> stringResource(R.string.pantry_empty_search, searchQuery)
else -> stringResource(R.string.pantry_empty_general)
}
Text(
text = emptyText,
modifier = Modifier.align(Alignment.Center),
color = Color.Gray
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredProducts, key = { it.id }) { product ->
SwipeToDeleteWrapper(
onDismiss = {
scope.launch {
val result = repository.deleteProduct(product.id)
if (result.isSuccess) {
allProducts = allProducts.filter { it.id != product.id }
snackbarHostState.showSnackbar(context.getString(R.string.pantry_product_deleted, product.name))
} else {
snackbarHostState.showSnackbar(context.getString(R.string.pantry_delete_error))
}
}
}
) {
ProductItem(
product = product,
onItemClick = { onNavigateToDetail(product.id) },
onQuantityChange = { newQty ->
scope.launch {
val success = repository.updateProductQuantity(product.id, newQty)
if (success.isSuccess) {
allProducts = allProducts.map {
if (it.id == product.id) it.copy(quantity = newQty) else it
}
}
}
}
)
}
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeToDeleteWrapper(onDismiss: () -> Unit, content: @Composable () -> Unit) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
if (it == SwipeToDismissBoxValue.EndToStart) {
onDismiss()
true
} else {
false
}
}
)
SwipeToDismissBox(
state = dismissState,
backgroundContent = {
val color = when (dismissState.targetValue) {
SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.errorContainer
else -> Color.Transparent
}
Box(
modifier = Modifier
.fillMaxSize()
.background(color, MaterialTheme.shapes.medium)
.padding(horizontal = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.delete),
tint = if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart)
MaterialTheme.colorScheme.error else Color.LightGray
)
}
},
enableDismissFromStartToEnd = false,
content = { content() }
)
}
fun isNearExpiry(dateStr: String?): Boolean {
if (dateStr == null) return false
return try {
val expiryDate = LocalDate.parse(dateStr)
val today = LocalDate.now()
val daysUntil = ChronoUnit.DAYS.between(today, expiryDate)
daysUntil <= 7 // Incluye caducados (negativos) y próximos (0-7 días)
} catch (e: DateTimeParseException) {
false
}
}
@Composable
fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (Int) -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onItemClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.size(64.dp)) {
AsyncImage(
model = product.imageUrl,
contentDescription = product.name,
modifier = Modifier
.fillMaxSize()
.background(Color.White, MaterialTheme.shapes.small)
.padding(4.dp),
error = null
)
// Icono de Categoría Superpuesto
Surface(
modifier = Modifier.align(Alignment.BottomEnd).offset(x = 4.dp, y = 4.dp),
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.primaryContainer,
tonalElevation = 4.dp
) {
Icon(
CategoryMapper.getIconForCategory(product.category),
contentDescription = null,
modifier = Modifier.size(16.dp).padding(2.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(modifier = Modifier.width(16.dp))
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) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = stringResource(R.string.pantry_low_stock_warning),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.height(8.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
)
}
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))
}
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun PantryScreenPreview() {
DespensappTheme {
PantryScreen(onNavigateToAdd = {}, onNavigateToDetail = {}, onLogout = {})
}
}
@@ -0,0 +1,329 @@
package com.example.despensapp.ui.screens
import android.app.DatePickerDialog
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
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 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.util.CategoryMapper
import com.example.despensapp.util.DateUtils
import kotlinx.coroutines.launch
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val repository = remember { PantryRepository() }
var product by remember { mutableStateOf<Product?>(null) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// Edit Mode State
var isEditing by remember { mutableStateOf(false) }
var editName by remember { mutableStateOf("") }
var editQuantity by remember { mutableStateOf(0) }
var editMinQuantity by remember { mutableStateOf(0) }
var editUnit by remember { mutableStateOf("") }
var editCategory by remember { mutableStateOf("") }
var editExpirationDate by remember { mutableStateOf<String?>(null) }
val units = listOf("unidades", "kg", "gr", "l", "ml", "paquetes")
var unitsExpanded by remember { mutableStateOf(false) }
val calendar = Calendar.getInstance()
val datePickerDialog = DatePickerDialog(
context,
{ _, year, month, dayOfMonth ->
editExpirationDate = "$year-${month + 1}-$dayOfMonth"
},
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH),
calendar.get(Calendar.DAY_OF_MONTH)
)
fun loadProduct() {
isLoading = true
scope.launch {
repository.getProductById(productId).onSuccess {
product = it
it?.let { p ->
editName = p.name
editQuantity = p.quantity
editMinQuantity = p.minQuantity
editUnit = p.unit
editCategory = p.category
editExpirationDate = p.expirationDate
}
isLoading = false
}.onFailure {
errorMessage = context.getString(R.string.login_error_generic, it.message ?: "")
isLoading = false
}
}
}
LaunchedEffect(productId) {
loadProduct()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isEditing) stringResource(R.string.detail_edit_title) else stringResource(R.string.detail_title)) },
navigationIcon = {
IconButton(onClick = if (isEditing) { { isEditing = false } } else onBack) {
Icon(if (isEditing) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(if (isEditing) R.string.close else R.string.back))
}
},
actions = {
if (!isEditing) {
IconButton(onClick = { isEditing = true }) {
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
}
}
}) {
Icon(Icons.Default.Save, contentDescription = stringResource(R.string.save))
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (errorMessage != null) {
Text(text = errorMessage!!, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp))
} else if (product != null) {
val p = product!!
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
if (!isEditing) {
// Modo Vista
AsyncImage(
model = p.imageUrl,
contentDescription = p.name,
modifier = Modifier
.fillMaxWidth()
.height(250.dp)
.background(Color.White),
contentScale = ContentScale.Fit
)
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
CategoryMapper.getIconForCategory(p.category),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(text = p.name, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(text = p.category, style = MaterialTheme.typography.titleMedium, color = Color.Gray)
}
}
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.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) {
DetailRow(icon = Icons.Default.Event, label = stringResource(R.string.detail_expiry_label), value = DateUtils.formatToDisplay(p.expirationDate)!!)
}
if (p.createdAt != null) {
DetailRow(icon = Icons.Default.AccessTime, label = stringResource(R.string.detail_added_label), value = DateUtils.formatToDisplay(p.createdAt)!!)
}
Spacer(modifier = Modifier.height(32.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Info, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.detail_mariadb_info_title), fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.detail_id_label, p.id), fontSize = 12.sp)
Text(stringResource(R.string.detail_server_label, "mariadb.peta9.com"), fontSize = 12.sp)
}
}
}
} else {
// Modo Edición
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = editName,
onValueChange = { editName = it },
label = { Text(stringResource(R.string.add_product_name_label)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = editCategory,
onValueChange = { editCategory = it },
label = { Text(stringResource(R.string.detail_category_label)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.add_product_quantity_label, editQuantity), modifier = Modifier.weight(1f))
IconButton(onClick = { if (editQuantity > 0) editQuantity-- }) {
Icon(Icons.Default.Remove, stringResource(R.string.add_product_less))
}
IconButton(onClick = { editQuantity++ }) {
Icon(Icons.Default.Add, stringResource(R.string.add_product_more))
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.detail_min_quantity_label) + ": $editMinQuantity", modifier = Modifier.weight(1f))
IconButton(onClick = { if (editMinQuantity > 0) editMinQuantity-- }) {
Icon(Icons.Default.Remove, null)
}
IconButton(onClick = { editMinQuantity++ }) {
Icon(Icons.Default.Add, null)
}
}
Spacer(modifier = Modifier.height(8.dp))
ExposedDropdownMenuBox(
expanded = unitsExpanded,
onExpandedChange = { unitsExpanded = !unitsExpanded },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = editUnit,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.add_product_unit_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitsExpanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors()
)
ExposedDropdownMenu(
expanded = unitsExpanded,
onDismissRequest = { unitsExpanded = false }
) {
units.forEach { unit ->
DropdownMenuItem(
text = { Text(unit) },
onClick = {
editUnit = unit
unitsExpanded = false
}
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = { datePickerDialog.show() },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.CalendarToday, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(if (editExpirationDate == null) stringResource(R.string.add_product_expiry_button_empty)
else stringResource(R.string.add_product_expiry_button_date, DateUtils.formatToDisplay(editExpirationDate) ?: ""))
}
if (editExpirationDate != null) {
TextButton(onClick = { editExpirationDate = null }, modifier = Modifier.align(Alignment.CenterHorizontally)) {
Text(stringResource(R.string.cancel) + " " + stringResource(R.string.detail_expiry_label))
}
}
}
}
}
}
}
}
}
@Composable
fun DetailRow(icon: ImageVector, label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(40.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(icon, contentDescription = null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer)
}
}
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(text = label, style = MaterialTheme.typography.labelMedium, color = Color.Gray)
Text(text = value, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
}
}
}
@@ -0,0 +1,124 @@
package com.example.despensapp.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 com.example.despensapp.R
import com.example.despensapp.data.repository.UserRepository
import kotlinx.coroutines.launch
@Composable
fun RegisterScreen(onRegisterSuccess: () -> Unit, onNavigateToLogin: () -> Unit) {
val context = LocalContext.current
val repository = remember { UserRepository() }
val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var familyCode by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.register_title),
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(32.dp))
if (errorMessage != null) {
Text(text = errorMessage!!, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.register_name_label)) },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text(stringResource(R.string.login_email_label)) },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = familyCode,
onValueChange = { familyCode = it },
label = { Text(stringResource(R.string.register_family_code_label)) },
placeholder = { Text(stringResource(R.string.register_family_code_placeholder)) },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.login_password_label)) },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
)
Spacer(modifier = Modifier.height(24.dp))
if (isLoading) {
CircularProgressIndicator()
} else {
Button(
onClick = {
if (name.isNotEmpty() && email.isNotEmpty() && password.isNotEmpty()) {
isLoading = true
errorMessage = null
scope.launch {
val result = repository.registerUser(name, email, password)
isLoading = false
result.onSuccess { onRegisterSuccess() }
.onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") }
}
} else {
errorMessage = context.getString(R.string.register_error_empty)
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.register_button))
}
}
TextButton(onClick = onNavigateToLogin, enabled = !isLoading) {
Text(stringResource(R.string.register_already_have_account))
}
}
}
@@ -0,0 +1,24 @@
package com.example.despensapp.util
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.vector.ImageVector
object CategoryMapper {
fun getIconForCategory(category: String): ImageVector {
return when (category.lowercase()) {
"lácteos", "lacteos", "queso", "leche" -> Icons.Default.Icecream
"carnicería", "carniceria", "carne", "embutidos" -> Icons.Default.LunchDining
"fresco", "fruta", "verdura" -> Icons.Default.Eco
"limpieza", "detergente", "baño" -> Icons.Default.CleaningServices
"despensa", "pasta", "arroz", "legumbres" -> Icons.Default.Inventory
"bebidas", "agua", "refrescos", "alcohol" -> Icons.Default.LocalDrink
"congelados" -> Icons.Default.AcUnit
"panadería", "panaderia", "pan", "bollería" -> Icons.Default.BakeryDining
"snacks", "patatas", "dulces" -> Icons.Default.Cookie
"higiene", "personal", "champú" -> Icons.Default.Face
"mascotas", "perro", "gato" -> Icons.Default.Pets
else -> Icons.Default.Category
}
}
}
@@ -0,0 +1,30 @@
package com.example.despensapp.util
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
object DateUtils {
private val dbDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
private val dbDateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
private val displayFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")
fun formatToDisplay(dateStr: String?): String? {
if (dateStr == null) return null
return try {
if (dateStr.length > 10) {
// Probablemente sea YYYY-MM-DD HH:MM:SS
val dateTime = LocalDateTime.parse(dateStr, dbDateTimeFormatter)
dateTime.format(displayFormatter)
} else {
// Probablemente sea YYYY-MM-DD
val date = LocalDate.parse(dateStr, dbDateFormatter)
date.format(displayFormatter)
}
} catch (e: DateTimeParseException) {
// Si falla el parseo, devolvemos el original para no perder datos
dateStr
}
}
}
@@ -0,0 +1,56 @@
package com.example.despensapp.worker
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.example.despensapp.R
import com.example.despensapp.data.repository.PantryRepository
import java.text.SimpleDateFormat
import java.util.*
class InventoryCheckWorker(
context: Context,
workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
val repository = PantryRepository()
val result = repository.getAllProductsFromDb()
result.onSuccess { products ->
val lowStockProducts = products.filter { it.isLowStock }
if (lowStockProducts.isNotEmpty()) {
showNotification(
applicationContext.getString(R.string.notification_title),
applicationContext.getString(R.string.notification_message, lowStockProducts.size)
)
}
}
return Result.success()
}
private fun showNotification(title: String, message: String) {
val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channelId = "inventory_alerts"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(channelId, applicationContext.getString(R.string.notification_channel_name), NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(applicationContext, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
notificationManager.notify(1, notification)
}
}
@@ -4,167 +4,8 @@
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Fondo crema suave para la app -->
<path <path
android:fillColor="#3DDC84" android:fillColor="#FDF5E6"
android:pathData="M0,0h108v108h-108z" /> android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector> </vector>
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Estructura del mueble (Madera) -->
<path
android:fillColor="#8D6E63"
android:pathData="M24,24 h60 v60 h-60 z M28,28 v52 h52 v-52 z" />
<!-- Estantería 1 -->
<path
android:fillColor="#795548"
android:pathData="M28,45 h52 v3 h-52 z" />
<!-- Estantería 2 -->
<path
android:fillColor="#795548"
android:pathData="M28,65 h52 v3 h-52 z" />
<!-- Productos Estante Superior -->
<!-- Botella Azul -->
<path
android:fillColor="#42A5F5"
android:pathData="M32,32 h6 v13 h-6 z" />
<!-- Caja Roja -->
<path
android:fillColor="#EF5350"
android:pathData="M42,35 h8 v10 h-8 z" />
<!-- Bote Amarillo -->
<path
android:fillColor="#FFCA28"
android:pathData="M55,38 h6 v7 h-6 z" />
<!-- Caja Verde -->
<path
android:fillColor="#66BB6A"
android:pathData="M65,33 h10 v12 h-10 z" />
<!-- Productos Estante Medio -->
<!-- Caja Naranja -->
<path
android:fillColor="#FFA726"
android:pathData="M32,52 h12 v13 h-12 z" />
<!-- Botella Violeta -->
<path
android:fillColor="#AB47BC"
android:pathData="M50,49 h6 v16 h-6 z" />
<!-- Bote Azul Oscuro -->
<path
android:fillColor="#3F51B5"
android:pathData="M62,55 h10 v10 h-10 z" />
<!-- Productos Estante Inferior -->
<!-- Dos cajas pequeñas -->
<path
android:fillColor="#78909C"
android:pathData="M32,72 h8 v12 h-8 z M44,72 h8 v12 h-12 z" />
<!-- Botella de aceite -->
<path
android:fillColor="#AFB42B"
android:pathData="M60,69 h8 v15 h-8 z" />
</vector>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground_custom"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" /> <monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground_custom"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" /> <monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+89
View File
@@ -1,3 +1,92 @@
<resources> <resources>
<string name="app_name">Despensapp</string> <string name="app_name">Despensapp</string>
<!-- General -->
<string name="error_db_connection">Error al conectar con la base de datos</string>
<string name="loading">Cargando...</string>
<string name="back">Volver</string>
<string name="close">Cerrar</string>
<string name="add">Añadir</string>
<string name="save">Guardar</string>
<string name="delete">Borrar</string>
<string name="cancel">Cancelar</string>
<string name="edit">Editar</string>
<string name="share">Compartir</string>
<string name="logout">Cerrar Sesión</string>
<string name="flash_on">Encender Flash</string>
<string name="flash_off">Apagar Flash</string>
<string name="error_loading_products">Error al cargar productos: %1$s</string>
<string name="shopping_list_message">Hay que comprar lo siguiente:\n%1$s</string>
<!-- Login -->
<string name="login_welcome">Bienvenido a Despensapp</string>
<string name="login_email_label">Correo Electrónico</string>
<string name="login_password_label">Contraseña</string>
<string name="login_button">Iniciar Sesión</string>
<string name="login_no_account">¿No tienes cuenta? Regístrate aquí</string>
<string name="login_error_empty">Introduce tus credenciales</string>
<string name="login_error_generic">Error: %1$s</string>
<!-- Register -->
<string name="register_title">Crear Cuenta Familiar</string>
<string name="register_name_label">Nombre Completo</string>
<string name="register_family_code_label">Código de Familia (Opcional)</string>
<string name="register_family_code_placeholder">Para unirse a una despensa existente</string>
<string name="register_button">Registrarse</string>
<string name="register_already_have_account">¿Ya tienes cuenta? Inicia sesión</string>
<string name="register_error_empty">Por favor, rellena todos los campos</string>
<!-- Pantry Screen -->
<string name="pantry_title">Mi Despensa</string>
<string name="shopping_list_title">Lista de la Compra</string>
<string name="pantry_search_placeholder">Buscar productos...</string>
<string name="pantry_search_clear">Limpiar</string>
<string name="pantry_search_scan">Escanear</string>
<string name="pantry_filter_expiry">Caducidad</string>
<string name="pantry_filter_low_stock">Stock Bajo</string>
<string name="pantry_filter_all">Todas</string>
<string name="pantry_empty_shopping_list">¡Genial! No falta nada en la despensa.</string>
<string name="pantry_empty_expiry">No hay productos próximos a caducar.</string>
<string name="pantry_empty_search">No se encontraron productos para \"%1$s\".</string>
<string name="pantry_empty_general">La despensa está vacía.</string>
<string name="pantry_product_deleted">Producto eliminado: %1$s</string>
<string name="pantry_delete_error">Error al eliminar de la base de datos</string>
<string name="pantry_low_stock_warning">Stock Bajo</string>
<string name="pantry_units_label">%1$s %2$s</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>
<!-- Add Product -->
<string name="add_product_title">Añadir Producto</string>
<string name="add_product_barcode_detected">Producto detectado: %1$s</string>
<string name="add_product_name_label">Nombre del Producto</string>
<string name="add_product_unit_label">Unidad de Medida</string>
<string name="add_product_expiry_button_empty">Elegir Fecha de Caducidad</string>
<string name="add_product_expiry_button_date">Caduca: %1$s</string>
<string name="add_product_quantity_label">Cantidad: %1$d</string>
<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_scan_another">Escanear otro</string>
<string name="add_product_camera_permission_required">Se necesita permiso de cámara para escanear</string>
<!-- Detail Screen -->
<string name="detail_title">Detalles del Producto</string>
<string name="detail_edit_title">Editar Producto</string>
<string name="detail_stock_label">Stock actual</string>
<string name="detail_low_stock_alert_label">Alerta stock bajo</string>
<string name="detail_low_stock_alert_value">Menos de %1$s %2$s</string>
<string name="detail_expiry_label">Fecha de caducidad</string>
<string name="detail_added_label">Fecha de alta</string>
<string name="detail_category_label">Categoría</string>
<string name="detail_min_quantity_label">Cantidad Mínima</string>
<string name="detail_mariadb_info_title">Información de MariaDB</string>
<string name="detail_id_label">ID interno: %1$d</string>
<string name="detail_server_label">Servidor: mariadb.peta9.com</string>
<!-- Worker / Notifications -->
<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>
</resources> </resources>
+26
View File
@@ -8,6 +8,15 @@ lifecycleRuntimeKtx = "2.11.0"
activityCompose = "1.13.0" activityCompose = "1.13.0"
kotlin = "2.2.10" kotlin = "2.2.10"
composeBom = "2026.02.01" composeBom = "2026.02.01"
navigationCompose = "2.8.5"
retrofit = "2.11.0"
okhttp = "4.12.0"
mariadbJavaClient = "3.5.10"
camerax = "1.4.1"
barcodeScanning = "17.3.0"
coil = "2.7.0"
workManager = "2.10.0"
room = "2.6.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -24,6 +33,23 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
mariadb-java-client = { group = "org.mariadb.jdbc", name = "mariadb-java-client", version.ref = "mariadbJavaClient" }
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "barcodeScanning" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }