Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5d1f2a9f1 | ||
|
|
f253274e52 | ||
|
|
9eebaca7c1 | ||
|
|
c6bf1ecbf2 | ||
|
|
b1e2a13a5f | ||
|
|
1d0228fae0 |
+7
-2
@@ -3,6 +3,7 @@ plugins {
|
||||
id "org.jetbrains.kotlin.android"
|
||||
id "org.jetbrains.kotlin.plugin.compose"
|
||||
id "org.jetbrains.kotlin.plugin.serialization"
|
||||
id "com.google.devtools.ksp"
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -13,8 +14,8 @@ android {
|
||||
applicationId "com.conta.app"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 2
|
||||
versionName "1.0.1"
|
||||
versionCode 8
|
||||
versionName "1.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -67,6 +68,10 @@ dependencies {
|
||||
|
||||
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-serialization-json:1.7.3")
|
||||
|
||||
|
||||
@@ -4,13 +4,18 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import com.conta.app.data.AppRepository
|
||||
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.ConnectivityObserver
|
||||
import com.conta.app.data.network.NetworkModule
|
||||
import com.conta.app.data.network.Session
|
||||
import com.conta.app.data.sync.SyncEngine
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ContaApp : Application() {
|
||||
@@ -28,6 +33,13 @@ class ContaApp : Application() {
|
||||
scope.launch {
|
||||
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
|
||||
private set
|
||||
|
||||
lateinit var connectivity: ConnectivityObserver
|
||||
private set
|
||||
|
||||
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) {
|
||||
if (::settings.isInitialized) return
|
||||
settings = SettingsRepository(context.applicationContext)
|
||||
connectivity = ConnectivityObserver(context.applicationContext)
|
||||
connectivity.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
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.AccountInfo
|
||||
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.UpdateTransactionRequest
|
||||
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.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
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 {
|
||||
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 updateAccount(id: Long, body: UpdateAccountRequest): Account = safe { api.updateAccount(id, body) }
|
||||
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 =
|
||||
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 =
|
||||
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 updateCategory(id: Long, body: UpdateCategoryRequest): Category = safe { api.updateCategory(id, body) }
|
||||
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 updateRule(id: Long, body: UpdateCategorizationRuleRequest): CategorizationRule = safe { api.updateRule(id, body) }
|
||||
suspend fun deleteRule(id: Long) = safe { api.deleteRule(id) }
|
||||
|
||||
suspend fun getSummary(): Summary = safe { api.getSummary() }
|
||||
suspend fun getStatistics(): Statistics = safe { api.getStatistics() }
|
||||
|
||||
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 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) }
|
||||
|
||||
// ---------- Otros ----------
|
||||
|
||||
suspend fun exportBackup(): BackupData = safe { api.exportBackup() }
|
||||
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 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 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 =
|
||||
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)
|
||||
|
||||
@@ -13,11 +13,17 @@ class SettingsRepository(private val context: Context) {
|
||||
|
||||
private object Keys {
|
||||
val BASE_URL = stringPreferencesKey("base_url")
|
||||
val THEME_ID = stringPreferencesKey("theme_id")
|
||||
}
|
||||
|
||||
val baseUrl: Flow<String> = context.dataStore.data.map { it[Keys.BASE_URL] ?: "" }
|
||||
val themeId: Flow<String> = context.dataStore.data.map { it[Keys.THEME_ID] ?: "default" }
|
||||
|
||||
suspend fun setBaseUrl(url: String) {
|
||||
context.dataStore.edit { it[Keys.BASE_URL] = url.trim() }
|
||||
}
|
||||
|
||||
suspend fun setThemeId(id: String) {
|
||||
context.dataStore.edit { it[Keys.THEME_ID] = id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountBalance
|
||||
import androidx.compose.material.icons.filled.BarChart
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.SwapHoriz
|
||||
@@ -42,11 +43,14 @@ import com.conta.app.ui.screens.categories.CategoriesScreen
|
||||
import com.conta.app.ui.screens.dashboard.DashboardScreen
|
||||
import com.conta.app.ui.screens.recurring.RecurringFormScreen
|
||||
import com.conta.app.ui.screens.recurring.RecurringScreen
|
||||
import com.conta.app.ui.screens.reports.ReportsScreen
|
||||
import com.conta.app.ui.screens.rules.RuleFormScreen
|
||||
import com.conta.app.ui.screens.rules.RulesScreen
|
||||
import com.conta.app.ui.screens.settings.BackupScreen
|
||||
import com.conta.app.ui.screens.settings.NtfyScreen
|
||||
import com.conta.app.ui.screens.settings.SettingsScreen
|
||||
import com.conta.app.ui.screens.settings.ThemeScreen
|
||||
import com.conta.app.ui.components.OfflineBanner
|
||||
import com.conta.app.ui.screens.transactions.TransactionFormScreen
|
||||
import com.conta.app.ui.screens.transactions.TransactionsScreen
|
||||
|
||||
@@ -57,6 +61,7 @@ object Routes {
|
||||
const val ACCOUNT_FORM = "account_form"
|
||||
const val TRANSACTIONS = "transactions"
|
||||
const val TRANSACTION_FORM = "transaction_form"
|
||||
const val REPORTS = "reports"
|
||||
const val CATEGORIES = "categories"
|
||||
const val CATEGORY_FORM = "category_form"
|
||||
const val RULES = "rules"
|
||||
@@ -66,6 +71,7 @@ object Routes {
|
||||
const val SETTINGS = "settings"
|
||||
const val NTFY = "ntfy"
|
||||
const val BACKUP = "backup"
|
||||
const val THEME = "theme"
|
||||
|
||||
fun accountDetail(id: Long) = "$ACCOUNT_DETAIL/$id"
|
||||
fun accountForm(id: Long? = null) =
|
||||
@@ -91,6 +97,7 @@ private val bottomItems = listOf(
|
||||
BottomNavItem(Routes.DASHBOARD, "Inicio", Icons.Filled.Home),
|
||||
BottomNavItem(Routes.ACCOUNTS, "Cuentas", Icons.Filled.AccountBalance),
|
||||
BottomNavItem(Routes.TRANSACTIONS, "Movimientos", Icons.Filled.SwapHoriz),
|
||||
BottomNavItem(Routes.REPORTS, "Reportes", Icons.Filled.BarChart),
|
||||
BottomNavItem(Routes.SETTINGS, "Ajustes", Icons.Filled.Settings),
|
||||
)
|
||||
|
||||
@@ -100,9 +107,13 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val message by viewModel.message.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 currentRoute = backStackEntry?.destination?.route
|
||||
val showBottomBar = bottomItems.any { it.route == currentRoute }
|
||||
val showOfflineBanner = !isOnline || pendingCount > 0
|
||||
|
||||
LaunchedEffect(message) {
|
||||
message?.let {
|
||||
@@ -113,6 +124,16 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) {
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
if (showOfflineBanner) {
|
||||
OfflineBanner(
|
||||
pendingCount = pendingCount,
|
||||
isOnline = isOnline,
|
||||
isSyncing = isSyncing,
|
||||
onSync = { viewModel.forceSync() },
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
if (showBottomBar) {
|
||||
NavigationBar {
|
||||
@@ -145,12 +166,14 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) {
|
||||
composable(Routes.DASHBOARD) { DashboardScreen(viewModel, navController) }
|
||||
composable(Routes.ACCOUNTS) { AccountsScreen(viewModel, navController) }
|
||||
composable(Routes.TRANSACTIONS) { TransactionsScreen(viewModel, navController) }
|
||||
composable(Routes.REPORTS) { ReportsScreen(viewModel, navController) }
|
||||
composable(Routes.SETTINGS) { SettingsScreen(viewModel, navController) }
|
||||
composable(Routes.CATEGORIES) { CategoriesScreen(viewModel, navController) }
|
||||
composable(Routes.RULES) { RulesScreen(viewModel, navController) }
|
||||
composable(Routes.RECURRING) { RecurringScreen(viewModel, navController) }
|
||||
composable(Routes.NTFY) { NtfyScreen(viewModel, navController) }
|
||||
composable(Routes.BACKUP) { BackupScreen(viewModel, navController) }
|
||||
composable(Routes.THEME) { ThemeScreen(viewModel, navController) }
|
||||
|
||||
composable(
|
||||
route = "${Routes.ACCOUNT_DETAIL}/{accountId}",
|
||||
|
||||
@@ -30,32 +30,53 @@ import com.conta.app.data.model.UpdateCategorizationRuleRequest
|
||||
import com.conta.app.data.model.UpdateCategoryRequest
|
||||
import com.conta.app.data.model.UpdateRecurringTransactionRequest
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
class AppViewModel : ViewModel() {
|
||||
|
||||
private val repo = ServiceLocator.repository
|
||||
private val store = ServiceLocator.store
|
||||
|
||||
private val _summary = MutableStateFlow<Summary?>(null)
|
||||
val summary: StateFlow<Summary?> = _summary.asStateFlow()
|
||||
// ---------- Flujos desde el espejo local (Room) ----------
|
||||
|
||||
private val _statistics = MutableStateFlow<Statistics?>(null)
|
||||
val statistics: StateFlow<Statistics?> = _statistics.asStateFlow()
|
||||
val accounts: StateFlow<List<AccountInfo>> = combine(store.accounts, store.transactions) { acc, tx ->
|
||||
applyLocalBalances(acc, tx)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private val _accounts = MutableStateFlow<List<AccountInfo>>(emptyList())
|
||||
val accounts: StateFlow<List<AccountInfo>> = _accounts.asStateFlow()
|
||||
val transactions: StateFlow<List<TransactionWithAccount>> = store.transactions.map { list ->
|
||||
list.map { it.toTransactionWithAccount() }
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private val _transactions = MutableStateFlow<List<TransactionWithAccount>>(emptyList())
|
||||
val transactions: StateFlow<List<TransactionWithAccount>> = _transactions.asStateFlow()
|
||||
val categories: StateFlow<List<Category>> = store.categories.map { list ->
|
||||
list.map { it.toCategory() }
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private val _categories = MutableStateFlow<List<Category>>(emptyList())
|
||||
val categories: StateFlow<List<Category>> = _categories.asStateFlow()
|
||||
/** Null mientras no hay datos ni conexión (se usa para la pantalla de "No conectado"). */
|
||||
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())
|
||||
val rules: StateFlow<List<CategorizationRule>> = _rules.asStateFlow()
|
||||
@@ -75,6 +96,21 @@ class AppViewModel : ViewModel() {
|
||||
private val _message = MutableStateFlow<String?>(null)
|
||||
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() {
|
||||
_message.value = null
|
||||
}
|
||||
@@ -87,6 +123,7 @@ class AppViewModel : ViewModel() {
|
||||
viewModelScope.launch {
|
||||
val saved = ServiceLocator.settings.baseUrl.first()
|
||||
Session.baseUrl = saved
|
||||
refreshCached()
|
||||
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() {
|
||||
viewModelScope.launch {
|
||||
_loading.value = true
|
||||
runRequest { _summary.value = repo.getSummary() }
|
||||
runRequest { _transactions.value = repo.listTransactions() }
|
||||
runRequest { _accounts.value = repo.listAccounts() }
|
||||
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() }
|
||||
pullFromServer()
|
||||
repo.syncNow()
|
||||
refreshCached()
|
||||
_loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshDashboard() {
|
||||
viewModelScope.launch {
|
||||
runRequest { _summary.value = repo.getSummary() }
|
||||
runRequest { _transactions.value = repo.listTransactions() }
|
||||
runRequest { _notices.value = repo.listNotices() }
|
||||
runRequest { _statistics.value = repo.getStatistics() }
|
||||
pullFromServer()
|
||||
repo.syncNow()
|
||||
refreshCached()
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
runRequest { _summary.value = repo.getSummary() }
|
||||
runRequest { _accounts.value = repo.listAccounts() }
|
||||
pullFromServer()
|
||||
repo.syncNow()
|
||||
refreshCached()
|
||||
if (isOnline.value) showMessage("Sincronizado")
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshCategories() {
|
||||
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 ----------
|
||||
// ---------- Cuentas (requieren conexión) ----------
|
||||
|
||||
fun createAccount(body: CreateAccountRequest, onDone: () -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
@@ -216,7 +253,7 @@ class AppViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Transacciones ----------
|
||||
// ---------- Movimientos (local-first) ----------
|
||||
|
||||
fun createTransaction(body: CreateTransactionRequest, onDone: () -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
@@ -273,7 +310,7 @@ class AppViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Categorías ----------
|
||||
// ---------- Categorías / Reglas / Recurrentes (requieren conexión) ----------
|
||||
|
||||
fun createCategory(body: CreateCategoryRequest, onDone: () -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
@@ -309,8 +346,6 @@ class AppViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Reglas ----------
|
||||
|
||||
fun createRule(body: CreateCategorizationRuleRequest, onDone: (CategorizationRuleCreated?) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
_busy.value = true
|
||||
@@ -352,8 +387,6 @@ class AppViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Recurrentes ----------
|
||||
|
||||
fun createRecurring(body: CreateRecurringTransactionRequest, onDone: () -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
_busy.value = true
|
||||
@@ -473,4 +506,10 @@ class AppViewModel : ViewModel() {
|
||||
refreshAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun setThemeId(id: String) {
|
||||
viewModelScope.launch {
|
||||
ServiceLocator.settings.setThemeId(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.conta.app.ui.util.formatMonth
|
||||
import com.conta.app.ui.util.formatMoneyCompact
|
||||
import com.conta.app.ui.util.resolveCategoryColor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun MonthlyBarChart(stats: List<MonthlyStat>, modifier: Modifier = Modifier) {
|
||||
@@ -193,3 +194,71 @@ fun CategoryBars(stats: List<CategoryStat>, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Barras de gasto por día del mes (espejo del "Gasto Diario" del frontend). */
|
||||
@Composable
|
||||
fun DailyExpenseChart(values: List<Double>, modifier: Modifier = Modifier) {
|
||||
val barColor = MaterialTheme.colorScheme.error
|
||||
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val chartHeight = 220.dp
|
||||
|
||||
Column(modifier) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(chartHeight),
|
||||
) {
|
||||
val gridColor = textColor.copy(alpha = 0.15f)
|
||||
val maxValue = values.maxOrNull() ?: 0.0
|
||||
val scale = if (maxValue <= 0.0) 1.0 else maxValue
|
||||
|
||||
// Líneas de referencia (0, 25, 50, 75, 100 %)
|
||||
for (frac in listOf(0f, 0.25f, 0.5f, 0.75f, 1f)) {
|
||||
val y = size.height * (1f - frac)
|
||||
drawLine(
|
||||
color = gridColor,
|
||||
start = Offset(0f, y),
|
||||
end = Offset(size.width, y),
|
||||
strokeWidth = 1f,
|
||||
)
|
||||
}
|
||||
|
||||
if (values.isEmpty()) return@Canvas
|
||||
|
||||
val slotWidth = size.width / values.size
|
||||
val barWidth = (slotWidth * 0.55f).coerceAtLeast(2f)
|
||||
val gap = slotWidth * 0.15f
|
||||
|
||||
values.forEachIndexed { i, total ->
|
||||
if (total <= 0.0) return@forEachIndexed
|
||||
val barH = (total / scale).toFloat() * size.height
|
||||
val top = size.height - barH
|
||||
drawRoundRect(
|
||||
color = barColor,
|
||||
topLeft = Offset(i * slotWidth + gap, top),
|
||||
size = Size(barWidth, barH),
|
||||
cornerRadius = CornerRadius(3f, 3f),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Etiquetas de días (cada 5 para no saturar)
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
val step = max(1, (values.size.toDouble() / 7.0).roundToInt())
|
||||
values.forEachIndexed { i, _ ->
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (i % step == 0 || i == values.size - 1) {
|
||||
Text(
|
||||
text = "${i + 1}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
account.id,
|
||||
UpdateAccountRequest(
|
||||
name = name.trim(),
|
||||
creditLimit = parseAmount(creditLimit),
|
||||
interestRate = parseAmount(interestRate),
|
||||
maturityDate = maturityDate.ifBlank { null },
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -20,7 +21,6 @@ import androidx.compose.material.icons.filled.AccountBalance
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CreditCard
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -30,7 +30,6 @@ import androidx.compose.material3.Icon
|
||||
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.ui.Alignment
|
||||
@@ -68,16 +67,7 @@ fun AccountsScreen(viewModel: AppViewModel, navController: NavController) {
|
||||
val loading by viewModel.loading.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Cuentas", fontWeight = FontWeight.Bold) },
|
||||
actions = {
|
||||
androidx.compose.material3.IconButton(onClick = { viewModel.refreshAccounts() }) {
|
||||
Icon(Icons.Filled.Sync, contentDescription = "Actualizar")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { navController.navigate(Routes.accountForm()) }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Nueva cuenta")
|
||||
|
||||
@@ -50,7 +50,7 @@ import com.conta.app.data.model.CreateCategoryRequest
|
||||
import com.conta.app.data.model.UpdateCategoryRequest
|
||||
import com.conta.app.ui.AppViewModel
|
||||
import com.conta.app.ui.components.SectionHeader
|
||||
import com.conta.app.ui.util.CATEGORY_COLOR_PALETTE
|
||||
import com.conta.app.ui.util.categoryColorPalette
|
||||
import com.conta.app.ui.util.CATEGORY_ICON_NAMES
|
||||
import com.conta.app.ui.util.categoryIcon
|
||||
|
||||
@@ -186,7 +186,8 @@ fun CategoryFormScreen(viewModel: AppViewModel, navController: NavController, ca
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
CATEGORY_COLOR_PALETTE.forEachIndexed { index, colorValue ->
|
||||
val palette = categoryColorPalette()
|
||||
palette.forEachIndexed { index, colorValue ->
|
||||
val selected = color == index.toString()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -21,7 +22,6 @@ import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.CreditCard
|
||||
import androidx.compose.material.icons.filled.Event
|
||||
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.Schedule
|
||||
import androidx.compose.material3.Card
|
||||
@@ -33,7 +33,6 @@ 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.ui.Alignment
|
||||
@@ -68,16 +67,7 @@ fun DashboardScreen(viewModel: AppViewModel, navController: NavController) {
|
||||
val loading by viewModel.loading.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Conta", fontWeight = FontWeight.Bold) },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.refreshDashboard() }) {
|
||||
Icon(Icons.Filled.Sync, contentDescription = "Actualizar")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { navController.navigate(Routes.transactionForm(preset = "expense")) }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Nueva transacción")
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.conta.app.ui.screens.reports
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import com.conta.app.ui.AppViewModel
|
||||
import com.conta.app.ui.components.DailyExpenseChart
|
||||
import com.conta.app.ui.components.EmptyState
|
||||
import com.conta.app.ui.components.LoadingScreen
|
||||
import com.conta.app.ui.util.currentMonthIso
|
||||
import com.conta.app.ui.util.daysInMonthIso
|
||||
import com.conta.app.ui.util.formatMoney
|
||||
import com.conta.app.ui.util.formatMonthFull
|
||||
import com.conta.app.ui.util.shiftMonthIso
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ReportsScreen(viewModel: AppViewModel, navController: NavController) {
|
||||
val transactions by viewModel.transactions.collectAsStateWithLifecycle()
|
||||
val summary by viewModel.summary.collectAsStateWithLifecycle()
|
||||
val loading by viewModel.loading.collectAsStateWithLifecycle()
|
||||
|
||||
var month by remember { mutableStateOf(currentMonthIso()) }
|
||||
val isCurrentMonth = month == currentMonthIso()
|
||||
|
||||
val days = daysInMonthIso(month)
|
||||
val values = remember(transactions, month) {
|
||||
val sums = DoubleArray(days)
|
||||
for (tx in transactions) {
|
||||
if (tx.transactionType != "expense" || !tx.date.startsWith(month)) continue
|
||||
val day = tx.date.substringAfterLast('-', "").toIntOrNull() ?: continue
|
||||
if (day in 1..days) sums[day - 1] += tx.amount
|
||||
}
|
||||
sums.toList()
|
||||
}
|
||||
val totalMonth = values.sum()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
) { padding ->
|
||||
when {
|
||||
summary == null && loading -> LoadingScreen(Modifier.padding(padding))
|
||||
summary == null -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
EmptyState("No conectado. Configura la URL del servidor en Ajustes.")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
androidx.compose.material3.Button(onClick = { viewModel.refreshAll() }) {
|
||||
Text("Reintentar")
|
||||
}
|
||||
androidx.compose.material3.OutlinedButton(onClick = { navController.navigate(com.conta.app.ui.Routes.SETTINGS) }) {
|
||||
Text("Ir a Ajustes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
"Reportes",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Gasto Diario del Mes",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = { month = shiftMonthIso(month, -1) }) {
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Mes anterior")
|
||||
}
|
||||
Text(
|
||||
text = formatMonthFull(month),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
IconButton(
|
||||
onClick = { month = shiftMonthIso(month, 1) },
|
||||
enabled = !isCurrentMonth,
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Mes siguiente")
|
||||
}
|
||||
}
|
||||
}
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "Total del mes: ${formatMoney(totalMonth)}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (transactions.isEmpty() && !loading) {
|
||||
EmptyState("Sin transacciones")
|
||||
} else if (totalMonth == 0.0 && !loading) {
|
||||
EmptyState("Sin gastos en ${formatMonthFull(month)}")
|
||||
} else {
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.size(16.dp))
|
||||
DailyExpenseChart(values, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -14,6 +15,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Http
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.Button
|
||||
@@ -26,7 +28,6 @@ import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.Routes
|
||||
import com.conta.app.ui.components.SectionHeader
|
||||
import com.conta.app.ui.components.SyncStatusCard
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -61,13 +63,13 @@ fun SettingsScreen(viewModel: AppViewModel, navController: NavController) {
|
||||
}
|
||||
|
||||
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(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Ajustes", fontWeight = FontWeight.Bold) },
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
) { padding ->
|
||||
Column(
|
||||
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))
|
||||
SectionHeader("Notificaciones")
|
||||
SettingsItemCard(
|
||||
@@ -129,6 +141,15 @@ fun SettingsScreen(viewModel: AppViewModel, navController: NavController) {
|
||||
onClick = { navController.navigate(Routes.NTFY) },
|
||||
)
|
||||
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
|
||||
SectionHeader("Apariencia")
|
||||
SettingsItemCard(
|
||||
icon = Icons.Filled.Palette,
|
||||
title = "Tema de colores",
|
||||
subtitle = "Catppuccin, Gruvbox, Nord, Monokai y más",
|
||||
onClick = { navController.navigate(Routes.THEME) },
|
||||
)
|
||||
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
|
||||
SectionHeader("Datos")
|
||||
SettingsItemCard(
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.conta.app.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import com.conta.app.ServiceLocator
|
||||
import com.conta.app.ui.AppViewModel
|
||||
import com.conta.app.ui.components.SectionHeader
|
||||
import com.conta.app.ui.theme.THEME_OPTIONS
|
||||
import com.conta.app.ui.theme.ThemeSpec
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ThemeScreen(viewModel: AppViewModel, navController: NavController) {
|
||||
val themeId by remember { ServiceLocator.settings.themeId }.collectAsStateWithLifecycle(initialValue = "default")
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Apariencia", fontWeight = FontWeight.Bold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Volver")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
SectionHeader("Tema")
|
||||
Text(
|
||||
text = "Elige el tema de colores de la aplicación. El cambio se aplica de inmediato y se guarda en este dispositivo.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 12.dp))
|
||||
|
||||
THEME_OPTIONS.forEach { spec ->
|
||||
ThemeRow(
|
||||
spec = spec,
|
||||
selected = themeId == spec.id,
|
||||
onClick = { viewModel.setThemeId(spec.id) },
|
||||
)
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
|
||||
}
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeRow(spec: ThemeSpec, selected: Boolean, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ThemeDot(spec.primary)
|
||||
ThemeDot(spec.secondary)
|
||||
ThemeDot(spec.background, border = true)
|
||||
}
|
||||
Text(
|
||||
text = spec.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (selected) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeDot(color: Color, border: Boolean = false) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.background(color, CircleShape)
|
||||
.then(
|
||||
if (border) Modifier.border(1.dp, MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
|
||||
else Modifier,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -5,23 +5,21 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
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.mutableStateOf
|
||||
@@ -56,16 +54,7 @@ fun TransactionsScreen(viewModel: AppViewModel, navController: NavController) {
|
||||
val totalExpense = filtered.filter { it.transactionType == "expense" }.sumOf { it.amount }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Movimientos", fontWeight = FontWeight.Bold) },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.refreshDashboard() }) {
|
||||
Icon(Icons.Filled.Sync, contentDescription = "Actualizar")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { navController.navigate(Routes.transactionForm()) }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Nueva transacción")
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.conta.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
data class ThemeSpec(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val isDark: Boolean,
|
||||
val primary: Color,
|
||||
val secondary: Color,
|
||||
val error: Color,
|
||||
val warning: Color,
|
||||
val success: Color,
|
||||
val info: Color,
|
||||
val background: Color,
|
||||
val paper: Color,
|
||||
val textPrimary: Color,
|
||||
val textSecondary: Color,
|
||||
val divider: Color,
|
||||
val categoryColors: List<Color>,
|
||||
)
|
||||
|
||||
val THEME_OPTIONS: List<ThemeSpec> = listOf(
|
||||
ThemeSpec(
|
||||
id = "default",
|
||||
label = "Predeterminado",
|
||||
isDark = false,
|
||||
primary = Color(0xFF1565C0),
|
||||
secondary = Color(0xFFE53935),
|
||||
error = Color(0xFFD32F2F),
|
||||
warning = Color(0xFFF57C00),
|
||||
success = Color(0xFF2E7D32),
|
||||
info = Color(0xFF0288D1),
|
||||
background = Color(0xFFF5F5F5),
|
||||
paper = Color(0xFFFFFFFF),
|
||||
textPrimary = Color(0xDE000000),
|
||||
textSecondary = Color(0x99000000),
|
||||
divider = Color(0x1F000000),
|
||||
categoryColors = listOf(
|
||||
Color(0xFF1976D2), Color(0xFF7B1FA2), Color(0xFF00897B), Color(0xFFF9A825),
|
||||
Color(0xFFE53935), Color(0xFF5D4037), Color(0xFF43A047), Color(0xFF6A1B9A),
|
||||
Color(0xFFC2185B), Color(0xFF303F9F), Color(0xFF00ACC1), Color(0xFF7CB342),
|
||||
Color(0xFFFFB300), Color(0xFFD81B60),
|
||||
),
|
||||
),
|
||||
ThemeSpec(
|
||||
id = "catppuccin-latte",
|
||||
label = "Catppuccin Latte (claro)",
|
||||
isDark = false,
|
||||
primary = Color(0xFF1E66F5),
|
||||
secondary = Color(0xFF8839EF),
|
||||
error = Color(0xFFD20F39),
|
||||
warning = Color(0xFFDF8E1D),
|
||||
success = Color(0xFF40A02B),
|
||||
info = Color(0xFF04A5E5),
|
||||
background = Color(0xFFEFF1F5),
|
||||
paper = Color(0xFFCCD0DA),
|
||||
textPrimary = Color(0xFF4C4F69),
|
||||
textSecondary = Color(0xFF6C6F85),
|
||||
divider = Color(0xFFACB0BE),
|
||||
categoryColors = listOf(
|
||||
Color(0xFF1E66F5), Color(0xFF8839EF), Color(0xFF179299), Color(0xFFDF8E1D),
|
||||
Color(0xFFD20F39), Color(0xFFFE640B), Color(0xFF40A02B), Color(0xFF7287FD),
|
||||
Color(0xFFE64553), Color(0xFF04A5E5), Color(0xFFDD7878), Color(0xFFEA76CB),
|
||||
Color(0xFF209FB5), Color(0xFF8C8FA1),
|
||||
),
|
||||
),
|
||||
ThemeSpec(
|
||||
id = "catppuccin-mocha",
|
||||
label = "Catppuccin Mocha (oscuro)",
|
||||
isDark = true,
|
||||
primary = Color(0xFF89B4FA),
|
||||
secondary = Color(0xFFCBA6F7),
|
||||
error = Color(0xFFF38BA8),
|
||||
warning = Color(0xFFF9E2AF),
|
||||
success = Color(0xFFA6E3A1),
|
||||
info = Color(0xFF89DCEB),
|
||||
background = Color(0xFF1E1E2E),
|
||||
paper = Color(0xFF313244),
|
||||
textPrimary = Color(0xFFCDD6F4),
|
||||
textSecondary = Color(0xFFA6ADC8),
|
||||
divider = Color(0xFF585B70),
|
||||
categoryColors = listOf(
|
||||
Color(0xFF89B4FA), Color(0xFFCBA6F7), Color(0xFF94E2D5), Color(0xFFF9E2AF),
|
||||
Color(0xFFF38BA8), Color(0xFFFAB387), Color(0xFFA6E3A1), Color(0xFFB4BEFE),
|
||||
Color(0xFFEBA0AC), Color(0xFF89DCEB), Color(0xFFF2CDCD), Color(0xFFF5C2E7),
|
||||
Color(0xFF74C7EC), Color(0xFFA6ADC8),
|
||||
),
|
||||
),
|
||||
ThemeSpec(
|
||||
id = "gruvbox-light",
|
||||
label = "Gruvbox Claro",
|
||||
isDark = false,
|
||||
primary = Color(0xFF076678),
|
||||
secondary = Color(0xFF8F3F71),
|
||||
error = Color(0xFFCC241D),
|
||||
warning = Color(0xFFB57614),
|
||||
success = Color(0xFF79740E),
|
||||
info = Color(0xFF427B58),
|
||||
background = Color(0xFFFBF1C7),
|
||||
paper = Color(0xFFEBDDB2),
|
||||
textPrimary = Color(0xFF282828),
|
||||
textSecondary = Color(0xFF928374),
|
||||
divider = Color(0xFFD5C4A1),
|
||||
categoryColors = listOf(
|
||||
Color(0xFF076678), Color(0xFF8F3F71), Color(0xFF79740E), Color(0xFFB57614),
|
||||
Color(0xFFCC241D), Color(0xFFAF3A03), Color(0xFF98971A), Color(0xFF427B58),
|
||||
Color(0xFFB16286), Color(0xFF9D0006), Color(0xFF7C6F64), Color(0xFFD65D0E),
|
||||
Color(0xFF689D6A), Color(0xFF504945),
|
||||
),
|
||||
),
|
||||
ThemeSpec(
|
||||
id = "gruvbox-dark",
|
||||
label = "Gruvbox Oscuro",
|
||||
isDark = true,
|
||||
primary = Color(0xFF83A598),
|
||||
secondary = Color(0xFFD3869B),
|
||||
error = Color(0xFFFB4934),
|
||||
warning = Color(0xFFFABD2F),
|
||||
success = Color(0xFFB8BB26),
|
||||
info = Color(0xFF8EC07C),
|
||||
background = Color(0xFF282828),
|
||||
paper = Color(0xFF3C3836),
|
||||
textPrimary = Color(0xFFEBDDB2),
|
||||
textSecondary = Color(0xFFBDAE93),
|
||||
divider = Color(0xFF665C54),
|
||||
categoryColors = listOf(
|
||||
Color(0xFF83A598), Color(0xFFD3869B), Color(0xFF8EC07C), Color(0xFFFABD2F),
|
||||
Color(0xFFFB4934), Color(0xFFD65D0E), Color(0xFFB8BB26), Color(0xFF458588),
|
||||
Color(0xFFB16286), Color(0xFFCC241D), Color(0xFF98971A), Color(0xFF689D6A),
|
||||
Color(0xFFA89984), Color(0xFFD79921),
|
||||
),
|
||||
),
|
||||
ThemeSpec(
|
||||
id = "everforest",
|
||||
label = "Everforest",
|
||||
isDark = true,
|
||||
primary = Color(0xFF7FBBB3),
|
||||
secondary = Color(0xFFD699B6),
|
||||
error = Color(0xFFE67E80),
|
||||
warning = Color(0xFFE69875),
|
||||
success = Color(0xFFA7C080),
|
||||
info = Color(0xFF83C092),
|
||||
background = Color(0xFF2D353B),
|
||||
paper = Color(0xFF343F44),
|
||||
textPrimary = Color(0xFFD3C6AA),
|
||||
textSecondary = Color(0xFF859289),
|
||||
divider = Color(0xFF4F585E),
|
||||
categoryColors = listOf(
|
||||
Color(0xFF7FBBB3), Color(0xFFD699B6), Color(0xFFA7C080), Color(0xFFE69875),
|
||||
Color(0xFFE67E80), Color(0xFFD3C6AA), Color(0xFF83C092), Color(0xFF899C97),
|
||||
Color(0xFF6A916D), Color(0xFF9DA9A0), Color(0xFFA8B370), Color(0xFFD3869B),
|
||||
Color(0xFF5C7C6A), Color(0xFFC4B28A),
|
||||
),
|
||||
),
|
||||
ThemeSpec(
|
||||
id = "nord",
|
||||
label = "Nord",
|
||||
isDark = true,
|
||||
primary = Color(0xFF88C0D0),
|
||||
secondary = Color(0xFFB48EAD),
|
||||
error = Color(0xFFBF616A),
|
||||
warning = Color(0xFFD08770),
|
||||
success = Color(0xFFA3BE8C),
|
||||
info = Color(0xFF81A1C1),
|
||||
background = Color(0xFF2E3440),
|
||||
paper = Color(0xFF3B4252),
|
||||
textPrimary = Color(0xFFECEFF4),
|
||||
textSecondary = Color(0xFFD8DEE9),
|
||||
divider = Color(0xFF4C566A),
|
||||
categoryColors = listOf(
|
||||
Color(0xFF88C0D0), Color(0xFFB48EAD), Color(0xFFA3BE8C), Color(0xFFEBCB8B),
|
||||
Color(0xFFBF616A), Color(0xFFD08770), Color(0xFF81A1C1), Color(0xFF8FBCBB),
|
||||
Color(0xFF5E81AC), Color(0xFFD8DEE9), Color(0xFF4C566A), Color(0xFF63B3AD),
|
||||
Color(0xFFC98076), Color(0xFF7EB4D8),
|
||||
),
|
||||
),
|
||||
ThemeSpec(
|
||||
id = "rose-pine-moon",
|
||||
label = "Rosé Pine Moon",
|
||||
isDark = true,
|
||||
primary = Color(0xFF9CCFD8),
|
||||
secondary = Color(0xFFC4A7E7),
|
||||
error = Color(0xFFEB6F92),
|
||||
warning = Color(0xFFF6C177),
|
||||
success = Color(0xFF3E8FB0),
|
||||
info = Color(0xFFEA9A97),
|
||||
background = Color(0xFF232136),
|
||||
paper = Color(0xFF2A273F),
|
||||
textPrimary = Color(0xFFE0DEF4),
|
||||
textSecondary = Color(0xFF908CAA),
|
||||
divider = Color(0xFF393552),
|
||||
categoryColors = listOf(
|
||||
Color(0xFF9CCFD8), Color(0xFFC4A7E7), Color(0xFF3E8FB0), Color(0xFFEA9A97),
|
||||
Color(0xFFEB6F92), Color(0xFFF6C177), Color(0xFF56949F), Color(0xFF908CAA),
|
||||
Color(0xFF31748F), Color(0xFF907AA9), Color(0xFFE0DEF4), Color(0xFF44415A),
|
||||
Color(0xFFD9D6E0), Color(0xFF2A273F),
|
||||
),
|
||||
),
|
||||
ThemeSpec(
|
||||
id = "monokai",
|
||||
label = "Monokai",
|
||||
isDark = true,
|
||||
primary = Color(0xFF66D9EF),
|
||||
secondary = Color(0xFFAE81FF),
|
||||
error = Color(0xFFF92672),
|
||||
warning = Color(0xFFFD971F),
|
||||
success = Color(0xFFA6E22E),
|
||||
info = Color(0xFFE6DB74),
|
||||
background = Color(0xFF272822),
|
||||
paper = Color(0xFF37372E),
|
||||
textPrimary = Color(0xFFF8F8F2),
|
||||
textSecondary = Color(0xFFCFCFC2),
|
||||
divider = Color(0xFF49483E),
|
||||
categoryColors = listOf(
|
||||
Color(0xFF66D9EF), Color(0xFFAE81FF), Color(0xFFA6E22E), Color(0xFFE6DB74),
|
||||
Color(0xFFF92672), Color(0xFFFD971F), Color(0xFFA1EFE4), Color(0xFFF8F8F2),
|
||||
Color(0xFF75715E), Color(0xFFE69F66), Color(0xFFCC6633), Color(0xFFBD93F9),
|
||||
Color(0xFF50FA7B), Color(0xFFFF79C6),
|
||||
),
|
||||
),
|
||||
ThemeSpec(
|
||||
id = "ayu-dark",
|
||||
label = "Ayu Dark",
|
||||
isDark = true,
|
||||
primary = Color(0xFFFF8F40),
|
||||
secondary = Color(0xFFD2A6FF),
|
||||
error = Color(0xFFF07178),
|
||||
warning = Color(0xFFFFB454),
|
||||
success = Color(0xFFAAD94C),
|
||||
info = Color(0xFF59C2FF),
|
||||
background = Color(0xFF0B0E14),
|
||||
paper = Color(0xFF141923),
|
||||
textPrimary = Color(0xFFD9D7CE),
|
||||
textSecondary = Color(0xFF626A73),
|
||||
divider = Color(0xFF2B333E),
|
||||
categoryColors = listOf(
|
||||
Color(0xFFFF8F40), Color(0xFFD2A6FF), Color(0xFFAAD94C), Color(0xFFFFB454),
|
||||
Color(0xFFF07178), Color(0xFF59C2FF), Color(0xFFE6B673), Color(0xFF95E6CB),
|
||||
Color(0xFFF29E74), Color(0xFFD4BFFF), Color(0xFF73D0FF), Color(0xFFB8CC52),
|
||||
Color(0xFFF7B2A1), Color(0xFF36A3D9),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun themeById(id: String): ThemeSpec = THEME_OPTIONS.firstOrNull { it.id == id } ?: THEME_OPTIONS.first()
|
||||
@@ -1,82 +1,106 @@
|
||||
package com.conta.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.conta.app.ServiceLocator
|
||||
|
||||
val PrimaryLight = Color(0xFF1565C0)
|
||||
val PrimaryDark = Color(0xFF90CAF9)
|
||||
val Accent = Color(0xFF00796B)
|
||||
val AccentDark = Color(0xFF80CBC4)
|
||||
val Tertiary = Color(0xFF6A1B9A)
|
||||
val TertiaryDark = Color(0xFFCE93D8)
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = PrimaryLight,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFD6E3FF),
|
||||
onPrimaryContainer = Color(0xFF0B2A56),
|
||||
secondary = Accent,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFB2DFDB),
|
||||
onSecondaryContainer = Color(0xFF00332E),
|
||||
tertiary = Tertiary,
|
||||
onTertiary = Color.White,
|
||||
tertiaryContainer = Color(0xFFE1BEE7),
|
||||
onTertiaryContainer = Color(0xFF310742),
|
||||
error = Color(0xFFD32F2F),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF410002),
|
||||
background = Color(0xFFF5F6F8),
|
||||
onBackground = Color(0xFF191C20),
|
||||
surface = Color(0xFFFAFAFA),
|
||||
onSurface = Color(0xFF191C20),
|
||||
surfaceVariant = Color(0xFFE7E8EC),
|
||||
onSurfaceVariant = Color(0xFF43474E),
|
||||
outline = Color(0xFF73777F),
|
||||
outlineVariant = Color(0xFFC4C6CC),
|
||||
surfaceTint = PrimaryLight,
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = PrimaryDark,
|
||||
onPrimary = Color(0xFF0B2A56),
|
||||
primaryContainer = Color(0xFF1E3A5F),
|
||||
onPrimaryContainer = Color(0xFFD6E3FF),
|
||||
secondary = AccentDark,
|
||||
onSecondary = Color(0xFF00332E),
|
||||
secondaryContainer = Color(0xFF00504A),
|
||||
onSecondaryContainer = Color(0xFFB2DFDB),
|
||||
tertiary = TertiaryDark,
|
||||
onTertiary = Color(0xFF310742),
|
||||
tertiaryContainer = Color(0xFF4A1584),
|
||||
onTertiaryContainer = Color(0xFFE1BEE7),
|
||||
error = Color(0xFFFFB4AB),
|
||||
onError = Color(0xFF690005),
|
||||
errorContainer = Color(0xFF93000A),
|
||||
onErrorContainer = Color(0xFFFFDAD6),
|
||||
background = Color(0xFF0F1216),
|
||||
onBackground = Color(0xFFE2E2E6),
|
||||
surface = Color(0xFF15181D),
|
||||
onSurface = Color(0xFFE2E2E6),
|
||||
surfaceVariant = Color(0xFF2A2E35),
|
||||
onSurfaceVariant = Color(0xFFC4C6CC),
|
||||
outline = Color(0xFF8E929A),
|
||||
outlineVariant = Color(0xFF2A2E35),
|
||||
surfaceTint = PrimaryDark,
|
||||
)
|
||||
|
||||
/** Aplica el esquema de colores según el tema persistido en Ajustes. */
|
||||
@Composable
|
||||
fun ContaTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
fun ContaTheme(content: @Composable () -> Unit) {
|
||||
val themeId by ServiceLocator.settings.themeId.collectAsStateWithLifecycle(initialValue = "default")
|
||||
val spec = themeById(themeId)
|
||||
val colorScheme = if (spec.isDark) spec.toDarkColorScheme() else spec.toLightColorScheme()
|
||||
CompositionLocalThemeSpec(spec) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||
colorScheme = colorScheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** CompositionLocal que expone el ThemeSpec activo (para colores de categorías, etc.). */
|
||||
val LocalThemeSpec = staticCompositionLocalOf<ThemeSpec> { THEME_OPTIONS.first() }
|
||||
|
||||
@Composable
|
||||
private fun CompositionLocalThemeSpec(spec: ThemeSpec, content: @Composable () -> Unit) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalThemeSpec provides spec, content = content)
|
||||
}
|
||||
|
||||
private fun ThemeSpec.toLightColorScheme() = lightColorScheme(
|
||||
primary = primary,
|
||||
onPrimary = onColor(primary),
|
||||
primaryContainer = lightContainer(primary),
|
||||
onPrimaryContainer = onColor(lightContainer(primary)),
|
||||
secondary = secondary,
|
||||
onSecondary = onColor(secondary),
|
||||
secondaryContainer = lightContainer(secondary),
|
||||
onSecondaryContainer = onColor(lightContainer(secondary)),
|
||||
tertiary = info,
|
||||
onTertiary = onColor(info),
|
||||
tertiaryContainer = lightContainer(info),
|
||||
onTertiaryContainer = onColor(lightContainer(info)),
|
||||
error = error,
|
||||
onError = onColor(error),
|
||||
errorContainer = lightContainer(error),
|
||||
onErrorContainer = onColor(lightContainer(error)),
|
||||
background = background,
|
||||
onBackground = textPrimary,
|
||||
surface = paper,
|
||||
onSurface = textPrimary,
|
||||
surfaceVariant = divider,
|
||||
onSurfaceVariant = textSecondary,
|
||||
outline = textSecondary,
|
||||
outlineVariant = divider,
|
||||
surfaceTint = primary,
|
||||
)
|
||||
|
||||
private fun ThemeSpec.toDarkColorScheme() = darkColorScheme(
|
||||
primary = primary,
|
||||
onPrimary = onColor(primary),
|
||||
primaryContainer = darkContainer(primary),
|
||||
onPrimaryContainer = onColor(darkContainer(primary)),
|
||||
secondary = secondary,
|
||||
onSecondary = onColor(secondary),
|
||||
secondaryContainer = darkContainer(secondary),
|
||||
onSecondaryContainer = onColor(darkContainer(secondary)),
|
||||
tertiary = info,
|
||||
onTertiary = onColor(info),
|
||||
tertiaryContainer = darkContainer(info),
|
||||
onTertiaryContainer = onColor(darkContainer(info)),
|
||||
error = error,
|
||||
onError = onColor(error),
|
||||
errorContainer = darkContainer(error),
|
||||
onErrorContainer = onColor(darkContainer(error)),
|
||||
background = background,
|
||||
onBackground = textPrimary,
|
||||
surface = paper,
|
||||
onSurface = textPrimary,
|
||||
surfaceVariant = divider,
|
||||
onSurfaceVariant = textSecondary,
|
||||
outline = textSecondary,
|
||||
outlineVariant = divider,
|
||||
surfaceTint = primary,
|
||||
)
|
||||
|
||||
/** Color de contraste (blanco/negro) para texto sobre un fondo dado. */
|
||||
private fun onColor(bg: Color): Color = if (bg.luminance() > 0.5f) Color.Black else Color.White
|
||||
|
||||
private fun Color.luminance(): Float =
|
||||
0.2126f * red + 0.7152f * green + 0.0722f * blue
|
||||
|
||||
private fun lightContainer(base: Color): Color = base.blend(Color.White, t = 0.75f)
|
||||
|
||||
private fun darkContainer(base: Color): Color = base.blend(Color.Black, t = 0.65f)
|
||||
|
||||
private fun Color.blend(other: Color, t: Float = 0.7f): Color = Color(
|
||||
red = red * (1 - t) + other.red * t,
|
||||
green = green * (1 - t) + other.green * t,
|
||||
blue = blue * (1 - t) + other.blue * t,
|
||||
alpha = 1f,
|
||||
)
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
package com.conta.app.ui.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.conta.app.ui.theme.LocalThemeSpec
|
||||
|
||||
/** Paleta por defecto para categorías (misma que el frontend). */
|
||||
val CATEGORY_COLOR_PALETTE = listOf(
|
||||
Color(0xFF1976D2), Color(0xFF7B1FA2), Color(0xFF00897B), Color(0xFFF9A825),
|
||||
Color(0xFFE53935), Color(0xFF5D4037), Color(0xFF43A047), Color(0xFF6A1B9A),
|
||||
Color(0xFFC2185B), Color(0xFF303F9F), Color(0xFF00ACC1), Color(0xFF7CB342),
|
||||
Color(0xFFFFB300), Color(0xFFD81B60),
|
||||
)
|
||||
/** Paleta de colores de categorías según el tema activo (misma que el frontend). */
|
||||
@Composable
|
||||
fun categoryColorPalette(): List<Color> = LocalThemeSpec.current.categoryColors
|
||||
|
||||
@Composable
|
||||
fun resolveCategoryColor(color: String?, fallback: Color): Color {
|
||||
if (color.isNullOrBlank()) return fallback
|
||||
// Si es un índice de la paleta (0-13), se resuelve contra la paleta.
|
||||
val palette = categoryColorPalette()
|
||||
// Si es un índice de la paleta (0-13), se resuelve contra la paleta del tema.
|
||||
val idx = color.toIntOrNull()
|
||||
if (idx != null && idx in 0 until CATEGORY_COLOR_PALETTE.size) {
|
||||
return CATEGORY_COLOR_PALETTE[idx]
|
||||
if (idx != null && idx in 0 until palette.size) {
|
||||
return palette[idx]
|
||||
}
|
||||
// Si es un color hex (##RRGGBB o #RRGGBB).
|
||||
val hex = color.trim().removePrefix("##").removePrefix("#")
|
||||
|
||||
@@ -35,6 +35,37 @@ fun formatMonth(monthStr: String): String {
|
||||
return "$label $year"
|
||||
}
|
||||
|
||||
/** "2024-01" -> "Enero 2024" */
|
||||
fun formatMonthFull(monthStr: String): String {
|
||||
val parts = monthStr.split("-")
|
||||
if (parts.size != 2) return monthStr
|
||||
val month = parts[1].toIntOrNull() ?: return monthStr
|
||||
val year = parts[0]
|
||||
val label = LocalDate.of(year.toIntOrNull() ?: 1970, month, 1)
|
||||
.month.getDisplayName(TextStyle.FULL, Locale.getDefault())
|
||||
return "$label $year"
|
||||
}
|
||||
|
||||
/** "2024-01" -> año y mes en forma "2024-01" (igual pero validado). */
|
||||
fun currentMonthIso(): String = LocalDate.now().toString().substring(0, 7)
|
||||
|
||||
/** Desplaza un mes "yyyy-MM" por delta y devuelve "yyyy-MM". */
|
||||
fun shiftMonthIso(month: String, delta: Int): String {
|
||||
val parts = month.split("-")
|
||||
val year = parts.getOrNull(0)?.toIntOrNull() ?: LocalDate.now().year
|
||||
val m = parts.getOrNull(1)?.toIntOrNull() ?: (LocalDate.now().monthValue)
|
||||
val date = LocalDate.of(year, m, 1).plusMonths(delta.toLong())
|
||||
return "${date.year}-${date.monthValue.toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
/** Número de días de un mes "yyyy-MM". */
|
||||
fun daysInMonthIso(month: String): Int {
|
||||
val parts = month.split("-")
|
||||
val year = parts.getOrNull(0)?.toIntOrNull() ?: LocalDate.now().year
|
||||
val m = parts.getOrNull(1)?.toIntOrNull() ?: 1
|
||||
return LocalDate.of(year, m, 1).lengthOfMonth()
|
||||
}
|
||||
|
||||
/** Formatea la cantidad con el prefijo de tipo de transacción: +, -, o sin signo. */
|
||||
fun signedAmount(amount: Double, type: String): String = when (type) {
|
||||
"income" -> "+${formatMoney(amount)}"
|
||||
|
||||
@@ -3,4 +3,5 @@ plugins {
|
||||
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.serialization" version "2.0.21" apply false
|
||||
id "com.google.devtools.ksp" version "2.0.21-1.0.28" apply false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user