6 Commits
28 changed files with 2272 additions and 201 deletions
+7 -2
View File
@@ -3,6 +3,7 @@ plugins {
id "org.jetbrains.kotlin.android" id "org.jetbrains.kotlin.android"
id "org.jetbrains.kotlin.plugin.compose" id "org.jetbrains.kotlin.plugin.compose"
id "org.jetbrains.kotlin.plugin.serialization" id "org.jetbrains.kotlin.plugin.serialization"
id "com.google.devtools.ksp"
} }
android { android {
@@ -13,8 +14,8 @@ android {
applicationId "com.conta.app" applicationId "com.conta.app"
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
versionCode 2 versionCode 8
versionName "1.0.1" versionName "1.1.0"
} }
buildTypes { buildTypes {
@@ -67,6 +68,10 @@ dependencies {
implementation("androidx.datastore:datastore-preferences:1.1.1") implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
+22 -1
View File
@@ -4,13 +4,18 @@ import android.app.Application
import android.content.Context import android.content.Context
import com.conta.app.data.AppRepository import com.conta.app.data.AppRepository
import com.conta.app.data.SettingsRepository import com.conta.app.data.SettingsRepository
import com.conta.app.data.local.ContaDatabase
import com.conta.app.data.local.LocalStore
import com.conta.app.data.network.ApiService import com.conta.app.data.network.ApiService
import com.conta.app.data.network.ConnectivityObserver
import com.conta.app.data.network.NetworkModule import com.conta.app.data.network.NetworkModule
import com.conta.app.data.network.Session import com.conta.app.data.network.Session
import com.conta.app.data.sync.SyncEngine
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ContaApp : Application() { class ContaApp : Application() {
@@ -28,6 +33,13 @@ class ContaApp : Application() {
scope.launch { scope.launch {
Session.baseUrl = ServiceLocator.settings.baseUrl.first() Session.baseUrl = ServiceLocator.settings.baseUrl.first()
} }
scope.launch {
ServiceLocator.connectivity.hasNetwork.collectLatest { online ->
if (online) {
ServiceLocator.repository.syncNow()
}
}
}
} }
} }
@@ -36,12 +48,21 @@ object ServiceLocator {
lateinit var settings: SettingsRepository lateinit var settings: SettingsRepository
private set private set
lateinit var connectivity: ConnectivityObserver
private set
val api: ApiService get() = NetworkModule.api val api: ApiService get() = NetworkModule.api
val repository: AppRepository by lazy { AppRepository(api) } val store: LocalStore by lazy { LocalStore(ContaDatabase.get(ContaApp.instance)) }
val sync: SyncEngine by lazy { SyncEngine(api, store, connectivity, NetworkModule.json) }
val repository: AppRepository by lazy { AppRepository(api, store, sync, NetworkModule.json) }
fun init(context: Context) { fun init(context: Context) {
if (::settings.isInitialized) return if (::settings.isInitialized) return
settings = SettingsRepository(context.applicationContext) settings = SettingsRepository(context.applicationContext)
connectivity = ConnectivityObserver(context.applicationContext)
connectivity.start()
} }
} }
@@ -1,5 +1,17 @@
package com.conta.app.data package com.conta.app.data
import com.conta.app.data.local.LocalStore
import com.conta.app.data.local.LocalTransaction
import com.conta.app.data.local.OpType
import com.conta.app.data.local.SyncState
import com.conta.app.data.local.applyLocalBalances
import com.conta.app.data.local.computeStatistics
import com.conta.app.data.local.computeSummary
import com.conta.app.data.local.nowIso
import com.conta.app.data.local.toCategory
import com.conta.app.data.local.toLocal
import com.conta.app.data.local.toTransactionWithAccount
import com.conta.app.data.local.withCategory
import com.conta.app.data.model.Account import com.conta.app.data.model.Account
import com.conta.app.data.model.AccountInfo import com.conta.app.data.model.AccountInfo
import com.conta.app.data.model.BackupData import com.conta.app.data.model.BackupData
@@ -29,13 +41,25 @@ import com.conta.app.data.model.UpdateCategoryRequest
import com.conta.app.data.model.UpdateRecurringTransactionRequest import com.conta.app.data.model.UpdateRecurringTransactionRequest
import com.conta.app.data.model.UpdateTransactionRequest import com.conta.app.data.model.UpdateTransactionRequest
import com.conta.app.data.network.ApiService import com.conta.app.data.network.ApiService
import com.conta.app.data.sync.SyncEngine
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File import java.io.File
/** Convierte los errores de red/HTTP en mensajes legibles en español. */ /**
class AppRepository(private val api: ApiService) { * Capa de datos local-first: los movimientos se guardan primero en el espejo local
* (outbox) y se sincronizan con el servidor. Cuentas/categorías/reglas/recurrentes
* se leen del espejo cuando están disponibles y se editan solo con conexión.
*/
class AppRepository(
private val api: ApiService,
private val store: LocalStore,
private val sync: SyncEngine,
private val json: Json,
) {
private inline fun <T> safe(block: () -> T): T { private inline fun <T> safe(block: () -> T): T {
return try { return try {
@@ -54,11 +78,183 @@ class AppRepository(private val api: ApiService) {
} }
} }
suspend fun listAccounts(): List<AccountInfo> = safe { api.listAccounts() } // ---------- Lecturas locales ----------
suspend fun listAccounts(): List<AccountInfo> =
applyLocalBalances(store.snapshotAccounts(), store.snapshotTransactions())
suspend fun listAccountTransactions(id: Long): List<TransactionWithAccount> =
listTransactions().filter { it.accountId == id || it.toAccountId == id }
suspend fun listTransactions(): List<TransactionWithAccount> =
store.snapshotTransactions().map { it.toTransactionWithAccount() }
suspend fun listCategories(): List<Category> =
store.snapshotCategories().map { it.toCategory() }
suspend fun getSummary(): Summary = computeSummary(listAccounts())
suspend fun getStatistics(): Statistics = computeStatistics(store.snapshotTransactions())
suspend fun listRules(): List<CategorizationRule> =
store.getCache(CACHE_RULES)?.let { runCatching { json.decodeFromString<List<CategorizationRule>>(it) }.getOrNull() }
?: emptyList()
suspend fun listRecurring(): List<RecurringTransactionWithNames> =
store.getCache(CACHE_RECURRING)?.let { runCatching { json.decodeFromString<List<RecurringTransactionWithNames>>(it) }.getOrNull() }
?: emptyList()
suspend fun listNotices(): List<Notice> =
store.getCache(CACHE_NOTICES)?.let { runCatching { json.decodeFromString<List<Notice>>(it) }.getOrNull() }
?: emptyList()
/** Refresca el espejo desde el servidor y cachea reglas/recurrentes/avisos. */
suspend fun refreshFromServer() {
safe {
sync.refreshMirror()
store.putCache(CACHE_RULES, json.encodeToString(api.listRules()))
store.putCache(CACHE_RECURRING, json.encodeToString(api.listRecurring()))
store.putCache(CACHE_NOTICES, json.encodeToString(api.listNotices()))
}
}
// ---------- Movimientos: local-first (outbox) ----------
suspend fun createTransaction(body: CreateTransactionRequest): Transaction {
val id = store.nextTempId()
val now = nowIso()
val accountName = store.snapshotAccounts().firstOrNull { it.id == body.accountId }?.name ?: ""
val category = store.snapshotCategories().firstOrNull { it.id == body.categoryId }
val local = LocalTransaction(
id = id,
accountId = body.accountId,
accountName = accountName,
transactionType = body.transactionType,
amount = body.amount,
description = body.description,
date = body.date,
categoryId = body.categoryId,
createdAt = now,
syncState = SyncState.PENDING_CREATE,
).withCategory(category)
store.insertPendingCreate(local, json.encodeToString(body))
sync.requestSync()
return local.toTransaction()
}
suspend fun createTransfer(body: TransferRequest): List<Transaction> {
val id = store.nextTempId()
val now = nowIso()
val accounts = store.snapshotAccounts().associateBy { it.id }
val category = store.snapshotCategories().firstOrNull { it.id == body.categoryId }
val local = LocalTransaction(
id = id,
accountId = body.fromAccountId,
accountName = accounts[body.fromAccountId]?.name ?: "",
toAccountId = body.toAccountId,
toAccountName = accounts[body.toAccountId]?.name,
transactionType = "transfer",
amount = body.amount,
description = body.description,
date = body.date,
categoryId = body.categoryId,
createdAt = now,
syncState = SyncState.PENDING_CREATE,
).withCategory(category)
store.insertPendingTransfer(local, json.encodeToString(body))
sync.requestSync()
return listOf(local.toTransaction())
}
suspend fun updateTransaction(id: Long, body: UpdateTransactionRequest): Transaction {
if (id < 0) {
// Aún no subida: se edita localmente (merge en el op pendiente).
val local = store.snapshotTransactions().firstOrNull { it.id == id } ?: return localPlaceholder(id)
val updated = local.copy(
accountId = body.accountId ?: local.accountId,
transactionType = body.transactionType ?: local.transactionType,
amount = body.amount ?: local.amount,
description = body.description ?: local.description,
date = body.date ?: local.date,
categoryId = body.categoryId?.value ?: local.categoryId,
).withCategory(store.snapshotCategories().firstOrNull { it.id == body.categoryId?.value })
val opType = if (updated.transactionType == "transfer") OpType.CREATE_TRANSFER else OpType.CREATE_TRANSACTION
val payload = if (updated.transactionType == "transfer") {
json.encodeToString(
TransferRequest(
fromAccountId = updated.accountId,
toAccountId = updated.toAccountId ?: updated.accountId,
amount = updated.amount,
description = updated.description,
date = updated.date,
categoryId = updated.categoryId,
),
)
} else {
json.encodeToString(
CreateTransactionRequest(
accountId = updated.accountId,
transactionType = updated.transactionType,
amount = updated.amount,
description = updated.description,
date = updated.date,
categoryId = updated.categoryId,
),
)
}
store.updatePendingCreate(updated, payload, opType)
sync.requestSync()
return updated.toTransaction()
}
store.enqueueUpdate(
store.snapshotTransactions().firstOrNull { it.id == id } ?: return localPlaceholder(id),
json.encodeToString(body),
)
sync.requestSync()
return localPlaceholder(id)
}
suspend fun updateTransactionToTransfer(id: Long, body: TransferRequest): List<Transaction> {
if (id < 0) {
val local = store.snapshotTransactions().firstOrNull { it.id == id } ?: return emptyList()
val accounts = store.snapshotAccounts().associateBy { it.id }
val updated = local.copy(
accountId = body.fromAccountId,
accountName = accounts[body.fromAccountId]?.name ?: local.accountName,
toAccountId = body.toAccountId,
toAccountName = accounts[body.toAccountId]?.name,
transactionType = "transfer",
amount = body.amount,
description = body.description,
date = body.date,
categoryId = body.categoryId,
).withCategory(store.snapshotCategories().firstOrNull { it.id == body.categoryId })
store.updatePendingCreate(updated, json.encodeToString(body), OpType.CREATE_TRANSFER)
sync.requestSync()
return listOf(updated.toTransaction())
}
store.enqueueUpdateToTransfer(
store.snapshotTransactions().firstOrNull { it.id == id } ?: return emptyList(),
json.encodeToString(body),
)
sync.requestSync()
return emptyList()
}
suspend fun deleteTransaction(id: Long) {
if (id < 0) {
store.removeLocal(id)
} else {
store.enqueueDelete(id)
sync.requestSync()
}
}
// ---------- Cuentas / categorías / reglas / recurrentes (requieren conexión) ----------
suspend fun createAccount(body: CreateAccountRequest): Account = safe { api.createAccount(body) } suspend fun createAccount(body: CreateAccountRequest): Account = safe { api.createAccount(body) }
suspend fun updateAccount(id: Long, body: UpdateAccountRequest): Account = safe { api.updateAccount(id, body) } suspend fun updateAccount(id: Long, body: UpdateAccountRequest): Account = safe { api.updateAccount(id, body) }
suspend fun deleteAccount(id: Long) = safe { api.deleteAccount(id) } suspend fun deleteAccount(id: Long) = safe { api.deleteAccount(id) }
suspend fun listAccountTransactions(id: Long): List<TransactionWithAccount> = safe { api.listAccountTransactions(id) }
suspend fun importStatement(accountId: Long, file: File): StatementImportResult = suspend fun importStatement(accountId: Long, file: File): StatementImportResult =
safe { api.importStatement(accountId, file.toMultipart("statement.pdf")) } safe { api.importStatement(accountId, file.toMultipart("statement.pdf")) }
@@ -66,34 +262,20 @@ class AppRepository(private val api: ApiService) {
suspend fun importSavingsStatement(accountId: Long, file: File): StatementImportResult = suspend fun importSavingsStatement(accountId: Long, file: File): StatementImportResult =
safe { api.importSavingsStatement(accountId, file.toMultipart("statement.pdf")) } safe { api.importSavingsStatement(accountId, file.toMultipart("statement.pdf")) }
suspend fun listTransactions(): List<TransactionWithAccount> = safe { api.listTransactions() }
suspend fun createTransaction(body: CreateTransactionRequest): Transaction = safe { api.createTransaction(body) }
suspend fun createTransfer(body: TransferRequest): List<Transaction> = safe { api.createTransfer(body) }
suspend fun updateTransaction(id: Long, body: UpdateTransactionRequest): Transaction = safe { api.updateTransaction(id, body) }
suspend fun updateTransactionToTransfer(id: Long, body: TransferRequest): List<Transaction> =
safe { api.updateTransactionToTransfer(id, body) }
suspend fun deleteTransaction(id: Long) = safe { api.deleteTransaction(id) }
suspend fun listCategories(): List<Category> = safe { api.listCategories() }
suspend fun createCategory(body: CreateCategoryRequest): Category = safe { api.createCategory(body) } suspend fun createCategory(body: CreateCategoryRequest): Category = safe { api.createCategory(body) }
suspend fun updateCategory(id: Long, body: UpdateCategoryRequest): Category = safe { api.updateCategory(id, body) } suspend fun updateCategory(id: Long, body: UpdateCategoryRequest): Category = safe { api.updateCategory(id, body) }
suspend fun deleteCategory(id: Long) = safe { api.deleteCategory(id) } suspend fun deleteCategory(id: Long) = safe { api.deleteCategory(id) }
suspend fun listRules(): List<CategorizationRule> = safe { api.listRules() }
suspend fun createRule(body: CreateCategorizationRuleRequest): CategorizationRuleCreated = safe { api.createRule(body) } suspend fun createRule(body: CreateCategorizationRuleRequest): CategorizationRuleCreated = safe { api.createRule(body) }
suspend fun updateRule(id: Long, body: UpdateCategorizationRuleRequest): CategorizationRule = safe { api.updateRule(id, body) } suspend fun updateRule(id: Long, body: UpdateCategorizationRuleRequest): CategorizationRule = safe { api.updateRule(id, body) }
suspend fun deleteRule(id: Long) = safe { api.deleteRule(id) } suspend fun deleteRule(id: Long) = safe { api.deleteRule(id) }
suspend fun getSummary(): Summary = safe { api.getSummary() } suspend fun createRecurring(body: CreateRecurringTransactionRequest): RecurringTransactionWithNames = safe { api.createRecurring(body) }
suspend fun getStatistics(): Statistics = safe { api.getStatistics() } suspend fun updateRecurring(id: Long, body: UpdateRecurringTransactionRequest): RecurringTransactionWithNames = safe { api.updateRecurring(id, body) }
suspend fun listRecurring(): List<RecurringTransactionWithNames> = safe { api.listRecurring() }
suspend fun createRecurring(body: CreateRecurringTransactionRequest): RecurringTransactionWithNames =
safe { api.createRecurring(body) }
suspend fun updateRecurring(id: Long, body: UpdateRecurringTransactionRequest): RecurringTransactionWithNames =
safe { api.updateRecurring(id, body) }
suspend fun deleteRecurring(id: Long) = safe { api.deleteRecurring(id) } suspend fun deleteRecurring(id: Long) = safe { api.deleteRecurring(id) }
// ---------- Otros ----------
suspend fun exportBackup(): BackupData = safe { api.exportBackup() } suspend fun exportBackup(): BackupData = safe { api.exportBackup() }
suspend fun importBackup(body: BackupData) = safe { api.importBackup(body) } suspend fun importBackup(body: BackupData) = safe { api.importBackup(body) }
@@ -101,11 +283,40 @@ class AppRepository(private val api: ApiService) {
suspend fun saveNtfySettings(body: NtfySettingsRequest): NtfySettings = safe { api.saveNtfySettings(body) } suspend fun saveNtfySettings(body: NtfySettingsRequest): NtfySettings = safe { api.saveNtfySettings(body) }
suspend fun testNtfy(): NtfyTestResult = safe { api.testNtfy() } suspend fun testNtfy(): NtfyTestResult = safe { api.testNtfy() }
suspend fun listNotices(): List<Notice> = safe { api.listNotices() }
suspend fun dismissNotice(fingerprint: String) = safe { api.dismissNotice(DismissNoticeRequest(fingerprint)) } suspend fun dismissNotice(fingerprint: String) = safe { api.dismissNotice(DismissNoticeRequest(fingerprint)) }
suspend fun syncNow(): Boolean = sync.syncNow()
private fun localPlaceholder(id: Long): Transaction = Transaction(
id = id,
accountId = 0,
transactionType = "expense",
amount = 0.0,
description = "",
date = "",
createdAt = "",
)
private fun LocalTransaction.toTransaction(): Transaction = Transaction(
id = id,
accountId = accountId,
toAccountId = toAccountId,
transactionType = transactionType,
amount = amount,
description = description,
date = date,
categoryId = categoryId,
createdAt = createdAt,
)
private fun File.toMultipart(name: String): MultipartBody.Part = private fun File.toMultipart(name: String): MultipartBody.Part =
MultipartBody.Part.createFormData("file", name, asRequestBody("application/pdf".toMediaType())) MultipartBody.Part.createFormData("file", name, asRequestBody("application/pdf".toMediaType()))
private companion object {
const val CACHE_RULES = "rules"
const val CACHE_RECURRING = "recurring"
const val CACHE_NOTICES = "notices"
}
} }
class ApiError(message: String) : Exception(message) class ApiError(message: String) : Exception(message)
@@ -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 }
}
} }
@@ -0,0 +1,39 @@
package com.conta.app.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [
LocalAccount::class,
LocalCategory::class,
LocalTransaction::class,
PendingOp::class,
SyncMeta::class,
],
version = 1,
exportSchema = false,
)
abstract class ContaDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao
abstract fun categoryDao(): CategoryDao
abstract fun transactionDao(): TransactionDao
abstract fun pendingOpDao(): PendingOpDao
abstract fun metaDao(): MetaDao
companion object {
@Volatile
private var instance: ContaDatabase? = null
fun get(context: Context): ContaDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
ContaDatabase::class.java,
"conta_local.db",
).build().also { instance = it }
}
}
}
@@ -0,0 +1,100 @@
package com.conta.app.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface AccountDao {
@Query("SELECT * FROM accounts ORDER BY accountType, name")
fun observeAll(): Flow<List<LocalAccount>>
@Query("SELECT * FROM accounts ORDER BY accountType, name")
suspend fun getAll(): List<LocalAccount>
@Query("SELECT * FROM accounts WHERE id = :id")
suspend fun findById(id: Long): LocalAccount?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(accounts: List<LocalAccount>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(account: LocalAccount)
@Query("DELETE FROM accounts")
suspend fun clear()
}
@Dao
interface CategoryDao {
@Query("SELECT * FROM categories ORDER BY name")
fun observeAll(): Flow<List<LocalCategory>>
@Query("SELECT * FROM categories ORDER BY name")
suspend fun getAll(): List<LocalCategory>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(categories: List<LocalCategory>)
@Query("DELETE FROM categories")
suspend fun clear()
}
@Dao
interface TransactionDao {
@Query("SELECT * FROM transactions ORDER BY date DESC, id DESC")
fun observeAll(): Flow<List<LocalTransaction>>
@Query("SELECT * FROM transactions ORDER BY date DESC, id DESC")
suspend fun getAll(): List<LocalTransaction>
@Query("SELECT * FROM transactions WHERE id = :id")
suspend fun findById(id: Long): LocalTransaction?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(transactions: List<LocalTransaction>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(transaction: LocalTransaction)
@Query("DELETE FROM transactions")
suspend fun clear()
@Query("DELETE FROM transactions WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("SELECT COUNT(*) FROM transactions WHERE syncState != :state")
suspend fun countPending(state: String = SyncState.SYNCED): Int
}
@Dao
interface PendingOpDao {
@Query("SELECT * FROM pending_ops ORDER BY opId ASC")
fun observeAll(): Flow<List<PendingOp>>
@Query("SELECT * FROM pending_ops ORDER BY opId ASC")
suspend fun getAll(): List<PendingOp>
@Query("SELECT COUNT(*) FROM pending_ops")
fun observeCount(): Flow<Int>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(op: PendingOp): Long
@Query("DELETE FROM pending_ops WHERE opId = :opId")
suspend fun deleteById(opId: Long)
@Query("UPDATE pending_ops SET retryCount = :retry, lastError = :error WHERE opId = :opId")
suspend fun updateRetry(opId: Long, retry: Int, error: String?)
}
@Dao
interface MetaDao {
@Query("SELECT value FROM sync_meta WHERE `key` = :key")
suspend fun get(key: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun put(entry: SyncMeta)
}
@@ -0,0 +1,116 @@
package com.conta.app.data.local
import com.conta.app.data.model.AccountInfo
import com.conta.app.data.model.CategoryStat
import com.conta.app.data.model.MonthlyStat
import com.conta.app.data.model.Statistics
import com.conta.app.data.model.Summary
import java.time.LocalDate
import java.util.TreeMap
/** Cálculos locales que replican la lógica del backend (saldos, resumen, estadísticas). */
fun transactionEffect(tx: LocalTransaction): Map<Long, Double> = when (tx.transactionType) {
"income" -> mapOf(tx.accountId to tx.amount)
"expense" -> mapOf(tx.accountId to -tx.amount)
"transfer" -> buildMap {
put(tx.accountId, -tx.amount)
tx.toAccountId?.let { put(it, tx.amount) }
}
else -> emptyMap()
}
/** Saldo efectivo por cuenta = saldo inicial + efectos de todas las transacciones. */
fun computeBalances(accounts: List<LocalAccount>, transactions: List<LocalTransaction>): Map<Long, Double> {
val balances = HashMap<Long, Double>()
for (a in accounts) {
val base = if (a.accountType == "credit_card") -a.initialBalance else a.initialBalance
balances[a.id] = base
}
for (tx in transactions) {
for ((accountId, delta) in transactionEffect(tx)) {
balances[accountId] = (balances[accountId] ?: 0.0) + delta
}
}
return balances
}
/** Aplica los saldos locales a los espejos, manteniendo los campos avanzados del servidor. */
fun applyLocalBalances(accounts: List<LocalAccount>, transactions: List<LocalTransaction>): List<AccountInfo> {
val balances = computeBalances(accounts, transactions)
return accounts.map { a ->
val balance = balances[a.id] ?: a.balance
val debt = (-balance).coerceAtLeast(0.0)
val saldoAFavor = balance.coerceAtLeast(0.0)
val creditAvailable = if (a.accountType == "credit_card" && a.creditLimit != null) {
(a.creditLimit - debt).coerceAtLeast(0.0)
} else {
a.creditAvailable
}
a.toAccountInfo().copy(
balance = balance,
debt = debt,
saldoAFavor = saldoAFavor,
creditAvailable = creditAvailable,
)
}
}
fun computeSummary(accounts: List<AccountInfo>): Summary = Summary(
totalBalance = accounts.sumOf { it.balance },
totalSavings = accounts.filter { it.accountType == "savings" }.sumOf { it.balance },
totalCredit = accounts.filter { it.accountType == "credit_card" }.sumOf { it.debt },
totalTimeDeposits = accounts.filter { it.accountType == "time_deposit" }.sumOf { it.balance },
accounts = accounts,
)
fun lastMonthLabels(count: Int, today: String): List<String> {
val parts = today.split("-")
val year = parts.getOrNull(0)?.toIntOrNull() ?: LocalDate.now().year
val month = parts.getOrNull(1)?.toIntOrNull() ?: 1
val total = year * 12 + (month - 1)
return (0 until count)
.map { i ->
val t = total - (count - 1 - i)
"%04d-%02d".format(t / 12, t % 12 + 1)
}
}
fun computeStatistics(transactions: List<LocalTransaction>, today: String = LocalDate.now().toString()): Statistics {
val labels = lastMonthLabels(6, today)
val currentMonth = labels.last()
val monthly = labels.map { month ->
var income = 0.0
var expense = 0.0
for (tx in transactions) {
if (!tx.date.startsWith(month)) continue
when (tx.transactionType) {
"income" -> income += tx.amount
"expense" -> expense += tx.amount
}
}
MonthlyStat(month = month, income = income, expense = expense)
}
val byCategory = TreeMap<Long?, CategoryStat> { a, b -> (a ?: Long.MIN_VALUE).compareTo(b ?: Long.MIN_VALUE) }
for (tx in transactions) {
if (tx.transactionType != "expense" || !tx.date.startsWith(currentMonth)) continue
val existing = byCategory[tx.categoryId]
if (existing == null) {
byCategory[tx.categoryId] = CategoryStat(
categoryId = tx.categoryId,
name = tx.categoryName ?: "Sin categoría",
color = tx.categoryColor,
icon = tx.categoryIcon,
total = tx.amount,
)
} else {
byCategory[tx.categoryId] = existing.copy(total = existing.total + tx.amount)
}
}
val expensesByCategory = byCategory.values.sortedByDescending { it.total }
return Statistics(expensesByCategory = expensesByCategory, monthly = monthly)
}
@@ -0,0 +1,100 @@
package com.conta.app.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
object SyncState {
const val SYNCED = "SYNCED"
const val PENDING_CREATE = "PENDING_CREATE"
const val PENDING_UPDATE = "PENDING_UPDATE"
const val PENDING_DELETE = "PENDING_DELETE"
const val FAILED = "FAILED"
}
/** Espejo local de AccountInfo (solo lectura: se actualiza desde el servidor). */
@Entity(tableName = "accounts")
data class LocalAccount(
@PrimaryKey val id: Long,
val name: String,
val accountType: String,
val balance: Double,
val initialBalance: Double = 0.0,
val creditLimit: Double? = null,
val interestRate: Double? = null,
val maturityDate: String? = null,
val paymentAccountId: Long? = null,
val cutDay: Int? = null,
val paymentDay: Int? = null,
val minPaymentPct: Double? = null,
val minPaymentMin: Double? = null,
val lateFee: Double? = null,
val createdAt: String,
val debt: Double = 0.0,
val creditAvailable: Double? = null,
val saldoAFavor: Double = 0.0,
val statementBalance: Double? = null,
val minPayment: Double? = null,
val nextCutDate: String? = null,
val nextPaymentDate: String? = null,
val overdue: Boolean = false,
)
/** Espejo local de Category (solo lectura). */
@Entity(tableName = "categories")
data class LocalCategory(
@PrimaryKey val id: Long,
val name: String,
val categoryType: String,
val icon: String? = null,
val color: String? = null,
val createdAt: String,
)
/** Espejo local de TransactionWithAccount + estado de sincronización. */
@Entity(tableName = "transactions")
data class LocalTransaction(
@PrimaryKey val id: Long,
val accountId: Long,
val accountName: String = "",
val toAccountId: Long? = null,
val toAccountName: String? = null,
val transactionType: String,
val amount: Double,
val description: String,
val date: String,
val categoryId: Long? = null,
val categoryName: String? = null,
val categoryIcon: String? = null,
val categoryColor: String? = null,
val createdAt: String,
val syncState: String = SyncState.SYNCED,
/** Identificador del servidor; null mientras el registro es local (id negativo). */
val clientRef: String? = null,
val lastError: String? = null,
)
/** Operación pendiente de subir al servidor (outbox). */
@Entity(tableName = "pending_ops")
data class PendingOp(
@PrimaryKey(autoGenerate = true) val opId: Long = 0,
val type: String,
val txId: Long,
val payloadJson: String,
val createdAt: String,
val retryCount: Int = 0,
val lastError: String? = null,
)
@Entity(tableName = "sync_meta")
data class SyncMeta(
@PrimaryKey val key: String,
val value: String,
)
object OpType {
const val CREATE_TRANSACTION = "CREATE_TRANSACTION"
const val CREATE_TRANSFER = "CREATE_TRANSFER"
const val UPDATE_TRANSACTION = "UPDATE_TRANSACTION"
const val UPDATE_TO_TRANSFER = "UPDATE_TO_TRANSFER"
const val DELETE_TRANSACTION = "DELETE_TRANSACTION"
}
@@ -0,0 +1,336 @@
package com.conta.app.data.local
import android.content.Context
import androidx.room.withTransaction
import com.conta.app.data.model.AccountInfo
import com.conta.app.data.model.Category
import com.conta.app.data.model.Transaction
import com.conta.app.data.model.TransactionWithAccount
import kotlinx.coroutines.flow.Flow
/**
* Capa de persistencia local: espejo de cuentas/categorías/transacciones
* más el outbox de operaciones pendientes de subir al servidor.
*/
class LocalStore(private val db: ContaDatabase) {
val accounts: Flow<List<LocalAccount>> = db.accountDao().observeAll()
val categories: Flow<List<LocalCategory>> = db.categoryDao().observeAll()
val transactions: Flow<List<LocalTransaction>> = db.transactionDao().observeAll()
val pendingCount: Flow<Int> = db.pendingOpDao().observeCount()
suspend fun snapshotAccounts(): List<LocalAccount> = db.accountDao().getAll()
suspend fun snapshotTransactions(): List<LocalTransaction> = db.transactionDao().getAll()
suspend fun snapshotCategories(): List<LocalCategory> = db.categoryDao().getAll()
suspend fun pendingOps(): List<PendingOp> = db.pendingOpDao().getAll()
suspend fun lastSyncedAt(): String? = db.metaDao().get(MetaKeys.LAST_SYNCED_AT)
suspend fun setLastSyncedAt(iso: String) = db.metaDao().put(SyncMeta(MetaKeys.LAST_SYNCED_AT, iso))
suspend fun getCache(key: String): String? = db.metaDao().get("cache_$key")
suspend fun putCache(key: String, json: String) = db.metaDao().put(SyncMeta("cache_$key", json))
/** Reemplaza todo el espejo con datos frescos del servidor. */
suspend fun replaceAll(
accounts: List<LocalAccount>,
categories: List<LocalCategory>,
transactions: List<LocalTransaction>,
) {
db.withTransaction {
db.accountDao().clear()
if (accounts.isNotEmpty()) db.accountDao().upsertAll(accounts)
db.categoryDao().clear()
if (categories.isNotEmpty()) db.categoryDao().upsertAll(categories)
db.transactionDao().clear()
if (transactions.isNotEmpty()) db.transactionDao().upsertAll(transactions)
}
}
/** Inserta una transacción nueva (id negativo) junto con su op pendiente. */
suspend fun insertPendingCreate(tx: LocalTransaction, payloadJson: String) {
db.withTransaction {
db.transactionDao().upsert(tx)
db.pendingOpDao().insert(
PendingOp(
type = OpType.CREATE_TRANSACTION,
txId = tx.id,
payloadJson = payloadJson,
createdAt = tx.createdAt,
),
)
}
}
/** Inserta una transferencia nueva junto con su op pendiente. */
suspend fun insertPendingTransfer(tx: LocalTransaction, payloadJson: String) {
db.withTransaction {
db.transactionDao().upsert(tx)
db.pendingOpDao().insert(
PendingOp(
type = OpType.CREATE_TRANSFER,
txId = tx.id,
payloadJson = payloadJson,
createdAt = tx.createdAt,
),
)
}
}
/** Actualiza una transacción que aún no se ha subido (merge en el op existente). */
suspend fun updatePendingCreate(tx: LocalTransaction, payloadJson: String, opType: String = OpType.CREATE_TRANSACTION) {
db.withTransaction {
val oldOp = db.pendingOpDao().getAll().firstOrNull { it.txId == tx.id }
if (oldOp != null) {
db.pendingOpDao().deleteById(oldOp.opId)
db.pendingOpDao().insert(
oldOp.copy(
type = opType,
payloadJson = payloadJson,
retryCount = 0,
lastError = null,
),
)
}
db.transactionDao().upsert(tx)
}
}
/** Marca una transacción ya sincronizada para editar: PENDING_UPDATE + op. */
suspend fun enqueueUpdate(tx: LocalTransaction, payloadJson: String) {
db.withTransaction {
db.transactionDao().upsert(tx.copy(syncState = SyncState.PENDING_UPDATE, lastError = null))
db.pendingOpDao().insert(
PendingOp(
type = OpType.UPDATE_TRANSACTION,
txId = tx.id,
payloadJson = payloadJson,
createdAt = nowIso(),
),
)
}
}
/** Marca una transacción para convertirla en transferencia. */
suspend fun enqueueUpdateToTransfer(tx: LocalTransaction, payloadJson: String) {
db.withTransaction {
db.transactionDao().upsert(tx.copy(syncState = SyncState.PENDING_UPDATE, lastError = null))
db.pendingOpDao().insert(
PendingOp(
type = OpType.UPDATE_TO_TRANSFER,
txId = tx.id,
payloadJson = payloadJson,
createdAt = nowIso(),
),
)
}
}
/** Marca una transacción sincronizada para borrar: PENDING_DELETE + op. */
suspend fun enqueueDelete(id: Long) {
db.withTransaction {
val tx = db.transactionDao().findById(id) ?: return@withTransaction
db.transactionDao().upsert(tx.copy(syncState = SyncState.PENDING_DELETE, lastError = null))
db.pendingOpDao().insert(
PendingOp(
type = OpType.DELETE_TRANSACTION,
txId = id,
payloadJson = "",
createdAt = nowIso(),
),
)
}
}
/**
* Elimina una transacción que aún no se ha subido (junto con su op pendiente).
* Si ya estaba marcada como PENDING_DELETE de algo no subido, simplemente se quita.
*/
suspend fun removeLocal(id: Long) {
db.withTransaction {
db.transactionDao().deleteById(id)
db.pendingOpDao().getAll().filter { it.txId == id }.forEach { db.pendingOpDao().deleteById(it.opId) }
}
}
/** Reemplaza una fila temp (id negativo) por la fila servida real y borra su op. */
suspend fun resolveSyncedCreate(tempId: Long, serverTx: LocalTransaction) {
db.withTransaction {
db.transactionDao().deleteById(tempId)
db.transactionDao().upsert(serverTx.copy(syncState = SyncState.SYNCED, lastError = null))
db.pendingOpDao().getAll().filter { it.txId == tempId }.forEach { db.pendingOpDao().deleteById(it.opId) }
}
}
/** Marca una transacción como sincronizada y borra su op (usado tras un update exitoso). */
suspend fun markSynced(id: Long) {
db.withTransaction {
val tx = db.transactionDao().findById(id) ?: return@withTransaction
db.transactionDao().upsert(tx.copy(syncState = SyncState.SYNCED, lastError = null))
db.pendingOpDao().getAll().filter { it.txId == id }.forEach { db.pendingOpDao().deleteById(it.opId) }
}
}
/** Borra la fila local de una transacción (p. ej. borrado confirmado o descartado por 404). */
suspend fun dropTransaction(id: Long) {
db.withTransaction {
db.transactionDao().deleteById(id)
db.pendingOpDao().getAll().filter { it.txId == id }.forEach { db.pendingOpDao().deleteById(it.opId) }
}
}
suspend fun markFailed(op: PendingOp, error: String) {
db.withTransaction {
db.pendingOpDao().deleteById(op.opId)
val tx = db.transactionDao().findById(op.txId)
if (tx != null) db.transactionDao().upsert(tx.copy(syncState = SyncState.FAILED, lastError = error))
}
}
suspend fun nextTempId(): Long {
val key = MetaKeys.NEXT_TEMP_ID
val current = db.metaDao().get(key)?.toLongOrNull() ?: 0L
val next = current - 1
db.metaDao().put(SyncMeta(key, next.toString()))
return next
}
private object MetaKeys {
const val LAST_SYNCED_AT = "last_synced_at"
const val NEXT_TEMP_ID = "next_temp_id"
}
}
fun nowIso(): String = java.time.LocalDateTime.now().toString()
// -------- Conversión modelo <-> entidad --------
fun AccountInfo.toLocal(): LocalAccount = LocalAccount(
id = id,
name = name,
accountType = accountType,
balance = balance,
initialBalance = initialBalance,
creditLimit = creditLimit,
interestRate = interestRate,
maturityDate = maturityDate,
paymentAccountId = paymentAccountId,
cutDay = cutDay,
paymentDay = paymentDay,
minPaymentPct = minPaymentPct,
minPaymentMin = minPaymentMin,
lateFee = lateFee,
createdAt = createdAt,
debt = debt,
creditAvailable = creditAvailable,
saldoAFavor = saldoAFavor,
statementBalance = statementBalance,
minPayment = minPayment,
nextCutDate = nextCutDate,
nextPaymentDate = nextPaymentDate,
overdue = overdue,
)
fun LocalAccount.toAccountInfo(): AccountInfo = AccountInfo(
id = id,
name = name,
accountType = accountType,
balance = balance,
initialBalance = initialBalance,
creditLimit = creditLimit,
interestRate = interestRate,
maturityDate = maturityDate,
paymentAccountId = paymentAccountId,
cutDay = cutDay,
paymentDay = paymentDay,
minPaymentPct = minPaymentPct,
minPaymentMin = minPaymentMin,
lateFee = lateFee,
createdAt = createdAt,
debt = debt,
creditAvailable = creditAvailable,
saldoAFavor = saldoAFavor,
statementBalance = statementBalance,
minPayment = minPayment,
nextCutDate = nextCutDate,
nextPaymentDate = nextPaymentDate,
overdue = overdue,
)
fun Category.toLocal(): LocalCategory = LocalCategory(
id = id,
name = name,
categoryType = categoryType,
icon = icon,
color = color,
createdAt = createdAt,
)
fun LocalCategory.toCategory(): Category = Category(
id = id,
name = name,
categoryType = categoryType,
icon = icon,
color = color,
createdAt = createdAt,
)
fun TransactionWithAccount.toLocal(syncState: String = SyncState.SYNCED, clientRef: String? = null): LocalTransaction =
LocalTransaction(
id = id,
accountId = accountId,
accountName = accountName,
toAccountId = toAccountId,
toAccountName = toAccountName,
transactionType = transactionType,
amount = amount,
description = description,
date = date,
categoryId = categoryId,
categoryName = categoryName,
categoryIcon = categoryIcon,
categoryColor = categoryColor,
createdAt = createdAt,
syncState = syncState,
clientRef = clientRef,
)
fun Transaction.toLocal(syncState: String = SyncState.SYNCED, clientRef: String? = null): LocalTransaction =
LocalTransaction(
id = id,
accountId = accountId,
toAccountId = toAccountId,
transactionType = transactionType,
amount = amount,
description = description,
date = date,
categoryId = categoryId,
createdAt = createdAt,
syncState = syncState,
clientRef = clientRef,
)
fun LocalTransaction.toTransactionWithAccount(): TransactionWithAccount = TransactionWithAccount(
id = id,
accountId = accountId,
accountName = accountName,
toAccountId = toAccountId,
toAccountName = toAccountName,
transactionType = transactionType,
amount = amount,
description = description,
date = date,
categoryId = categoryId,
categoryName = categoryName,
categoryIcon = categoryIcon,
categoryColor = categoryColor,
createdAt = createdAt,
)
fun LocalTransaction.withAccountName(name: String): LocalTransaction = copy(accountName = name)
fun LocalTransaction.withCategory(cat: LocalCategory?): LocalTransaction = copy(
categoryId = cat?.id ?: categoryId,
categoryName = cat?.name,
categoryIcon = cat?.icon,
categoryColor = cat?.color,
)
@@ -0,0 +1,46 @@
package com.conta.app.data.network
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** Observa si el dispositivo tiene una conexión de red activa con acceso a Internet. */
class ConnectivityObserver(private val context: Context) {
private val _hasNetwork = MutableStateFlow(false)
val hasNetwork: StateFlow<Boolean> = _hasNetwork
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = update()
override fun onLost(network: Network) = update()
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) = update()
}
fun start() {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
update()
try {
cm.registerDefaultNetworkCallback(callback)
} catch (_: Exception) {
}
}
fun stop() {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
try {
cm.unregisterNetworkCallback(callback)
} catch (_: Exception) {
}
}
private fun update() {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: run { _hasNetwork.value = false; return }
val caps = cm.getNetworkCapabilities(network)
val online = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
_hasNetwork.value = online
}
}
@@ -0,0 +1,228 @@
package com.conta.app.data.sync
import com.conta.app.data.local.LocalAccount
import com.conta.app.data.local.LocalStore
import com.conta.app.data.local.LocalTransaction
import com.conta.app.data.local.OpType
import com.conta.app.data.local.SyncState
import com.conta.app.data.local.toLocal
import com.conta.app.data.model.CreateTransactionRequest
import com.conta.app.data.model.TransactionWithAccount
import com.conta.app.data.model.TransferRequest
import com.conta.app.data.model.UpdateTransactionRequest
import com.conta.app.data.network.ApiService
import com.conta.app.data.network.ConnectivityObserver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.io.IOException
import java.time.LocalDateTime
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.roundToLong
/**
* Sincroniza el outbox local con el servidor y refresca el espejo.
* Dedupe de creaciones por clave (cuenta + fecha + monto + tipo + descripción):
* si ya existe en el servidor, se omite la subida y se mapea el id.
*/
class SyncEngine(
private val api: ApiService,
private val store: LocalStore,
private val connectivity: ConnectivityObserver,
private val json: Json,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
private val _isSyncing = MutableStateFlow(false)
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
private val _serverReachable = MutableStateFlow(false)
val serverReachable: StateFlow<Boolean> = _serverReachable.asStateFlow()
private val _lastSyncedAt = MutableStateFlow<String?>(null)
val lastSyncedAt: StateFlow<String?> = _lastSyncedAt.asStateFlow()
private val syncQueued = AtomicBoolean(false)
/** Pide una sincronización (no bloqueante, debounced). */
fun requestSync() {
if (!connectivity.hasNetwork.value) return
if (!syncQueued.compareAndSet(false, true)) return
scope.launch {
kotlinx.coroutines.delay(1500)
syncQueued.set(false)
syncNow()
}
}
/** Sincroniza el outbox y refresca el espejo. Devuelve true si pudo conectarse. */
suspend fun syncNow(): Boolean {
if (_isSyncing.value) return _serverReachable.value
_isSyncing.value = true
try {
if (!connectivity.hasNetwork.value) {
_serverReachable.value = false
return false
}
val serverTx = try {
api.listTransactions()
} catch (e: IOException) {
_serverReachable.value = false
return false
} catch (e: Exception) {
_serverReachable.value = false
return false
}
val accounts = store.snapshotAccounts().associateBy { it.id }
val serverKeySet = serverTx.map { dedupeKey(it.accountId, it.date, it.amount, it.transactionType, it.description) }.toSet()
val ops = store.pendingOps()
for (op in ops) {
when (op.type) {
OpType.CREATE_TRANSACTION, OpType.CREATE_TRANSFER -> {
val local = store.snapshotTransactions().firstOrNull { it.id == op.txId } ?: continue
val key = dedupeKey(local.accountId, local.date, local.amount, local.transactionType, local.description)
if (key in serverKeySet) {
val existing = serverTx.first { dedupeKey(it.accountId, it.date, it.amount, it.transactionType, it.description) == key }
store.resolveSyncedCreate(op.txId, fillNames(existing.toLocal(), accounts))
continue
}
try {
val created: LocalTransaction = if (op.type == OpType.CREATE_TRANSACTION) {
val body = json.decodeFromString<CreateTransactionRequest>(op.payloadJson)
api.createTransaction(body).toLocal()
} else {
val body = json.decodeFromString<TransferRequest>(op.payloadJson)
api.createTransfer(body).first().toLocal()
}
store.resolveSyncedCreate(op.txId, fillNames(created, accounts))
} catch (e: IOException) {
_serverReachable.value = false
return false
} catch (e: Exception) {
if (isHttpError(e)) store.markFailed(op, friendlyError(e)) else {
_serverReachable.value = false
return false
}
}
}
OpType.UPDATE_TRANSACTION -> {
try {
val body = json.decodeFromString<UpdateTransactionRequest>(op.payloadJson)
api.updateTransaction(op.txId, body)
store.markSynced(op.txId)
} catch (e: IOException) {
_serverReachable.value = false
return false
} catch (e: Exception) {
if (isHttpError(e)) {
if (is404(e)) store.dropTransaction(op.txId) else store.markFailed(op, friendlyError(e))
} else {
_serverReachable.value = false
return false
}
}
}
OpType.UPDATE_TO_TRANSFER -> {
try {
val body = json.decodeFromString<TransferRequest>(op.payloadJson)
api.updateTransactionToTransfer(op.txId, body)
store.markSynced(op.txId)
} catch (e: IOException) {
_serverReachable.value = false
return false
} catch (e: Exception) {
if (isHttpError(e)) {
if (is404(e)) store.dropTransaction(op.txId) else store.markFailed(op, friendlyError(e))
} else {
_serverReachable.value = false
return false
}
}
}
OpType.DELETE_TRANSACTION -> {
try {
api.deleteTransaction(op.txId)
store.dropTransaction(op.txId)
} catch (e: IOException) {
_serverReachable.value = false
return false
} catch (e: Exception) {
if (isHttpError(e)) {
if (is404(e)) store.dropTransaction(op.txId) else store.markFailed(op, friendlyError(e))
} else {
_serverReachable.value = false
return false
}
}
}
}
}
// Si quedaron ops (no se pudieron procesar todas), no refrescamos el espejo para no perder pendientes.
if (store.pendingOps().isEmpty()) {
refreshMirror()
_serverReachable.value = true
_lastSyncedAt.value = LocalDateTime.now().toString()
store.setLastSyncedAt(LocalDateTime.now().toString())
}
return _serverReachable.value
} finally {
_isSyncing.value = false
}
}
/** Actualiza el espejo de cuentas/categorías/transacciones desde el servidor, conservando pendientes. */
suspend fun refreshMirror() {
val serverAccounts = api.listAccounts()
val serverCategories = api.listCategories()
val serverTx = api.listTransactions()
val pending = store.snapshotTransactions().filter { it.syncState != SyncState.SYNCED }
val accountsByName = serverAccounts.associateBy { it.id }.mapValues { it.value.name }
store.replaceAll(
accounts = serverAccounts.map { it.toLocal() },
categories = serverCategories.map { it.toLocal() },
transactions = pending + serverTx.map {
it.toLocal().let { t ->
t.copy(
accountName = accountsByName[t.accountId] ?: t.accountName,
toAccountName = t.toAccountId?.let { id -> accountsByName[id] },
)
}
},
)
_serverReachable.value = true
_lastSyncedAt.value = LocalDateTime.now().toString()
store.setLastSyncedAt(LocalDateTime.now().toString())
}
private fun fillNames(tx: LocalTransaction, accounts: Map<Long, LocalAccount>): LocalTransaction = tx.copy(
accountName = accounts[tx.accountId]?.name ?: tx.accountName,
toAccountName = tx.toAccountId?.let { accounts[it]?.name } ?: tx.toAccountName,
)
private fun dedupeKey(accountId: Long, date: String, amount: Double, type: String, description: String): String =
"$accountId|$date|${amount.round()}|$type|${description.trim()}".lowercase()
private fun Double.round(): Long = (this * 100).roundToLong()
private fun isHttpError(e: Exception): Boolean = e is retrofit2.HttpException
private fun is404(e: Exception): Boolean = (e as? retrofit2.HttpException)?.code() == 404
private fun friendlyError(e: Exception): String = when {
e is retrofit2.HttpException && e.code() == 400 -> "Solicitud inválida (verifica los datos)"
e is retrofit2.HttpException && e.code() == 404 -> "Ya no existe en el servidor"
e is retrofit2.HttpException -> "Error del servidor (${e.code()})"
else -> e.message ?: "Error"
}
}
@@ -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,14 @@ 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.components.OfflineBanner
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 +61,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 +71,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 +97,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),
) )
@@ -100,9 +107,13 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val message by viewModel.message.collectAsStateWithLifecycle() val message by viewModel.message.collectAsStateWithLifecycle()
val busy by viewModel.busy.collectAsStateWithLifecycle() val busy by viewModel.busy.collectAsStateWithLifecycle()
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
val pendingCount by viewModel.pendingCount.collectAsStateWithLifecycle()
val backStackEntry by navController.currentBackStackEntryAsState() val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route val currentRoute = backStackEntry?.destination?.route
val showBottomBar = bottomItems.any { it.route == currentRoute } val showBottomBar = bottomItems.any { it.route == currentRoute }
val showOfflineBanner = !isOnline || pendingCount > 0
LaunchedEffect(message) { LaunchedEffect(message) {
message?.let { message?.let {
@@ -113,6 +124,16 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) {
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
if (showOfflineBanner) {
OfflineBanner(
pendingCount = pendingCount,
isOnline = isOnline,
isSyncing = isSyncing,
onSync = { viewModel.forceSync() },
)
}
},
bottomBar = { bottomBar = {
if (showBottomBar) { if (showBottomBar) {
NavigationBar { NavigationBar {
@@ -145,12 +166,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}",
@@ -30,32 +30,53 @@ import com.conta.app.data.model.UpdateCategorizationRuleRequest
import com.conta.app.data.model.UpdateCategoryRequest import com.conta.app.data.model.UpdateCategoryRequest
import com.conta.app.data.model.UpdateRecurringTransactionRequest import com.conta.app.data.model.UpdateRecurringTransactionRequest
import com.conta.app.data.model.UpdateTransactionRequest import com.conta.app.data.model.UpdateTransactionRequest
import com.conta.app.data.local.applyLocalBalances
import com.conta.app.data.local.computeStatistics
import com.conta.app.data.local.computeSummary
import com.conta.app.data.local.toCategory
import com.conta.app.data.local.toTransactionWithAccount
import com.conta.app.data.network.Session import com.conta.app.data.network.Session
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
class AppViewModel : ViewModel() { class AppViewModel : ViewModel() {
private val repo = ServiceLocator.repository private val repo = ServiceLocator.repository
private val store = ServiceLocator.store
private val _summary = MutableStateFlow<Summary?>(null) // ---------- Flujos desde el espejo local (Room) ----------
val summary: StateFlow<Summary?> = _summary.asStateFlow()
private val _statistics = MutableStateFlow<Statistics?>(null) val accounts: StateFlow<List<AccountInfo>> = combine(store.accounts, store.transactions) { acc, tx ->
val statistics: StateFlow<Statistics?> = _statistics.asStateFlow() applyLocalBalances(acc, tx)
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
private val _accounts = MutableStateFlow<List<AccountInfo>>(emptyList()) val transactions: StateFlow<List<TransactionWithAccount>> = store.transactions.map { list ->
val accounts: StateFlow<List<AccountInfo>> = _accounts.asStateFlow() list.map { it.toTransactionWithAccount() }
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
private val _transactions = MutableStateFlow<List<TransactionWithAccount>>(emptyList()) val categories: StateFlow<List<Category>> = store.categories.map { list ->
val transactions: StateFlow<List<TransactionWithAccount>> = _transactions.asStateFlow() list.map { it.toCategory() }
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
private val _categories = MutableStateFlow<List<Category>>(emptyList()) /** Null mientras no hay datos ni conexión (se usa para la pantalla de "No conectado"). */
val categories: StateFlow<List<Category>> = _categories.asStateFlow() private val _connected = MutableStateFlow(false)
val hasConnected: StateFlow<Boolean> = _connected.asStateFlow()
val summary: StateFlow<Summary?> = combine(store.accounts, store.transactions, _connected) { acc, tx, connected ->
if (!connected && acc.isEmpty() && tx.isEmpty()) null
else computeSummary(applyLocalBalances(acc, tx))
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val statistics: StateFlow<Statistics?> = store.transactions.map { computeStatistics(it) }
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val _rules = MutableStateFlow<List<CategorizationRule>>(emptyList()) private val _rules = MutableStateFlow<List<CategorizationRule>>(emptyList())
val rules: StateFlow<List<CategorizationRule>> = _rules.asStateFlow() val rules: StateFlow<List<CategorizationRule>> = _rules.asStateFlow()
@@ -75,6 +96,21 @@ class AppViewModel : ViewModel() {
private val _message = MutableStateFlow<String?>(null) private val _message = MutableStateFlow<String?>(null)
val message: StateFlow<String?> = _message.asStateFlow() val message: StateFlow<String?> = _message.asStateFlow()
val isOnline: StateFlow<Boolean> = combine(
ServiceLocator.connectivity.hasNetwork,
ServiceLocator.sync.serverReachable,
) { hasNetwork, reachable -> hasNetwork && reachable }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val isSyncing: StateFlow<Boolean> = ServiceLocator.sync.isSyncing
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val pendingCount: StateFlow<Int> = store.pendingCount
.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
val lastSyncAt: StateFlow<String?> = ServiceLocator.sync.lastSyncedAt
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
fun consumeMessage() { fun consumeMessage() {
_message.value = null _message.value = null
} }
@@ -87,6 +123,7 @@ class AppViewModel : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
val saved = ServiceLocator.settings.baseUrl.first() val saved = ServiceLocator.settings.baseUrl.first()
Session.baseUrl = saved Session.baseUrl = saved
refreshCached()
if (saved.isNotBlank()) refreshAll() if (saved.isNotBlank()) refreshAll()
} }
} }
@@ -101,56 +138,56 @@ class AppViewModel : ViewModel() {
} }
} }
private suspend fun refreshCached() {
_rules.value = repo.listRules()
_recurring.value = repo.listRecurring()
_notices.value = repo.listNotices()
}
/** Trae datos del servidor al espejo local y cachea reglas/recurrentes/avisos. */
private suspend fun pullFromServer() {
try {
repo.refreshFromServer()
_connected.value = true
refreshCached()
} catch (_: Exception) {
}
}
fun refreshAll() { fun refreshAll() {
viewModelScope.launch { viewModelScope.launch {
_loading.value = true _loading.value = true
runRequest { _summary.value = repo.getSummary() } pullFromServer()
runRequest { _transactions.value = repo.listTransactions() } repo.syncNow()
runRequest { _accounts.value = repo.listAccounts() } refreshCached()
runRequest { _categories.value = repo.listCategories() }
runRequest { _rules.value = repo.listRules() }
runRequest { _recurring.value = repo.listRecurring() }
runRequest { _notices.value = repo.listNotices() }
runRequest { _statistics.value = repo.getStatistics() }
_loading.value = false _loading.value = false
} }
} }
fun refreshDashboard() { fun refreshDashboard() {
viewModelScope.launch { viewModelScope.launch {
runRequest { _summary.value = repo.getSummary() } pullFromServer()
runRequest { _transactions.value = repo.listTransactions() } repo.syncNow()
runRequest { _notices.value = repo.listNotices() } refreshCached()
runRequest { _statistics.value = repo.getStatistics() }
} }
} }
fun refreshAccounts() { fun refreshAccounts() = refreshDashboard()
fun refreshCategories() = refreshDashboard()
fun refreshRules() = refreshDashboard()
fun refreshRecurring() = refreshDashboard()
/** Sincronización manual (botón en Ajustes). */
fun forceSync() {
viewModelScope.launch { viewModelScope.launch {
runRequest { _summary.value = repo.getSummary() } pullFromServer()
runRequest { _accounts.value = repo.listAccounts() } repo.syncNow()
refreshCached()
if (isOnline.value) showMessage("Sincronizado")
} }
} }
fun refreshCategories() { // ---------- Cuentas (requieren conexión) ----------
viewModelScope.launch {
runRequest { _categories.value = repo.listCategories() }
}
}
fun refreshRules() {
viewModelScope.launch {
runRequest { _rules.value = repo.listRules() }
}
}
fun refreshRecurring() {
viewModelScope.launch {
runRequest { _recurring.value = repo.listRecurring() }
}
}
// ---------- Cuentas ----------
fun createAccount(body: CreateAccountRequest, onDone: () -> Unit = {}) { fun createAccount(body: CreateAccountRequest, onDone: () -> Unit = {}) {
viewModelScope.launch { viewModelScope.launch {
@@ -216,7 +253,7 @@ class AppViewModel : ViewModel() {
} }
} }
// ---------- Transacciones ---------- // ---------- Movimientos (local-first) ----------
fun createTransaction(body: CreateTransactionRequest, onDone: () -> Unit = {}) { fun createTransaction(body: CreateTransactionRequest, onDone: () -> Unit = {}) {
viewModelScope.launch { viewModelScope.launch {
@@ -273,7 +310,7 @@ class AppViewModel : ViewModel() {
} }
} }
// ---------- Categorías ---------- // ---------- Categorías / Reglas / Recurrentes (requieren conexión) ----------
fun createCategory(body: CreateCategoryRequest, onDone: () -> Unit = {}) { fun createCategory(body: CreateCategoryRequest, onDone: () -> Unit = {}) {
viewModelScope.launch { viewModelScope.launch {
@@ -309,8 +346,6 @@ class AppViewModel : ViewModel() {
} }
} }
// ---------- Reglas ----------
fun createRule(body: CreateCategorizationRuleRequest, onDone: (CategorizationRuleCreated?) -> Unit) { fun createRule(body: CreateCategorizationRuleRequest, onDone: (CategorizationRuleCreated?) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
_busy.value = true _busy.value = true
@@ -352,8 +387,6 @@ class AppViewModel : ViewModel() {
} }
} }
// ---------- Recurrentes ----------
fun createRecurring(body: CreateRecurringTransactionRequest, onDone: () -> Unit = {}) { fun createRecurring(body: CreateRecurringTransactionRequest, onDone: () -> Unit = {}) {
viewModelScope.launch { viewModelScope.launch {
_busy.value = true _busy.value = true
@@ -473,4 +506,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,
)
}
}
}
}
}
}
@@ -0,0 +1,130 @@
package com.conta.app.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
/** Banner superior mostrado cuando hay cambios pendientes o no hay conexión. */
@Composable
fun OfflineBanner(pendingCount: Int, isOnline: Boolean, isSyncing: Boolean, onSync: () -> Unit) {
val text = when {
isSyncing -> "Sincronizando cambios..."
pendingCount > 0 -> "Modo offline · $pendingCount cambios pendientes de sincronizar"
else -> "Sin conexión · los cambios se guardarán localmente"
}
Surface(
color = if (pendingCount > 0) MaterialTheme.colorScheme.tertiaryContainer
else MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = if (pendingCount > 0) Icons.Filled.CloudOff else Icons.Filled.Sync,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (pendingCount > 0 && !isSyncing) {
TextButton(onClick = onSync) {
Text("Sincronizar")
}
}
}
}
}
/** Tarjeta de estado de sincronización (Ajustes). */
@Composable
fun SyncStatusCard(
isOnline: Boolean,
isSyncing: Boolean,
pendingCount: Int,
lastSyncAt: String?,
onSync: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
imageVector = if (isOnline) Icons.Filled.Sync else Icons.Filled.CloudOff,
contentDescription = null,
tint = if (isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
)
Text(
"Sincronización",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
Text(
text = if (isOnline) "En línea" else "Sin conexión",
style = MaterialTheme.typography.bodySmall,
color = if (isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
)
Text(
text = if (pendingCount > 0) "$pendingCount cambios pendientes"
else "Al día",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
lastSyncAt?.let {
Text(
text = "Última sincronización: ${formatSyncTime(it)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (pendingCount > 0) {
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
androidx.compose.material3.Button(onClick = onSync, enabled = !isSyncing) {
Icon(Icons.Filled.Sync, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
Text(if (isSyncing) "Sincronizando..." else "Sincronizar ahora")
}
}
}
}
}
private fun formatSyncTime(iso: String): String {
val dt = runCatching { LocalDateTime.parse(iso) }.getOrNull() ?: return iso
return dt.format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm", Locale.getDefault()))
}
@@ -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")
@@ -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
@@ -47,6 +48,7 @@ import com.conta.app.ServiceLocator
import com.conta.app.ui.AppViewModel import com.conta.app.ui.AppViewModel
import com.conta.app.ui.Routes import com.conta.app.ui.Routes
import com.conta.app.ui.components.SectionHeader import com.conta.app.ui.components.SectionHeader
import com.conta.app.ui.components.SyncStatusCard
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -61,13 +63,13 @@ fun SettingsScreen(viewModel: AppViewModel, navController: NavController) {
} }
val summary by viewModel.summary.collectAsStateWithLifecycle() val summary by viewModel.summary.collectAsStateWithLifecycle()
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
val pendingCount by viewModel.pendingCount.collectAsStateWithLifecycle()
val lastSyncAt by viewModel.lastSyncAt.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
@@ -120,6 +122,16 @@ fun SettingsScreen(viewModel: AppViewModel, navController: NavController) {
} }
} }
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
SectionHeader("Sincronización")
SyncStatusCard(
isOnline = isOnline,
isSyncing = isSyncing,
pendingCount = pendingCount,
lastSyncAt = lastSyncAt,
onSync = { viewModel.forceSync() },
)
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp)) androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
SectionHeader("Notificaciones") SectionHeader("Notificaciones")
SettingsItemCard( SettingsItemCard(
@@ -129,6 +141,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)}"
+1
View File
@@ -3,4 +3,5 @@ plugins {
id "org.jetbrains.kotlin.android" version "2.0.21" apply false id "org.jetbrains.kotlin.android" version "2.0.21" apply false
id "org.jetbrains.kotlin.plugin.compose" version "2.0.21" apply false id "org.jetbrains.kotlin.plugin.compose" version "2.0.21" apply false
id "org.jetbrains.kotlin.plugin.serialization" version "2.0.21" apply false id "org.jetbrains.kotlin.plugin.serialization" version "2.0.21" apply false
id "com.google.devtools.ksp" version "2.0.21-1.0.28" apply false
} }