Fin de aplicación
@@ -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).
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
|
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>
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 18 KiB |
@@ -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>
|
||||||
@@ -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" }
|
||||||
|
|||||||