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(libs.androidx.activity.compose)
|
||||
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.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
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
|
||||
android:allowBackup="true"
|
||||
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.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.example.despensapp.ui.navigation.NavGraph
|
||||
import com.example.despensapp.ui.theme.DespensappTheme
|
||||
import com.example.despensapp.worker.InventoryCheckWorker
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Programar revisión de inventario cada 24 horas
|
||||
val workRequest = PeriodicWorkRequestBuilder<InventoryCheckWorker>(24, TimeUnit.HOURS).build()
|
||||
WorkManager.getInstance(this).enqueue(workRequest)
|
||||
|
||||
setContent {
|
||||
DespensappTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
Greeting(
|
||||
name = "Android",
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
val navController = rememberNavController()
|
||||
NavGraph(navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Fondo crema suave para la app -->
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:fillColor="#FDF5E6"
|
||||
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"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground_custom"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground_custom"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</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>
|
||||
<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>
|
||||
@@ -8,6 +8,15 @@ lifecycleRuntimeKtx = "2.11.0"
|
||||
activityCompose = "1.13.0"
|
||||
kotlin = "2.2.10"
|
||||
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]
|
||||
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-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
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]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||