6 Commits
17 changed files with 822 additions and 129 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 1 versionCode 7
versionName "1.0.0" versionName "1.0.6"
} }
buildTypes { buildTypes {
@@ -13,11 +13,17 @@ class SettingsRepository(private val context: Context) {
private object Keys { private object Keys {
val BASE_URL = stringPreferencesKey("base_url") 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 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) { suspend fun setBaseUrl(url: String) {
context.dataStore.edit { it[Keys.BASE_URL] = url.trim() } 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.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,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.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
import com.conta.app.ui.screens.settings.NtfyScreen import com.conta.app.ui.screens.settings.NtfyScreen
import com.conta.app.ui.screens.settings.SettingsScreen 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.TransactionFormScreen
import com.conta.app.ui.screens.transactions.TransactionsScreen import com.conta.app.ui.screens.transactions.TransactionsScreen
@@ -57,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"
@@ -66,6 +70,7 @@ object Routes {
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val NTFY = "ntfy" const val NTFY = "ntfy"
const val BACKUP = "backup" const val BACKUP = "backup"
const val THEME = "theme"
fun accountDetail(id: Long) = "$ACCOUNT_DETAIL/$id" fun accountDetail(id: Long) = "$ACCOUNT_DETAIL/$id"
fun accountForm(id: Long? = null) = fun accountForm(id: Long? = null) =
@@ -91,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),
) )
@@ -145,12 +151,14 @@ 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) }
composable(Routes.RECURRING) { RecurringScreen(viewModel, navController) } composable(Routes.RECURRING) { RecurringScreen(viewModel, navController) }
composable(Routes.NTFY) { NtfyScreen(viewModel, navController) } composable(Routes.NTFY) { NtfyScreen(viewModel, navController) }
composable(Routes.BACKUP) { BackupScreen(viewModel, navController) } composable(Routes.BACKUP) { BackupScreen(viewModel, navController) }
composable(Routes.THEME) { ThemeScreen(viewModel, navController) }
composable( composable(
route = "${Routes.ACCOUNT_DETAIL}/{accountId}", route = "${Routes.ACCOUNT_DETAIL}/{accountId}",
@@ -34,6 +34,7 @@ import com.conta.app.data.network.Session
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@@ -82,6 +83,14 @@ class AppViewModel : ViewModel() {
_message.value = msg _message.value = msg
} }
init {
viewModelScope.launch {
val saved = ServiceLocator.settings.baseUrl.first()
Session.baseUrl = saved
if (saved.isNotBlank()) refreshAll()
}
}
private suspend fun <T> runRequest(block: suspend () -> T): Boolean { private suspend fun <T> runRequest(block: suspend () -> T): Boolean {
return try { return try {
block() block()
@@ -111,6 +120,7 @@ class AppViewModel : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
runRequest { _summary.value = repo.getSummary() } runRequest { _summary.value = repo.getSummary() }
runRequest { _transactions.value = repo.listTransactions() } runRequest { _transactions.value = repo.listTransactions() }
runRequest { _accounts.value = repo.listAccounts() }
runRequest { _notices.value = repo.listNotices() } runRequest { _notices.value = repo.listNotices() }
runRequest { _statistics.value = repo.getStatistics() } runRequest { _statistics.value = repo.getStatistics() }
} }
@@ -120,6 +130,9 @@ class AppViewModel : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
runRequest { _summary.value = repo.getSummary() } runRequest { _summary.value = repo.getSummary() }
runRequest { _accounts.value = repo.listAccounts() } runRequest { _accounts.value = repo.listAccounts() }
runRequest { _transactions.value = repo.listTransactions() }
runRequest { _statistics.value = repo.getStatistics() }
runRequest { _notices.value = repo.listNotices() }
} }
} }
@@ -464,4 +477,10 @@ class AppViewModel : ViewModel() {
refreshAll() 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.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,
)
}
}
}
}
}
}
@@ -93,6 +93,7 @@ fun AccountFormScreen(viewModel: AppViewModel, navController: NavController, acc
viewModel.updateAccount( viewModel.updateAccount(
account.id, account.id,
UpdateAccountRequest( UpdateAccountRequest(
name = name.trim(),
creditLimit = parseAmount(creditLimit), creditLimit = parseAmount(creditLimit),
interestRate = parseAmount(interestRate), interestRate = parseAmount(interestRate),
maturityDate = maturityDate.ifBlank { null }, 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -20,7 +21,6 @@ import androidx.compose.material.icons.filled.AccountBalance
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CreditCard import androidx.compose.material.icons.filled.CreditCard
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -30,7 +30,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -68,16 +67,7 @@ fun AccountsScreen(viewModel: AppViewModel, navController: NavController) {
val loading by viewModel.loading.collectAsStateWithLifecycle() val loading by viewModel.loading.collectAsStateWithLifecycle()
Scaffold( Scaffold(
topBar = { contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
TopAppBar(
title = { Text("Cuentas", fontWeight = FontWeight.Bold) },
actions = {
androidx.compose.material3.IconButton(onClick = { viewModel.refreshAccounts() }) {
Icon(Icons.Filled.Sync, contentDescription = "Actualizar")
}
},
)
},
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = { navController.navigate(Routes.accountForm()) }) { FloatingActionButton(onClick = { navController.navigate(Routes.accountForm()) }) {
Icon(Icons.Filled.Add, contentDescription = "Nueva cuenta") 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.data.model.UpdateCategoryRequest
import com.conta.app.ui.AppViewModel import com.conta.app.ui.AppViewModel
import com.conta.app.ui.components.SectionHeader 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.CATEGORY_ICON_NAMES
import com.conta.app.ui.util.categoryIcon import com.conta.app.ui.util.categoryIcon
@@ -186,7 +186,8 @@ fun CategoryFormScreen(viewModel: AppViewModel, navController: NavController, ca
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
CATEGORY_COLOR_PALETTE.forEachIndexed { index, colorValue -> val palette = categoryColorPalette()
palette.forEachIndexed { index, colorValue ->
val selected = color == index.toString() val selected = color == index.toString()
Box( Box(
modifier = Modifier modifier = Modifier
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -21,7 +22,6 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.CreditCard import androidx.compose.material.icons.filled.CreditCard
import androidx.compose.material.icons.filled.Event import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.NotificationsActive import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Savings import androidx.compose.material.icons.filled.Savings
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -33,7 +33,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -68,16 +67,7 @@ fun DashboardScreen(viewModel: AppViewModel, navController: NavController) {
val loading by viewModel.loading.collectAsStateWithLifecycle() val loading by viewModel.loading.collectAsStateWithLifecycle()
Scaffold( Scaffold(
topBar = { contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
TopAppBar(
title = { Text("Conta", fontWeight = FontWeight.Bold) },
actions = {
IconButton(onClick = { viewModel.refreshDashboard() }) {
Icon(Icons.Filled.Sync, contentDescription = "Actualizar")
}
},
)
},
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = { navController.navigate(Routes.transactionForm(preset = "expense")) }) { FloatingActionButton(onClick = { navController.navigate(Routes.transactionForm(preset = "expense")) }) {
Icon(Icons.Filled.Add, contentDescription = "Nueva transacción") Icon(Icons.Filled.Add, contentDescription = "Nueva transacción")
@@ -95,11 +85,16 @@ fun DashboardScreen(viewModel: AppViewModel, navController: NavController) {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
EmptyState("No conectado. Configura la URL del servidor en Ajustes.") EmptyState("No conectado. Configura la URL del servidor en Ajustes.")
androidx.compose.material3.Button(onClick = { navController.navigate(Routes.SETTINGS) }) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
androidx.compose.material3.Button(onClick = { viewModel.refreshAll() }) {
Text("Reintentar")
}
androidx.compose.material3.OutlinedButton(onClick = { navController.navigate(Routes.SETTINGS) }) {
Text("Ir a Ajustes") Text("Ir a Ajustes")
} }
} }
} }
}
else -> DashboardContent( else -> DashboardContent(
summary = summary!!, summary = summary!!,
notices = notices, notices = notices,
@@ -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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll 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.Cloud
import androidx.compose.material.icons.filled.Http import androidx.compose.material.icons.filled.Http
import androidx.compose.material.icons.filled.NotificationsActive 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.Save
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -26,7 +28,6 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -63,11 +64,7 @@ fun SettingsScreen(viewModel: AppViewModel, navController: NavController) {
val summary by viewModel.summary.collectAsStateWithLifecycle() val summary by viewModel.summary.collectAsStateWithLifecycle()
Scaffold( Scaffold(
topBar = { contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
TopAppBar(
title = { Text("Ajustes", fontWeight = FontWeight.Bold) },
)
},
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -129,6 +126,15 @@ fun SettingsScreen(viewModel: AppViewModel, navController: NavController) {
onClick = { navController.navigate(Routes.NTFY) }, 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)) androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
SectionHeader("Datos") SectionHeader("Datos")
SettingsItemCard( 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,23 +5,21 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -56,16 +54,7 @@ fun TransactionsScreen(viewModel: AppViewModel, navController: NavController) {
val totalExpense = filtered.filter { it.transactionType == "expense" }.sumOf { it.amount } val totalExpense = filtered.filter { it.transactionType == "expense" }.sumOf { it.amount }
Scaffold( Scaffold(
topBar = { contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
TopAppBar(
title = { Text("Movimientos", fontWeight = FontWeight.Bold) },
actions = {
IconButton(onClick = { viewModel.refreshDashboard() }) {
Icon(Icons.Filled.Sync, contentDescription = "Actualizar")
}
},
)
},
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = { navController.navigate(Routes.transactionForm()) }) { FloatingActionButton(onClick = { navController.navigate(Routes.transactionForm()) }) {
Icon(Icons.Filled.Add, contentDescription = "Nueva transacción") 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 package com.conta.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.conta.app.ServiceLocator
val PrimaryLight = Color(0xFF1565C0) /** Aplica el esquema de colores según el tema persistido en Ajustes. */
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,
)
@Composable @Composable
fun ContaTheme( fun ContaTheme(content: @Composable () -> Unit) {
darkTheme: Boolean = isSystemInDarkTheme(), val themeId by ServiceLocator.settings.themeId.collectAsStateWithLifecycle(initialValue = "default")
content: @Composable () -> Unit, val spec = themeById(themeId)
) { val colorScheme = if (spec.isDark) spec.toDarkColorScheme() else spec.toLightColorScheme()
CompositionLocalThemeSpec(spec) {
MaterialTheme( MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors, colorScheme = colorScheme,
content = content, 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 package com.conta.app.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.conta.app.ui.theme.LocalThemeSpec
/** Paleta por defecto para categorías (misma que el frontend). */ /** Paleta de colores de categorías según el tema activo (misma que el frontend). */
val CATEGORY_COLOR_PALETTE = listOf( @Composable
Color(0xFF1976D2), Color(0xFF7B1FA2), Color(0xFF00897B), Color(0xFFF9A825), fun categoryColorPalette(): List<Color> = LocalThemeSpec.current.categoryColors
Color(0xFFE53935), Color(0xFF5D4037), Color(0xFF43A047), Color(0xFF6A1B9A),
Color(0xFFC2185B), Color(0xFF303F9F), Color(0xFF00ACC1), Color(0xFF7CB342),
Color(0xFFFFB300), Color(0xFFD81B60),
)
@Composable
fun resolveCategoryColor(color: String?, fallback: Color): Color { fun resolveCategoryColor(color: String?, fallback: Color): Color {
if (color.isNullOrBlank()) return fallback 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() val idx = color.toIntOrNull()
if (idx != null && idx in 0 until CATEGORY_COLOR_PALETTE.size) { if (idx != null && idx in 0 until palette.size) {
return CATEGORY_COLOR_PALETTE[idx] return palette[idx]
} }
// Si es un color hex (##RRGGBB o #RRGGBB). // Si es un color hex (##RRGGBB o #RRGGBB).
val hex = color.trim().removePrefix("##").removePrefix("#") val hex = color.trim().removePrefix("##").removePrefix("#")
@@ -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)}"