1 Commits
5 changed files with 268 additions and 2 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.conta.app" applicationId "com.conta.app"
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
versionCode 6 versionCode 7
versionName "1.0.5" versionName "1.0.6"
} }
buildTypes { buildTypes {
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBalance 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.Home
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapHoriz 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.dashboard.DashboardScreen
import com.conta.app.ui.screens.recurring.RecurringFormScreen import com.conta.app.ui.screens.recurring.RecurringFormScreen
import com.conta.app.ui.screens.recurring.RecurringScreen 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.RuleFormScreen
import com.conta.app.ui.screens.rules.RulesScreen import com.conta.app.ui.screens.rules.RulesScreen
import com.conta.app.ui.screens.settings.BackupScreen import com.conta.app.ui.screens.settings.BackupScreen
@@ -58,6 +60,7 @@ object Routes {
const val ACCOUNT_FORM = "account_form" const val ACCOUNT_FORM = "account_form"
const val TRANSACTIONS = "transactions" const val TRANSACTIONS = "transactions"
const val TRANSACTION_FORM = "transaction_form" const val TRANSACTION_FORM = "transaction_form"
const val REPORTS = "reports"
const val CATEGORIES = "categories" const val CATEGORIES = "categories"
const val CATEGORY_FORM = "category_form" const val CATEGORY_FORM = "category_form"
const val RULES = "rules" const val RULES = "rules"
@@ -93,6 +96,7 @@ private val bottomItems = listOf(
BottomNavItem(Routes.DASHBOARD, "Inicio", Icons.Filled.Home), BottomNavItem(Routes.DASHBOARD, "Inicio", Icons.Filled.Home),
BottomNavItem(Routes.ACCOUNTS, "Cuentas", Icons.Filled.AccountBalance), BottomNavItem(Routes.ACCOUNTS, "Cuentas", Icons.Filled.AccountBalance),
BottomNavItem(Routes.TRANSACTIONS, "Movimientos", Icons.Filled.SwapHoriz), BottomNavItem(Routes.TRANSACTIONS, "Movimientos", Icons.Filled.SwapHoriz),
BottomNavItem(Routes.REPORTS, "Reportes", Icons.Filled.BarChart),
BottomNavItem(Routes.SETTINGS, "Ajustes", Icons.Filled.Settings), BottomNavItem(Routes.SETTINGS, "Ajustes", Icons.Filled.Settings),
) )
@@ -147,6 +151,7 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) {
composable(Routes.DASHBOARD) { DashboardScreen(viewModel, navController) } composable(Routes.DASHBOARD) { DashboardScreen(viewModel, navController) }
composable(Routes.ACCOUNTS) { AccountsScreen(viewModel, navController) } composable(Routes.ACCOUNTS) { AccountsScreen(viewModel, navController) }
composable(Routes.TRANSACTIONS) { TransactionsScreen(viewModel, navController) } composable(Routes.TRANSACTIONS) { TransactionsScreen(viewModel, navController) }
composable(Routes.REPORTS) { ReportsScreen(viewModel, navController) }
composable(Routes.SETTINGS) { SettingsScreen(viewModel, navController) } composable(Routes.SETTINGS) { SettingsScreen(viewModel, navController) }
composable(Routes.CATEGORIES) { CategoriesScreen(viewModel, navController) } composable(Routes.CATEGORIES) { CategoriesScreen(viewModel, navController) }
composable(Routes.RULES) { RulesScreen(viewModel, navController) } composable(Routes.RULES) { RulesScreen(viewModel, navController) }
@@ -28,6 +28,7 @@ import com.conta.app.ui.util.formatMonth
import com.conta.app.ui.util.formatMoneyCompact import com.conta.app.ui.util.formatMoneyCompact
import com.conta.app.ui.util.resolveCategoryColor import com.conta.app.ui.util.resolveCategoryColor
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt
@Composable @Composable
fun MonthlyBarChart(stats: List<MonthlyStat>, modifier: Modifier = Modifier) { fun MonthlyBarChart(stats: List<MonthlyStat>, modifier: Modifier = Modifier) {
@@ -193,3 +194,71 @@ fun CategoryBars(stats: List<CategoryStat>, modifier: Modifier = Modifier) {
} }
} }
} }
/** Barras de gasto por día del mes (espejo del "Gasto Diario" del frontend). */
@Composable
fun DailyExpenseChart(values: List<Double>, 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,
)
}
}
}
}
}
}
@@ -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))
}
}
}
}
@@ -35,6 +35,37 @@ fun formatMonth(monthStr: String): String {
return "$label $year" 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. */ /** Formatea la cantidad con el prefijo de tipo de transacción: +, -, o sin signo. */
fun signedAmount(amount: Double, type: String): String = when (type) { fun signedAmount(amount: Double, type: String): String = when (type) {
"income" -> "+${formatMoney(amount)}" "income" -> "+${formatMoney(amount)}"