diff --git a/README.md b/README.md new file mode 100644 index 0000000..4bcc07a --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Despensapp - Gestión de Despensa Familiar + +Aplicación Android diseñada para gestionar el inventario de una despensa de forma compartida, con integración directa a MariaDB y Open Food Facts. + +## Funcionalidades Actuales + +### 🔐 Autenticación Multiusuario +* **Login y Registro:** Conexión directa a una base de datos MariaDB externa (`mariadb.peta9.com`). +* **Gestión de Cuentas:** Registro de usuarios con nombre completo, email y contraseña. +* **Seguridad:** Uso de hilos secundarios (Coroutines) para no bloquear la interfaz durante la conexión. + +### 🍱 Gestión de Inventario +* **Visualización en Tiempo Real:** Lista de productos traída directamente desde MariaDB con soporte para unidades (kg, l, gr, etc.). +* **Imágenes de Productos:** Integración con **Coil** para mostrar las imágenes de los productos obtenidas de Open Food Facts. +* **Detalles Extendidos:** Pantalla de detalles para cada producto con información técnica de MariaDB. +* **Búsqueda y Categorización:** Filtro por nombre y categorías dinámicas para una localización rápida. +* **Swipe to Delete:** Borrado intuitivo de productos deslizando hacia la izquierda (Material 3). +* **Fecha de Caducidad:** Seguimiento de la caducidad de cada producto para evitar desperdicios. +* **Filtro Inteligente:** Filtro para ver rápidamente qué productos han caducado o están a punto de hacerlo (7 días o menos). +* **Formato de Fecha:** Visualización de fechas en formato amigable (DD/MM/YYYY) en toda la aplicación. +* **Lista de la Compra:** Modo dedicado que muestra únicamente los productos con stock bajo. +* **Historial de Alta:** Registro automático de la fecha y hora en la que se añadió cada producto. +* **Alertas de Stock Bajo:** Indicadores visuales en rojo cuando un producto está por debajo de su cantidad mínima. +* **Notificaciones Automáticas:** El sistema revisa diariamente el inventario y envía notificaciones al móvil sobre stock bajo. +* **Control Rápido:** Botones de incremento/decremento en la pantalla principal para actualizar el stock rápidamente. +* **Edición Completa:** Modo de edición total en la pantalla de detalle para modificar nombre, stock, stock mínimo, categoría y caducidad. +* **Compartir Lista:** Botón para compartir la lista de la compra directamente desde la aplicación a servicios de mensajería (WhatsApp, etc.). +* **Iconografía Dinámica:** Asignación inteligente de iconos de Material Design basados en la categoría del producto (Lácteos, Carnicería, Fresco, etc.). +* **Cierre de Sesión:** Opción para cerrar la sesión actual y volver de forma segura a la pantalla de bienvenida. + +### 🎨 Identidad Visual +* **Icono Personalizado:** Diseño de icono adaptativo único que representa un mueble de despensa lleno de productos. +* **Interfaz Material 3:** Uso de los últimos estándares de diseño de Google para una experiencia fluida y moderna. +* **Colores Amigables:** Paleta de colores personalizada para mejorar la legibilidad y la estética familiar. + +### 🔍 Integración con APIs y Hardware +* **Escáner de Código de Barras:** Uso de **CameraX** y **Google ML Kit** con **Feedback Háptico** (vibración) al detectar productos. +* **Linterna:** Botón integrado en el escáner para encender y apagar el flash en entornos oscuros. +* **Open Food Facts:** Búsqueda automática de nombres, categorías e imágenes al escanear un código de barras. +* **MariaDB Directo:** Conexión JDBC para persistencia de datos compartida. + +## Próximos Pasos +* [x] Gestión de fechas de caducidad. +* [x] Lista de la compra automática basada en el stock bajo. +* [ ] Compartir despensas específicas mediante códigos de familia. +* [ ] Exportar inventario a PDF/CSV. + +## Requisitos Técnicos +* Android SDK 34+ +* Conexión a Internet (para API y DB remota). +* Permiso de Cámara (para el escáner). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2be0682..34f38a8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -39,6 +39,20 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.navigation.compose) + implementation(libs.retrofit) + implementation(libs.retrofit.gson) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + implementation(libs.mariadb.java.client) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(libs.barcode.scanning) + implementation(libs.coil.compose) + implementation(libs.androidx.work.runtime) + implementation(libs.androidx.work.runtime) + implementation(libs.material.icons.extended) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 11caddc..fbb14f8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,12 @@ + + + + + + (24, TimeUnit.HOURS).build() + WorkManager.getInstance(this).enqueue(workRequest) + setContent { DespensappTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } + val navController = rememberNavController() + NavGraph(navController = navController) } } } } - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - DespensappTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/despensapp/data/api/OpenFoodFactsApi.kt b/app/src/main/java/com/example/despensapp/data/api/OpenFoodFactsApi.kt new file mode 100644 index 0000000..06ce688 --- /dev/null +++ b/app/src/main/java/com/example/despensapp/data/api/OpenFoodFactsApi.kt @@ -0,0 +1,12 @@ +package com.example.despensapp.data.api + +import com.example.despensapp.data.model.off.OFFProductResponse +import retrofit2.http.GET +import retrofit2.http.Path + +interface OpenFoodFactsApi { + @GET("api/v0/product/{barcode}.json") + suspend fun getProductByBarcode( + @Path("barcode") barcode: String + ): OFFProductResponse +} \ No newline at end of file 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 new file mode 100644 index 0000000..f276570 --- /dev/null +++ b/app/src/main/java/com/example/despensapp/data/model/Product.kt @@ -0,0 +1,15 @@ +package com.example.despensapp.data.model + +data class Product( + val id: Int = 0, + val name: String, + val quantity: Int, + val minQuantity: Int, + val unit: String = "unidades", + val category: String = "General", + val imageUrl: String? = null, + val expirationDate: String? = null, + val createdAt: String? = null +) { + val isLowStock: Boolean get() = quantity <= minQuantity +} \ No newline at end of file diff --git a/app/src/main/java/com/example/despensapp/data/model/off/OFFProductResponse.kt b/app/src/main/java/com/example/despensapp/data/model/off/OFFProductResponse.kt new file mode 100644 index 0000000..ee68211 --- /dev/null +++ b/app/src/main/java/com/example/despensapp/data/model/off/OFFProductResponse.kt @@ -0,0 +1,19 @@ +package com.example.despensapp.data.model.off + +import com.google.gson.annotations.SerializedName + +data class OFFProductResponse( + val status: Int, + val code: String, + val product: OFFProduct? +) + +data class OFFProduct( + @SerializedName("product_name") val productName: String?, + @SerializedName("product_name_es") val productNameEs: String?, + val brands: String?, + val categories: String?, + @SerializedName("image_url") val imageUrl: String? +) { + val displayName: String get() = productNameEs ?: productName ?: "Producto desconocido" +} \ No newline at end of file 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 new file mode 100644 index 0000000..3e492c1 --- /dev/null +++ b/app/src/main/java/com/example/despensapp/data/repository/PantryRepository.kt @@ -0,0 +1,204 @@ +package com.example.despensapp.data.repository + +import com.example.despensapp.data.api.OpenFoodFactsApi +import com.example.despensapp.data.model.Product +import com.example.despensapp.data.model.off.OFFProduct +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.sql.DriverManager +import java.sql.SQLException + +class PantryRepository { + private val dbUrl = "jdbc:mariadb://mariadb.peta9.com:3306/despensa_db" + private val dbUser = "despensa_dba" + private val dbPassword = "Pedro@110387" + + private val offApi = Retrofit.Builder() + .baseUrl("https://es.openfoodfacts.org/") + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(OpenFoodFactsApi::class.java) + + suspend fun getProductFromOFF(barcode: String): Result { + return try { + val response = offApi.getProductByBarcode(barcode) + if (response.status == 1) { + Result.success(response.product) + } else { + Result.failure(Exception("Producto no encontrado en Open Food Facts")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getAllProductsFromDb(): Result> { + return withContext(Dispatchers.IO) { + try { + Class.forName("org.mariadb.jdbc.Driver") + val connection = DriverManager.getConnection(dbUrl, dbUser, dbPassword) + val sql = "SELECT * FROM productos" + val statement = connection.prepareStatement(sql) + val resultSet = statement.executeQuery() + + val products = mutableListOf() + while (resultSet.next()) { + products.add( + Product( + id = resultSet.getInt("id"), + name = resultSet.getString("nombre"), + quantity = resultSet.getInt("cantidad"), + minQuantity = resultSet.getInt("cantidad_minima"), + unit = resultSet.getString("unidad") ?: "unidades", + category = resultSet.getString("categoria"), + imageUrl = resultSet.getString("imagen_url"), + expirationDate = resultSet.getString("fecha_caducidad"), + createdAt = resultSet.getString("fecha_alta") + ) + ) + } + connection.close() + Result.success(products) + } catch (e: Exception) { + Result.failure(e) + } + } + } + + suspend fun updateProductQuantity(productId: Int, newQuantity: Int): Result { + return withContext(Dispatchers.IO) { + try { + Class.forName("org.mariadb.jdbc.Driver") + val connection = DriverManager.getConnection(dbUrl, dbUser, dbPassword) + val sql = "UPDATE productos SET cantidad = ? WHERE id = ?" + val statement = connection.prepareStatement(sql) + statement.setInt(1, newQuantity) + statement.setInt(2, productId) + + val rowsUpdated = statement.executeUpdate() + connection.close() + Result.success(rowsUpdated > 0) + } catch (e: Exception) { + Result.failure(e) + } + } + } + + suspend fun updateProductFull( + productId: Int, + nombre: String, + cantidad: Int, + minCantidad: Int, + unidad: String, + categoria: String, + fechaCaducidad: String? + ): Result { + return withContext(Dispatchers.IO) { + try { + Class.forName("org.mariadb.jdbc.Driver") + val connection = DriverManager.getConnection(dbUrl, dbUser, dbPassword) + val sql = "UPDATE productos SET nombre = ?, cantidad = ?, cantidad_minima = ?, unidad = ?, categoria = ?, fecha_caducidad = ? WHERE id = ?" + val statement = connection.prepareStatement(sql) + statement.setString(1, nombre) + statement.setInt(2, cantidad) + statement.setInt(3, minCantidad) + statement.setString(4, unidad) + statement.setString(5, categoria) + statement.setString(6, fechaCaducidad) + statement.setInt(7, productId) + + val rowsUpdated = statement.executeUpdate() + connection.close() + Result.success(rowsUpdated > 0) + } catch (e: Exception) { + Result.failure(e) + } + } + } + + suspend fun addProduct( + barcode: String, + nombre: String, + cantidad: Int, + minCantidad: Int, + unidad: String, + categoria: String, + imageUrl: String?, + fechaCaducidad: String? + ): Result { + return withContext(Dispatchers.IO) { + try { + Class.forName("org.mariadb.jdbc.Driver") + val connection = DriverManager.getConnection(dbUrl, dbUser, dbPassword) + val sql = "INSERT INTO productos (barcode, nombre, cantidad, cantidad_minima, unidad, categoria, imagen_url, fecha_caducidad) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + val statement = connection.prepareStatement(sql) + statement.setString(1, barcode) + statement.setString(2, nombre) + statement.setInt(3, cantidad) + statement.setInt(4, minCantidad) + statement.setString(5, unidad) + statement.setString(6, categoria) + statement.setString(7, imageUrl) + statement.setString(8, fechaCaducidad) + + val rowsInserted = statement.executeUpdate() + connection.close() + Result.success(rowsInserted > 0) + } catch (e: Exception) { + Result.failure(e) + } + } + } + + suspend fun deleteProduct(productId: Int): Result { + return withContext(Dispatchers.IO) { + try { + Class.forName("org.mariadb.jdbc.Driver") + val connection = DriverManager.getConnection(dbUrl, dbUser, dbPassword) + val sql = "DELETE FROM productos WHERE id = ?" + val statement = connection.prepareStatement(sql) + statement.setInt(1, productId) + + val rowsDeleted = statement.executeUpdate() + connection.close() + Result.success(rowsDeleted > 0) + } catch (e: Exception) { + Result.failure(e) + } + } + } + + suspend fun getProductById(productId: Int): Result { + return withContext(Dispatchers.IO) { + try { + Class.forName("org.mariadb.jdbc.Driver") + val connection = DriverManager.getConnection(dbUrl, dbUser, dbPassword) + val sql = "SELECT * FROM productos WHERE id = ?" + val statement = connection.prepareStatement(sql) + statement.setInt(1, productId) + val resultSet = statement.executeQuery() + + var product: Product? = null + if (resultSet.next()) { + product = Product( + id = resultSet.getInt("id"), + name = resultSet.getString("nombre"), + quantity = resultSet.getInt("cantidad"), + minQuantity = resultSet.getInt("cantidad_minima"), + unit = resultSet.getString("unidad") ?: "unidades", + category = resultSet.getString("categoria"), + imageUrl = resultSet.getString("imagen_url"), + expirationDate = resultSet.getString("fecha_caducidad"), + createdAt = resultSet.getString("fecha_alta") + ) + } + connection.close() + Result.success(product) + } catch (e: Exception) { + Result.failure(e) + } + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..366b192 --- /dev/null +++ b/app/src/main/java/com/example/despensapp/data/repository/UserRepository.kt @@ -0,0 +1,66 @@ +package com.example.despensapp.data.repository + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.sql.DriverManager +import java.sql.SQLException + +class UserRepository { + private val url = "jdbc:mariadb://mariadb.peta9.com:3306/despensa_db" + private val user = "despensa_dba" + private val password = "Pedro@110387" + + suspend fun registerUser(nombre: String, email: String, contrasena: String): Result { + return withContext(Dispatchers.IO) { + try { + // Forzar carga del driver + Class.forName("org.mariadb.jdbc.Driver") + + val connection = DriverManager.getConnection(url, user, password) + val sql = "INSERT INTO usuarios (nombre_completo, email, contrasena) VALUES (?, ?, ?)" + val statement = connection.prepareStatement(sql) + statement.setString(1, nombre) + statement.setString(2, email) + statement.setString(3, contrasena) + + val rowsInserted = statement.executeUpdate() + connection.close() + + if (rowsInserted > 0) { + Result.success(true) + } else { + Result.failure(Exception("No se pudo insertar el usuario")) + } + } catch (e: SQLException) { + Result.failure(e) + } catch (e: Exception) { + Result.failure(e) + } + } + } + + suspend fun loginUser(email: String, contrasena: String): Result { + return withContext(Dispatchers.IO) { + try { + Class.forName("org.mariadb.jdbc.Driver") + val connection = DriverManager.getConnection(url, user, password) + val sql = "SELECT * FROM usuarios WHERE email = ? AND contrasena = ?" + val statement = connection.prepareStatement(sql) + statement.setString(1, email) + statement.setString(2, contrasena) + + val resultSet = statement.executeQuery() + val exists = resultSet.next() + connection.close() + + if (exists) { + Result.success(true) + } else { + Result.failure(Exception("Credenciales incorrectas")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..a71ef9a --- /dev/null +++ b/app/src/main/java/com/example/despensapp/ui/navigation/NavGraph.kt @@ -0,0 +1,86 @@ +package com.example.despensapp.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import com.example.despensapp.ui.screens.LoginScreen +import com.example.despensapp.ui.screens.PantryScreen +import com.example.despensapp.ui.screens.RegisterScreen +import com.example.despensapp.ui.screens.AddProductScreen +import com.example.despensapp.ui.screens.ProductDetailScreen +import androidx.navigation.NavType +import androidx.navigation.navArgument + +sealed class Screen(val route: String) { + object Login : Screen("login") + object Register : Screen("register") + object Pantry : Screen("pantry") + object AddProduct : Screen("add_product") + object ProductDetail : Screen("product_detail/{productId}") { + fun createRoute(productId: Int) = "product_detail/$productId" + } +} + +@Composable +fun NavGraph(navController: NavHostController) { + NavHost( + navController = navController, + startDestination = Screen.Login.route + ) { + composable(Screen.Login.route) { + LoginScreen( + onLoginSuccess = { + navController.navigate(Screen.Pantry.route) { + popUpTo(Screen.Login.route) { inclusive = true } + } + }, + onNavigateToRegister = { + navController.navigate(Screen.Register.route) + } + ) + } + composable(Screen.Register.route) { + RegisterScreen( + onRegisterSuccess = { + navController.navigate(Screen.Pantry.route) { + popUpTo(Screen.Login.route) { inclusive = true } + } + }, + onNavigateToLogin = { + navController.popBackStack() + } + ) + } + composable(Screen.Pantry.route) { + PantryScreen( + onNavigateToAdd = { + navController.navigate(Screen.AddProduct.route) + }, + onNavigateToDetail = { productId -> + navController.navigate(Screen.ProductDetail.createRoute(productId)) + }, + onLogout = { + navController.navigate(Screen.Login.route) { + popUpTo(Screen.Pantry.route) { inclusive = true } + } + } + ) + } + composable(Screen.AddProduct.route) { + AddProductScreen(onProductAdded = { + navController.popBackStack() + }) + } + composable( + route = Screen.ProductDetail.route, + arguments = listOf(navArgument("productId") { type = NavType.IntType }) + ) { backStackEntry -> + val productId = backStackEntry.arguments?.getInt("productId") ?: 0 + ProductDetailScreen( + productId = productId, + onBack = { navController.popBackStack() } + ) + } + } +} 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 new file mode 100644 index 0000000..f5aed07 --- /dev/null +++ b/app/src/main/java/com/example/despensapp/ui/screens/AddProductScreen.kt @@ -0,0 +1,308 @@ +package com.example.despensapp.ui.screens + +import android.Manifest +import android.app.DatePickerDialog +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.* +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.example.despensapp.R +import com.example.despensapp.data.repository.PantryRepository +import com.example.despensapp.util.DateUtils +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.common.InputImage +import kotlinx.coroutines.launch +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddProductScreen(onProductAdded: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val repository = remember { PantryRepository() } + + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + ) + } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { granted -> hasCameraPermission = granted } + ) + + LaunchedEffect(Unit) { + if (!hasCameraPermission) { + launcher.launch(Manifest.permission.CAMERA) + } + } + + var scannedBarcode by remember { mutableStateOf(null) } + var productName by remember { mutableStateOf("") } + var productCategory by remember { mutableStateOf("General") } + 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 isLoading by remember { mutableStateOf(false) } + + var torchEnabled by remember { mutableStateOf(false) } + + val units = listOf("unidades", "kg", "gr", "l", "ml", "paquetes") + var unitsExpanded by remember { mutableStateOf(false) } + + val calendar = Calendar.getInstance() + val datePickerDialog = DatePickerDialog( + context, + { _, year, month, dayOfMonth -> + expirationDate = "$year-${month + 1}-$dayOfMonth" + }, + calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH), + calendar.get(Calendar.DAY_OF_MONTH) + ) + + fun triggerVibration() { + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(100) + } + } + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + if (hasCameraPermission && scannedBarcode == null) { + Box(modifier = Modifier.fillMaxWidth().height(300.dp)) { + BarcodeScannerView( + torchEnabled = torchEnabled, + onBarcodeScanned = { barcode -> + triggerVibration() + scannedBarcode = barcode + scope.launch { + isLoading = true + repository.getProductFromOFF(barcode).onSuccess { offProduct -> + productName = offProduct?.displayName ?: "Desconocido" + productCategory = offProduct?.categories?.split(",")?.firstOrNull() ?: "General" + productImageUrl = offProduct?.imageUrl + isLoading = false + }.onFailure { + isLoading = false + } + } + } + ) + + // Botón de Linterna + IconButton( + onClick = { torchEnabled = !torchEnabled }, + modifier = Modifier.align(Alignment.TopEnd).padding(8.dp), + colors = IconButtonDefaults.iconButtonColors(containerColor = Color.Black.copy(alpha = 0.5f)) + ) { + Icon( + if (torchEnabled) Icons.Default.FlashOn else Icons.Default.FlashOff, + contentDescription = stringResource(if (torchEnabled) R.string.flash_off else R.string.flash_on), + tint = if (torchEnabled) Color.Yellow else Color.White + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (scannedBarcode != null) { + Text(stringResource(R.string.add_product_barcode_detected, scannedBarcode!!), style = MaterialTheme.typography.titleMedium) + + OutlinedTextField( + value = productName, + onValueChange = { productName = it }, + label = { Text(stringResource(R.string.add_product_name_label)) }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Selector de Unidad + ExposedDropdownMenuBox( + expanded = unitsExpanded, + onExpandedChange = { unitsExpanded = !unitsExpanded }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = selectedUnit, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.add_product_unit_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitsExpanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors() + ) + ExposedDropdownMenu( + expanded = unitsExpanded, + onDismissRequest = { unitsExpanded = false } + ) { + units.forEach { unit -> + DropdownMenuItem( + text = { Text(unit) }, + onClick = { + selectedUnit = unit + unitsExpanded = false + } + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Selector de Fecha + OutlinedButton( + onClick = { datePickerDialog.show() }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.CalendarToday, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(if (expirationDate == null) stringResource(R.string.add_product_expiry_button_empty) + else stringResource(R.string.add_product_expiry_button_date, DateUtils.formatToDisplay(expirationDate) ?: "")) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.add_product_quantity_label, quantity), modifier = Modifier.weight(1f)) + IconButton(onClick = { if (quantity > 1) quantity-- }) { + Icon(Icons.Default.Remove, stringResource(R.string.add_product_less)) + } + IconButton(onClick = { quantity++ }) { + Icon(Icons.Default.Add, stringResource(R.string.add_product_more)) + } + } + + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + } else { + Button( + onClick = { + scope.launch { + isLoading = true + val success = repository.addProduct( + barcode = scannedBarcode!!, + nombre = productName, + cantidad = quantity, + minCantidad = minQuantity, + unidad = selectedUnit, + categoria = productCategory, + imageUrl = productImageUrl, + fechaCaducidad = expirationDate + ) + isLoading = false + if (success.isSuccess) onProductAdded() + } + }, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp) + ) { + Text(stringResource(R.string.add_product_save_button)) + } + } + + TextButton(onClick = { scannedBarcode = null }, modifier = Modifier.align(Alignment.CenterHorizontally)) { + Text(stringResource(R.string.add_product_scan_another)) + } + } else if (!hasCameraPermission) { + Text(stringResource(R.string.add_product_camera_permission_required)) + } + } +} + +@Composable +fun BarcodeScannerView(torchEnabled: Boolean, onBarcodeScanned: (String) -> Unit) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + + var camera by remember { mutableStateOf(null) } + + LaunchedEffect(torchEnabled) { + camera?.cameraControl?.enableTorch(torchEnabled) + } + + AndroidView( + factory = { ctx -> + val previewView = PreviewView(ctx) + val executor = ContextCompat.getMainExecutor(ctx) + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + val scanner = BarcodeScanning.getClient() + + @androidx.camera.core.ExperimentalGetImage + imageAnalysis.setAnalyzer(executor) { imageProxy -> + val mediaImage = imageProxy.image + if (mediaImage != null) { + val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + scanner.process(image) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + barcode.rawValue?.let { + onBarcodeScanned(it) + cameraProvider.unbindAll() // Parar cámara al detectar + } + } + } + .addOnCompleteListener { imageProxy.close() } + } + } + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + try { + cameraProvider.unbindAll() + camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis) + camera?.cameraControl?.enableTorch(torchEnabled) + } catch (e: Exception) { + Log.e("Camera", "Use case binding failed", e) + } + }, executor) + previewView + }, + modifier = Modifier.fillMaxSize() + ) +} \ 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 new file mode 100644 index 0000000..a3d9d5a --- /dev/null +++ b/app/src/main/java/com/example/despensapp/ui/screens/LoginScreen.kt @@ -0,0 +1,111 @@ +package com.example.despensapp.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.example.despensapp.R +import com.example.despensapp.data.repository.UserRepository +import com.example.despensapp.ui.theme.DespensappTheme +import kotlinx.coroutines.launch + +@Composable +fun LoginScreen(onLoginSuccess: () -> Unit, onNavigateToRegister: () -> Unit) { + val context = LocalContext.current + val repository = remember { UserRepository() } + val scope = rememberCoroutineScope() + + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.login_welcome), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(32.dp)) + + if (errorMessage != null) { + Text(text = errorMessage!!, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + } + + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text(stringResource(R.string.login_email_label)) }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.login_password_label)) }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) + + Spacer(modifier = Modifier.height(24.dp)) + + if (isLoading) { + CircularProgressIndicator() + } else { + Button( + onClick = { + if (email.isNotEmpty() && password.isNotEmpty()) { + isLoading = true + errorMessage = null + scope.launch { + val result = repository.loginUser(email, password) + isLoading = false + result.onSuccess { onLoginSuccess() } + .onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") } + } + } else { + errorMessage = context.getString(R.string.login_error_empty) + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.login_button)) + } + } + + TextButton(onClick = onNavigateToRegister, enabled = !isLoading) { + Text(stringResource(R.string.login_no_account)) + } + } +} + +@Preview(showBackground = true) +@Composable +fun LoginPreview() { + DespensappTheme { + LoginScreen(onLoginSuccess = {}, onNavigateToRegister = {}) + } +} \ 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 new file mode 100644 index 0000000..603883c --- /dev/null +++ b/app/src/main/java/com/example/despensapp/ui/screens/PantryScreen.kt @@ -0,0 +1,486 @@ +package com.example.despensapp.ui.screens + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.example.despensapp.R +import coil.compose.AsyncImage +import com.example.despensapp.ui.theme.DespensappTheme +import com.example.despensapp.data.model.Product +import com.example.despensapp.data.repository.PantryRepository +import com.example.despensapp.util.CategoryMapper +import com.example.despensapp.util.DateUtils +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.format.DateTimeParseException +import java.time.temporal.ChronoUnit + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PantryScreen( + onNavigateToAdd: () -> Unit, + onNavigateToDetail: (Int) -> Unit, + onLogout: () -> Unit +) { + val context = LocalContext.current + val repository = remember { PantryRepository() } + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + var allProducts by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + + // Filters and Search + var searchQuery by remember { mutableStateOf("") } + var selectedCategory by remember { mutableStateOf(null) } + var showOnlyNearExpiry by remember { mutableStateOf(false) } + var showShoppingList by remember { mutableStateOf(false) } + + // Cargar productos al iniciar + LaunchedEffect(Unit) { + val result = repository.getAllProductsFromDb() + result.onSuccess { + allProducts = it + isLoading = false + }.onFailure { + errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") + isLoading = false + } + } + + val categories = remember(allProducts) { + allProducts.map { it.category }.distinct().sorted() + } + + val filteredProducts = allProducts.filter { product -> + val matchesSearch = product.name.contains(searchQuery, ignoreCase = true) + val matchesCategory = selectedCategory == null || product.category == selectedCategory + val matchesExpiry = !showOnlyNearExpiry || isNearExpiry(product.expirationDate) + val matchesLowStock = !showShoppingList || product.isLowStock + + matchesSearch && matchesCategory && matchesExpiry && matchesLowStock + } + + fun shareShoppingList() { + val lowStockProducts = allProducts.filter { it.isLowStock } + if (lowStockProducts.isEmpty()) { + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.pantry_empty_shopping_list)) } + return + } + + val listText = lowStockProducts.joinToString("\n") { "- ${it.name} ${it.minQuantity} ${it.unit}" } + val shareText = context.getString(R.string.shopping_list_message, listText) + + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, shareText) + } + context.startActivity(Intent.createChooser(intent, context.getString(R.string.share))) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (showShoppingList) stringResource(R.string.shopping_list_title) else stringResource(R.string.pantry_title)) }, + actions = { + if (showShoppingList) { + IconButton(onClick = { shareShoppingList() }) { + Icon(Icons.Default.Share, contentDescription = stringResource(R.string.share)) + } + } + IconButton(onClick = { showShoppingList = !showShoppingList }) { + Icon( + if (showShoppingList) Icons.Default.Inventory else Icons.Default.ShoppingCart, + contentDescription = "Modo Lista Compra", + tint = if (showShoppingList) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + } + IconButton(onClick = onLogout) { + Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.logout)) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = { + FloatingActionButton(onClick = onNavigateToAdd) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add)) + } + } + ) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + // Search Bar + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text(stringResource(R.string.pantry_search_placeholder)) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.pantry_search_clear)) + } + } + IconButton(onClick = onNavigateToAdd) { + Icon(Icons.Default.QrCodeScanner, contentDescription = stringResource(R.string.pantry_search_scan)) + } + } + }, + singleLine = true, + shape = MaterialTheme.shapes.medium + ) + + // Filtros Rápidos + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FilterChip( + selected = showOnlyNearExpiry, + onClick = { showOnlyNearExpiry = !showOnlyNearExpiry }, + label = { Text(stringResource(R.string.pantry_filter_expiry)) }, + leadingIcon = if (showOnlyNearExpiry) { + { Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(18.dp)) } + } else null + ) + + FilterChip( + selected = showShoppingList, + onClick = { showShoppingList = !showShoppingList }, + label = { Text(stringResource(R.string.pantry_filter_low_stock)) }, + leadingIcon = if (showShoppingList) { + { Icon(Icons.Default.PriorityHigh, contentDescription = null, modifier = Modifier.size(18.dp)) } + } else null + ) + } + + // Categorías Scrollable + ScrollableTabRow( + selectedTabIndex = if (selectedCategory == null) 0 else categories.indexOf(selectedCategory) + 1, + edgePadding = 16.dp, + divider = {}, + containerColor = Color.Transparent, + indicator = {} + ) { + Tab( + selected = selectedCategory == null, + onClick = { selectedCategory = null }, + text = { Text(stringResource(R.string.pantry_filter_all)) } + ) + categories.forEach { category -> + Tab( + selected = selectedCategory == category, + onClick = { selectedCategory = category }, + text = { Text(category) } + ) + } + } + + Box(modifier = Modifier.weight(1f)) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else if (errorMessage != null) { + Text( + text = errorMessage!!, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp).align(Alignment.Center) + ) + } else if (filteredProducts.isEmpty()) { + val emptyText = when { + showShoppingList -> stringResource(R.string.pantry_empty_shopping_list) + showOnlyNearExpiry -> stringResource(R.string.pantry_empty_expiry) + searchQuery.isNotEmpty() -> stringResource(R.string.pantry_empty_search, searchQuery) + else -> stringResource(R.string.pantry_empty_general) + } + Text( + text = emptyText, + modifier = Modifier.align(Alignment.Center), + color = Color.Gray + ) + } else { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(filteredProducts, key = { it.id }) { product -> + SwipeToDeleteWrapper( + onDismiss = { + scope.launch { + val result = repository.deleteProduct(product.id) + if (result.isSuccess) { + allProducts = allProducts.filter { it.id != product.id } + snackbarHostState.showSnackbar(context.getString(R.string.pantry_product_deleted, product.name)) + } else { + snackbarHostState.showSnackbar(context.getString(R.string.pantry_delete_error)) + } + } + } + ) { + ProductItem( + product = product, + onItemClick = { onNavigateToDetail(product.id) }, + onQuantityChange = { newQty -> + scope.launch { + val success = repository.updateProductQuantity(product.id, newQty) + if (success.isSuccess) { + allProducts = allProducts.map { + if (it.id == product.id) it.copy(quantity = newQty) else it + } + } + } + } + ) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SwipeToDeleteWrapper(onDismiss: () -> Unit, content: @Composable () -> Unit) { + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { + if (it == SwipeToDismissBoxValue.EndToStart) { + onDismiss() + true + } else { + false + } + } + ) + + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + val color = when (dismissState.targetValue) { + SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.errorContainer + else -> Color.Transparent + } + Box( + modifier = Modifier + .fillMaxSize() + .background(color, MaterialTheme.shapes.medium) + .padding(horizontal = 20.dp), + contentAlignment = Alignment.CenterEnd + ) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.delete), + tint = if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart) + MaterialTheme.colorScheme.error else Color.LightGray + ) + } + }, + enableDismissFromStartToEnd = false, + content = { content() } + ) +} + +fun isNearExpiry(dateStr: String?): Boolean { + if (dateStr == null) return false + return try { + val expiryDate = LocalDate.parse(dateStr) + val today = LocalDate.now() + val daysUntil = ChronoUnit.DAYS.between(today, expiryDate) + daysUntil <= 7 // Incluye caducados (negativos) y próximos (0-7 días) + } catch (e: DateTimeParseException) { + false + } +} + +@Composable +fun ProductItem(product: Product, onItemClick: () -> Unit, onQuantityChange: (Int) -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onItemClick), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.size(64.dp)) { + AsyncImage( + model = product.imageUrl, + contentDescription = product.name, + modifier = Modifier + .fillMaxSize() + .background(Color.White, MaterialTheme.shapes.small) + .padding(4.dp), + error = null + ) + + // Icono de Categoría Superpuesto + Surface( + modifier = Modifier.align(Alignment.BottomEnd).offset(x = 4.dp, y = 4.dp), + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.primaryContainer, + tonalElevation = 4.dp + ) { + Icon( + CategoryMapper.getIconForCategory(product.category), + contentDescription = null, + modifier = Modifier.size(16.dp).padding(2.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = product.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = product.category, + fontSize = 14.sp, + color = Color.Gray + ) + + // Información de Fechas + Column { + if (product.expirationDate != null) { + val expiryDate = try { LocalDate.parse(product.expirationDate) } catch (e: Exception) { null } + val today = LocalDate.now() + val isExpired = expiryDate?.isBefore(today) == true + val nearExpiry = isNearExpiry(product.expirationDate) + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.CalendarToday, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = if (nearExpiry) MaterialTheme.colorScheme.error else Color.Gray + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = if (isExpired) stringResource(R.string.pantry_expiry_expired, DateUtils.formatToDisplay(product.expirationDate) ?: "") + else stringResource(R.string.pantry_expiry_date, DateUtils.formatToDisplay(product.expirationDate) ?: ""), + fontSize = 12.sp, + color = if (nearExpiry) MaterialTheme.colorScheme.error else Color.Gray, + fontWeight = if (nearExpiry) FontWeight.Bold else FontWeight.Normal + ) + } + } + + if (product.createdAt != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.History, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = Color.Gray + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.pantry_added_date, DateUtils.formatToDisplay(product.createdAt) ?: ""), + fontSize = 11.sp, + color = Color.Gray + ) + } + } + } + } + + if (product.isLowStock) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = stringResource(R.string.pantry_low_stock_warning), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(24.dp) + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Surface( + shape = MaterialTheme.shapes.medium, + color = if (product.isLowStock) MaterialTheme.colorScheme.errorContainer + else MaterialTheme.colorScheme.secondaryContainer, + contentColor = if (product.isLowStock) MaterialTheme.colorScheme.onErrorContainer + else MaterialTheme.colorScheme.onSecondaryContainer + ) { + Text( + text = stringResource(R.string.pantry_units_label, product.quantity, product.unit), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + + Row { + FilledTonalIconButton( + onClick = { if (product.quantity > 0) onQuantityChange(product.quantity - 1) }, + modifier = Modifier.size(32.dp) + ) { + Icon(Icons.Default.Remove, contentDescription = stringResource(R.string.add_product_less), modifier = Modifier.size(18.dp)) + } + Spacer(modifier = Modifier.width(8.dp)) + FilledIconButton( + onClick = { onQuantityChange(product.quantity + 1) }, + modifier = Modifier.size(32.dp) + ) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_product_more), modifier = Modifier.size(18.dp)) + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun PantryScreenPreview() { + DespensappTheme { + PantryScreen(onNavigateToAdd = {}, onNavigateToDetail = {}, onLogout = {}) + } +} \ 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 new file mode 100644 index 0000000..e12b874 --- /dev/null +++ b/app/src/main/java/com/example/despensapp/ui/screens/ProductDetailScreen.kt @@ -0,0 +1,329 @@ +package com.example.despensapp.ui.screens + +import android.app.DatePickerDialog +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.example.despensapp.R +import coil.compose.AsyncImage +import com.example.despensapp.data.model.Product +import com.example.despensapp.data.repository.PantryRepository +import com.example.despensapp.util.CategoryMapper +import com.example.despensapp.util.DateUtils +import kotlinx.coroutines.launch +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProductDetailScreen(productId: Int, onBack: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val repository = remember { PantryRepository() } + var product by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + + // Edit Mode State + var isEditing by remember { mutableStateOf(false) } + var editName by remember { mutableStateOf("") } + var editQuantity by remember { mutableStateOf(0) } + var editMinQuantity by remember { mutableStateOf(0) } + var editUnit by remember { mutableStateOf("") } + var editCategory by remember { mutableStateOf("") } + var editExpirationDate by remember { mutableStateOf(null) } + + val units = listOf("unidades", "kg", "gr", "l", "ml", "paquetes") + var unitsExpanded by remember { mutableStateOf(false) } + + val calendar = Calendar.getInstance() + val datePickerDialog = DatePickerDialog( + context, + { _, year, month, dayOfMonth -> + editExpirationDate = "$year-${month + 1}-$dayOfMonth" + }, + calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH), + calendar.get(Calendar.DAY_OF_MONTH) + ) + + fun loadProduct() { + isLoading = true + scope.launch { + repository.getProductById(productId).onSuccess { + product = it + it?.let { p -> + editName = p.name + editQuantity = p.quantity + editMinQuantity = p.minQuantity + editUnit = p.unit + editCategory = p.category + editExpirationDate = p.expirationDate + } + isLoading = false + }.onFailure { + errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") + isLoading = false + } + } + } + + LaunchedEffect(productId) { + loadProduct() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (isEditing) stringResource(R.string.detail_edit_title) else stringResource(R.string.detail_title)) }, + navigationIcon = { + IconButton(onClick = if (isEditing) { { isEditing = false } } else onBack) { + Icon(if (isEditing) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(if (isEditing) R.string.close else R.string.back)) + } + }, + actions = { + if (!isEditing) { + IconButton(onClick = { isEditing = true }) { + Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit)) + } + } else { + IconButton(onClick = { + scope.launch { + isLoading = true + val result = repository.updateProductFull( + productId = productId, + nombre = editName, + cantidad = editQuantity, + minCantidad = editMinQuantity, + unidad = editUnit, + categoria = editCategory, + fechaCaducidad = editExpirationDate + ) + if (result.isSuccess) { + isEditing = false + loadProduct() + } else { + errorMessage = context.getString(R.string.error_db_connection) + isLoading = false + } + } + }) { + Icon(Icons.Default.Save, contentDescription = stringResource(R.string.save)) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else if (errorMessage != null) { + Text(text = errorMessage!!, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) + } else if (product != null) { + val p = product!! + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + if (!isEditing) { + // Modo Vista + AsyncImage( + model = p.imageUrl, + contentDescription = p.name, + modifier = Modifier + .fillMaxWidth() + .height(250.dp) + .background(Color.White), + contentScale = ContentScale.Fit + ) + + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + CategoryMapper.getIconForCategory(p.category), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text(text = p.name, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text(text = p.category, style = MaterialTheme.typography.titleMedium, color = Color.Gray) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + DetailRow(icon = Icons.Default.Inventory, label = stringResource(R.string.detail_stock_label), value = stringResource(R.string.pantry_units_label, p.quantity, p.unit)) + DetailRow(icon = Icons.Default.NotificationsActive, label = stringResource(R.string.detail_low_stock_alert_label), value = stringResource(R.string.detail_low_stock_alert_value, p.minQuantity, p.unit)) + + if (p.expirationDate != null) { + DetailRow(icon = Icons.Default.Event, label = stringResource(R.string.detail_expiry_label), value = DateUtils.formatToDisplay(p.expirationDate)!!) + } + + if (p.createdAt != null) { + DetailRow(icon = Icons.Default.AccessTime, label = stringResource(R.string.detail_added_label), value = DateUtils.formatToDisplay(p.createdAt)!!) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Info, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.detail_mariadb_info_title), fontWeight = FontWeight.Bold) + } + Spacer(modifier = Modifier.height(8.dp)) + Text(stringResource(R.string.detail_id_label, p.id), fontSize = 12.sp) + Text(stringResource(R.string.detail_server_label, "mariadb.peta9.com"), fontSize = 12.sp) + } + } + } + } else { + // Modo Edición + Column(modifier = Modifier.padding(16.dp)) { + OutlinedTextField( + value = editName, + onValueChange = { editName = it }, + label = { Text(stringResource(R.string.add_product_name_label)) }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = editCategory, + onValueChange = { editCategory = it }, + label = { Text(stringResource(R.string.detail_category_label)) }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.add_product_quantity_label, editQuantity), modifier = Modifier.weight(1f)) + IconButton(onClick = { if (editQuantity > 0) editQuantity-- }) { + Icon(Icons.Default.Remove, stringResource(R.string.add_product_less)) + } + IconButton(onClick = { editQuantity++ }) { + Icon(Icons.Default.Add, stringResource(R.string.add_product_more)) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.detail_min_quantity_label) + ": $editMinQuantity", modifier = Modifier.weight(1f)) + IconButton(onClick = { if (editMinQuantity > 0) editMinQuantity-- }) { + Icon(Icons.Default.Remove, null) + } + IconButton(onClick = { editMinQuantity++ }) { + Icon(Icons.Default.Add, null) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + ExposedDropdownMenuBox( + expanded = unitsExpanded, + onExpandedChange = { unitsExpanded = !unitsExpanded }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = editUnit, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.add_product_unit_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitsExpanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors() + ) + ExposedDropdownMenu( + expanded = unitsExpanded, + onDismissRequest = { unitsExpanded = false } + ) { + units.forEach { unit -> + DropdownMenuItem( + text = { Text(unit) }, + onClick = { + editUnit = unit + unitsExpanded = false + } + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedButton( + onClick = { datePickerDialog.show() }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.CalendarToday, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(if (editExpirationDate == null) stringResource(R.string.add_product_expiry_button_empty) + else stringResource(R.string.add_product_expiry_button_date, DateUtils.formatToDisplay(editExpirationDate) ?: "")) + } + + if (editExpirationDate != null) { + TextButton(onClick = { editExpirationDate = null }, modifier = Modifier.align(Alignment.CenterHorizontally)) { + Text(stringResource(R.string.cancel) + " " + stringResource(R.string.detail_expiry_label)) + } + } + } + } + } + } + } + } +} + +@Composable +fun DetailRow(icon: ImageVector, label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.size(40.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon(icon, contentDescription = null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer) + } + } + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text(text = label, style = MaterialTheme.typography.labelMedium, color = Color.Gray) + Text(text = value, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + } + } +} \ 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 new file mode 100644 index 0000000..af857d3 --- /dev/null +++ b/app/src/main/java/com/example/despensapp/ui/screens/RegisterScreen.kt @@ -0,0 +1,124 @@ +package com.example.despensapp.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.example.despensapp.R +import com.example.despensapp.data.repository.UserRepository +import kotlinx.coroutines.launch + +@Composable +fun RegisterScreen(onRegisterSuccess: () -> Unit, onNavigateToLogin: () -> Unit) { + val context = LocalContext.current + val repository = remember { UserRepository() } + val scope = rememberCoroutineScope() + + var name by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var familyCode by remember { mutableStateOf("") } + + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.register_title), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(32.dp)) + + if (errorMessage != null) { + Text(text = errorMessage!!, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + } + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.register_name_label)) }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text(stringResource(R.string.login_email_label)) }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = familyCode, + onValueChange = { familyCode = it }, + label = { Text(stringResource(R.string.register_family_code_label)) }, + placeholder = { Text(stringResource(R.string.register_family_code_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.login_password_label)) }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) + + Spacer(modifier = Modifier.height(24.dp)) + + if (isLoading) { + CircularProgressIndicator() + } else { + Button( + onClick = { + if (name.isNotEmpty() && email.isNotEmpty() && password.isNotEmpty()) { + isLoading = true + errorMessage = null + scope.launch { + val result = repository.registerUser(name, email, password) + isLoading = false + result.onSuccess { onRegisterSuccess() } + .onFailure { errorMessage = context.getString(R.string.login_error_generic, it.message ?: "") } + } + } else { + errorMessage = context.getString(R.string.register_error_empty) + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.register_button)) + } + } + + TextButton(onClick = onNavigateToLogin, enabled = !isLoading) { + Text(stringResource(R.string.register_already_have_account)) + } + } +} diff --git a/app/src/main/java/com/example/despensapp/util/CategoryMapper.kt b/app/src/main/java/com/example/despensapp/util/CategoryMapper.kt new file mode 100644 index 0000000..2b6f38d --- /dev/null +++ b/app/src/main/java/com/example/despensapp/util/CategoryMapper.kt @@ -0,0 +1,24 @@ +package com.example.despensapp.util + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.ui.graphics.vector.ImageVector + +object CategoryMapper { + fun getIconForCategory(category: String): ImageVector { + return when (category.lowercase()) { + "lácteos", "lacteos", "queso", "leche" -> Icons.Default.Icecream + "carnicería", "carniceria", "carne", "embutidos" -> Icons.Default.LunchDining + "fresco", "fruta", "verdura" -> Icons.Default.Eco + "limpieza", "detergente", "baño" -> Icons.Default.CleaningServices + "despensa", "pasta", "arroz", "legumbres" -> Icons.Default.Inventory + "bebidas", "agua", "refrescos", "alcohol" -> Icons.Default.LocalDrink + "congelados" -> Icons.Default.AcUnit + "panadería", "panaderia", "pan", "bollería" -> Icons.Default.BakeryDining + "snacks", "patatas", "dulces" -> Icons.Default.Cookie + "higiene", "personal", "champú" -> Icons.Default.Face + "mascotas", "perro", "gato" -> Icons.Default.Pets + else -> Icons.Default.Category + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/despensapp/util/DateUtils.kt b/app/src/main/java/com/example/despensapp/util/DateUtils.kt new file mode 100644 index 0000000..4722a91 --- /dev/null +++ b/app/src/main/java/com/example/despensapp/util/DateUtils.kt @@ -0,0 +1,30 @@ +package com.example.despensapp.util + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +object DateUtils { + private val dbDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + private val dbDateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + private val displayFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy") + + fun formatToDisplay(dateStr: String?): String? { + if (dateStr == null) return null + return try { + if (dateStr.length > 10) { + // Probablemente sea YYYY-MM-DD HH:MM:SS + val dateTime = LocalDateTime.parse(dateStr, dbDateTimeFormatter) + dateTime.format(displayFormatter) + } else { + // Probablemente sea YYYY-MM-DD + val date = LocalDate.parse(dateStr, dbDateFormatter) + date.format(displayFormatter) + } + } catch (e: DateTimeParseException) { + // Si falla el parseo, devolvemos el original para no perder datos + dateStr + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/despensapp/worker/InventoryCheckWorker.kt b/app/src/main/java/com/example/despensapp/worker/InventoryCheckWorker.kt new file mode 100644 index 0000000..332b1d1 --- /dev/null +++ b/app/src/main/java/com/example/despensapp/worker/InventoryCheckWorker.kt @@ -0,0 +1,56 @@ +package com.example.despensapp.worker + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.example.despensapp.R +import com.example.despensapp.data.repository.PantryRepository +import java.text.SimpleDateFormat +import java.util.* + +class InventoryCheckWorker( + context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result { + val repository = PantryRepository() + val result = repository.getAllProductsFromDb() + + result.onSuccess { products -> + val lowStockProducts = products.filter { it.isLowStock } + + if (lowStockProducts.isNotEmpty()) { + showNotification( + applicationContext.getString(R.string.notification_title), + applicationContext.getString(R.string.notification_message, lowStockProducts.size) + ) + } + } + + return Result.success() + } + + private fun showNotification(title: String, message: String) { + val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelId = "inventory_alerts" + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(channelId, applicationContext.getString(R.string.notification_channel_name), NotificationManager.IMPORTANCE_DEFAULT) + notificationManager.createNotificationChannel(channel) + } + + val notification = NotificationCompat.Builder(applicationContext, channelId) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + + notificationManager.notify(1, notification) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9..d15f1bf 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -4,167 +4,8 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground_custom.xml b/app/src/main/res/drawable/ic_launcher_foreground_custom.xml new file mode 100644 index 0000000..730714a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground_custom.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 74% rename from app/src/main/res/mipmap-anydpi/ic_launcher.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 6f3b755..369004c 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 74% rename from app/src/main/res/mipmap-anydpi/ic_launcher_round.xml rename to app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 6f3b755..369004c 100644 --- a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,6 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index c209e78..e511204 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..5727994 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index b2dfe3d..c4563d2 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 4f0f1d6..c5b70fb 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..99e86fb Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 62b611d..ee9fcc0 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 948a307..5007b74 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..992d38e Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 1b9a695..55cd2e2 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 28d4b77..4f987df 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..320ab74 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 9287f50..163bc50 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index aa7d642..c5df75c 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..853a111 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 9126ae3..8ac3f1d 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fdd5850..84514c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,92 @@ Despensapp + + + Error al conectar con la base de datos + Cargando... + Volver + Cerrar + Añadir + Guardar + Borrar + Cancelar + Editar + Compartir + Cerrar Sesión + Encender Flash + Apagar Flash + Error al cargar productos: %1$s + Hay que comprar lo siguiente:\n%1$s + + + Bienvenido a Despensapp + Correo Electrónico + Contraseña + Iniciar Sesión + ¿No tienes cuenta? Regístrate aquí + Introduce tus credenciales + Error: %1$s + + + Crear Cuenta Familiar + Nombre Completo + Código de Familia (Opcional) + Para unirse a una despensa existente + Registrarse + ¿Ya tienes cuenta? Inicia sesión + Por favor, rellena todos los campos + + + Mi Despensa + Lista de la Compra + Buscar productos... + Limpiar + Escanear + Caducidad + Stock Bajo + Todas + ¡Genial! No falta nada en la despensa. + No hay productos próximos a caducar. + No se encontraron productos para \"%1$s\". + La despensa está vacía. + Producto eliminado: %1$s + Error al eliminar de la base de datos + Stock Bajo + %1$s %2$s + CADUCADO: %1$s + Caduca: %1$s + Añadido: %1$s + + + Añadir Producto + Producto detectado: %1$s + Nombre del Producto + Unidad de Medida + Elegir Fecha de Caducidad + Caduca: %1$s + Cantidad: %1$d + Menos + Más + Guardar en Despensa + Escanear otro + Se necesita permiso de cámara para escanear + + + Detalles del Producto + Editar Producto + Stock actual + Alerta stock bajo + Menos de %1$s %2$s + Fecha de caducidad + Fecha de alta + Categoría + Cantidad Mínima + Información de MariaDB + ID interno: %1$d + Servidor: mariadb.peta9.com + + + ¡Revisa tu despensa! + Tienes %1$d productos con stock bajo. + Alertas de Inventario \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eaf63d0..b148454 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,15 @@ lifecycleRuntimeKtx = "2.11.0" activityCompose = "1.13.0" kotlin = "2.2.10" composeBom = "2026.02.01" +navigationCompose = "2.8.5" +retrofit = "2.11.0" +okhttp = "4.12.0" +mariadbJavaClient = "3.5.10" +camerax = "1.4.1" +barcodeScanning = "17.3.0" +coil = "2.7.0" +workManager = "2.10.0" +room = "2.6.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -24,6 +33,23 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +mariadb-java-client = { group = "org.mariadb.jdbc", name = "mariadb-java-client", version.ref = "mariadbJavaClient" } +androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } +barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "barcodeScanning" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }