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