From f253274e52a6871ab92472d83008859bb200da81 Mon Sep 17 00:00:00 2001 From: Nino Date: Tue, 4 Aug 2026 14:44:06 -0600 Subject: [PATCH] =?UTF-8?q?v1.0.6:=20pesta=C3=B1a=20Reportes=20con=20gasto?= =?UTF-8?q?=20diario=20del=20mes=20(navegaci=C3=B3n=20por=20mes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 4 +- app/src/main/java/com/conta/app/ui/AppRoot.kt | 5 + .../com/conta/app/ui/components/Charts.kt | 69 ++++++++ .../app/ui/screens/reports/ReportsScreen.kt | 161 ++++++++++++++++++ .../java/com/conta/app/ui/util/Formatters.kt | 31 ++++ 5 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/conta/app/ui/screens/reports/ReportsScreen.kt diff --git a/app/build.gradle b/app/build.gradle index 207cbcf..86133b8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,8 +13,8 @@ android { applicationId "com.conta.app" minSdk 24 targetSdk 34 - versionCode 6 - versionName "1.0.5" + versionCode 7 + versionName "1.0.6" } buildTypes { diff --git a/app/src/main/java/com/conta/app/ui/AppRoot.kt b/app/src/main/java/com/conta/app/ui/AppRoot.kt index f2710b0..89b159f 100644 --- a/app/src/main/java/com/conta/app/ui/AppRoot.kt +++ b/app/src/main/java/com/conta/app/ui/AppRoot.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountBalance +import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SwapHoriz @@ -42,6 +43,7 @@ import com.conta.app.ui.screens.categories.CategoriesScreen import com.conta.app.ui.screens.dashboard.DashboardScreen import com.conta.app.ui.screens.recurring.RecurringFormScreen import com.conta.app.ui.screens.recurring.RecurringScreen +import com.conta.app.ui.screens.reports.ReportsScreen import com.conta.app.ui.screens.rules.RuleFormScreen import com.conta.app.ui.screens.rules.RulesScreen import com.conta.app.ui.screens.settings.BackupScreen @@ -58,6 +60,7 @@ object Routes { const val ACCOUNT_FORM = "account_form" const val TRANSACTIONS = "transactions" const val TRANSACTION_FORM = "transaction_form" + const val REPORTS = "reports" const val CATEGORIES = "categories" const val CATEGORY_FORM = "category_form" const val RULES = "rules" @@ -93,6 +96,7 @@ private val bottomItems = listOf( BottomNavItem(Routes.DASHBOARD, "Inicio", Icons.Filled.Home), BottomNavItem(Routes.ACCOUNTS, "Cuentas", Icons.Filled.AccountBalance), BottomNavItem(Routes.TRANSACTIONS, "Movimientos", Icons.Filled.SwapHoriz), + BottomNavItem(Routes.REPORTS, "Reportes", Icons.Filled.BarChart), BottomNavItem(Routes.SETTINGS, "Ajustes", Icons.Filled.Settings), ) @@ -147,6 +151,7 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) { composable(Routes.DASHBOARD) { DashboardScreen(viewModel, navController) } composable(Routes.ACCOUNTS) { AccountsScreen(viewModel, navController) } composable(Routes.TRANSACTIONS) { TransactionsScreen(viewModel, navController) } + composable(Routes.REPORTS) { ReportsScreen(viewModel, navController) } composable(Routes.SETTINGS) { SettingsScreen(viewModel, navController) } composable(Routes.CATEGORIES) { CategoriesScreen(viewModel, navController) } composable(Routes.RULES) { RulesScreen(viewModel, navController) } diff --git a/app/src/main/java/com/conta/app/ui/components/Charts.kt b/app/src/main/java/com/conta/app/ui/components/Charts.kt index 3517a30..29bbe89 100644 --- a/app/src/main/java/com/conta/app/ui/components/Charts.kt +++ b/app/src/main/java/com/conta/app/ui/components/Charts.kt @@ -28,6 +28,7 @@ import com.conta.app.ui.util.formatMonth import com.conta.app.ui.util.formatMoneyCompact import com.conta.app.ui.util.resolveCategoryColor import kotlin.math.max +import kotlin.math.roundToInt @Composable fun MonthlyBarChart(stats: List, modifier: Modifier = Modifier) { @@ -193,3 +194,71 @@ fun CategoryBars(stats: List, modifier: Modifier = Modifier) { } } } + +/** Barras de gasto por día del mes (espejo del "Gasto Diario" del frontend). */ +@Composable +fun DailyExpenseChart(values: List, modifier: Modifier = Modifier) { + val barColor = MaterialTheme.colorScheme.error + val textColor = MaterialTheme.colorScheme.onSurfaceVariant + val chartHeight = 220.dp + + Column(modifier) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(chartHeight), + ) { + val gridColor = textColor.copy(alpha = 0.15f) + val maxValue = values.maxOrNull() ?: 0.0 + val scale = if (maxValue <= 0.0) 1.0 else maxValue + + // Líneas de referencia (0, 25, 50, 75, 100 %) + for (frac in listOf(0f, 0.25f, 0.5f, 0.75f, 1f)) { + val y = size.height * (1f - frac) + drawLine( + color = gridColor, + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = 1f, + ) + } + + if (values.isEmpty()) return@Canvas + + val slotWidth = size.width / values.size + val barWidth = (slotWidth * 0.55f).coerceAtLeast(2f) + val gap = slotWidth * 0.15f + + values.forEachIndexed { i, total -> + if (total <= 0.0) return@forEachIndexed + val barH = (total / scale).toFloat() * size.height + val top = size.height - barH + drawRoundRect( + color = barColor, + topLeft = Offset(i * slotWidth + gap, top), + size = Size(barWidth, barH), + cornerRadius = CornerRadius(3f, 3f), + ) + } + } + // Etiquetas de días (cada 5 para no saturar) + Row(modifier = Modifier.fillMaxWidth()) { + val step = max(1, (values.size.toDouble() / 7.0).roundToInt()) + values.forEachIndexed { i, _ -> + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center, + ) { + if (i % step == 0 || i == values.size - 1) { + Text( + text = "${i + 1}", + style = MaterialTheme.typography.labelSmall, + color = textColor, + maxLines = 1, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/reports/ReportsScreen.kt b/app/src/main/java/com/conta/app/ui/screens/reports/ReportsScreen.kt new file mode 100644 index 0000000..0bf061f --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/reports/ReportsScreen.kt @@ -0,0 +1,161 @@ +package com.conta.app.ui.screens.reports + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.conta.app.ui.AppViewModel +import com.conta.app.ui.components.DailyExpenseChart +import com.conta.app.ui.components.EmptyState +import com.conta.app.ui.components.LoadingScreen +import com.conta.app.ui.util.currentMonthIso +import com.conta.app.ui.util.daysInMonthIso +import com.conta.app.ui.util.formatMoney +import com.conta.app.ui.util.formatMonthFull +import com.conta.app.ui.util.shiftMonthIso + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportsScreen(viewModel: AppViewModel, navController: NavController) { + val transactions by viewModel.transactions.collectAsStateWithLifecycle() + val summary by viewModel.summary.collectAsStateWithLifecycle() + val loading by viewModel.loading.collectAsStateWithLifecycle() + + var month by remember { mutableStateOf(currentMonthIso()) } + val isCurrentMonth = month == currentMonthIso() + + val days = daysInMonthIso(month) + val values = remember(transactions, month) { + val sums = DoubleArray(days) + for (tx in transactions) { + if (tx.transactionType != "expense" || !tx.date.startsWith(month)) continue + val day = tx.date.substringAfterLast('-', "").toIntOrNull() ?: continue + if (day in 1..days) sums[day - 1] += tx.amount + } + sums.toList() + } + val totalMonth = values.sum() + + Scaffold( + contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp), + ) { padding -> + when { + summary == null && loading -> LoadingScreen(Modifier.padding(padding)) + summary == null -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + EmptyState("No conectado. Configura la URL del servidor en Ajustes.") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + androidx.compose.material3.Button(onClick = { viewModel.refreshAll() }) { + Text("Reintentar") + } + androidx.compose.material3.OutlinedButton(onClick = { navController.navigate(com.conta.app.ui.Routes.SETTINGS) }) { + Text("Ir a Ajustes") + } + } + } + } + else -> Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Text( + "Reportes", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Gasto Diario del Mes", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { month = shiftMonthIso(month, -1) }) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Mes anterior") + } + Text( + text = formatMonthFull(month), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 4.dp), + maxLines = 1, + ) + IconButton( + onClick = { month = shiftMonthIso(month, 1) }, + enabled = !isCurrentMonth, + ) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Mes siguiente") + } + } + } + androidx.compose.foundation.layout.Spacer(Modifier.size(8.dp)) + Text( + text = "Total del mes: ${formatMoney(totalMonth)}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (transactions.isEmpty() && !loading) { + EmptyState("Sin transacciones") + } else if (totalMonth == 0.0 && !loading) { + EmptyState("Sin gastos en ${formatMonthFull(month)}") + } else { + androidx.compose.foundation.layout.Spacer(Modifier.size(16.dp)) + DailyExpenseChart(values, modifier = Modifier.fillMaxWidth()) + } + } + } + androidx.compose.foundation.layout.Spacer(Modifier.size(24.dp)) + } + } + } +} diff --git a/app/src/main/java/com/conta/app/ui/util/Formatters.kt b/app/src/main/java/com/conta/app/ui/util/Formatters.kt index 4b86140..d087f8e 100644 --- a/app/src/main/java/com/conta/app/ui/util/Formatters.kt +++ b/app/src/main/java/com/conta/app/ui/util/Formatters.kt @@ -35,6 +35,37 @@ fun formatMonth(monthStr: String): String { return "$label $year" } +/** "2024-01" -> "Enero 2024" */ +fun formatMonthFull(monthStr: String): String { + val parts = monthStr.split("-") + if (parts.size != 2) return monthStr + val month = parts[1].toIntOrNull() ?: return monthStr + val year = parts[0] + val label = LocalDate.of(year.toIntOrNull() ?: 1970, month, 1) + .month.getDisplayName(TextStyle.FULL, Locale.getDefault()) + return "$label $year" +} + +/** "2024-01" -> año y mes en forma "2024-01" (igual pero validado). */ +fun currentMonthIso(): String = LocalDate.now().toString().substring(0, 7) + +/** Desplaza un mes "yyyy-MM" por delta y devuelve "yyyy-MM". */ +fun shiftMonthIso(month: String, delta: Int): String { + val parts = month.split("-") + val year = parts.getOrNull(0)?.toIntOrNull() ?: LocalDate.now().year + val m = parts.getOrNull(1)?.toIntOrNull() ?: (LocalDate.now().monthValue) + val date = LocalDate.of(year, m, 1).plusMonths(delta.toLong()) + return "${date.year}-${date.monthValue.toString().padStart(2, '0')}" +} + +/** Número de días de un mes "yyyy-MM". */ +fun daysInMonthIso(month: String): Int { + val parts = month.split("-") + val year = parts.getOrNull(0)?.toIntOrNull() ?: LocalDate.now().year + val m = parts.getOrNull(1)?.toIntOrNull() ?: 1 + return LocalDate.of(year, m, 1).lengthOfMonth() +} + /** Formatea la cantidad con el prefijo de tipo de transacción: +, -, o sin signo. */ fun signedAmount(amount: Double, type: String): String = when (type) { "income" -> "+${formatMoney(amount)}"