Mejoras varias
This commit is contained in:
@@ -28,6 +28,12 @@ Aplicación Android diseñada para gestionar el inventario de una despensa de fo
|
|||||||
* **Iconografía Dinámica:** Asignación inteligente de iconos de Material Design basados en la categoría del producto (Lácteos, Carnicería, Fresco, etc.).
|
* **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.
|
* **Cierre de Sesión:** Opción para cerrar la sesión actual y volver de forma segura a la pantalla de bienvenida.
|
||||||
|
|
||||||
|
### 💰 Control de Precios y Valor Financiero
|
||||||
|
* **Gestión de Precios:** Seguimiento del precio unitario de cada producto para una mejor planificación económica.
|
||||||
|
* **Valoración de la Despensa:** Cálculo automático del valor total de todo el inventario acumulado.
|
||||||
|
* **Presupuesto de Compra:** Cálculo del coste estimado necesario para reponer los productos en stock bajo.
|
||||||
|
* **Visualización Detallada:** Visualización del precio junto a las unidades en la lista principal y detalles.
|
||||||
|
|
||||||
### 🎨 Identidad Visual
|
### 🎨 Identidad Visual
|
||||||
* **Icono Personalizado:** Diseño de icono adaptativo único que representa un mueble de despensa lleno de productos.
|
* **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.
|
* **Interfaz Material 3:** Uso de los últimos estándares de diseño de Google para una experiencia fluida y moderna.
|
||||||
|
|||||||
@@ -5,21 +5,17 @@ import androidx.activity.ComponentActivity
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.navigation.compose.rememberNavController
|
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.navigation.NavGraph
|
||||||
import com.example.despensapp.ui.theme.DespensappTheme
|
import com.example.despensapp.ui.theme.DespensappTheme
|
||||||
import com.example.despensapp.worker.InventoryCheckWorker
|
import com.example.despensapp.util.NotificationScheduler
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
// Programar revisión de inventario cada 24 horas
|
// Programar revisión de inventario usando la frecuencia guardada
|
||||||
val workRequest = PeriodicWorkRequestBuilder<InventoryCheckWorker>(24, TimeUnit.HOURS).build()
|
NotificationScheduler.schedule(this, NotificationScheduler.getSavedFrequency(this))
|
||||||
WorkManager.getInstance(this).enqueue(workRequest)
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
DespensappTheme {
|
DespensappTheme {
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ data class Product(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val quantity: Int,
|
val quantity: Int,
|
||||||
val minQuantity: Int,
|
val minQuantity: Int,
|
||||||
val unit: String = "unidades",
|
val unit: String = "unidad",
|
||||||
val category: String = "General",
|
val category: String = "General",
|
||||||
|
val price: Double = 0.0,
|
||||||
val imageUrl: String? = null,
|
val imageUrl: String? = null,
|
||||||
val expirationDate: String? = null,
|
val expirationDate: String? = null,
|
||||||
val createdAt: String? = null
|
val createdAt: String? = null,
|
||||||
|
val lastModifiedBy: String? = null
|
||||||
) {
|
) {
|
||||||
val isLowStock: Boolean get() = quantity <= minQuantity
|
val isLowStock: Boolean get() = quantity <= minQuantity
|
||||||
}
|
val totalPrice: Double get() = quantity * price
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class PantryRepository {
|
|||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val connection = getConnection()
|
val connection = getConnection()
|
||||||
val sql = "SELECT * FROM productos"
|
val sql = "SELECT * FROM productos WHERE visible = 1"
|
||||||
val statement = connection.prepareStatement(sql)
|
val statement = connection.prepareStatement(sql)
|
||||||
val resultSet = statement.executeQuery()
|
val resultSet = statement.executeQuery()
|
||||||
|
|
||||||
@@ -62,11 +62,13 @@ class PantryRepository {
|
|||||||
name = resultSet.getString("nombre"),
|
name = resultSet.getString("nombre"),
|
||||||
quantity = resultSet.getInt("cantidad"),
|
quantity = resultSet.getInt("cantidad"),
|
||||||
minQuantity = resultSet.getInt("cantidad_minima"),
|
minQuantity = resultSet.getInt("cantidad_minima"),
|
||||||
unit = resultSet.getString("unidad") ?: "unidades",
|
unit = resultSet.getString("unidad") ?: "unidad",
|
||||||
category = resultSet.getString("categoria"),
|
category = resultSet.getString("categoria"),
|
||||||
|
price = resultSet.getDouble("precio"),
|
||||||
imageUrl = resultSet.getString("imagen_url"),
|
imageUrl = resultSet.getString("imagen_url"),
|
||||||
expirationDate = resultSet.getString("fecha_caducidad"),
|
expirationDate = resultSet.getString("fecha_caducidad"),
|
||||||
createdAt = resultSet.getString("fecha_alta")
|
createdAt = resultSet.getString("fecha_alta"),
|
||||||
|
lastModifiedBy = resultSet.getString("modificado_por")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -78,14 +80,15 @@ class PantryRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateProductQuantity(productId: Int, newQuantity: Int): Result<Boolean> {
|
suspend fun updateProductQuantity(productId: Int, newQuantity: Int, userName: String): Result<Boolean> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val connection = getConnection()
|
val connection = getConnection()
|
||||||
val sql = "UPDATE productos SET cantidad = ? WHERE id = ?"
|
val sql = "UPDATE productos SET cantidad = ?, modificado_por = ? WHERE id = ?"
|
||||||
val statement = connection.prepareStatement(sql)
|
val statement = connection.prepareStatement(sql)
|
||||||
statement.setInt(1, newQuantity)
|
statement.setInt(1, newQuantity)
|
||||||
statement.setInt(2, productId)
|
statement.setString(2, userName)
|
||||||
|
statement.setInt(3, productId)
|
||||||
|
|
||||||
val rowsUpdated = statement.executeUpdate()
|
val rowsUpdated = statement.executeUpdate()
|
||||||
connection.close()
|
connection.close()
|
||||||
@@ -103,20 +106,26 @@ class PantryRepository {
|
|||||||
minCantidad: Int,
|
minCantidad: Int,
|
||||||
unidad: String,
|
unidad: String,
|
||||||
category: String,
|
category: String,
|
||||||
fechaCaducidad: String?
|
price: Double,
|
||||||
|
fechaCaducidad: String?,
|
||||||
|
imageUrl: String? = null,
|
||||||
|
userName: String
|
||||||
): Result<Boolean> {
|
): Result<Boolean> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val connection = getConnection()
|
val connection = getConnection()
|
||||||
val sql = "UPDATE productos SET nombre = ?, cantidad = ?, cantidad_minima = ?, unidad = ?, categoria = ?, fecha_caducidad = ? WHERE id = ?"
|
val sql = "UPDATE productos SET nombre = ?, cantidad = ?, cantidad_minima = ?, unidad = ?, categoria = ?, precio = ?, fecha_caducidad = ?, imagen_url = ?, visible = 1, modificado_por = ? WHERE id = ?"
|
||||||
val statement = connection.prepareStatement(sql)
|
val statement = connection.prepareStatement(sql)
|
||||||
statement.setString(1, nombre)
|
statement.setString(1, nombre)
|
||||||
statement.setInt(2, cantidad)
|
statement.setInt(2, cantidad)
|
||||||
statement.setInt(3, minCantidad)
|
statement.setInt(3, minCantidad)
|
||||||
statement.setString(4, unidad)
|
statement.setString(4, unidad)
|
||||||
statement.setString(5, category)
|
statement.setString(5, category)
|
||||||
statement.setString(6, fechaCaducidad)
|
statement.setDouble(6, price)
|
||||||
statement.setInt(7, productId)
|
statement.setString(7, fechaCaducidad)
|
||||||
|
statement.setString(8, imageUrl)
|
||||||
|
statement.setString(9, userName)
|
||||||
|
statement.setInt(10, productId)
|
||||||
|
|
||||||
val rowsUpdated = statement.executeUpdate()
|
val rowsUpdated = statement.executeUpdate()
|
||||||
connection.close()
|
connection.close()
|
||||||
@@ -133,14 +142,31 @@ class PantryRepository {
|
|||||||
cantidad: Int,
|
cantidad: Int,
|
||||||
minCantidad: Int,
|
minCantidad: Int,
|
||||||
unidad: String,
|
unidad: String,
|
||||||
categoria: String,
|
categoria: String,
|
||||||
|
price: Double,
|
||||||
imageUrl: String?,
|
imageUrl: String?,
|
||||||
fechaCaducidad: String?
|
fechaCaducidad: String?,
|
||||||
|
userName: String
|
||||||
): Result<Boolean> {
|
): Result<Boolean> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val connection = getConnection()
|
val connection = getConnection()
|
||||||
val sql = "INSERT INTO productos (barcode, nombre, cantidad, cantidad_minima, unidad, categoria, imagen_url, fecha_caducidad) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
val sql = """
|
||||||
|
INSERT INTO productos (barcode, nombre, cantidad, cantidad_minima, unidad, categoria, precio, imagen_url, fecha_caducidad, visible, modificado_por)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
nombre = VALUES(nombre),
|
||||||
|
cantidad = VALUES(cantidad),
|
||||||
|
cantidad_minima = VALUES(cantidad_minima),
|
||||||
|
unidad = VALUES(unidad),
|
||||||
|
categoria = VALUES(categoria),
|
||||||
|
precio = VALUES(precio),
|
||||||
|
imagen_url = IF(VALUES(imagen_url) IS NULL, imagen_url, VALUES(imagen_url)),
|
||||||
|
fecha_caducidad = VALUES(fecha_caducidad),
|
||||||
|
visible = 1,
|
||||||
|
modificado_por = VALUES(modificado_por)
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
val statement = connection.prepareStatement(sql)
|
val statement = connection.prepareStatement(sql)
|
||||||
statement.setString(1, barcode)
|
statement.setString(1, barcode)
|
||||||
statement.setString(2, nombre)
|
statement.setString(2, nombre)
|
||||||
@@ -148,8 +174,10 @@ class PantryRepository {
|
|||||||
statement.setInt(4, minCantidad)
|
statement.setInt(4, minCantidad)
|
||||||
statement.setString(5, unidad)
|
statement.setString(5, unidad)
|
||||||
statement.setString(6, categoria)
|
statement.setString(6, categoria)
|
||||||
statement.setString(7, imageUrl)
|
statement.setDouble(7, price)
|
||||||
statement.setString(8, fechaCaducidad)
|
statement.setString(8, imageUrl)
|
||||||
|
statement.setString(9, fechaCaducidad)
|
||||||
|
statement.setString(10, userName)
|
||||||
|
|
||||||
val rowsInserted = statement.executeUpdate()
|
val rowsInserted = statement.executeUpdate()
|
||||||
connection.close()
|
connection.close()
|
||||||
@@ -160,17 +188,18 @@ class PantryRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteProduct(productId: Int): Result<Boolean> {
|
suspend fun deleteProduct(productId: Int, userName: String): Result<Boolean> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val connection = getConnection()
|
val connection = getConnection()
|
||||||
val sql = "DELETE FROM productos WHERE id = ?"
|
val sql = "UPDATE productos SET visible = 0, cantidad = 0, modificado_por = ? WHERE id = ?"
|
||||||
val statement = connection.prepareStatement(sql)
|
val statement = connection.prepareStatement(sql)
|
||||||
statement.setInt(1, productId)
|
statement.setString(1, userName)
|
||||||
|
statement.setInt(2, productId)
|
||||||
|
|
||||||
val rowsDeleted = statement.executeUpdate()
|
val rowsUpdated = statement.executeUpdate()
|
||||||
connection.close()
|
connection.close()
|
||||||
Result.success(rowsDeleted > 0)
|
Result.success(rowsUpdated > 0)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
@@ -193,11 +222,46 @@ class PantryRepository {
|
|||||||
name = resultSet.getString("nombre"),
|
name = resultSet.getString("nombre"),
|
||||||
quantity = resultSet.getInt("cantidad"),
|
quantity = resultSet.getInt("cantidad"),
|
||||||
minQuantity = resultSet.getInt("cantidad_minima"),
|
minQuantity = resultSet.getInt("cantidad_minima"),
|
||||||
unit = resultSet.getString("unidad") ?: "unidades",
|
unit = resultSet.getString("unidad") ?: "unidad",
|
||||||
category = resultSet.getString("categoria"),
|
category = resultSet.getString("categoria"),
|
||||||
|
price = resultSet.getDouble("precio"),
|
||||||
imageUrl = resultSet.getString("imagen_url"),
|
imageUrl = resultSet.getString("imagen_url"),
|
||||||
expirationDate = resultSet.getString("fecha_caducidad"),
|
expirationDate = resultSet.getString("fecha_caducidad"),
|
||||||
createdAt = resultSet.getString("fecha_alta")
|
createdAt = resultSet.getString("fecha_alta"),
|
||||||
|
lastModifiedBy = resultSet.getString("modificado_por")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
connection.close()
|
||||||
|
Result.success(product)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getProductByBarcode(barcode: String): Result<Product?> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val connection = getConnection()
|
||||||
|
val sql = "SELECT * FROM productos WHERE barcode = ?"
|
||||||
|
val statement = connection.prepareStatement(sql)
|
||||||
|
statement.setString(1, barcode)
|
||||||
|
val resultSet = statement.executeQuery()
|
||||||
|
|
||||||
|
var product: Product? = null
|
||||||
|
if (resultSet.next()) {
|
||||||
|
product = Product(
|
||||||
|
id = resultSet.getInt("id"),
|
||||||
|
name = resultSet.getString("nombre"),
|
||||||
|
quantity = resultSet.getInt("cantidad"),
|
||||||
|
minQuantity = resultSet.getInt("cantidad_minima"),
|
||||||
|
unit = resultSet.getString("unidad") ?: "unidad",
|
||||||
|
category = resultSet.getString("categoria"),
|
||||||
|
price = resultSet.getDouble("precio"),
|
||||||
|
imageUrl = resultSet.getString("imagen_url"),
|
||||||
|
expirationDate = resultSet.getString("fecha_caducidad"),
|
||||||
|
createdAt = resultSet.getString("fecha_alta"),
|
||||||
|
lastModifiedBy = resultSet.getString("modificado_por")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class UserRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun registerUser(nombre: String, email: String, contrasena: String): Result<Boolean> {
|
suspend fun registerUser(nombre: String, email: String, contrasena: String): Result<String> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val connection = getConnection()
|
val connection = getConnection()
|
||||||
@@ -38,7 +38,7 @@ class UserRepository {
|
|||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
if (rowsInserted > 0) {
|
if (rowsInserted > 0) {
|
||||||
Result.success(true)
|
Result.success(nombre)
|
||||||
} else {
|
} else {
|
||||||
Result.failure(Exception("No se pudo insertar el usuario"))
|
Result.failure(Exception("No se pudo insertar el usuario"))
|
||||||
}
|
}
|
||||||
@@ -48,22 +48,22 @@ class UserRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun loginUser(email: String, contrasena: String): Result<Boolean> {
|
suspend fun loginUser(email: String, contrasena: String): Result<String> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val connection = getConnection()
|
val connection = getConnection()
|
||||||
val sql = "SELECT * FROM usuarios WHERE email = ? AND contrasena = ?"
|
val sql = "SELECT nombre_completo FROM usuarios WHERE email = ? AND contrasena = ?"
|
||||||
val statement = connection.prepareStatement(sql)
|
val statement = connection.prepareStatement(sql)
|
||||||
statement.setString(1, email)
|
statement.setString(1, email)
|
||||||
statement.setString(2, contrasena)
|
statement.setString(2, contrasena)
|
||||||
|
|
||||||
val resultSet = statement.executeQuery()
|
val resultSet = statement.executeQuery()
|
||||||
val exists = resultSet.next()
|
if (resultSet.next()) {
|
||||||
connection.close()
|
val nombre = resultSet.getString("nombre_completo")
|
||||||
|
connection.close()
|
||||||
if (exists) {
|
Result.success(nombre)
|
||||||
Result.success(true)
|
|
||||||
} else {
|
} else {
|
||||||
|
connection.close()
|
||||||
Result.failure(Exception("Credenciales incorrectas"))
|
Result.failure(Exception("Credenciales incorrectas"))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.despensapp.data.session
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
object UserSession {
|
||||||
|
private const val PREFS_NAME = "user_session"
|
||||||
|
private const val KEY_USER_NAME = "user_name"
|
||||||
|
|
||||||
|
fun saveUserName(context: Context, name: String) {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit().putString(KEY_USER_NAME, name).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUserName(context: Context): String {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getString(KEY_USER_NAME, "Familiar") ?: "Familiar"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(context: Context) {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import com.example.despensapp.ui.screens.PantryScreen
|
|||||||
import com.example.despensapp.ui.screens.RegisterScreen
|
import com.example.despensapp.ui.screens.RegisterScreen
|
||||||
import com.example.despensapp.ui.screens.AddProductScreen
|
import com.example.despensapp.ui.screens.AddProductScreen
|
||||||
import com.example.despensapp.ui.screens.ProductDetailScreen
|
import com.example.despensapp.ui.screens.ProductDetailScreen
|
||||||
|
import com.example.despensapp.ui.screens.SettingsScreen
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ sealed class Screen(val route: String) {
|
|||||||
object Register : Screen("register")
|
object Register : Screen("register")
|
||||||
object Pantry : Screen("pantry")
|
object Pantry : Screen("pantry")
|
||||||
object AddProduct : Screen("add_product")
|
object AddProduct : Screen("add_product")
|
||||||
|
object Settings : Screen("settings")
|
||||||
object ProductDetail : Screen("product_detail/{productId}") {
|
object ProductDetail : Screen("product_detail/{productId}") {
|
||||||
fun createRoute(productId: Int) = "product_detail/$productId"
|
fun createRoute(productId: Int) = "product_detail/$productId"
|
||||||
}
|
}
|
||||||
@@ -60,6 +62,9 @@ fun NavGraph(navController: NavHostController) {
|
|||||||
onNavigateToDetail = { productId ->
|
onNavigateToDetail = { productId ->
|
||||||
navController.navigate(Screen.ProductDetail.createRoute(productId))
|
navController.navigate(Screen.ProductDetail.createRoute(productId))
|
||||||
},
|
},
|
||||||
|
onNavigateToSettings = {
|
||||||
|
navController.navigate(Screen.Settings.route)
|
||||||
|
},
|
||||||
onLogout = {
|
onLogout = {
|
||||||
navController.navigate(Screen.Login.route) {
|
navController.navigate(Screen.Login.route) {
|
||||||
popUpTo(Screen.Pantry.route) { inclusive = true }
|
popUpTo(Screen.Pantry.route) { inclusive = true }
|
||||||
@@ -72,6 +77,11 @@ fun NavGraph(navController: NavHostController) {
|
|||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
composable(Screen.Settings.route) {
|
||||||
|
SettingsScreen(onBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
})
|
||||||
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Screen.ProductDetail.route,
|
route = Screen.ProductDetail.route,
|
||||||
arguments = listOf(navArgument("productId") { type = NavType.IntType })
|
arguments = listOf(navArgument("productId") { type = NavType.IntType })
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.Manifest
|
|||||||
import android.app.DatePickerDialog
|
import android.app.DatePickerDialog
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.VibrationEffect
|
import android.os.VibrationEffect
|
||||||
import android.os.Vibrator
|
import android.os.Vibrator
|
||||||
@@ -15,22 +16,30 @@ import androidx.camera.core.*
|
|||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
import androidx.camera.view.PreviewView
|
import androidx.camera.view.PreviewView
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import coil.compose.AsyncImage
|
||||||
import com.example.despensapp.R
|
import com.example.despensapp.R
|
||||||
import com.example.despensapp.data.repository.PantryRepository
|
import com.example.despensapp.data.repository.PantryRepository
|
||||||
|
import com.example.despensapp.data.session.UserSession
|
||||||
import com.example.despensapp.util.DateUtils
|
import com.example.despensapp.util.DateUtils
|
||||||
|
import com.example.despensapp.util.ImageUtils
|
||||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||||
import com.google.mlkit.vision.common.InputImage
|
import com.google.mlkit.vision.common.InputImage
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -42,6 +51,7 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val repository = remember { PantryRepository() }
|
val repository = remember { PantryRepository() }
|
||||||
|
val userName = remember { UserSession.getUserName(context) }
|
||||||
|
|
||||||
var hasCameraPermission by remember {
|
var hasCameraPermission by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -63,16 +73,126 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
|
|||||||
var scannedBarcode by remember { mutableStateOf<String?>(null) }
|
var scannedBarcode by remember { mutableStateOf<String?>(null) }
|
||||||
var productName by remember { mutableStateOf("") }
|
var productName by remember { mutableStateOf("") }
|
||||||
var productCategory by remember { mutableStateOf("General") }
|
var productCategory by remember { mutableStateOf("General") }
|
||||||
|
var productPrice by remember { mutableStateOf(0.0) }
|
||||||
var productImageUrl by remember { mutableStateOf<String?>(null) }
|
var productImageUrl by remember { mutableStateOf<String?>(null) }
|
||||||
var expirationDate by remember { mutableStateOf<String?>(null) }
|
var expirationDate by remember { mutableStateOf<String?>(null) }
|
||||||
var quantity by remember { mutableStateOf(1) }
|
var quantity by remember { mutableStateOf(1) }
|
||||||
var minQuantity by remember { mutableStateOf(1) }
|
var minQuantity by remember { mutableStateOf(1) }
|
||||||
var selectedUnit by remember { mutableStateOf("unidades") }
|
var selectedUnit by remember { mutableStateOf("unidad") }
|
||||||
var isLoading by remember { mutableStateOf(false) }
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Nuevo: Estado para saber si la imagen se está procesando
|
||||||
|
var isProcessingImage by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Vista previa inmediata de la imagen
|
||||||
|
var imagePreviewModel by remember { mutableStateOf<Any?>(null) }
|
||||||
|
|
||||||
var torchEnabled by remember { mutableStateOf(false) }
|
var torchEnabled by remember { mutableStateOf(false) }
|
||||||
|
var showPhotoSourceDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val units = listOf("unidades", "kg", "gr", "l", "ml", "paquetes")
|
val galleryLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetContent()
|
||||||
|
) { uri: Uri? ->
|
||||||
|
uri?.let {
|
||||||
|
imagePreviewModel = it
|
||||||
|
isProcessingImage = true
|
||||||
|
scope.launch {
|
||||||
|
val base64 = ImageUtils.uriToBase64(context, it)
|
||||||
|
if (base64 != null) {
|
||||||
|
productImageUrl = base64
|
||||||
|
}
|
||||||
|
isProcessingImage = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cameraLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.TakePicturePreview()
|
||||||
|
) { bitmap ->
|
||||||
|
bitmap?.let {
|
||||||
|
imagePreviewModel = it
|
||||||
|
isProcessingImage = true
|
||||||
|
scope.launch {
|
||||||
|
val base64 = ImageUtils.bitmapToBase64(it)
|
||||||
|
if (base64 != null) {
|
||||||
|
productImageUrl = base64
|
||||||
|
}
|
||||||
|
isProcessingImage = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showPhotoSourceDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showPhotoSourceDialog = false },
|
||||||
|
title = { Text(stringResource(R.string.photo_source_title)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showPhotoSourceDialog = false
|
||||||
|
cameraLauncher.launch(null)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Icon(Icons.Default.PhotoCamera, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.photo_source_camera))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showPhotoSourceDialog = false
|
||||||
|
galleryLauncher.launch("image/*")
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Icon(Icons.Default.PhotoLibrary, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.photo_source_gallery))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showPhotoSourceDialog = false
|
||||||
|
productImageUrl = null
|
||||||
|
imagePreviewModel = null
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Icon(Icons.Default.NoPhotography, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.photo_source_none))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showPhotoSourceDialog = false }) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val units = listOf(
|
||||||
|
stringResource(R.string.unit_unidad),
|
||||||
|
stringResource(R.string.unit_kg),
|
||||||
|
stringResource(R.string.unit_gr),
|
||||||
|
stringResource(R.string.unit_l),
|
||||||
|
stringResource(R.string.unit_ml),
|
||||||
|
stringResource(R.string.unit_paquete),
|
||||||
|
stringResource(R.string.unit_botella),
|
||||||
|
stringResource(R.string.unit_sobre),
|
||||||
|
stringResource(R.string.unit_lata),
|
||||||
|
stringResource(R.string.unit_bote),
|
||||||
|
stringResource(R.string.unit_docena),
|
||||||
|
stringResource(R.string.unit_caja)
|
||||||
|
)
|
||||||
var unitsExpanded by remember { mutableStateOf(false) }
|
var unitsExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val calendar = Calendar.getInstance()
|
val calendar = Calendar.getInstance()
|
||||||
@@ -113,13 +233,40 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
|
|||||||
scannedBarcode = barcode
|
scannedBarcode = barcode
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
repository.getProductFromOFF(barcode).onSuccess { offProduct ->
|
|
||||||
productName = offProduct?.displayName ?: "Desconocido"
|
// 1. Intentar con Open Food Facts
|
||||||
productCategory = offProduct?.categories?.split(",")?.firstOrNull() ?: "General"
|
val offResult = repository.getProductFromOFF(barcode)
|
||||||
|
|
||||||
|
offResult.onSuccess { offProduct ->
|
||||||
|
productName = offProduct?.displayName ?: ""
|
||||||
|
productCategory = offProduct?.categories?.split(",")?.firstOrNull()?.trim() ?: "General"
|
||||||
|
productPrice = 0.0 // OFF usually doesn't provide price easily
|
||||||
productImageUrl = offProduct?.imageUrl
|
productImageUrl = offProduct?.imageUrl
|
||||||
|
imagePreviewModel = offProduct?.imageUrl
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
isLoading = false
|
// 2. Si falla OFF o no hay internet, intentar con MariaDB Local
|
||||||
|
repository.getProductByBarcode(barcode).onSuccess { dbProduct ->
|
||||||
|
if (dbProduct != null) {
|
||||||
|
productName = dbProduct.name
|
||||||
|
productCategory = dbProduct.category
|
||||||
|
productPrice = dbProduct.price
|
||||||
|
productImageUrl = dbProduct.imageUrl
|
||||||
|
imagePreviewModel = dbProduct.imageUrl
|
||||||
|
selectedUnit = dbProduct.unit
|
||||||
|
} else {
|
||||||
|
productName = ""
|
||||||
|
productCategory = "General"
|
||||||
|
productPrice = 0.0
|
||||||
|
productImageUrl = null
|
||||||
|
imagePreviewModel = null
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}.onFailure {
|
||||||
|
productName = ""
|
||||||
|
productPrice = 0.0
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +301,41 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = if (productPrice == 0.0) "" else productPrice.toString(),
|
||||||
|
onValueChange = { productPrice = it.toDoubleOrNull() ?: 0.0 },
|
||||||
|
label = { Text("Precio (€)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Euro, contentDescription = null) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Selector de Imagen
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Button(
|
||||||
|
onClick = { showPhotoSourceDialog = true },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PhotoCamera, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(if (productImageUrl == null) R.string.add_product_add_photo else R.string.add_product_change_photo))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imagePreviewModel != null) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
AsyncImage(
|
||||||
|
model = imagePreviewModel,
|
||||||
|
contentDescription = "Preview",
|
||||||
|
modifier = Modifier.size(64.dp).clip(RoundedCornerShape(8.dp)),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Selector de Unidad
|
// Selector de Unidad
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = unitsExpanded,
|
expanded = unitsExpanded,
|
||||||
@@ -210,7 +392,7 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading || isProcessingImage) {
|
||||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||||
} else {
|
} else {
|
||||||
Button(
|
Button(
|
||||||
@@ -224,8 +406,10 @@ fun AddProductScreen(onProductAdded: () -> Unit) {
|
|||||||
minCantidad = minQuantity,
|
minCantidad = minQuantity,
|
||||||
unidad = selectedUnit,
|
unidad = selectedUnit,
|
||||||
categoria = productCategory,
|
categoria = productCategory,
|
||||||
|
price = productPrice,
|
||||||
imageUrl = productImageUrl,
|
imageUrl = productImageUrl,
|
||||||
fechaCaducidad = expirationDate
|
fechaCaducidad = expirationDate,
|
||||||
|
userName = userName
|
||||||
)
|
)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
if (success.isSuccess) onProductAdded()
|
if (success.isSuccess) onProductAdded()
|
||||||
@@ -305,4 +489,4 @@ fun BarcodeScannerView(torchEnabled: Boolean, onBarcodeScanned: (String) -> Unit
|
|||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import com.example.despensapp.R
|
import com.example.despensapp.R
|
||||||
import com.example.despensapp.data.repository.UserRepository
|
import com.example.despensapp.data.repository.UserRepository
|
||||||
|
import com.example.despensapp.data.session.UserSession
|
||||||
import com.example.despensapp.ui.theme.DespensappTheme
|
import com.example.despensapp.ui.theme.DespensappTheme
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -83,7 +84,10 @@ fun LoginScreen(onLoginSuccess: () -> Unit, onNavigateToRegister: () -> Unit) {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
val result = repository.loginUser(email, password)
|
val result = repository.loginUser(email, password)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
result.onSuccess { onLoginSuccess() }
|
result.onSuccess { name ->
|
||||||
|
UserSession.saveUserName(context, name)
|
||||||
|
onLoginSuccess()
|
||||||
|
}
|
||||||
.onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") }
|
.onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -108,4 +112,4 @@ fun LoginPreview() {
|
|||||||
DespensappTheme {
|
DespensappTheme {
|
||||||
LoginScreen(onLoginSuccess = {}, onNavigateToRegister = {})
|
LoginScreen(onLoginSuccess = {}, onNavigateToRegister = {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.despensapp.ui.screens
|
package com.example.despensapp.ui.screens
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -20,13 +21,16 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||||
import com.example.despensapp.R
|
import com.example.despensapp.R
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.example.despensapp.ui.theme.DespensappTheme
|
import com.example.despensapp.ui.theme.DespensappTheme
|
||||||
import com.example.despensapp.data.model.Product
|
import com.example.despensapp.data.model.Product
|
||||||
import com.example.despensapp.data.repository.PantryRepository
|
import com.example.despensapp.data.repository.PantryRepository
|
||||||
|
import com.example.despensapp.data.session.UserSession
|
||||||
import com.example.despensapp.util.CategoryMapper
|
import com.example.despensapp.util.CategoryMapper
|
||||||
import com.example.despensapp.util.DateUtils
|
import com.example.despensapp.util.DateUtils
|
||||||
|
import com.example.despensapp.util.ImageUtils
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.format.DateTimeParseException
|
import java.time.format.DateTimeParseException
|
||||||
@@ -37,12 +41,14 @@ import java.time.temporal.ChronoUnit
|
|||||||
fun PantryScreen(
|
fun PantryScreen(
|
||||||
onNavigateToAdd: () -> Unit,
|
onNavigateToAdd: () -> Unit,
|
||||||
onNavigateToDetail: (Int) -> Unit,
|
onNavigateToDetail: (Int) -> Unit,
|
||||||
|
onNavigateToSettings: () -> Unit,
|
||||||
onLogout: () -> Unit
|
onLogout: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val repository = remember { PantryRepository() }
|
val repository = remember { PantryRepository() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val userName = remember { UserSession.getUserName(context) }
|
||||||
|
|
||||||
var allProducts by remember { mutableStateOf<List<Product>>(emptyList()) }
|
var allProducts by remember { mutableStateOf<List<Product>>(emptyList()) }
|
||||||
var isLoading by remember { mutableStateOf(true) }
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
@@ -79,6 +85,12 @@ fun PantryScreen(
|
|||||||
matchesSearch && matchesCategory && matchesExpiry && matchesLowStock
|
matchesSearch && matchesCategory && matchesExpiry && matchesLowStock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val lowStockCount = allProducts.count { it.isLowStock }
|
||||||
|
|
||||||
|
val totalPantryValue = allProducts.sumOf { it.quantity * it.price }
|
||||||
|
val estimatedShoppingCost = allProducts.filter { it.isLowStock }
|
||||||
|
.sumOf { (it.minQuantity - it.quantity).coerceAtLeast(0) * it.price }
|
||||||
|
|
||||||
fun shareShoppingList() {
|
fun shareShoppingList() {
|
||||||
val lowStockProducts = allProducts.filter { it.isLowStock }
|
val lowStockProducts = allProducts.filter { it.isLowStock }
|
||||||
if (lowStockProducts.isEmpty()) {
|
if (lowStockProducts.isEmpty()) {
|
||||||
@@ -107,13 +119,29 @@ fun PantryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = { showShoppingList = !showShoppingList }) {
|
IconButton(onClick = { showShoppingList = !showShoppingList }) {
|
||||||
Icon(
|
BadgedBox(
|
||||||
if (showShoppingList) Icons.Default.Inventory else Icons.Default.ShoppingCart,
|
badge = {
|
||||||
contentDescription = "Modo Lista Compra",
|
if (lowStockCount > 0) {
|
||||||
tint = if (showShoppingList) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
Badge {
|
||||||
)
|
Text(lowStockCount.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (showShoppingList) Icons.Default.Inventory else Icons.Default.ShoppingCart,
|
||||||
|
contentDescription = "Modo Lista Compra",
|
||||||
|
tint = if (showShoppingList) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onLogout) {
|
IconButton(onClick = onNavigateToSettings) {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings_title))
|
||||||
|
}
|
||||||
|
IconButton(onClick = {
|
||||||
|
UserSession.clear(context)
|
||||||
|
onLogout()
|
||||||
|
}) {
|
||||||
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.logout))
|
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.logout))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -131,6 +159,40 @@ fun PantryScreen(
|
|||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
|
Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
|
||||||
|
// Summary Card
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (showShoppingList) MaterialTheme.colorScheme.tertiaryContainer
|
||||||
|
else MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (showShoppingList) Icons.Default.ReceiptLong else Icons.Default.AccountBalanceWallet,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (showShoppingList) MaterialTheme.colorScheme.onTertiaryContainer
|
||||||
|
else MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = if (showShoppingList) stringResource(R.string.shopping_list_total, estimatedShoppingCost)
|
||||||
|
else stringResource(R.string.pantry_total_value, totalPantryValue),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (showShoppingList) MaterialTheme.colorScheme.onTertiaryContainer
|
||||||
|
else MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Search Bar
|
// Search Bar
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = searchQuery,
|
value = searchQuery,
|
||||||
@@ -235,7 +297,7 @@ fun PantryScreen(
|
|||||||
SwipeToDeleteWrapper(
|
SwipeToDeleteWrapper(
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val result = repository.deleteProduct(product.id)
|
val result = repository.deleteProduct(product.id, userName)
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
allProducts = allProducts.filter { it.id != product.id }
|
allProducts = allProducts.filter { it.id != product.id }
|
||||||
snackbarHostState.showSnackbar(context.getString(R.string.pantry_product_deleted, product.name))
|
snackbarHostState.showSnackbar(context.getString(R.string.pantry_product_deleted, product.name))
|
||||||
@@ -250,10 +312,10 @@ fun PantryScreen(
|
|||||||
onItemClick = { onNavigateToDetail(product.id) },
|
onItemClick = { onNavigateToDetail(product.id) },
|
||||||
onQuantityChange = { newQty ->
|
onQuantityChange = { newQty ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val success = repository.updateProductQuantity(product.id, newQty)
|
val success = repository.updateProductQuantity(product.id, newQty, userName)
|
||||||
if (success.isSuccess) {
|
if (success.isSuccess) {
|
||||||
allProducts = allProducts.map {
|
allProducts = allProducts.map {
|
||||||
if (it.id == product.id) it.copy(quantity = newQty) else it
|
if (it.id == product.id) it.copy(quantity = newQty, lastModifiedBy = userName) else it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,148 +389,179 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onItemClick),
|
.clickable(onClick = onItemClick),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||||
|
// Añadimos un borde rojo sutil si el stock es bajo
|
||||||
|
border = if (product.isLowStock) BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)) else null
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.height(IntrinsicSize.Min), // Para que la barra lateral ocupe todo el alto
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.size(64.dp)) {
|
// Barra lateral indicadora de Stock Bajo
|
||||||
AsyncImage(
|
if (product.isLowStock) {
|
||||||
model = product.imageUrl,
|
Box(
|
||||||
contentDescription = product.name,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxHeight()
|
||||||
.background(Color.White, MaterialTheme.shapes.small)
|
.width(6.dp)
|
||||||
.padding(4.dp),
|
.background(MaterialTheme.colorScheme.error)
|
||||||
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))
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
verticalAlignment = Alignment.CenterVertically
|
||||||
Row(
|
) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Box(modifier = Modifier.size(64.dp)) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
AsyncImage(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
model = ImageUtils.getCoilModel(product.imageUrl),
|
||||||
) {
|
contentDescription = product.name,
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
modifier = Modifier
|
||||||
Text(
|
.fillMaxSize()
|
||||||
text = product.name,
|
.background(Color.White, MaterialTheme.shapes.small)
|
||||||
fontSize = 18.sp,
|
.padding(4.dp),
|
||||||
fontWeight = FontWeight.Bold
|
error = rememberVectorPainter(Icons.Default.Inventory2),
|
||||||
)
|
placeholder = rememberVectorPainter(Icons.Default.Inventory2)
|
||||||
Text(
|
)
|
||||||
text = product.category,
|
|
||||||
fontSize = 14.sp,
|
// Icono de Categoría Superpuesto
|
||||||
color = Color.Gray
|
Surface(
|
||||||
)
|
modifier = Modifier.align(Alignment.BottomEnd).offset(x = 4.dp, y = 4.dp),
|
||||||
|
shape = MaterialTheme.shapes.extraSmall,
|
||||||
// Información de Fechas
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
Column {
|
tonalElevation = 4.dp
|
||||||
if (product.expirationDate != null) {
|
) {
|
||||||
val expiryDate = try { LocalDate.parse(product.expirationDate) } catch (e: Exception) { null }
|
|
||||||
val today = LocalDate.now()
|
|
||||||
val isExpired = expiryDate?.isBefore(today) == true
|
|
||||||
val nearExpiry = isNearExpiry(product.expirationDate)
|
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.CalendarToday,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(12.dp),
|
|
||||||
tint = if (nearExpiry) MaterialTheme.colorScheme.error else Color.Gray
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = if (isExpired) stringResource(R.string.pantry_expiry_expired, DateUtils.formatToDisplay(product.expirationDate) ?: "")
|
|
||||||
else stringResource(R.string.pantry_expiry_date, DateUtils.formatToDisplay(product.expirationDate) ?: ""),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = if (nearExpiry) MaterialTheme.colorScheme.error else Color.Gray,
|
|
||||||
fontWeight = if (nearExpiry) FontWeight.Bold else FontWeight.Normal
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (product.createdAt != null) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.History,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(12.dp),
|
|
||||||
tint = Color.Gray
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.pantry_added_date, DateUtils.formatToDisplay(product.createdAt) ?: ""),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
color = Color.Gray
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (product.isLowStock) {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Warning,
|
CategoryMapper.getIconForCategory(product.category),
|
||||||
contentDescription = stringResource(R.string.pantry_low_stock_warning),
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.error,
|
modifier = Modifier.size(16.dp).padding(2.dp),
|
||||||
modifier = Modifier.size(24.dp)
|
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
Row(
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Text(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
text = product.name,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
fontSize = 18.sp,
|
||||||
) {
|
fontWeight = FontWeight.Bold,
|
||||||
Surface(
|
maxLines = 1 // Evitamos que nombres muy largos rompan el diseño
|
||||||
shape = MaterialTheme.shapes.medium,
|
)
|
||||||
color = if (product.isLowStock) MaterialTheme.colorScheme.errorContainer
|
Text(
|
||||||
else MaterialTheme.colorScheme.secondaryContainer,
|
text = product.category,
|
||||||
contentColor = if (product.isLowStock) MaterialTheme.colorScheme.onErrorContainer
|
fontSize = 14.sp,
|
||||||
else MaterialTheme.colorScheme.onSecondaryContainer
|
color = Color.Gray
|
||||||
) {
|
)
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.pantry_units_label, product.quantity, product.unit),
|
// Información de Fechas y Usuario (organizada en columna para evitar solapamientos)
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
Column(modifier = Modifier.padding(top = 4.dp)) {
|
||||||
style = MaterialTheme.typography.bodyMedium
|
if (product.expirationDate != null) {
|
||||||
)
|
val expiryDate = try { LocalDate.parse(product.expirationDate) } catch (e: Exception) { null }
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val isExpired = expiryDate?.isBefore(today) == true
|
||||||
|
val nearExpiry = isNearExpiry(product.expirationDate)
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CalendarToday,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = if (nearExpiry) MaterialTheme.colorScheme.error else Color.Gray
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = if (isExpired) stringResource(R.string.pantry_expiry_expired, DateUtils.formatToDisplay(product.expirationDate) ?: "")
|
||||||
|
else stringResource(R.string.pantry_expiry_date, DateUtils.formatToDisplay(product.expirationDate) ?: ""),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = if (nearExpiry) MaterialTheme.colorScheme.error else Color.Gray,
|
||||||
|
fontWeight = if (nearExpiry) FontWeight.Bold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.createdAt != null) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.History,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = Color.Gray
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.pantry_added_date, DateUtils.formatToDisplay(product.createdAt) ?: ""),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.lastModifiedBy != null) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.pantry_last_modified_by, product.lastModifiedBy),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
FilledTonalIconButton(
|
|
||||||
onClick = { if (product.quantity > 0) onQuantityChange(product.quantity - 1) },
|
Row(
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = if (product.isLowStock) MaterialTheme.colorScheme.errorContainer
|
||||||
|
else MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = if (product.isLowStock) MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
else MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Remove, contentDescription = stringResource(R.string.add_product_less), modifier = Modifier.size(18.dp))
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.pantry_units_label, product.quantity, product.unit),
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
if (product.price > 0) {
|
||||||
|
VerticalDivider(modifier = Modifier.height(16.dp).padding(horizontal = 4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.pantry_price_label, product.price),
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
FilledIconButton(
|
Row {
|
||||||
onClick = { onQuantityChange(product.quantity + 1) },
|
FilledTonalIconButton(
|
||||||
modifier = Modifier.size(32.dp)
|
onClick = { if (product.quantity > 0) 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))
|
) {
|
||||||
|
Icon(Icons.Default.Remove, contentDescription = stringResource(R.string.add_product_less), modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = { onQuantityChange(product.quantity + 1) },
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_product_more), modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -481,6 +574,6 @@ fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (In
|
|||||||
@Composable
|
@Composable
|
||||||
fun PantryScreenPreview() {
|
fun PantryScreenPreview() {
|
||||||
DespensappTheme {
|
DespensappTheme {
|
||||||
PantryScreen(onNavigateToAdd = {}, onNavigateToDetail = {}, onLogout = {})
|
PantryScreen(onNavigateToAdd = {}, onNavigateToDetail = {}, onNavigateToSettings = {}, onLogout = {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.example.despensapp.ui.screens
|
package com.example.despensapp.ui.screens
|
||||||
|
|
||||||
import android.app.DatePickerDialog
|
import android.app.DatePickerDialog
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -20,12 +22,17 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import com.example.despensapp.R
|
import com.example.despensapp.R
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.example.despensapp.data.model.Product
|
import com.example.despensapp.data.model.Product
|
||||||
import com.example.despensapp.data.repository.PantryRepository
|
import com.example.despensapp.data.repository.PantryRepository
|
||||||
|
import com.example.despensapp.data.session.UserSession
|
||||||
import com.example.despensapp.util.CategoryMapper
|
import com.example.despensapp.util.CategoryMapper
|
||||||
import com.example.despensapp.util.DateUtils
|
import com.example.despensapp.util.DateUtils
|
||||||
|
import com.example.despensapp.util.ImageUtils
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@@ -35,6 +42,8 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val repository = remember { PantryRepository() }
|
val repository = remember { PantryRepository() }
|
||||||
|
val userName = remember { UserSession.getUserName(context) }
|
||||||
|
|
||||||
var product by remember { mutableStateOf<Product?>(null) }
|
var product by remember { mutableStateOf<Product?>(null) }
|
||||||
var isLoading by remember { mutableStateOf(true) }
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -46,9 +55,117 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
|
|||||||
var editMinQuantity by remember { mutableStateOf(0) }
|
var editMinQuantity by remember { mutableStateOf(0) }
|
||||||
var editUnit by remember { mutableStateOf("") }
|
var editUnit by remember { mutableStateOf("") }
|
||||||
var editCategory by remember { mutableStateOf("") }
|
var editCategory by remember { mutableStateOf("") }
|
||||||
|
var editPrice by remember { mutableStateOf(0.0) }
|
||||||
var editExpirationDate by remember { mutableStateOf<String?>(null) }
|
var editExpirationDate by remember { mutableStateOf<String?>(null) }
|
||||||
|
var editImageUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
var isProcessingImage by remember { mutableStateOf(false) }
|
||||||
|
// Nuevo estado para la vista previa inmediata (soporta Uri, Bitmap o String)
|
||||||
|
var imagePreviewModel by remember { mutableStateOf<Any?>(null) }
|
||||||
|
var showPhotoSourceDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val units = listOf("unidades", "kg", "gr", "l", "ml", "paquetes")
|
val galleryLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetContent()
|
||||||
|
) { uri ->
|
||||||
|
uri?.let {
|
||||||
|
imagePreviewModel = it // Vista previa instantánea con la Uri
|
||||||
|
isProcessingImage = true
|
||||||
|
scope.launch {
|
||||||
|
val base64 = ImageUtils.uriToBase64(context, it)
|
||||||
|
if (base64 != null) {
|
||||||
|
editImageUrl = base64
|
||||||
|
}
|
||||||
|
isProcessingImage = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cameraLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.TakePicturePreview()
|
||||||
|
) { bitmap ->
|
||||||
|
bitmap?.let {
|
||||||
|
imagePreviewModel = it // Vista previa instantánea con el Bitmap
|
||||||
|
isProcessingImage = true
|
||||||
|
scope.launch {
|
||||||
|
val base64 = ImageUtils.bitmapToBase64(it)
|
||||||
|
if (base64 != null) {
|
||||||
|
editImageUrl = base64
|
||||||
|
}
|
||||||
|
isProcessingImage = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showPhotoSourceDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showPhotoSourceDialog = false },
|
||||||
|
title = { Text(stringResource(R.string.photo_source_title)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showPhotoSourceDialog = false
|
||||||
|
cameraLauncher.launch(null)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Icon(Icons.Default.PhotoCamera, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.photo_source_camera))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showPhotoSourceDialog = false
|
||||||
|
galleryLauncher.launch("image/*")
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Icon(Icons.Default.PhotoLibrary, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.photo_source_gallery))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showPhotoSourceDialog = false
|
||||||
|
editImageUrl = null
|
||||||
|
imagePreviewModel = null
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Icon(Icons.Default.NoPhotography, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.photo_source_none))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showPhotoSourceDialog = false }) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val units = listOf(
|
||||||
|
stringResource(R.string.unit_unidad),
|
||||||
|
stringResource(R.string.unit_kg),
|
||||||
|
stringResource(R.string.unit_gr),
|
||||||
|
stringResource(R.string.unit_l),
|
||||||
|
stringResource(R.string.unit_ml),
|
||||||
|
stringResource(R.string.unit_paquete),
|
||||||
|
stringResource(R.string.unit_botella),
|
||||||
|
stringResource(R.string.unit_sobre),
|
||||||
|
stringResource(R.string.unit_lata),
|
||||||
|
stringResource(R.string.unit_bote),
|
||||||
|
stringResource(R.string.unit_docena),
|
||||||
|
stringResource(R.string.unit_caja)
|
||||||
|
)
|
||||||
var unitsExpanded by remember { mutableStateOf(false) }
|
var unitsExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val calendar = Calendar.getInstance()
|
val calendar = Calendar.getInstance()
|
||||||
@@ -73,7 +190,10 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
|
|||||||
editMinQuantity = p.minQuantity
|
editMinQuantity = p.minQuantity
|
||||||
editUnit = p.unit
|
editUnit = p.unit
|
||||||
editCategory = p.category
|
editCategory = p.category
|
||||||
|
editPrice = p.price
|
||||||
editExpirationDate = p.expirationDate
|
editExpirationDate = p.expirationDate
|
||||||
|
editImageUrl = p.imageUrl
|
||||||
|
imagePreviewModel = p.imageUrl
|
||||||
}
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
@@ -103,27 +223,40 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
|
|||||||
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit))
|
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
IconButton(onClick = {
|
if (isProcessingImage) {
|
||||||
scope.launch {
|
CircularProgressIndicator(
|
||||||
isLoading = true
|
modifier = Modifier.size(24.dp).padding(4.dp),
|
||||||
val result = repository.updateProductFull(
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
productId = productId,
|
strokeWidth = 2.dp
|
||||||
nombre = editName,
|
)
|
||||||
cantidad = editQuantity,
|
}
|
||||||
minCantidad = editMinQuantity,
|
IconButton(
|
||||||
unidad = editUnit,
|
enabled = !isProcessingImage,
|
||||||
categoria = editCategory,
|
onClick = {
|
||||||
fechaCaducidad = editExpirationDate
|
scope.launch {
|
||||||
)
|
isLoading = true
|
||||||
if (result.isSuccess) {
|
val result = repository.updateProductFull(
|
||||||
isEditing = false
|
productId = productId,
|
||||||
loadProduct()
|
nombre = editName,
|
||||||
} else {
|
cantidad = editQuantity,
|
||||||
errorMessage = context.getString(R.string.error_db_connection)
|
minCantidad = editMinQuantity,
|
||||||
isLoading = false
|
unidad = editUnit,
|
||||||
|
category = editCategory,
|
||||||
|
price = editPrice,
|
||||||
|
fechaCaducidad = editExpirationDate,
|
||||||
|
imageUrl = editImageUrl,
|
||||||
|
userName = userName
|
||||||
|
)
|
||||||
|
if (result.isSuccess) {
|
||||||
|
isEditing = false
|
||||||
|
loadProduct()
|
||||||
|
} else {
|
||||||
|
errorMessage = context.getString(R.string.error_db_connection)
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}) {
|
) {
|
||||||
Icon(Icons.Default.Save, contentDescription = stringResource(R.string.save))
|
Icon(Icons.Default.Save, contentDescription = stringResource(R.string.save))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,18 +280,39 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
if (!isEditing) {
|
// Imagen del producto (común a ambos modos)
|
||||||
// Modo Vista
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(250.dp)
|
||||||
|
.background(Color.White)
|
||||||
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = p.imageUrl,
|
model = ImageUtils.getCoilModel(if (isEditing) imagePreviewModel else product?.imageUrl),
|
||||||
contentDescription = p.name,
|
contentDescription = product?.name,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxWidth()
|
contentScale = ContentScale.Fit,
|
||||||
.height(250.dp)
|
error = rememberVectorPainter(Icons.Default.Inventory2),
|
||||||
.background(Color.White),
|
placeholder = rememberVectorPainter(Icons.Default.Inventory2)
|
||||||
contentScale = ContentScale.Fit
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { showPhotoSourceDialog = true },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(16.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PhotoCamera, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.add_product_change_photo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditing) {
|
||||||
|
// Modo Vista
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -177,6 +331,7 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
|
|||||||
Spacer(modifier = Modifier.height(24.dp))
|
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.Inventory, label = stringResource(R.string.detail_stock_label), value = stringResource(R.string.pantry_units_label, p.quantity, p.unit))
|
||||||
|
DetailRow(icon = Icons.Default.Payments, label = "Precio unitario", value = stringResource(R.string.pantry_price_label, p.price))
|
||||||
DetailRow(icon = Icons.Default.NotificationsActive, label = stringResource(R.string.detail_low_stock_alert_label), value = stringResource(R.string.detail_low_stock_alert_value, p.minQuantity, p.unit))
|
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) {
|
if (p.expirationDate != null) {
|
||||||
@@ -187,6 +342,10 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
|
|||||||
DetailRow(icon = Icons.Default.AccessTime, label = stringResource(R.string.detail_added_label), value = DateUtils.formatToDisplay(p.createdAt)!!)
|
DetailRow(icon = Icons.Default.AccessTime, label = stringResource(R.string.detail_added_label), value = DateUtils.formatToDisplay(p.createdAt)!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (p.lastModifiedBy != null) {
|
||||||
|
DetailRow(icon = Icons.Default.Person, label = "Modificado por", value = p.lastModifiedBy)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
@@ -224,6 +383,17 @@ fun ProductDetailScreen(productId: Int, onBack: () -> Unit) {
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = if (editPrice == 0.0) "" else editPrice.toString(),
|
||||||
|
onValueChange = { editPrice = it.toDoubleOrNull() ?: 0.0 },
|
||||||
|
label = { Text("Precio Unitario (€)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Euro, contentDescription = null) }
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -326,4 +496,4 @@ fun DetailRow(icon: ImageVector, label: String, value: String) {
|
|||||||
Text(text = value, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
|
Text(text = value, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import com.example.despensapp.R
|
import com.example.despensapp.R
|
||||||
import com.example.despensapp.data.repository.UserRepository
|
import com.example.despensapp.data.repository.UserRepository
|
||||||
|
import com.example.despensapp.data.session.UserSession
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -104,7 +105,10 @@ fun RegisterScreen(onRegisterSuccess: () -> Unit, onNavigateToLogin: () -> Unit)
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
val result = repository.registerUser(name, email, password)
|
val result = repository.registerUser(name, email, password)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
result.onSuccess { onRegisterSuccess() }
|
result.onSuccess { userName ->
|
||||||
|
UserSession.saveUserName(context, userName)
|
||||||
|
onRegisterSuccess()
|
||||||
|
}
|
||||||
.onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") }
|
.onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.example.despensapp.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.despensapp.R
|
||||||
|
import com.example.despensapp.util.NotificationScheduler
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(onBack: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val currentFreq = remember { NotificationScheduler.getSavedFrequency(context) }
|
||||||
|
var selectedFreq by remember { mutableStateOf(currentFreq) }
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
val options = listOf(
|
||||||
|
12L to stringResource(R.string.settings_freq_12h),
|
||||||
|
24L to stringResource(R.string.settings_freq_24h),
|
||||||
|
48L to stringResource(R.string.settings_freq_2d),
|
||||||
|
168L to stringResource(R.string.settings_freq_weekly)
|
||||||
|
)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.settings_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_notification_frequency),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(Modifier.selectableGroup()) {
|
||||||
|
options.forEach { (hours, label) ->
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
|
.selectable(
|
||||||
|
selected = (selectedFreq == hours),
|
||||||
|
onClick = {
|
||||||
|
selectedFreq = hours
|
||||||
|
NotificationScheduler.schedule(context, hours)
|
||||||
|
},
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = (selectedFreq == hours),
|
||||||
|
onClick = null // null because the row handles the click
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.example.despensapp.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Base64
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
object ImageUtils {
|
||||||
|
/**
|
||||||
|
* Convierte una URI de imagen a una cadena Base64, redimensionándola para ahorrar espacio en DB.
|
||||||
|
*/
|
||||||
|
fun uriToBase64(context: Context, uri: Uri, maxWidth: Int = 800, maxHeight: Int = 800): String? {
|
||||||
|
return try {
|
||||||
|
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
|
||||||
|
val originalBitmap = BitmapFactory.decodeStream(inputStream)
|
||||||
|
inputStream?.close()
|
||||||
|
|
||||||
|
if (originalBitmap == null) return null
|
||||||
|
bitmapToBase64(originalBitmap, maxWidth, maxHeight)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte un Bitmap a una cadena Base64 con el prefijo necesario para Coil.
|
||||||
|
*/
|
||||||
|
fun bitmapToBase64(bitmap: Bitmap, maxWidth: Int = 800, maxHeight: Int = 800): String? {
|
||||||
|
return try {
|
||||||
|
val scaledBitmap = scaleBitmap(bitmap, maxWidth, maxHeight)
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
// Comprimimos un poco menos para ganar calidad
|
||||||
|
scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
|
||||||
|
val byteArray = outputStream.toByteArray()
|
||||||
|
|
||||||
|
// NO_WRAP es crucial para que la cadena sea una URL válida para Coil
|
||||||
|
val base64String = Base64.encodeToString(byteArray, Base64.NO_WRAP)
|
||||||
|
"data:image/jpeg;base64,$base64String"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scaleBitmap(bitmap: Bitmap, maxWidth: Int, maxHeight: Int): Bitmap {
|
||||||
|
var width = bitmap.width
|
||||||
|
var height = bitmap.height
|
||||||
|
|
||||||
|
// Evitar redimensionar si ya es pequeña (como las miniaturas de cámara)
|
||||||
|
if (width <= maxWidth && height <= maxHeight) return bitmap
|
||||||
|
|
||||||
|
val ratioBitmap = width.toFloat() / height.toFloat()
|
||||||
|
val ratioMax = maxWidth.toFloat() / maxHeight.toFloat()
|
||||||
|
|
||||||
|
var finalWidth = maxWidth
|
||||||
|
var finalHeight = maxHeight
|
||||||
|
|
||||||
|
if (ratioMax > ratioBitmap) {
|
||||||
|
finalWidth = (maxHeight.toFloat() * ratioBitmap).toInt()
|
||||||
|
} else {
|
||||||
|
finalHeight = (maxWidth.toFloat() / ratioBitmap).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Bitmap.createScaledBitmap(bitmap, finalWidth, finalHeight, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepara el modelo para Coil. Si es Base64, lo decodifica a ByteArray para mayor estabilidad.
|
||||||
|
*/
|
||||||
|
fun getCoilModel(imageData: Any?): Any? {
|
||||||
|
if (imageData == null) return null
|
||||||
|
if (imageData !is String) return imageData // Si es Uri o Bitmap, dejarlo como está
|
||||||
|
|
||||||
|
return if (imageData.startsWith("data:image")) {
|
||||||
|
try {
|
||||||
|
val base64Data = imageData.substringAfter("base64,")
|
||||||
|
Base64.decode(base64Data, Base64.NO_WRAP)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
imageData
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
imageData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.example.despensapp.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import com.example.despensapp.worker.InventoryCheckWorker
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
object NotificationScheduler {
|
||||||
|
private const val WORK_NAME = "inventory_check_work"
|
||||||
|
private const val PREFS_NAME = "settings_prefs"
|
||||||
|
private const val KEY_FREQUENCY = "notification_frequency"
|
||||||
|
|
||||||
|
fun schedule(context: Context, hours: Long) {
|
||||||
|
val workRequest = PeriodicWorkRequestBuilder<InventoryCheckWorker>(hours, TimeUnit.HOURS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.REPLACE,
|
||||||
|
workRequest
|
||||||
|
)
|
||||||
|
|
||||||
|
saveFrequency(context, hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSavedFrequency(context: Context): Long {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getLong(KEY_FREQUENCY, 24) // 24h por defecto
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveFrequency(context: Context, hours: Long) {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit().putLong(KEY_FREQUENCY, hours).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,9 +53,13 @@
|
|||||||
<string name="pantry_delete_error">Error al eliminar de la base de datos</string>
|
<string name="pantry_delete_error">Error al eliminar de la base de datos</string>
|
||||||
<string name="pantry_low_stock_warning">Stock Bajo</string>
|
<string name="pantry_low_stock_warning">Stock Bajo</string>
|
||||||
<string name="pantry_units_label">%1$s %2$s</string>
|
<string name="pantry_units_label">%1$s %2$s</string>
|
||||||
|
<string name="pantry_price_label">%1$.2f €</string>
|
||||||
|
<string name="pantry_total_value">Valor Despensa: %1$.2f €</string>
|
||||||
|
<string name="shopping_list_total">Coste estimado: %1$.2f €</string>
|
||||||
<string name="pantry_expiry_expired">CADUCADO: %1$s</string>
|
<string name="pantry_expiry_expired">CADUCADO: %1$s</string>
|
||||||
<string name="pantry_expiry_date">Caduca: %1$s</string>
|
<string name="pantry_expiry_date">Caduca: %1$s</string>
|
||||||
<string name="pantry_added_date">Añadido: %1$s</string>
|
<string name="pantry_added_date">Añadido: %1$s</string>
|
||||||
|
<string name="pantry_last_modified_by">Por: %1$s</string>
|
||||||
|
|
||||||
<!-- Add Product -->
|
<!-- Add Product -->
|
||||||
<string name="add_product_title">Añadir Producto</string>
|
<string name="add_product_title">Añadir Producto</string>
|
||||||
@@ -68,8 +72,29 @@
|
|||||||
<string name="add_product_less">Menos</string>
|
<string name="add_product_less">Menos</string>
|
||||||
<string name="add_product_more">Más</string>
|
<string name="add_product_more">Más</string>
|
||||||
<string name="add_product_save_button">Guardar en Despensa</string>
|
<string name="add_product_save_button">Guardar en Despensa</string>
|
||||||
|
<string name="add_product_add_photo">Añadir Foto</string>
|
||||||
|
<string name="add_product_change_photo">Cambiar Foto</string>
|
||||||
|
<string name="photo_source_title">Seleccionar origen</string>
|
||||||
|
<string name="photo_source_camera">Cámara</string>
|
||||||
|
<string name="photo_source_gallery">Galería</string>
|
||||||
|
<string name="photo_source_none">Sin foto</string>
|
||||||
<string name="add_product_scan_another">Escanear otro</string>
|
<string name="add_product_scan_another">Escanear otro</string>
|
||||||
<string name="add_product_camera_permission_required">Se necesita permiso de cámara para escanear</string>
|
<string name="add_product_camera_permission_required">Se necesita permiso de cámara para escanear</string>
|
||||||
|
<string name="add_product_image_picker">Seleccionar Imagen</string>
|
||||||
|
|
||||||
|
<!-- Units -->
|
||||||
|
<string name="unit_unidad">unidad</string>
|
||||||
|
<string name="unit_kg">kg</string>
|
||||||
|
<string name="unit_gr">gr</string>
|
||||||
|
<string name="unit_l">l</string>
|
||||||
|
<string name="unit_ml">ml</string>
|
||||||
|
<string name="unit_paquete">paquete</string>
|
||||||
|
<string name="unit_botella">botella</string>
|
||||||
|
<string name="unit_sobre">sobre</string>
|
||||||
|
<string name="unit_lata">lata</string>
|
||||||
|
<string name="unit_bote">bote</string>
|
||||||
|
<string name="unit_docena">docena</string>
|
||||||
|
<string name="unit_caja">caja</string>
|
||||||
|
|
||||||
<!-- Detail Screen -->
|
<!-- Detail Screen -->
|
||||||
<string name="detail_title">Detalles del Producto</string>
|
<string name="detail_title">Detalles del Producto</string>
|
||||||
@@ -89,4 +114,13 @@
|
|||||||
<string name="notification_title">¡Revisa tu despensa!</string>
|
<string name="notification_title">¡Revisa tu despensa!</string>
|
||||||
<string name="notification_message">Tienes %1$d productos con stock bajo.</string>
|
<string name="notification_message">Tienes %1$d productos con stock bajo.</string>
|
||||||
<string name="notification_channel_name">Alertas de Inventario</string>
|
<string name="notification_channel_name">Alertas de Inventario</string>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<string name="settings_title">Configuración</string>
|
||||||
|
<string name="settings_notification_frequency">Frecuencia de Notificaciones</string>
|
||||||
|
<string name="settings_freq_12h">Cada 12 horas</string>
|
||||||
|
<string name="settings_freq_24h">Cada 24 horas (Recomendado)</string>
|
||||||
|
<string name="settings_freq_2d">Cada 2 días</string>
|
||||||
|
<string name="settings_freq_weekly">Una vez a la semana</string>
|
||||||
|
<string name="settings_save_success">Configuración guardada correctamente</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in New Issue
Block a user