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