Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f253274e52 |
+2
-2
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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<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"
|
||||
}
|
||||
|
||||
/** "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)}"
|
||||
|
||||
Reference in New Issue
Block a user