Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f253274e52 | ||
|
|
9eebaca7c1 | ||
|
|
c6bf1ecbf2 |
+2
-2
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.conta.app"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 4
|
||||
versionName "1.0.3"
|
||||
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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,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
|
||||
@@ -125,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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
/** Aplica el esquema de colores según el tema persistido en Ajustes. */
|
||||
@Composable
|
||||
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 = colorScheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
/** CompositionLocal que expone el ThemeSpec activo (para colores de categorías, etc.). */
|
||||
val LocalThemeSpec = staticCompositionLocalOf<ThemeSpec> { THEME_OPTIONS.first() }
|
||||
|
||||
@Composable
|
||||
fun ContaTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||
content = content,
|
||||
)
|
||||
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)}"
|
||||
|
||||
Reference in New Issue
Block a user