4 Commits
17 changed files with 808 additions and 85 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.conta.app"
minSdk 24
targetSdk 34
versionCode 3
versionName "1.0.2"
versionCode 7
versionName "1.0.6"
}
buildTypes {
@@ -13,11 +13,17 @@ class SettingsRepository(private val context: Context) {
private object Keys {
val BASE_URL = stringPreferencesKey("base_url")
val THEME_ID = stringPreferencesKey("theme_id")
}
val baseUrl: Flow<String> = context.dataStore.data.map { it[Keys.BASE_URL] ?: "" }
val themeId: Flow<String> = context.dataStore.data.map { it[Keys.THEME_ID] ?: "default" }
suspend fun setBaseUrl(url: String) {
context.dataStore.edit { it[Keys.BASE_URL] = url.trim() }
}
suspend fun setThemeId(id: String) {
context.dataStore.edit { it[Keys.THEME_ID] = id }
}
}
@@ -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,11 +43,13 @@ 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
import com.conta.app.ui.screens.settings.NtfyScreen
import com.conta.app.ui.screens.settings.SettingsScreen
import com.conta.app.ui.screens.settings.ThemeScreen
import com.conta.app.ui.screens.transactions.TransactionFormScreen
import com.conta.app.ui.screens.transactions.TransactionsScreen
@@ -57,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"
@@ -66,6 +70,7 @@ object Routes {
const val SETTINGS = "settings"
const val NTFY = "ntfy"
const val BACKUP = "backup"
const val THEME = "theme"
fun accountDetail(id: Long) = "$ACCOUNT_DETAIL/$id"
fun accountForm(id: Long? = null) =
@@ -91,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),
)
@@ -145,12 +151,14 @@ 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) }
composable(Routes.RECURRING) { RecurringScreen(viewModel, navController) }
composable(Routes.NTFY) { NtfyScreen(viewModel, navController) }
composable(Routes.BACKUP) { BackupScreen(viewModel, navController) }
composable(Routes.THEME) { ThemeScreen(viewModel, navController) }
composable(
route = "${Routes.ACCOUNT_DETAIL}/{accountId}",
@@ -120,6 +120,7 @@ class AppViewModel : ViewModel() {
viewModelScope.launch {
runRequest { _summary.value = repo.getSummary() }
runRequest { _transactions.value = repo.listTransactions() }
runRequest { _accounts.value = repo.listAccounts() }
runRequest { _notices.value = repo.listNotices() }
runRequest { _statistics.value = repo.getStatistics() }
}
@@ -129,6 +130,9 @@ class AppViewModel : ViewModel() {
viewModelScope.launch {
runRequest { _summary.value = repo.getSummary() }
runRequest { _accounts.value = repo.listAccounts() }
runRequest { _transactions.value = repo.listTransactions() }
runRequest { _statistics.value = repo.getStatistics() }
runRequest { _notices.value = repo.listNotices() }
}
}
@@ -473,4 +477,10 @@ class AppViewModel : ViewModel() {
refreshAll()
}
}
fun setThemeId(id: String) {
viewModelScope.launch {
ServiceLocator.settings.setThemeId(id)
}
}
}
@@ -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,
)
}
}
}
}
}
}
@@ -93,6 +93,7 @@ fun AccountFormScreen(viewModel: AppViewModel, navController: NavController, acc
viewModel.updateAccount(
account.id,
UpdateAccountRequest(
name = name.trim(),
creditLimit = parseAmount(creditLimit),
interestRate = parseAmount(interestRate),
maturityDate = maturityDate.ifBlank { null },
@@ -10,6 +10,7 @@ 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.layout.WindowInsets
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
@@ -66,6 +67,7 @@ fun AccountsScreen(viewModel: AppViewModel, navController: NavController) {
val loading by viewModel.loading.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
floatingActionButton = {
FloatingActionButton(onClick = { navController.navigate(Routes.accountForm()) }) {
Icon(Icons.Filled.Add, contentDescription = "Nueva cuenta")
@@ -50,7 +50,7 @@ import com.conta.app.data.model.CreateCategoryRequest
import com.conta.app.data.model.UpdateCategoryRequest
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.components.SectionHeader
import com.conta.app.ui.util.CATEGORY_COLOR_PALETTE
import com.conta.app.ui.util.categoryColorPalette
import com.conta.app.ui.util.CATEGORY_ICON_NAMES
import com.conta.app.ui.util.categoryIcon
@@ -186,7 +186,8 @@ fun CategoryFormScreen(viewModel: AppViewModel, navController: NavController, ca
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
CATEGORY_COLOR_PALETTE.forEachIndexed { index, colorValue ->
val palette = categoryColorPalette()
palette.forEachIndexed { index, colorValue ->
val selected = color == index.toString()
Box(
modifier = Modifier
@@ -9,6 +9,7 @@ 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.layout.WindowInsets
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -66,6 +67,7 @@ fun DashboardScreen(viewModel: AppViewModel, navController: NavController) {
val loading by viewModel.loading.collectAsStateWithLifecycle()
Scaffold(
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
floatingActionButton = {
FloatingActionButton(onClick = { navController.navigate(Routes.transactionForm(preset = "expense")) }) {
Icon(Icons.Filled.Add, contentDescription = "Nueva transacción")
@@ -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))
}
}
}
}
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -14,6 +15,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Http
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Button
@@ -61,7 +63,9 @@ fun SettingsScreen(viewModel: AppViewModel, navController: NavController) {
val summary by viewModel.summary.collectAsStateWithLifecycle()
Scaffold { padding ->
Scaffold(
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
) { padding ->
Column(
modifier = Modifier
.padding(padding)
@@ -122,6 +126,15 @@ fun SettingsScreen(viewModel: AppViewModel, navController: NavController) {
onClick = { navController.navigate(Routes.NTFY) },
)
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
SectionHeader("Apariencia")
SettingsItemCard(
icon = Icons.Filled.Palette,
title = "Tema de colores",
subtitle = "Catppuccin, Gruvbox, Nord, Monokai y más",
onClick = { navController.navigate(Routes.THEME) },
)
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
SectionHeader("Datos")
SettingsItemCard(
@@ -0,0 +1,147 @@
package com.conta.app.ui.screens.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.Check
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.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.conta.app.ServiceLocator
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.components.SectionHeader
import com.conta.app.ui.theme.THEME_OPTIONS
import com.conta.app.ui.theme.ThemeSpec
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ThemeScreen(viewModel: AppViewModel, navController: NavController) {
val themeId by remember { ServiceLocator.settings.themeId }.collectAsStateWithLifecycle(initialValue = "default")
Scaffold(
topBar = {
TopAppBar(
title = { Text("Apariencia", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Volver")
}
},
)
},
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
SectionHeader("Tema")
Text(
text = "Elige el tema de colores de la aplicación. El cambio se aplica de inmediato y se guarda en este dispositivo.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 12.dp))
THEME_OPTIONS.forEach { spec ->
ThemeRow(
spec = spec,
selected = themeId == spec.id,
onClick = { viewModel.setThemeId(spec.id) },
)
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 16.dp))
}
}
}
@Composable
private fun ThemeRow(spec: ThemeSpec, selected: Boolean, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)
} else {
MaterialTheme.colorScheme.surface
},
),
) {
Row(
modifier = Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
ThemeDot(spec.primary)
ThemeDot(spec.secondary)
ThemeDot(spec.background, border = true)
}
Text(
text = spec.label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
if (selected) {
Icon(
Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
}
}
@Composable
private fun ThemeDot(color: Color, border: Boolean = false) {
Box(
modifier = Modifier
.size(16.dp)
.background(color, CircleShape)
.then(
if (border) Modifier.border(1.dp, MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
else Modifier,
),
)
}
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -53,6 +54,7 @@ fun TransactionsScreen(viewModel: AppViewModel, navController: NavController) {
val totalExpense = filtered.filter { it.transactionType == "expense" }.sumOf { it.amount }
Scaffold(
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
floatingActionButton = {
FloatingActionButton(onClick = { navController.navigate(Routes.transactionForm()) }) {
Icon(Icons.Filled.Add, contentDescription = "Nueva transacción")
@@ -0,0 +1,246 @@
package com.conta.app.ui.theme
import androidx.compose.ui.graphics.Color
data class ThemeSpec(
val id: String,
val label: String,
val isDark: Boolean,
val primary: Color,
val secondary: Color,
val error: Color,
val warning: Color,
val success: Color,
val info: Color,
val background: Color,
val paper: Color,
val textPrimary: Color,
val textSecondary: Color,
val divider: Color,
val categoryColors: List<Color>,
)
val THEME_OPTIONS: List<ThemeSpec> = listOf(
ThemeSpec(
id = "default",
label = "Predeterminado",
isDark = false,
primary = Color(0xFF1565C0),
secondary = Color(0xFFE53935),
error = Color(0xFFD32F2F),
warning = Color(0xFFF57C00),
success = Color(0xFF2E7D32),
info = Color(0xFF0288D1),
background = Color(0xFFF5F5F5),
paper = Color(0xFFFFFFFF),
textPrimary = Color(0xDE000000),
textSecondary = Color(0x99000000),
divider = Color(0x1F000000),
categoryColors = listOf(
Color(0xFF1976D2), Color(0xFF7B1FA2), Color(0xFF00897B), Color(0xFFF9A825),
Color(0xFFE53935), Color(0xFF5D4037), Color(0xFF43A047), Color(0xFF6A1B9A),
Color(0xFFC2185B), Color(0xFF303F9F), Color(0xFF00ACC1), Color(0xFF7CB342),
Color(0xFFFFB300), Color(0xFFD81B60),
),
),
ThemeSpec(
id = "catppuccin-latte",
label = "Catppuccin Latte (claro)",
isDark = false,
primary = Color(0xFF1E66F5),
secondary = Color(0xFF8839EF),
error = Color(0xFFD20F39),
warning = Color(0xFFDF8E1D),
success = Color(0xFF40A02B),
info = Color(0xFF04A5E5),
background = Color(0xFFEFF1F5),
paper = Color(0xFFCCD0DA),
textPrimary = Color(0xFF4C4F69),
textSecondary = Color(0xFF6C6F85),
divider = Color(0xFFACB0BE),
categoryColors = listOf(
Color(0xFF1E66F5), Color(0xFF8839EF), Color(0xFF179299), Color(0xFFDF8E1D),
Color(0xFFD20F39), Color(0xFFFE640B), Color(0xFF40A02B), Color(0xFF7287FD),
Color(0xFFE64553), Color(0xFF04A5E5), Color(0xFFDD7878), Color(0xFFEA76CB),
Color(0xFF209FB5), Color(0xFF8C8FA1),
),
),
ThemeSpec(
id = "catppuccin-mocha",
label = "Catppuccin Mocha (oscuro)",
isDark = true,
primary = Color(0xFF89B4FA),
secondary = Color(0xFFCBA6F7),
error = Color(0xFFF38BA8),
warning = Color(0xFFF9E2AF),
success = Color(0xFFA6E3A1),
info = Color(0xFF89DCEB),
background = Color(0xFF1E1E2E),
paper = Color(0xFF313244),
textPrimary = Color(0xFFCDD6F4),
textSecondary = Color(0xFFA6ADC8),
divider = Color(0xFF585B70),
categoryColors = listOf(
Color(0xFF89B4FA), Color(0xFFCBA6F7), Color(0xFF94E2D5), Color(0xFFF9E2AF),
Color(0xFFF38BA8), Color(0xFFFAB387), Color(0xFFA6E3A1), Color(0xFFB4BEFE),
Color(0xFFEBA0AC), Color(0xFF89DCEB), Color(0xFFF2CDCD), Color(0xFFF5C2E7),
Color(0xFF74C7EC), Color(0xFFA6ADC8),
),
),
ThemeSpec(
id = "gruvbox-light",
label = "Gruvbox Claro",
isDark = false,
primary = Color(0xFF076678),
secondary = Color(0xFF8F3F71),
error = Color(0xFFCC241D),
warning = Color(0xFFB57614),
success = Color(0xFF79740E),
info = Color(0xFF427B58),
background = Color(0xFFFBF1C7),
paper = Color(0xFFEBDDB2),
textPrimary = Color(0xFF282828),
textSecondary = Color(0xFF928374),
divider = Color(0xFFD5C4A1),
categoryColors = listOf(
Color(0xFF076678), Color(0xFF8F3F71), Color(0xFF79740E), Color(0xFFB57614),
Color(0xFFCC241D), Color(0xFFAF3A03), Color(0xFF98971A), Color(0xFF427B58),
Color(0xFFB16286), Color(0xFF9D0006), Color(0xFF7C6F64), Color(0xFFD65D0E),
Color(0xFF689D6A), Color(0xFF504945),
),
),
ThemeSpec(
id = "gruvbox-dark",
label = "Gruvbox Oscuro",
isDark = true,
primary = Color(0xFF83A598),
secondary = Color(0xFFD3869B),
error = Color(0xFFFB4934),
warning = Color(0xFFFABD2F),
success = Color(0xFFB8BB26),
info = Color(0xFF8EC07C),
background = Color(0xFF282828),
paper = Color(0xFF3C3836),
textPrimary = Color(0xFFEBDDB2),
textSecondary = Color(0xFFBDAE93),
divider = Color(0xFF665C54),
categoryColors = listOf(
Color(0xFF83A598), Color(0xFFD3869B), Color(0xFF8EC07C), Color(0xFFFABD2F),
Color(0xFFFB4934), Color(0xFFD65D0E), Color(0xFFB8BB26), Color(0xFF458588),
Color(0xFFB16286), Color(0xFFCC241D), Color(0xFF98971A), Color(0xFF689D6A),
Color(0xFFA89984), Color(0xFFD79921),
),
),
ThemeSpec(
id = "everforest",
label = "Everforest",
isDark = true,
primary = Color(0xFF7FBBB3),
secondary = Color(0xFFD699B6),
error = Color(0xFFE67E80),
warning = Color(0xFFE69875),
success = Color(0xFFA7C080),
info = Color(0xFF83C092),
background = Color(0xFF2D353B),
paper = Color(0xFF343F44),
textPrimary = Color(0xFFD3C6AA),
textSecondary = Color(0xFF859289),
divider = Color(0xFF4F585E),
categoryColors = listOf(
Color(0xFF7FBBB3), Color(0xFFD699B6), Color(0xFFA7C080), Color(0xFFE69875),
Color(0xFFE67E80), Color(0xFFD3C6AA), Color(0xFF83C092), Color(0xFF899C97),
Color(0xFF6A916D), Color(0xFF9DA9A0), Color(0xFFA8B370), Color(0xFFD3869B),
Color(0xFF5C7C6A), Color(0xFFC4B28A),
),
),
ThemeSpec(
id = "nord",
label = "Nord",
isDark = true,
primary = Color(0xFF88C0D0),
secondary = Color(0xFFB48EAD),
error = Color(0xFFBF616A),
warning = Color(0xFFD08770),
success = Color(0xFFA3BE8C),
info = Color(0xFF81A1C1),
background = Color(0xFF2E3440),
paper = Color(0xFF3B4252),
textPrimary = Color(0xFFECEFF4),
textSecondary = Color(0xFFD8DEE9),
divider = Color(0xFF4C566A),
categoryColors = listOf(
Color(0xFF88C0D0), Color(0xFFB48EAD), Color(0xFFA3BE8C), Color(0xFFEBCB8B),
Color(0xFFBF616A), Color(0xFFD08770), Color(0xFF81A1C1), Color(0xFF8FBCBB),
Color(0xFF5E81AC), Color(0xFFD8DEE9), Color(0xFF4C566A), Color(0xFF63B3AD),
Color(0xFFC98076), Color(0xFF7EB4D8),
),
),
ThemeSpec(
id = "rose-pine-moon",
label = "Rosé Pine Moon",
isDark = true,
primary = Color(0xFF9CCFD8),
secondary = Color(0xFFC4A7E7),
error = Color(0xFFEB6F92),
warning = Color(0xFFF6C177),
success = Color(0xFF3E8FB0),
info = Color(0xFFEA9A97),
background = Color(0xFF232136),
paper = Color(0xFF2A273F),
textPrimary = Color(0xFFE0DEF4),
textSecondary = Color(0xFF908CAA),
divider = Color(0xFF393552),
categoryColors = listOf(
Color(0xFF9CCFD8), Color(0xFFC4A7E7), Color(0xFF3E8FB0), Color(0xFFEA9A97),
Color(0xFFEB6F92), Color(0xFFF6C177), Color(0xFF56949F), Color(0xFF908CAA),
Color(0xFF31748F), Color(0xFF907AA9), Color(0xFFE0DEF4), Color(0xFF44415A),
Color(0xFFD9D6E0), Color(0xFF2A273F),
),
),
ThemeSpec(
id = "monokai",
label = "Monokai",
isDark = true,
primary = Color(0xFF66D9EF),
secondary = Color(0xFFAE81FF),
error = Color(0xFFF92672),
warning = Color(0xFFFD971F),
success = Color(0xFFA6E22E),
info = Color(0xFFE6DB74),
background = Color(0xFF272822),
paper = Color(0xFF37372E),
textPrimary = Color(0xFFF8F8F2),
textSecondary = Color(0xFFCFCFC2),
divider = Color(0xFF49483E),
categoryColors = listOf(
Color(0xFF66D9EF), Color(0xFFAE81FF), Color(0xFFA6E22E), Color(0xFFE6DB74),
Color(0xFFF92672), Color(0xFFFD971F), Color(0xFFA1EFE4), Color(0xFFF8F8F2),
Color(0xFF75715E), Color(0xFFE69F66), Color(0xFFCC6633), Color(0xFFBD93F9),
Color(0xFF50FA7B), Color(0xFFFF79C6),
),
),
ThemeSpec(
id = "ayu-dark",
label = "Ayu Dark",
isDark = true,
primary = Color(0xFFFF8F40),
secondary = Color(0xFFD2A6FF),
error = Color(0xFFF07178),
warning = Color(0xFFFFB454),
success = Color(0xFFAAD94C),
info = Color(0xFF59C2FF),
background = Color(0xFF0B0E14),
paper = Color(0xFF141923),
textPrimary = Color(0xFFD9D7CE),
textSecondary = Color(0xFF626A73),
divider = Color(0xFF2B333E),
categoryColors = listOf(
Color(0xFFFF8F40), Color(0xFFD2A6FF), Color(0xFFAAD94C), Color(0xFFFFB454),
Color(0xFFF07178), Color(0xFF59C2FF), Color(0xFFE6B673), Color(0xFF95E6CB),
Color(0xFFF29E74), Color(0xFFD4BFFF), Color(0xFF73D0FF), Color(0xFFB8CC52),
Color(0xFFF7B2A1), Color(0xFF36A3D9),
),
),
)
fun themeById(id: String): ThemeSpec = THEME_OPTIONS.firstOrNull { it.id == id } ?: THEME_OPTIONS.first()
@@ -1,82 +1,106 @@
package com.conta.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.conta.app.ServiceLocator
val PrimaryLight = Color(0xFF1565C0)
val PrimaryDark = Color(0xFF90CAF9)
val Accent = Color(0xFF00796B)
val AccentDark = Color(0xFF80CBC4)
val Tertiary = Color(0xFF6A1B9A)
val TertiaryDark = Color(0xFFCE93D8)
private val LightColors = lightColorScheme(
primary = PrimaryLight,
onPrimary = Color.White,
primaryContainer = Color(0xFFD6E3FF),
onPrimaryContainer = Color(0xFF0B2A56),
secondary = Accent,
onSecondary = Color.White,
secondaryContainer = Color(0xFFB2DFDB),
onSecondaryContainer = Color(0xFF00332E),
tertiary = Tertiary,
onTertiary = Color.White,
tertiaryContainer = Color(0xFFE1BEE7),
onTertiaryContainer = Color(0xFF310742),
error = Color(0xFFD32F2F),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFF5F6F8),
onBackground = Color(0xFF191C20),
surface = Color(0xFFFAFAFA),
onSurface = Color(0xFF191C20),
surfaceVariant = Color(0xFFE7E8EC),
onSurfaceVariant = Color(0xFF43474E),
outline = Color(0xFF73777F),
outlineVariant = Color(0xFFC4C6CC),
surfaceTint = PrimaryLight,
)
private val DarkColors = darkColorScheme(
primary = PrimaryDark,
onPrimary = Color(0xFF0B2A56),
primaryContainer = Color(0xFF1E3A5F),
onPrimaryContainer = Color(0xFFD6E3FF),
secondary = AccentDark,
onSecondary = Color(0xFF00332E),
secondaryContainer = Color(0xFF00504A),
onSecondaryContainer = Color(0xFFB2DFDB),
tertiary = TertiaryDark,
onTertiary = Color(0xFF310742),
tertiaryContainer = Color(0xFF4A1584),
onTertiaryContainer = Color(0xFFE1BEE7),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF0F1216),
onBackground = Color(0xFFE2E2E6),
surface = Color(0xFF15181D),
onSurface = Color(0xFFE2E2E6),
surfaceVariant = Color(0xFF2A2E35),
onSurfaceVariant = Color(0xFFC4C6CC),
outline = Color(0xFF8E929A),
outlineVariant = Color(0xFF2A2E35),
surfaceTint = PrimaryDark,
)
/** Aplica el esquema de colores según el tema persistido en Ajustes. */
@Composable
fun ContaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
fun ContaTheme(content: @Composable () -> Unit) {
val themeId by ServiceLocator.settings.themeId.collectAsStateWithLifecycle(initialValue = "default")
val spec = themeById(themeId)
val colorScheme = if (spec.isDark) spec.toDarkColorScheme() else spec.toLightColorScheme()
CompositionLocalThemeSpec(spec) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
colorScheme = colorScheme,
content = content,
)
}
}
/** CompositionLocal que expone el ThemeSpec activo (para colores de categorías, etc.). */
val LocalThemeSpec = staticCompositionLocalOf<ThemeSpec> { THEME_OPTIONS.first() }
@Composable
private fun CompositionLocalThemeSpec(spec: ThemeSpec, content: @Composable () -> Unit) {
androidx.compose.runtime.CompositionLocalProvider(LocalThemeSpec provides spec, content = content)
}
private fun ThemeSpec.toLightColorScheme() = lightColorScheme(
primary = primary,
onPrimary = onColor(primary),
primaryContainer = lightContainer(primary),
onPrimaryContainer = onColor(lightContainer(primary)),
secondary = secondary,
onSecondary = onColor(secondary),
secondaryContainer = lightContainer(secondary),
onSecondaryContainer = onColor(lightContainer(secondary)),
tertiary = info,
onTertiary = onColor(info),
tertiaryContainer = lightContainer(info),
onTertiaryContainer = onColor(lightContainer(info)),
error = error,
onError = onColor(error),
errorContainer = lightContainer(error),
onErrorContainer = onColor(lightContainer(error)),
background = background,
onBackground = textPrimary,
surface = paper,
onSurface = textPrimary,
surfaceVariant = divider,
onSurfaceVariant = textSecondary,
outline = textSecondary,
outlineVariant = divider,
surfaceTint = primary,
)
private fun ThemeSpec.toDarkColorScheme() = darkColorScheme(
primary = primary,
onPrimary = onColor(primary),
primaryContainer = darkContainer(primary),
onPrimaryContainer = onColor(darkContainer(primary)),
secondary = secondary,
onSecondary = onColor(secondary),
secondaryContainer = darkContainer(secondary),
onSecondaryContainer = onColor(darkContainer(secondary)),
tertiary = info,
onTertiary = onColor(info),
tertiaryContainer = darkContainer(info),
onTertiaryContainer = onColor(darkContainer(info)),
error = error,
onError = onColor(error),
errorContainer = darkContainer(error),
onErrorContainer = onColor(darkContainer(error)),
background = background,
onBackground = textPrimary,
surface = paper,
onSurface = textPrimary,
surfaceVariant = divider,
onSurfaceVariant = textSecondary,
outline = textSecondary,
outlineVariant = divider,
surfaceTint = primary,
)
/** Color de contraste (blanco/negro) para texto sobre un fondo dado. */
private fun onColor(bg: Color): Color = if (bg.luminance() > 0.5f) Color.Black else Color.White
private fun Color.luminance(): Float =
0.2126f * red + 0.7152f * green + 0.0722f * blue
private fun lightContainer(base: Color): Color = base.blend(Color.White, t = 0.75f)
private fun darkContainer(base: Color): Color = base.blend(Color.Black, t = 0.65f)
private fun Color.blend(other: Color, t: Float = 0.7f): Color = Color(
red = red * (1 - t) + other.red * t,
green = green * (1 - t) + other.green * t,
blue = blue * (1 - t) + other.blue * t,
alpha = 1f,
)
@@ -1,21 +1,21 @@
package com.conta.app.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import com.conta.app.ui.theme.LocalThemeSpec
/** Paleta por defecto para categorías (misma que el frontend). */
val CATEGORY_COLOR_PALETTE = listOf(
Color(0xFF1976D2), Color(0xFF7B1FA2), Color(0xFF00897B), Color(0xFFF9A825),
Color(0xFFE53935), Color(0xFF5D4037), Color(0xFF43A047), Color(0xFF6A1B9A),
Color(0xFFC2185B), Color(0xFF303F9F), Color(0xFF00ACC1), Color(0xFF7CB342),
Color(0xFFFFB300), Color(0xFFD81B60),
)
/** Paleta de colores de categorías según el tema activo (misma que el frontend). */
@Composable
fun categoryColorPalette(): List<Color> = LocalThemeSpec.current.categoryColors
@Composable
fun resolveCategoryColor(color: String?, fallback: Color): Color {
if (color.isNullOrBlank()) return fallback
// Si es un índice de la paleta (0-13), se resuelve contra la paleta.
val palette = categoryColorPalette()
// Si es un índice de la paleta (0-13), se resuelve contra la paleta del tema.
val idx = color.toIntOrNull()
if (idx != null && idx in 0 until CATEGORY_COLOR_PALETTE.size) {
return CATEGORY_COLOR_PALETTE[idx]
if (idx != null && idx in 0 until palette.size) {
return palette[idx]
}
// Si es un color hex (##RRGGBB o #RRGGBB).
val hex = color.trim().removePrefix("##").removePrefix("#")
@@ -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)}"