Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5d1f2a9f1 | ||
|
|
f253274e52 | ||
|
|
9eebaca7c1 | ||
|
|
c6bf1ecbf2 |
+7
-2
@@ -3,6 +3,7 @@ plugins {
|
|||||||
id "org.jetbrains.kotlin.android"
|
id "org.jetbrains.kotlin.android"
|
||||||
id "org.jetbrains.kotlin.plugin.compose"
|
id "org.jetbrains.kotlin.plugin.compose"
|
||||||
id "org.jetbrains.kotlin.plugin.serialization"
|
id "org.jetbrains.kotlin.plugin.serialization"
|
||||||
|
id "com.google.devtools.ksp"
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -13,8 +14,8 @@ android {
|
|||||||
applicationId "com.conta.app"
|
applicationId "com.conta.app"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 4
|
versionCode 8
|
||||||
versionName "1.0.3"
|
versionName "1.1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -67,6 +68,10 @@ dependencies {
|
|||||||
|
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||||
|
|
||||||
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
|
implementation("androidx.room:room-ktx:2.6.1")
|
||||||
|
ksp("androidx.room:room-compiler:2.6.1")
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import android.app.Application
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.conta.app.data.AppRepository
|
import com.conta.app.data.AppRepository
|
||||||
import com.conta.app.data.SettingsRepository
|
import com.conta.app.data.SettingsRepository
|
||||||
|
import com.conta.app.data.local.ContaDatabase
|
||||||
|
import com.conta.app.data.local.LocalStore
|
||||||
import com.conta.app.data.network.ApiService
|
import com.conta.app.data.network.ApiService
|
||||||
|
import com.conta.app.data.network.ConnectivityObserver
|
||||||
import com.conta.app.data.network.NetworkModule
|
import com.conta.app.data.network.NetworkModule
|
||||||
import com.conta.app.data.network.Session
|
import com.conta.app.data.network.Session
|
||||||
|
import com.conta.app.data.sync.SyncEngine
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class ContaApp : Application() {
|
class ContaApp : Application() {
|
||||||
@@ -28,6 +33,13 @@ class ContaApp : Application() {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
Session.baseUrl = ServiceLocator.settings.baseUrl.first()
|
Session.baseUrl = ServiceLocator.settings.baseUrl.first()
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
|
ServiceLocator.connectivity.hasNetwork.collectLatest { online ->
|
||||||
|
if (online) {
|
||||||
|
ServiceLocator.repository.syncNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,12 +48,21 @@ object ServiceLocator {
|
|||||||
lateinit var settings: SettingsRepository
|
lateinit var settings: SettingsRepository
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
lateinit var connectivity: ConnectivityObserver
|
||||||
|
private set
|
||||||
|
|
||||||
val api: ApiService get() = NetworkModule.api
|
val api: ApiService get() = NetworkModule.api
|
||||||
|
|
||||||
val repository: AppRepository by lazy { AppRepository(api) }
|
val store: LocalStore by lazy { LocalStore(ContaDatabase.get(ContaApp.instance)) }
|
||||||
|
|
||||||
|
val sync: SyncEngine by lazy { SyncEngine(api, store, connectivity, NetworkModule.json) }
|
||||||
|
|
||||||
|
val repository: AppRepository by lazy { AppRepository(api, store, sync, NetworkModule.json) }
|
||||||
|
|
||||||
fun init(context: Context) {
|
fun init(context: Context) {
|
||||||
if (::settings.isInitialized) return
|
if (::settings.isInitialized) return
|
||||||
settings = SettingsRepository(context.applicationContext)
|
settings = SettingsRepository(context.applicationContext)
|
||||||
|
connectivity = ConnectivityObserver(context.applicationContext)
|
||||||
|
connectivity.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
package com.conta.app.data
|
package com.conta.app.data
|
||||||
|
|
||||||
|
import com.conta.app.data.local.LocalStore
|
||||||
|
import com.conta.app.data.local.LocalTransaction
|
||||||
|
import com.conta.app.data.local.OpType
|
||||||
|
import com.conta.app.data.local.SyncState
|
||||||
|
import com.conta.app.data.local.applyLocalBalances
|
||||||
|
import com.conta.app.data.local.computeStatistics
|
||||||
|
import com.conta.app.data.local.computeSummary
|
||||||
|
import com.conta.app.data.local.nowIso
|
||||||
|
import com.conta.app.data.local.toCategory
|
||||||
|
import com.conta.app.data.local.toLocal
|
||||||
|
import com.conta.app.data.local.toTransactionWithAccount
|
||||||
|
import com.conta.app.data.local.withCategory
|
||||||
import com.conta.app.data.model.Account
|
import com.conta.app.data.model.Account
|
||||||
import com.conta.app.data.model.AccountInfo
|
import com.conta.app.data.model.AccountInfo
|
||||||
import com.conta.app.data.model.BackupData
|
import com.conta.app.data.model.BackupData
|
||||||
@@ -29,13 +41,25 @@ import com.conta.app.data.model.UpdateCategoryRequest
|
|||||||
import com.conta.app.data.model.UpdateRecurringTransactionRequest
|
import com.conta.app.data.model.UpdateRecurringTransactionRequest
|
||||||
import com.conta.app.data.model.UpdateTransactionRequest
|
import com.conta.app.data.model.UpdateTransactionRequest
|
||||||
import com.conta.app.data.network.ApiService
|
import com.conta.app.data.network.ApiService
|
||||||
|
import com.conta.app.data.sync.SyncEngine
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.RequestBody.Companion.asRequestBody
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/** Convierte los errores de red/HTTP en mensajes legibles en español. */
|
/**
|
||||||
class AppRepository(private val api: ApiService) {
|
* Capa de datos local-first: los movimientos se guardan primero en el espejo local
|
||||||
|
* (outbox) y se sincronizan con el servidor. Cuentas/categorías/reglas/recurrentes
|
||||||
|
* se leen del espejo cuando están disponibles y se editan solo con conexión.
|
||||||
|
*/
|
||||||
|
class AppRepository(
|
||||||
|
private val api: ApiService,
|
||||||
|
private val store: LocalStore,
|
||||||
|
private val sync: SyncEngine,
|
||||||
|
private val json: Json,
|
||||||
|
) {
|
||||||
|
|
||||||
private inline fun <T> safe(block: () -> T): T {
|
private inline fun <T> safe(block: () -> T): T {
|
||||||
return try {
|
return try {
|
||||||
@@ -54,11 +78,183 @@ class AppRepository(private val api: ApiService) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun listAccounts(): List<AccountInfo> = safe { api.listAccounts() }
|
// ---------- Lecturas locales ----------
|
||||||
|
|
||||||
|
suspend fun listAccounts(): List<AccountInfo> =
|
||||||
|
applyLocalBalances(store.snapshotAccounts(), store.snapshotTransactions())
|
||||||
|
|
||||||
|
suspend fun listAccountTransactions(id: Long): List<TransactionWithAccount> =
|
||||||
|
listTransactions().filter { it.accountId == id || it.toAccountId == id }
|
||||||
|
|
||||||
|
suspend fun listTransactions(): List<TransactionWithAccount> =
|
||||||
|
store.snapshotTransactions().map { it.toTransactionWithAccount() }
|
||||||
|
|
||||||
|
suspend fun listCategories(): List<Category> =
|
||||||
|
store.snapshotCategories().map { it.toCategory() }
|
||||||
|
|
||||||
|
suspend fun getSummary(): Summary = computeSummary(listAccounts())
|
||||||
|
|
||||||
|
suspend fun getStatistics(): Statistics = computeStatistics(store.snapshotTransactions())
|
||||||
|
|
||||||
|
suspend fun listRules(): List<CategorizationRule> =
|
||||||
|
store.getCache(CACHE_RULES)?.let { runCatching { json.decodeFromString<List<CategorizationRule>>(it) }.getOrNull() }
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
|
suspend fun listRecurring(): List<RecurringTransactionWithNames> =
|
||||||
|
store.getCache(CACHE_RECURRING)?.let { runCatching { json.decodeFromString<List<RecurringTransactionWithNames>>(it) }.getOrNull() }
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
|
suspend fun listNotices(): List<Notice> =
|
||||||
|
store.getCache(CACHE_NOTICES)?.let { runCatching { json.decodeFromString<List<Notice>>(it) }.getOrNull() }
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
|
/** Refresca el espejo desde el servidor y cachea reglas/recurrentes/avisos. */
|
||||||
|
suspend fun refreshFromServer() {
|
||||||
|
safe {
|
||||||
|
sync.refreshMirror()
|
||||||
|
store.putCache(CACHE_RULES, json.encodeToString(api.listRules()))
|
||||||
|
store.putCache(CACHE_RECURRING, json.encodeToString(api.listRecurring()))
|
||||||
|
store.putCache(CACHE_NOTICES, json.encodeToString(api.listNotices()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Movimientos: local-first (outbox) ----------
|
||||||
|
|
||||||
|
suspend fun createTransaction(body: CreateTransactionRequest): Transaction {
|
||||||
|
val id = store.nextTempId()
|
||||||
|
val now = nowIso()
|
||||||
|
val accountName = store.snapshotAccounts().firstOrNull { it.id == body.accountId }?.name ?: ""
|
||||||
|
val category = store.snapshotCategories().firstOrNull { it.id == body.categoryId }
|
||||||
|
val local = LocalTransaction(
|
||||||
|
id = id,
|
||||||
|
accountId = body.accountId,
|
||||||
|
accountName = accountName,
|
||||||
|
transactionType = body.transactionType,
|
||||||
|
amount = body.amount,
|
||||||
|
description = body.description,
|
||||||
|
date = body.date,
|
||||||
|
categoryId = body.categoryId,
|
||||||
|
createdAt = now,
|
||||||
|
syncState = SyncState.PENDING_CREATE,
|
||||||
|
).withCategory(category)
|
||||||
|
store.insertPendingCreate(local, json.encodeToString(body))
|
||||||
|
sync.requestSync()
|
||||||
|
return local.toTransaction()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createTransfer(body: TransferRequest): List<Transaction> {
|
||||||
|
val id = store.nextTempId()
|
||||||
|
val now = nowIso()
|
||||||
|
val accounts = store.snapshotAccounts().associateBy { it.id }
|
||||||
|
val category = store.snapshotCategories().firstOrNull { it.id == body.categoryId }
|
||||||
|
val local = LocalTransaction(
|
||||||
|
id = id,
|
||||||
|
accountId = body.fromAccountId,
|
||||||
|
accountName = accounts[body.fromAccountId]?.name ?: "",
|
||||||
|
toAccountId = body.toAccountId,
|
||||||
|
toAccountName = accounts[body.toAccountId]?.name,
|
||||||
|
transactionType = "transfer",
|
||||||
|
amount = body.amount,
|
||||||
|
description = body.description,
|
||||||
|
date = body.date,
|
||||||
|
categoryId = body.categoryId,
|
||||||
|
createdAt = now,
|
||||||
|
syncState = SyncState.PENDING_CREATE,
|
||||||
|
).withCategory(category)
|
||||||
|
store.insertPendingTransfer(local, json.encodeToString(body))
|
||||||
|
sync.requestSync()
|
||||||
|
return listOf(local.toTransaction())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateTransaction(id: Long, body: UpdateTransactionRequest): Transaction {
|
||||||
|
if (id < 0) {
|
||||||
|
// Aún no subida: se edita localmente (merge en el op pendiente).
|
||||||
|
val local = store.snapshotTransactions().firstOrNull { it.id == id } ?: return localPlaceholder(id)
|
||||||
|
val updated = local.copy(
|
||||||
|
accountId = body.accountId ?: local.accountId,
|
||||||
|
transactionType = body.transactionType ?: local.transactionType,
|
||||||
|
amount = body.amount ?: local.amount,
|
||||||
|
description = body.description ?: local.description,
|
||||||
|
date = body.date ?: local.date,
|
||||||
|
categoryId = body.categoryId?.value ?: local.categoryId,
|
||||||
|
).withCategory(store.snapshotCategories().firstOrNull { it.id == body.categoryId?.value })
|
||||||
|
val opType = if (updated.transactionType == "transfer") OpType.CREATE_TRANSFER else OpType.CREATE_TRANSACTION
|
||||||
|
val payload = if (updated.transactionType == "transfer") {
|
||||||
|
json.encodeToString(
|
||||||
|
TransferRequest(
|
||||||
|
fromAccountId = updated.accountId,
|
||||||
|
toAccountId = updated.toAccountId ?: updated.accountId,
|
||||||
|
amount = updated.amount,
|
||||||
|
description = updated.description,
|
||||||
|
date = updated.date,
|
||||||
|
categoryId = updated.categoryId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
json.encodeToString(
|
||||||
|
CreateTransactionRequest(
|
||||||
|
accountId = updated.accountId,
|
||||||
|
transactionType = updated.transactionType,
|
||||||
|
amount = updated.amount,
|
||||||
|
description = updated.description,
|
||||||
|
date = updated.date,
|
||||||
|
categoryId = updated.categoryId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
store.updatePendingCreate(updated, payload, opType)
|
||||||
|
sync.requestSync()
|
||||||
|
return updated.toTransaction()
|
||||||
|
}
|
||||||
|
store.enqueueUpdate(
|
||||||
|
store.snapshotTransactions().firstOrNull { it.id == id } ?: return localPlaceholder(id),
|
||||||
|
json.encodeToString(body),
|
||||||
|
)
|
||||||
|
sync.requestSync()
|
||||||
|
return localPlaceholder(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateTransactionToTransfer(id: Long, body: TransferRequest): List<Transaction> {
|
||||||
|
if (id < 0) {
|
||||||
|
val local = store.snapshotTransactions().firstOrNull { it.id == id } ?: return emptyList()
|
||||||
|
val accounts = store.snapshotAccounts().associateBy { it.id }
|
||||||
|
val updated = local.copy(
|
||||||
|
accountId = body.fromAccountId,
|
||||||
|
accountName = accounts[body.fromAccountId]?.name ?: local.accountName,
|
||||||
|
toAccountId = body.toAccountId,
|
||||||
|
toAccountName = accounts[body.toAccountId]?.name,
|
||||||
|
transactionType = "transfer",
|
||||||
|
amount = body.amount,
|
||||||
|
description = body.description,
|
||||||
|
date = body.date,
|
||||||
|
categoryId = body.categoryId,
|
||||||
|
).withCategory(store.snapshotCategories().firstOrNull { it.id == body.categoryId })
|
||||||
|
store.updatePendingCreate(updated, json.encodeToString(body), OpType.CREATE_TRANSFER)
|
||||||
|
sync.requestSync()
|
||||||
|
return listOf(updated.toTransaction())
|
||||||
|
}
|
||||||
|
store.enqueueUpdateToTransfer(
|
||||||
|
store.snapshotTransactions().firstOrNull { it.id == id } ?: return emptyList(),
|
||||||
|
json.encodeToString(body),
|
||||||
|
)
|
||||||
|
sync.requestSync()
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteTransaction(id: Long) {
|
||||||
|
if (id < 0) {
|
||||||
|
store.removeLocal(id)
|
||||||
|
} else {
|
||||||
|
store.enqueueDelete(id)
|
||||||
|
sync.requestSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Cuentas / categorías / reglas / recurrentes (requieren conexión) ----------
|
||||||
|
|
||||||
suspend fun createAccount(body: CreateAccountRequest): Account = safe { api.createAccount(body) }
|
suspend fun createAccount(body: CreateAccountRequest): Account = safe { api.createAccount(body) }
|
||||||
suspend fun updateAccount(id: Long, body: UpdateAccountRequest): Account = safe { api.updateAccount(id, body) }
|
suspend fun updateAccount(id: Long, body: UpdateAccountRequest): Account = safe { api.updateAccount(id, body) }
|
||||||
suspend fun deleteAccount(id: Long) = safe { api.deleteAccount(id) }
|
suspend fun deleteAccount(id: Long) = safe { api.deleteAccount(id) }
|
||||||
suspend fun listAccountTransactions(id: Long): List<TransactionWithAccount> = safe { api.listAccountTransactions(id) }
|
|
||||||
|
|
||||||
suspend fun importStatement(accountId: Long, file: File): StatementImportResult =
|
suspend fun importStatement(accountId: Long, file: File): StatementImportResult =
|
||||||
safe { api.importStatement(accountId, file.toMultipart("statement.pdf")) }
|
safe { api.importStatement(accountId, file.toMultipart("statement.pdf")) }
|
||||||
@@ -66,34 +262,20 @@ class AppRepository(private val api: ApiService) {
|
|||||||
suspend fun importSavingsStatement(accountId: Long, file: File): StatementImportResult =
|
suspend fun importSavingsStatement(accountId: Long, file: File): StatementImportResult =
|
||||||
safe { api.importSavingsStatement(accountId, file.toMultipart("statement.pdf")) }
|
safe { api.importSavingsStatement(accountId, file.toMultipart("statement.pdf")) }
|
||||||
|
|
||||||
suspend fun listTransactions(): List<TransactionWithAccount> = safe { api.listTransactions() }
|
|
||||||
suspend fun createTransaction(body: CreateTransactionRequest): Transaction = safe { api.createTransaction(body) }
|
|
||||||
suspend fun createTransfer(body: TransferRequest): List<Transaction> = safe { api.createTransfer(body) }
|
|
||||||
suspend fun updateTransaction(id: Long, body: UpdateTransactionRequest): Transaction = safe { api.updateTransaction(id, body) }
|
|
||||||
suspend fun updateTransactionToTransfer(id: Long, body: TransferRequest): List<Transaction> =
|
|
||||||
safe { api.updateTransactionToTransfer(id, body) }
|
|
||||||
suspend fun deleteTransaction(id: Long) = safe { api.deleteTransaction(id) }
|
|
||||||
|
|
||||||
suspend fun listCategories(): List<Category> = safe { api.listCategories() }
|
|
||||||
suspend fun createCategory(body: CreateCategoryRequest): Category = safe { api.createCategory(body) }
|
suspend fun createCategory(body: CreateCategoryRequest): Category = safe { api.createCategory(body) }
|
||||||
suspend fun updateCategory(id: Long, body: UpdateCategoryRequest): Category = safe { api.updateCategory(id, body) }
|
suspend fun updateCategory(id: Long, body: UpdateCategoryRequest): Category = safe { api.updateCategory(id, body) }
|
||||||
suspend fun deleteCategory(id: Long) = safe { api.deleteCategory(id) }
|
suspend fun deleteCategory(id: Long) = safe { api.deleteCategory(id) }
|
||||||
|
|
||||||
suspend fun listRules(): List<CategorizationRule> = safe { api.listRules() }
|
|
||||||
suspend fun createRule(body: CreateCategorizationRuleRequest): CategorizationRuleCreated = safe { api.createRule(body) }
|
suspend fun createRule(body: CreateCategorizationRuleRequest): CategorizationRuleCreated = safe { api.createRule(body) }
|
||||||
suspend fun updateRule(id: Long, body: UpdateCategorizationRuleRequest): CategorizationRule = safe { api.updateRule(id, body) }
|
suspend fun updateRule(id: Long, body: UpdateCategorizationRuleRequest): CategorizationRule = safe { api.updateRule(id, body) }
|
||||||
suspend fun deleteRule(id: Long) = safe { api.deleteRule(id) }
|
suspend fun deleteRule(id: Long) = safe { api.deleteRule(id) }
|
||||||
|
|
||||||
suspend fun getSummary(): Summary = safe { api.getSummary() }
|
suspend fun createRecurring(body: CreateRecurringTransactionRequest): RecurringTransactionWithNames = safe { api.createRecurring(body) }
|
||||||
suspend fun getStatistics(): Statistics = safe { api.getStatistics() }
|
suspend fun updateRecurring(id: Long, body: UpdateRecurringTransactionRequest): RecurringTransactionWithNames = safe { api.updateRecurring(id, body) }
|
||||||
|
|
||||||
suspend fun listRecurring(): List<RecurringTransactionWithNames> = safe { api.listRecurring() }
|
|
||||||
suspend fun createRecurring(body: CreateRecurringTransactionRequest): RecurringTransactionWithNames =
|
|
||||||
safe { api.createRecurring(body) }
|
|
||||||
suspend fun updateRecurring(id: Long, body: UpdateRecurringTransactionRequest): RecurringTransactionWithNames =
|
|
||||||
safe { api.updateRecurring(id, body) }
|
|
||||||
suspend fun deleteRecurring(id: Long) = safe { api.deleteRecurring(id) }
|
suspend fun deleteRecurring(id: Long) = safe { api.deleteRecurring(id) }
|
||||||
|
|
||||||
|
// ---------- Otros ----------
|
||||||
|
|
||||||
suspend fun exportBackup(): BackupData = safe { api.exportBackup() }
|
suspend fun exportBackup(): BackupData = safe { api.exportBackup() }
|
||||||
suspend fun importBackup(body: BackupData) = safe { api.importBackup(body) }
|
suspend fun importBackup(body: BackupData) = safe { api.importBackup(body) }
|
||||||
|
|
||||||
@@ -101,11 +283,40 @@ class AppRepository(private val api: ApiService) {
|
|||||||
suspend fun saveNtfySettings(body: NtfySettingsRequest): NtfySettings = safe { api.saveNtfySettings(body) }
|
suspend fun saveNtfySettings(body: NtfySettingsRequest): NtfySettings = safe { api.saveNtfySettings(body) }
|
||||||
suspend fun testNtfy(): NtfyTestResult = safe { api.testNtfy() }
|
suspend fun testNtfy(): NtfyTestResult = safe { api.testNtfy() }
|
||||||
|
|
||||||
suspend fun listNotices(): List<Notice> = safe { api.listNotices() }
|
|
||||||
suspend fun dismissNotice(fingerprint: String) = safe { api.dismissNotice(DismissNoticeRequest(fingerprint)) }
|
suspend fun dismissNotice(fingerprint: String) = safe { api.dismissNotice(DismissNoticeRequest(fingerprint)) }
|
||||||
|
|
||||||
|
suspend fun syncNow(): Boolean = sync.syncNow()
|
||||||
|
|
||||||
|
private fun localPlaceholder(id: Long): Transaction = Transaction(
|
||||||
|
id = id,
|
||||||
|
accountId = 0,
|
||||||
|
transactionType = "expense",
|
||||||
|
amount = 0.0,
|
||||||
|
description = "",
|
||||||
|
date = "",
|
||||||
|
createdAt = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun LocalTransaction.toTransaction(): Transaction = Transaction(
|
||||||
|
id = id,
|
||||||
|
accountId = accountId,
|
||||||
|
toAccountId = toAccountId,
|
||||||
|
transactionType = transactionType,
|
||||||
|
amount = amount,
|
||||||
|
description = description,
|
||||||
|
date = date,
|
||||||
|
categoryId = categoryId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
)
|
||||||
|
|
||||||
private fun File.toMultipart(name: String): MultipartBody.Part =
|
private fun File.toMultipart(name: String): MultipartBody.Part =
|
||||||
MultipartBody.Part.createFormData("file", name, asRequestBody("application/pdf".toMediaType()))
|
MultipartBody.Part.createFormData("file", name, asRequestBody("application/pdf".toMediaType()))
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CACHE_RULES = "rules"
|
||||||
|
const val CACHE_RECURRING = "recurring"
|
||||||
|
const val CACHE_NOTICES = "notices"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ApiError(message: String) : Exception(message)
|
class ApiError(message: String) : Exception(message)
|
||||||
|
|||||||
@@ -13,11 +13,17 @@ class SettingsRepository(private val context: Context) {
|
|||||||
|
|
||||||
private object Keys {
|
private object Keys {
|
||||||
val BASE_URL = stringPreferencesKey("base_url")
|
val BASE_URL = stringPreferencesKey("base_url")
|
||||||
|
val THEME_ID = stringPreferencesKey("theme_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
val baseUrl: Flow<String> = context.dataStore.data.map { it[Keys.BASE_URL] ?: "" }
|
val baseUrl: Flow<String> = context.dataStore.data.map { it[Keys.BASE_URL] ?: "" }
|
||||||
|
val themeId: Flow<String> = context.dataStore.data.map { it[Keys.THEME_ID] ?: "default" }
|
||||||
|
|
||||||
suspend fun setBaseUrl(url: String) {
|
suspend fun setBaseUrl(url: String) {
|
||||||
context.dataStore.edit { it[Keys.BASE_URL] = url.trim() }
|
context.dataStore.edit { it[Keys.BASE_URL] = url.trim() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setThemeId(id: String) {
|
||||||
|
context.dataStore.edit { it[Keys.THEME_ID] = id }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.conta.app.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [
|
||||||
|
LocalAccount::class,
|
||||||
|
LocalCategory::class,
|
||||||
|
LocalTransaction::class,
|
||||||
|
PendingOp::class,
|
||||||
|
SyncMeta::class,
|
||||||
|
],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false,
|
||||||
|
)
|
||||||
|
abstract class ContaDatabase : RoomDatabase() {
|
||||||
|
abstract fun accountDao(): AccountDao
|
||||||
|
abstract fun categoryDao(): CategoryDao
|
||||||
|
abstract fun transactionDao(): TransactionDao
|
||||||
|
abstract fun pendingOpDao(): PendingOpDao
|
||||||
|
abstract fun metaDao(): MetaDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var instance: ContaDatabase? = null
|
||||||
|
|
||||||
|
fun get(context: Context): ContaDatabase =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
ContaDatabase::class.java,
|
||||||
|
"conta_local.db",
|
||||||
|
).build().also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.conta.app.data.local
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface AccountDao {
|
||||||
|
@Query("SELECT * FROM accounts ORDER BY accountType, name")
|
||||||
|
fun observeAll(): Flow<List<LocalAccount>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM accounts ORDER BY accountType, name")
|
||||||
|
suspend fun getAll(): List<LocalAccount>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM accounts WHERE id = :id")
|
||||||
|
suspend fun findById(id: Long): LocalAccount?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsertAll(accounts: List<LocalAccount>)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(account: LocalAccount)
|
||||||
|
|
||||||
|
@Query("DELETE FROM accounts")
|
||||||
|
suspend fun clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CategoryDao {
|
||||||
|
@Query("SELECT * FROM categories ORDER BY name")
|
||||||
|
fun observeAll(): Flow<List<LocalCategory>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM categories ORDER BY name")
|
||||||
|
suspend fun getAll(): List<LocalCategory>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsertAll(categories: List<LocalCategory>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM categories")
|
||||||
|
suspend fun clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface TransactionDao {
|
||||||
|
@Query("SELECT * FROM transactions ORDER BY date DESC, id DESC")
|
||||||
|
fun observeAll(): Flow<List<LocalTransaction>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM transactions ORDER BY date DESC, id DESC")
|
||||||
|
suspend fun getAll(): List<LocalTransaction>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM transactions WHERE id = :id")
|
||||||
|
suspend fun findById(id: Long): LocalTransaction?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsertAll(transactions: List<LocalTransaction>)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(transaction: LocalTransaction)
|
||||||
|
|
||||||
|
@Query("DELETE FROM transactions")
|
||||||
|
suspend fun clear()
|
||||||
|
|
||||||
|
@Query("DELETE FROM transactions WHERE id = :id")
|
||||||
|
suspend fun deleteById(id: Long)
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM transactions WHERE syncState != :state")
|
||||||
|
suspend fun countPending(state: String = SyncState.SYNCED): Int
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface PendingOpDao {
|
||||||
|
@Query("SELECT * FROM pending_ops ORDER BY opId ASC")
|
||||||
|
fun observeAll(): Flow<List<PendingOp>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM pending_ops ORDER BY opId ASC")
|
||||||
|
suspend fun getAll(): List<PendingOp>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM pending_ops")
|
||||||
|
fun observeCount(): Flow<Int>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(op: PendingOp): Long
|
||||||
|
|
||||||
|
@Query("DELETE FROM pending_ops WHERE opId = :opId")
|
||||||
|
suspend fun deleteById(opId: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE pending_ops SET retryCount = :retry, lastError = :error WHERE opId = :opId")
|
||||||
|
suspend fun updateRetry(opId: Long, retry: Int, error: String?)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface MetaDao {
|
||||||
|
@Query("SELECT value FROM sync_meta WHERE `key` = :key")
|
||||||
|
suspend fun get(key: String): String?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun put(entry: SyncMeta)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.conta.app.data.local
|
||||||
|
|
||||||
|
import com.conta.app.data.model.AccountInfo
|
||||||
|
import com.conta.app.data.model.CategoryStat
|
||||||
|
import com.conta.app.data.model.MonthlyStat
|
||||||
|
import com.conta.app.data.model.Statistics
|
||||||
|
import com.conta.app.data.model.Summary
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.util.TreeMap
|
||||||
|
|
||||||
|
/** Cálculos locales que replican la lógica del backend (saldos, resumen, estadísticas). */
|
||||||
|
|
||||||
|
fun transactionEffect(tx: LocalTransaction): Map<Long, Double> = when (tx.transactionType) {
|
||||||
|
"income" -> mapOf(tx.accountId to tx.amount)
|
||||||
|
"expense" -> mapOf(tx.accountId to -tx.amount)
|
||||||
|
"transfer" -> buildMap {
|
||||||
|
put(tx.accountId, -tx.amount)
|
||||||
|
tx.toAccountId?.let { put(it, tx.amount) }
|
||||||
|
}
|
||||||
|
else -> emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saldo efectivo por cuenta = saldo inicial + efectos de todas las transacciones. */
|
||||||
|
fun computeBalances(accounts: List<LocalAccount>, transactions: List<LocalTransaction>): Map<Long, Double> {
|
||||||
|
val balances = HashMap<Long, Double>()
|
||||||
|
for (a in accounts) {
|
||||||
|
val base = if (a.accountType == "credit_card") -a.initialBalance else a.initialBalance
|
||||||
|
balances[a.id] = base
|
||||||
|
}
|
||||||
|
for (tx in transactions) {
|
||||||
|
for ((accountId, delta) in transactionEffect(tx)) {
|
||||||
|
balances[accountId] = (balances[accountId] ?: 0.0) + delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return balances
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aplica los saldos locales a los espejos, manteniendo los campos avanzados del servidor. */
|
||||||
|
fun applyLocalBalances(accounts: List<LocalAccount>, transactions: List<LocalTransaction>): List<AccountInfo> {
|
||||||
|
val balances = computeBalances(accounts, transactions)
|
||||||
|
return accounts.map { a ->
|
||||||
|
val balance = balances[a.id] ?: a.balance
|
||||||
|
val debt = (-balance).coerceAtLeast(0.0)
|
||||||
|
val saldoAFavor = balance.coerceAtLeast(0.0)
|
||||||
|
val creditAvailable = if (a.accountType == "credit_card" && a.creditLimit != null) {
|
||||||
|
(a.creditLimit - debt).coerceAtLeast(0.0)
|
||||||
|
} else {
|
||||||
|
a.creditAvailable
|
||||||
|
}
|
||||||
|
a.toAccountInfo().copy(
|
||||||
|
balance = balance,
|
||||||
|
debt = debt,
|
||||||
|
saldoAFavor = saldoAFavor,
|
||||||
|
creditAvailable = creditAvailable,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun computeSummary(accounts: List<AccountInfo>): Summary = Summary(
|
||||||
|
totalBalance = accounts.sumOf { it.balance },
|
||||||
|
totalSavings = accounts.filter { it.accountType == "savings" }.sumOf { it.balance },
|
||||||
|
totalCredit = accounts.filter { it.accountType == "credit_card" }.sumOf { it.debt },
|
||||||
|
totalTimeDeposits = accounts.filter { it.accountType == "time_deposit" }.sumOf { it.balance },
|
||||||
|
accounts = accounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun lastMonthLabels(count: Int, today: String): List<String> {
|
||||||
|
val parts = today.split("-")
|
||||||
|
val year = parts.getOrNull(0)?.toIntOrNull() ?: LocalDate.now().year
|
||||||
|
val month = parts.getOrNull(1)?.toIntOrNull() ?: 1
|
||||||
|
val total = year * 12 + (month - 1)
|
||||||
|
return (0 until count)
|
||||||
|
.map { i ->
|
||||||
|
val t = total - (count - 1 - i)
|
||||||
|
"%04d-%02d".format(t / 12, t % 12 + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun computeStatistics(transactions: List<LocalTransaction>, today: String = LocalDate.now().toString()): Statistics {
|
||||||
|
val labels = lastMonthLabels(6, today)
|
||||||
|
val currentMonth = labels.last()
|
||||||
|
|
||||||
|
val monthly = labels.map { month ->
|
||||||
|
var income = 0.0
|
||||||
|
var expense = 0.0
|
||||||
|
for (tx in transactions) {
|
||||||
|
if (!tx.date.startsWith(month)) continue
|
||||||
|
when (tx.transactionType) {
|
||||||
|
"income" -> income += tx.amount
|
||||||
|
"expense" -> expense += tx.amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MonthlyStat(month = month, income = income, expense = expense)
|
||||||
|
}
|
||||||
|
|
||||||
|
val byCategory = TreeMap<Long?, CategoryStat> { a, b -> (a ?: Long.MIN_VALUE).compareTo(b ?: Long.MIN_VALUE) }
|
||||||
|
for (tx in transactions) {
|
||||||
|
if (tx.transactionType != "expense" || !tx.date.startsWith(currentMonth)) continue
|
||||||
|
val existing = byCategory[tx.categoryId]
|
||||||
|
if (existing == null) {
|
||||||
|
byCategory[tx.categoryId] = CategoryStat(
|
||||||
|
categoryId = tx.categoryId,
|
||||||
|
name = tx.categoryName ?: "Sin categoría",
|
||||||
|
color = tx.categoryColor,
|
||||||
|
icon = tx.categoryIcon,
|
||||||
|
total = tx.amount,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
byCategory[tx.categoryId] = existing.copy(total = existing.total + tx.amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val expensesByCategory = byCategory.values.sortedByDescending { it.total }
|
||||||
|
|
||||||
|
return Statistics(expensesByCategory = expensesByCategory, monthly = monthly)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.conta.app.data.local
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
object SyncState {
|
||||||
|
const val SYNCED = "SYNCED"
|
||||||
|
const val PENDING_CREATE = "PENDING_CREATE"
|
||||||
|
const val PENDING_UPDATE = "PENDING_UPDATE"
|
||||||
|
const val PENDING_DELETE = "PENDING_DELETE"
|
||||||
|
const val FAILED = "FAILED"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Espejo local de AccountInfo (solo lectura: se actualiza desde el servidor). */
|
||||||
|
@Entity(tableName = "accounts")
|
||||||
|
data class LocalAccount(
|
||||||
|
@PrimaryKey val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val accountType: String,
|
||||||
|
val balance: Double,
|
||||||
|
val initialBalance: Double = 0.0,
|
||||||
|
val creditLimit: Double? = null,
|
||||||
|
val interestRate: Double? = null,
|
||||||
|
val maturityDate: String? = null,
|
||||||
|
val paymentAccountId: Long? = null,
|
||||||
|
val cutDay: Int? = null,
|
||||||
|
val paymentDay: Int? = null,
|
||||||
|
val minPaymentPct: Double? = null,
|
||||||
|
val minPaymentMin: Double? = null,
|
||||||
|
val lateFee: Double? = null,
|
||||||
|
val createdAt: String,
|
||||||
|
val debt: Double = 0.0,
|
||||||
|
val creditAvailable: Double? = null,
|
||||||
|
val saldoAFavor: Double = 0.0,
|
||||||
|
val statementBalance: Double? = null,
|
||||||
|
val minPayment: Double? = null,
|
||||||
|
val nextCutDate: String? = null,
|
||||||
|
val nextPaymentDate: String? = null,
|
||||||
|
val overdue: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Espejo local de Category (solo lectura). */
|
||||||
|
@Entity(tableName = "categories")
|
||||||
|
data class LocalCategory(
|
||||||
|
@PrimaryKey val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val categoryType: String,
|
||||||
|
val icon: String? = null,
|
||||||
|
val color: String? = null,
|
||||||
|
val createdAt: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Espejo local de TransactionWithAccount + estado de sincronización. */
|
||||||
|
@Entity(tableName = "transactions")
|
||||||
|
data class LocalTransaction(
|
||||||
|
@PrimaryKey val id: Long,
|
||||||
|
val accountId: Long,
|
||||||
|
val accountName: String = "",
|
||||||
|
val toAccountId: Long? = null,
|
||||||
|
val toAccountName: String? = null,
|
||||||
|
val transactionType: String,
|
||||||
|
val amount: Double,
|
||||||
|
val description: String,
|
||||||
|
val date: String,
|
||||||
|
val categoryId: Long? = null,
|
||||||
|
val categoryName: String? = null,
|
||||||
|
val categoryIcon: String? = null,
|
||||||
|
val categoryColor: String? = null,
|
||||||
|
val createdAt: String,
|
||||||
|
val syncState: String = SyncState.SYNCED,
|
||||||
|
/** Identificador del servidor; null mientras el registro es local (id negativo). */
|
||||||
|
val clientRef: String? = null,
|
||||||
|
val lastError: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Operación pendiente de subir al servidor (outbox). */
|
||||||
|
@Entity(tableName = "pending_ops")
|
||||||
|
data class PendingOp(
|
||||||
|
@PrimaryKey(autoGenerate = true) val opId: Long = 0,
|
||||||
|
val type: String,
|
||||||
|
val txId: Long,
|
||||||
|
val payloadJson: String,
|
||||||
|
val createdAt: String,
|
||||||
|
val retryCount: Int = 0,
|
||||||
|
val lastError: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(tableName = "sync_meta")
|
||||||
|
data class SyncMeta(
|
||||||
|
@PrimaryKey val key: String,
|
||||||
|
val value: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
object OpType {
|
||||||
|
const val CREATE_TRANSACTION = "CREATE_TRANSACTION"
|
||||||
|
const val CREATE_TRANSFER = "CREATE_TRANSFER"
|
||||||
|
const val UPDATE_TRANSACTION = "UPDATE_TRANSACTION"
|
||||||
|
const val UPDATE_TO_TRANSFER = "UPDATE_TO_TRANSFER"
|
||||||
|
const val DELETE_TRANSACTION = "DELETE_TRANSACTION"
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package com.conta.app.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import com.conta.app.data.model.AccountInfo
|
||||||
|
import com.conta.app.data.model.Category
|
||||||
|
import com.conta.app.data.model.Transaction
|
||||||
|
import com.conta.app.data.model.TransactionWithAccount
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capa de persistencia local: espejo de cuentas/categorías/transacciones
|
||||||
|
* más el outbox de operaciones pendientes de subir al servidor.
|
||||||
|
*/
|
||||||
|
class LocalStore(private val db: ContaDatabase) {
|
||||||
|
|
||||||
|
val accounts: Flow<List<LocalAccount>> = db.accountDao().observeAll()
|
||||||
|
val categories: Flow<List<LocalCategory>> = db.categoryDao().observeAll()
|
||||||
|
val transactions: Flow<List<LocalTransaction>> = db.transactionDao().observeAll()
|
||||||
|
val pendingCount: Flow<Int> = db.pendingOpDao().observeCount()
|
||||||
|
|
||||||
|
suspend fun snapshotAccounts(): List<LocalAccount> = db.accountDao().getAll()
|
||||||
|
suspend fun snapshotTransactions(): List<LocalTransaction> = db.transactionDao().getAll()
|
||||||
|
suspend fun snapshotCategories(): List<LocalCategory> = db.categoryDao().getAll()
|
||||||
|
|
||||||
|
suspend fun pendingOps(): List<PendingOp> = db.pendingOpDao().getAll()
|
||||||
|
|
||||||
|
suspend fun lastSyncedAt(): String? = db.metaDao().get(MetaKeys.LAST_SYNCED_AT)
|
||||||
|
suspend fun setLastSyncedAt(iso: String) = db.metaDao().put(SyncMeta(MetaKeys.LAST_SYNCED_AT, iso))
|
||||||
|
|
||||||
|
suspend fun getCache(key: String): String? = db.metaDao().get("cache_$key")
|
||||||
|
suspend fun putCache(key: String, json: String) = db.metaDao().put(SyncMeta("cache_$key", json))
|
||||||
|
|
||||||
|
/** Reemplaza todo el espejo con datos frescos del servidor. */
|
||||||
|
suspend fun replaceAll(
|
||||||
|
accounts: List<LocalAccount>,
|
||||||
|
categories: List<LocalCategory>,
|
||||||
|
transactions: List<LocalTransaction>,
|
||||||
|
) {
|
||||||
|
db.withTransaction {
|
||||||
|
db.accountDao().clear()
|
||||||
|
if (accounts.isNotEmpty()) db.accountDao().upsertAll(accounts)
|
||||||
|
db.categoryDao().clear()
|
||||||
|
if (categories.isNotEmpty()) db.categoryDao().upsertAll(categories)
|
||||||
|
db.transactionDao().clear()
|
||||||
|
if (transactions.isNotEmpty()) db.transactionDao().upsertAll(transactions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inserta una transacción nueva (id negativo) junto con su op pendiente. */
|
||||||
|
suspend fun insertPendingCreate(tx: LocalTransaction, payloadJson: String) {
|
||||||
|
db.withTransaction {
|
||||||
|
db.transactionDao().upsert(tx)
|
||||||
|
db.pendingOpDao().insert(
|
||||||
|
PendingOp(
|
||||||
|
type = OpType.CREATE_TRANSACTION,
|
||||||
|
txId = tx.id,
|
||||||
|
payloadJson = payloadJson,
|
||||||
|
createdAt = tx.createdAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inserta una transferencia nueva junto con su op pendiente. */
|
||||||
|
suspend fun insertPendingTransfer(tx: LocalTransaction, payloadJson: String) {
|
||||||
|
db.withTransaction {
|
||||||
|
db.transactionDao().upsert(tx)
|
||||||
|
db.pendingOpDao().insert(
|
||||||
|
PendingOp(
|
||||||
|
type = OpType.CREATE_TRANSFER,
|
||||||
|
txId = tx.id,
|
||||||
|
payloadJson = payloadJson,
|
||||||
|
createdAt = tx.createdAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Actualiza una transacción que aún no se ha subido (merge en el op existente). */
|
||||||
|
suspend fun updatePendingCreate(tx: LocalTransaction, payloadJson: String, opType: String = OpType.CREATE_TRANSACTION) {
|
||||||
|
db.withTransaction {
|
||||||
|
val oldOp = db.pendingOpDao().getAll().firstOrNull { it.txId == tx.id }
|
||||||
|
if (oldOp != null) {
|
||||||
|
db.pendingOpDao().deleteById(oldOp.opId)
|
||||||
|
db.pendingOpDao().insert(
|
||||||
|
oldOp.copy(
|
||||||
|
type = opType,
|
||||||
|
payloadJson = payloadJson,
|
||||||
|
retryCount = 0,
|
||||||
|
lastError = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
db.transactionDao().upsert(tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marca una transacción ya sincronizada para editar: PENDING_UPDATE + op. */
|
||||||
|
suspend fun enqueueUpdate(tx: LocalTransaction, payloadJson: String) {
|
||||||
|
db.withTransaction {
|
||||||
|
db.transactionDao().upsert(tx.copy(syncState = SyncState.PENDING_UPDATE, lastError = null))
|
||||||
|
db.pendingOpDao().insert(
|
||||||
|
PendingOp(
|
||||||
|
type = OpType.UPDATE_TRANSACTION,
|
||||||
|
txId = tx.id,
|
||||||
|
payloadJson = payloadJson,
|
||||||
|
createdAt = nowIso(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marca una transacción para convertirla en transferencia. */
|
||||||
|
suspend fun enqueueUpdateToTransfer(tx: LocalTransaction, payloadJson: String) {
|
||||||
|
db.withTransaction {
|
||||||
|
db.transactionDao().upsert(tx.copy(syncState = SyncState.PENDING_UPDATE, lastError = null))
|
||||||
|
db.pendingOpDao().insert(
|
||||||
|
PendingOp(
|
||||||
|
type = OpType.UPDATE_TO_TRANSFER,
|
||||||
|
txId = tx.id,
|
||||||
|
payloadJson = payloadJson,
|
||||||
|
createdAt = nowIso(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marca una transacción sincronizada para borrar: PENDING_DELETE + op. */
|
||||||
|
suspend fun enqueueDelete(id: Long) {
|
||||||
|
db.withTransaction {
|
||||||
|
val tx = db.transactionDao().findById(id) ?: return@withTransaction
|
||||||
|
db.transactionDao().upsert(tx.copy(syncState = SyncState.PENDING_DELETE, lastError = null))
|
||||||
|
db.pendingOpDao().insert(
|
||||||
|
PendingOp(
|
||||||
|
type = OpType.DELETE_TRANSACTION,
|
||||||
|
txId = id,
|
||||||
|
payloadJson = "",
|
||||||
|
createdAt = nowIso(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina una transacción que aún no se ha subido (junto con su op pendiente).
|
||||||
|
* Si ya estaba marcada como PENDING_DELETE de algo no subido, simplemente se quita.
|
||||||
|
*/
|
||||||
|
suspend fun removeLocal(id: Long) {
|
||||||
|
db.withTransaction {
|
||||||
|
db.transactionDao().deleteById(id)
|
||||||
|
db.pendingOpDao().getAll().filter { it.txId == id }.forEach { db.pendingOpDao().deleteById(it.opId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reemplaza una fila temp (id negativo) por la fila servida real y borra su op. */
|
||||||
|
suspend fun resolveSyncedCreate(tempId: Long, serverTx: LocalTransaction) {
|
||||||
|
db.withTransaction {
|
||||||
|
db.transactionDao().deleteById(tempId)
|
||||||
|
db.transactionDao().upsert(serverTx.copy(syncState = SyncState.SYNCED, lastError = null))
|
||||||
|
db.pendingOpDao().getAll().filter { it.txId == tempId }.forEach { db.pendingOpDao().deleteById(it.opId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marca una transacción como sincronizada y borra su op (usado tras un update exitoso). */
|
||||||
|
suspend fun markSynced(id: Long) {
|
||||||
|
db.withTransaction {
|
||||||
|
val tx = db.transactionDao().findById(id) ?: return@withTransaction
|
||||||
|
db.transactionDao().upsert(tx.copy(syncState = SyncState.SYNCED, lastError = null))
|
||||||
|
db.pendingOpDao().getAll().filter { it.txId == id }.forEach { db.pendingOpDao().deleteById(it.opId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Borra la fila local de una transacción (p. ej. borrado confirmado o descartado por 404). */
|
||||||
|
suspend fun dropTransaction(id: Long) {
|
||||||
|
db.withTransaction {
|
||||||
|
db.transactionDao().deleteById(id)
|
||||||
|
db.pendingOpDao().getAll().filter { it.txId == id }.forEach { db.pendingOpDao().deleteById(it.opId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun markFailed(op: PendingOp, error: String) {
|
||||||
|
db.withTransaction {
|
||||||
|
db.pendingOpDao().deleteById(op.opId)
|
||||||
|
val tx = db.transactionDao().findById(op.txId)
|
||||||
|
if (tx != null) db.transactionDao().upsert(tx.copy(syncState = SyncState.FAILED, lastError = error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun nextTempId(): Long {
|
||||||
|
val key = MetaKeys.NEXT_TEMP_ID
|
||||||
|
val current = db.metaDao().get(key)?.toLongOrNull() ?: 0L
|
||||||
|
val next = current - 1
|
||||||
|
db.metaDao().put(SyncMeta(key, next.toString()))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
private object MetaKeys {
|
||||||
|
const val LAST_SYNCED_AT = "last_synced_at"
|
||||||
|
const val NEXT_TEMP_ID = "next_temp_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nowIso(): String = java.time.LocalDateTime.now().toString()
|
||||||
|
|
||||||
|
// -------- Conversión modelo <-> entidad --------
|
||||||
|
|
||||||
|
fun AccountInfo.toLocal(): LocalAccount = LocalAccount(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
accountType = accountType,
|
||||||
|
balance = balance,
|
||||||
|
initialBalance = initialBalance,
|
||||||
|
creditLimit = creditLimit,
|
||||||
|
interestRate = interestRate,
|
||||||
|
maturityDate = maturityDate,
|
||||||
|
paymentAccountId = paymentAccountId,
|
||||||
|
cutDay = cutDay,
|
||||||
|
paymentDay = paymentDay,
|
||||||
|
minPaymentPct = minPaymentPct,
|
||||||
|
minPaymentMin = minPaymentMin,
|
||||||
|
lateFee = lateFee,
|
||||||
|
createdAt = createdAt,
|
||||||
|
debt = debt,
|
||||||
|
creditAvailable = creditAvailable,
|
||||||
|
saldoAFavor = saldoAFavor,
|
||||||
|
statementBalance = statementBalance,
|
||||||
|
minPayment = minPayment,
|
||||||
|
nextCutDate = nextCutDate,
|
||||||
|
nextPaymentDate = nextPaymentDate,
|
||||||
|
overdue = overdue,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun LocalAccount.toAccountInfo(): AccountInfo = AccountInfo(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
accountType = accountType,
|
||||||
|
balance = balance,
|
||||||
|
initialBalance = initialBalance,
|
||||||
|
creditLimit = creditLimit,
|
||||||
|
interestRate = interestRate,
|
||||||
|
maturityDate = maturityDate,
|
||||||
|
paymentAccountId = paymentAccountId,
|
||||||
|
cutDay = cutDay,
|
||||||
|
paymentDay = paymentDay,
|
||||||
|
minPaymentPct = minPaymentPct,
|
||||||
|
minPaymentMin = minPaymentMin,
|
||||||
|
lateFee = lateFee,
|
||||||
|
createdAt = createdAt,
|
||||||
|
debt = debt,
|
||||||
|
creditAvailable = creditAvailable,
|
||||||
|
saldoAFavor = saldoAFavor,
|
||||||
|
statementBalance = statementBalance,
|
||||||
|
minPayment = minPayment,
|
||||||
|
nextCutDate = nextCutDate,
|
||||||
|
nextPaymentDate = nextPaymentDate,
|
||||||
|
overdue = overdue,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Category.toLocal(): LocalCategory = LocalCategory(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
categoryType = categoryType,
|
||||||
|
icon = icon,
|
||||||
|
color = color,
|
||||||
|
createdAt = createdAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun LocalCategory.toCategory(): Category = Category(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
categoryType = categoryType,
|
||||||
|
icon = icon,
|
||||||
|
color = color,
|
||||||
|
createdAt = createdAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun TransactionWithAccount.toLocal(syncState: String = SyncState.SYNCED, clientRef: String? = null): LocalTransaction =
|
||||||
|
LocalTransaction(
|
||||||
|
id = id,
|
||||||
|
accountId = accountId,
|
||||||
|
accountName = accountName,
|
||||||
|
toAccountId = toAccountId,
|
||||||
|
toAccountName = toAccountName,
|
||||||
|
transactionType = transactionType,
|
||||||
|
amount = amount,
|
||||||
|
description = description,
|
||||||
|
date = date,
|
||||||
|
categoryId = categoryId,
|
||||||
|
categoryName = categoryName,
|
||||||
|
categoryIcon = categoryIcon,
|
||||||
|
categoryColor = categoryColor,
|
||||||
|
createdAt = createdAt,
|
||||||
|
syncState = syncState,
|
||||||
|
clientRef = clientRef,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Transaction.toLocal(syncState: String = SyncState.SYNCED, clientRef: String? = null): LocalTransaction =
|
||||||
|
LocalTransaction(
|
||||||
|
id = id,
|
||||||
|
accountId = accountId,
|
||||||
|
toAccountId = toAccountId,
|
||||||
|
transactionType = transactionType,
|
||||||
|
amount = amount,
|
||||||
|
description = description,
|
||||||
|
date = date,
|
||||||
|
categoryId = categoryId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
syncState = syncState,
|
||||||
|
clientRef = clientRef,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun LocalTransaction.toTransactionWithAccount(): TransactionWithAccount = TransactionWithAccount(
|
||||||
|
id = id,
|
||||||
|
accountId = accountId,
|
||||||
|
accountName = accountName,
|
||||||
|
toAccountId = toAccountId,
|
||||||
|
toAccountName = toAccountName,
|
||||||
|
transactionType = transactionType,
|
||||||
|
amount = amount,
|
||||||
|
description = description,
|
||||||
|
date = date,
|
||||||
|
categoryId = categoryId,
|
||||||
|
categoryName = categoryName,
|
||||||
|
categoryIcon = categoryIcon,
|
||||||
|
categoryColor = categoryColor,
|
||||||
|
createdAt = createdAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun LocalTransaction.withAccountName(name: String): LocalTransaction = copy(accountName = name)
|
||||||
|
fun LocalTransaction.withCategory(cat: LocalCategory?): LocalTransaction = copy(
|
||||||
|
categoryId = cat?.id ?: categoryId,
|
||||||
|
categoryName = cat?.name,
|
||||||
|
categoryIcon = cat?.icon,
|
||||||
|
categoryColor = cat?.color,
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.conta.app.data.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/** Observa si el dispositivo tiene una conexión de red activa con acceso a Internet. */
|
||||||
|
class ConnectivityObserver(private val context: Context) {
|
||||||
|
|
||||||
|
private val _hasNetwork = MutableStateFlow(false)
|
||||||
|
val hasNetwork: StateFlow<Boolean> = _hasNetwork
|
||||||
|
|
||||||
|
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) = update()
|
||||||
|
override fun onLost(network: Network) = update()
|
||||||
|
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) = update()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
update()
|
||||||
|
try {
|
||||||
|
cm.registerDefaultNetworkCallback(callback)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
try {
|
||||||
|
cm.unregisterNetworkCallback(callback)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun update() {
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
val network = cm.activeNetwork ?: run { _hasNetwork.value = false; return }
|
||||||
|
val caps = cm.getNetworkCapabilities(network)
|
||||||
|
val online = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||||
|
_hasNetwork.value = online
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package com.conta.app.data.sync
|
||||||
|
|
||||||
|
import com.conta.app.data.local.LocalAccount
|
||||||
|
import com.conta.app.data.local.LocalStore
|
||||||
|
import com.conta.app.data.local.LocalTransaction
|
||||||
|
import com.conta.app.data.local.OpType
|
||||||
|
import com.conta.app.data.local.SyncState
|
||||||
|
import com.conta.app.data.local.toLocal
|
||||||
|
import com.conta.app.data.model.CreateTransactionRequest
|
||||||
|
import com.conta.app.data.model.TransactionWithAccount
|
||||||
|
import com.conta.app.data.model.TransferRequest
|
||||||
|
import com.conta.app.data.model.UpdateTransactionRequest
|
||||||
|
import com.conta.app.data.network.ApiService
|
||||||
|
import com.conta.app.data.network.ConnectivityObserver
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.IOException
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza el outbox local con el servidor y refresca el espejo.
|
||||||
|
* Dedupe de creaciones por clave (cuenta + fecha + monto + tipo + descripción):
|
||||||
|
* si ya existe en el servidor, se omite la subida y se mapea el id.
|
||||||
|
*/
|
||||||
|
class SyncEngine(
|
||||||
|
private val api: ApiService,
|
||||||
|
private val store: LocalStore,
|
||||||
|
private val connectivity: ConnectivityObserver,
|
||||||
|
private val json: Json,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
) {
|
||||||
|
private val _isSyncing = MutableStateFlow(false)
|
||||||
|
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
|
||||||
|
|
||||||
|
private val _serverReachable = MutableStateFlow(false)
|
||||||
|
val serverReachable: StateFlow<Boolean> = _serverReachable.asStateFlow()
|
||||||
|
|
||||||
|
private val _lastSyncedAt = MutableStateFlow<String?>(null)
|
||||||
|
val lastSyncedAt: StateFlow<String?> = _lastSyncedAt.asStateFlow()
|
||||||
|
|
||||||
|
private val syncQueued = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/** Pide una sincronización (no bloqueante, debounced). */
|
||||||
|
fun requestSync() {
|
||||||
|
if (!connectivity.hasNetwork.value) return
|
||||||
|
if (!syncQueued.compareAndSet(false, true)) return
|
||||||
|
scope.launch {
|
||||||
|
kotlinx.coroutines.delay(1500)
|
||||||
|
syncQueued.set(false)
|
||||||
|
syncNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sincroniza el outbox y refresca el espejo. Devuelve true si pudo conectarse. */
|
||||||
|
suspend fun syncNow(): Boolean {
|
||||||
|
if (_isSyncing.value) return _serverReachable.value
|
||||||
|
_isSyncing.value = true
|
||||||
|
try {
|
||||||
|
if (!connectivity.hasNetwork.value) {
|
||||||
|
_serverReachable.value = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val serverTx = try {
|
||||||
|
api.listTransactions()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
_serverReachable.value = false
|
||||||
|
return false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_serverReachable.value = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val accounts = store.snapshotAccounts().associateBy { it.id }
|
||||||
|
val serverKeySet = serverTx.map { dedupeKey(it.accountId, it.date, it.amount, it.transactionType, it.description) }.toSet()
|
||||||
|
|
||||||
|
val ops = store.pendingOps()
|
||||||
|
for (op in ops) {
|
||||||
|
when (op.type) {
|
||||||
|
OpType.CREATE_TRANSACTION, OpType.CREATE_TRANSFER -> {
|
||||||
|
val local = store.snapshotTransactions().firstOrNull { it.id == op.txId } ?: continue
|
||||||
|
val key = dedupeKey(local.accountId, local.date, local.amount, local.transactionType, local.description)
|
||||||
|
if (key in serverKeySet) {
|
||||||
|
val existing = serverTx.first { dedupeKey(it.accountId, it.date, it.amount, it.transactionType, it.description) == key }
|
||||||
|
store.resolveSyncedCreate(op.txId, fillNames(existing.toLocal(), accounts))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val created: LocalTransaction = if (op.type == OpType.CREATE_TRANSACTION) {
|
||||||
|
val body = json.decodeFromString<CreateTransactionRequest>(op.payloadJson)
|
||||||
|
api.createTransaction(body).toLocal()
|
||||||
|
} else {
|
||||||
|
val body = json.decodeFromString<TransferRequest>(op.payloadJson)
|
||||||
|
api.createTransfer(body).first().toLocal()
|
||||||
|
}
|
||||||
|
store.resolveSyncedCreate(op.txId, fillNames(created, accounts))
|
||||||
|
} catch (e: IOException) {
|
||||||
|
_serverReachable.value = false
|
||||||
|
return false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (isHttpError(e)) store.markFailed(op, friendlyError(e)) else {
|
||||||
|
_serverReachable.value = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OpType.UPDATE_TRANSACTION -> {
|
||||||
|
try {
|
||||||
|
val body = json.decodeFromString<UpdateTransactionRequest>(op.payloadJson)
|
||||||
|
api.updateTransaction(op.txId, body)
|
||||||
|
store.markSynced(op.txId)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
_serverReachable.value = false
|
||||||
|
return false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (isHttpError(e)) {
|
||||||
|
if (is404(e)) store.dropTransaction(op.txId) else store.markFailed(op, friendlyError(e))
|
||||||
|
} else {
|
||||||
|
_serverReachable.value = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OpType.UPDATE_TO_TRANSFER -> {
|
||||||
|
try {
|
||||||
|
val body = json.decodeFromString<TransferRequest>(op.payloadJson)
|
||||||
|
api.updateTransactionToTransfer(op.txId, body)
|
||||||
|
store.markSynced(op.txId)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
_serverReachable.value = false
|
||||||
|
return false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (isHttpError(e)) {
|
||||||
|
if (is404(e)) store.dropTransaction(op.txId) else store.markFailed(op, friendlyError(e))
|
||||||
|
} else {
|
||||||
|
_serverReachable.value = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OpType.DELETE_TRANSACTION -> {
|
||||||
|
try {
|
||||||
|
api.deleteTransaction(op.txId)
|
||||||
|
store.dropTransaction(op.txId)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
_serverReachable.value = false
|
||||||
|
return false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (isHttpError(e)) {
|
||||||
|
if (is404(e)) store.dropTransaction(op.txId) else store.markFailed(op, friendlyError(e))
|
||||||
|
} else {
|
||||||
|
_serverReachable.value = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si quedaron ops (no se pudieron procesar todas), no refrescamos el espejo para no perder pendientes.
|
||||||
|
if (store.pendingOps().isEmpty()) {
|
||||||
|
refreshMirror()
|
||||||
|
_serverReachable.value = true
|
||||||
|
_lastSyncedAt.value = LocalDateTime.now().toString()
|
||||||
|
store.setLastSyncedAt(LocalDateTime.now().toString())
|
||||||
|
}
|
||||||
|
return _serverReachable.value
|
||||||
|
} finally {
|
||||||
|
_isSyncing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Actualiza el espejo de cuentas/categorías/transacciones desde el servidor, conservando pendientes. */
|
||||||
|
suspend fun refreshMirror() {
|
||||||
|
val serverAccounts = api.listAccounts()
|
||||||
|
val serverCategories = api.listCategories()
|
||||||
|
val serverTx = api.listTransactions()
|
||||||
|
|
||||||
|
val pending = store.snapshotTransactions().filter { it.syncState != SyncState.SYNCED }
|
||||||
|
val accountsByName = serverAccounts.associateBy { it.id }.mapValues { it.value.name }
|
||||||
|
|
||||||
|
store.replaceAll(
|
||||||
|
accounts = serverAccounts.map { it.toLocal() },
|
||||||
|
categories = serverCategories.map { it.toLocal() },
|
||||||
|
transactions = pending + serverTx.map {
|
||||||
|
it.toLocal().let { t ->
|
||||||
|
t.copy(
|
||||||
|
accountName = accountsByName[t.accountId] ?: t.accountName,
|
||||||
|
toAccountName = t.toAccountId?.let { id -> accountsByName[id] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_serverReachable.value = true
|
||||||
|
_lastSyncedAt.value = LocalDateTime.now().toString()
|
||||||
|
store.setLastSyncedAt(LocalDateTime.now().toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fillNames(tx: LocalTransaction, accounts: Map<Long, LocalAccount>): LocalTransaction = tx.copy(
|
||||||
|
accountName = accounts[tx.accountId]?.name ?: tx.accountName,
|
||||||
|
toAccountName = tx.toAccountId?.let { accounts[it]?.name } ?: tx.toAccountName,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun dedupeKey(accountId: Long, date: String, amount: Double, type: String, description: String): String =
|
||||||
|
"$accountId|$date|${amount.round()}|$type|${description.trim()}".lowercase()
|
||||||
|
|
||||||
|
private fun Double.round(): Long = (this * 100).roundToLong()
|
||||||
|
|
||||||
|
private fun isHttpError(e: Exception): Boolean = e is retrofit2.HttpException
|
||||||
|
private fun is404(e: Exception): Boolean = (e as? retrofit2.HttpException)?.code() == 404
|
||||||
|
|
||||||
|
private fun friendlyError(e: Exception): String = when {
|
||||||
|
e is retrofit2.HttpException && e.code() == 400 -> "Solicitud inválida (verifica los datos)"
|
||||||
|
e is retrofit2.HttpException && e.code() == 404 -> "Ya no existe en el servidor"
|
||||||
|
e is retrofit2.HttpException -> "Error del servidor (${e.code()})"
|
||||||
|
else -> e.message ?: "Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AccountBalance
|
import androidx.compose.material.icons.filled.AccountBalance
|
||||||
|
import androidx.compose.material.icons.filled.BarChart
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.SwapHoriz
|
import androidx.compose.material.icons.filled.SwapHoriz
|
||||||
@@ -42,11 +43,14 @@ import com.conta.app.ui.screens.categories.CategoriesScreen
|
|||||||
import com.conta.app.ui.screens.dashboard.DashboardScreen
|
import com.conta.app.ui.screens.dashboard.DashboardScreen
|
||||||
import com.conta.app.ui.screens.recurring.RecurringFormScreen
|
import com.conta.app.ui.screens.recurring.RecurringFormScreen
|
||||||
import com.conta.app.ui.screens.recurring.RecurringScreen
|
import com.conta.app.ui.screens.recurring.RecurringScreen
|
||||||
|
import com.conta.app.ui.screens.reports.ReportsScreen
|
||||||
import com.conta.app.ui.screens.rules.RuleFormScreen
|
import com.conta.app.ui.screens.rules.RuleFormScreen
|
||||||
import com.conta.app.ui.screens.rules.RulesScreen
|
import com.conta.app.ui.screens.rules.RulesScreen
|
||||||
import com.conta.app.ui.screens.settings.BackupScreen
|
import com.conta.app.ui.screens.settings.BackupScreen
|
||||||
import com.conta.app.ui.screens.settings.NtfyScreen
|
import com.conta.app.ui.screens.settings.NtfyScreen
|
||||||
import com.conta.app.ui.screens.settings.SettingsScreen
|
import com.conta.app.ui.screens.settings.SettingsScreen
|
||||||
|
import com.conta.app.ui.screens.settings.ThemeScreen
|
||||||
|
import com.conta.app.ui.components.OfflineBanner
|
||||||
import com.conta.app.ui.screens.transactions.TransactionFormScreen
|
import com.conta.app.ui.screens.transactions.TransactionFormScreen
|
||||||
import com.conta.app.ui.screens.transactions.TransactionsScreen
|
import com.conta.app.ui.screens.transactions.TransactionsScreen
|
||||||
|
|
||||||
@@ -57,6 +61,7 @@ object Routes {
|
|||||||
const val ACCOUNT_FORM = "account_form"
|
const val ACCOUNT_FORM = "account_form"
|
||||||
const val TRANSACTIONS = "transactions"
|
const val TRANSACTIONS = "transactions"
|
||||||
const val TRANSACTION_FORM = "transaction_form"
|
const val TRANSACTION_FORM = "transaction_form"
|
||||||
|
const val REPORTS = "reports"
|
||||||
const val CATEGORIES = "categories"
|
const val CATEGORIES = "categories"
|
||||||
const val CATEGORY_FORM = "category_form"
|
const val CATEGORY_FORM = "category_form"
|
||||||
const val RULES = "rules"
|
const val RULES = "rules"
|
||||||
@@ -66,6 +71,7 @@ object Routes {
|
|||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
const val NTFY = "ntfy"
|
const val NTFY = "ntfy"
|
||||||
const val BACKUP = "backup"
|
const val BACKUP = "backup"
|
||||||
|
const val THEME = "theme"
|
||||||
|
|
||||||
fun accountDetail(id: Long) = "$ACCOUNT_DETAIL/$id"
|
fun accountDetail(id: Long) = "$ACCOUNT_DETAIL/$id"
|
||||||
fun accountForm(id: Long? = null) =
|
fun accountForm(id: Long? = null) =
|
||||||
@@ -91,6 +97,7 @@ private val bottomItems = listOf(
|
|||||||
BottomNavItem(Routes.DASHBOARD, "Inicio", Icons.Filled.Home),
|
BottomNavItem(Routes.DASHBOARD, "Inicio", Icons.Filled.Home),
|
||||||
BottomNavItem(Routes.ACCOUNTS, "Cuentas", Icons.Filled.AccountBalance),
|
BottomNavItem(Routes.ACCOUNTS, "Cuentas", Icons.Filled.AccountBalance),
|
||||||
BottomNavItem(Routes.TRANSACTIONS, "Movimientos", Icons.Filled.SwapHoriz),
|
BottomNavItem(Routes.TRANSACTIONS, "Movimientos", Icons.Filled.SwapHoriz),
|
||||||
|
BottomNavItem(Routes.REPORTS, "Reportes", Icons.Filled.BarChart),
|
||||||
BottomNavItem(Routes.SETTINGS, "Ajustes", Icons.Filled.Settings),
|
BottomNavItem(Routes.SETTINGS, "Ajustes", Icons.Filled.Settings),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,9 +107,13 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) {
|
|||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val message by viewModel.message.collectAsStateWithLifecycle()
|
val message by viewModel.message.collectAsStateWithLifecycle()
|
||||||
val busy by viewModel.busy.collectAsStateWithLifecycle()
|
val busy by viewModel.busy.collectAsStateWithLifecycle()
|
||||||
|
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
|
||||||
|
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
|
||||||
|
val pendingCount by viewModel.pendingCount.collectAsStateWithLifecycle()
|
||||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = backStackEntry?.destination?.route
|
val currentRoute = backStackEntry?.destination?.route
|
||||||
val showBottomBar = bottomItems.any { it.route == currentRoute }
|
val showBottomBar = bottomItems.any { it.route == currentRoute }
|
||||||
|
val showOfflineBanner = !isOnline || pendingCount > 0
|
||||||
|
|
||||||
LaunchedEffect(message) {
|
LaunchedEffect(message) {
|
||||||
message?.let {
|
message?.let {
|
||||||
@@ -113,6 +124,16 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) {
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
if (showOfflineBanner) {
|
||||||
|
OfflineBanner(
|
||||||
|
pendingCount = pendingCount,
|
||||||
|
isOnline = isOnline,
|
||||||
|
isSyncing = isSyncing,
|
||||||
|
onSync = { viewModel.forceSync() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
if (showBottomBar) {
|
if (showBottomBar) {
|
||||||
NavigationBar {
|
NavigationBar {
|
||||||
@@ -145,12 +166,14 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) {
|
|||||||
composable(Routes.DASHBOARD) { DashboardScreen(viewModel, navController) }
|
composable(Routes.DASHBOARD) { DashboardScreen(viewModel, navController) }
|
||||||
composable(Routes.ACCOUNTS) { AccountsScreen(viewModel, navController) }
|
composable(Routes.ACCOUNTS) { AccountsScreen(viewModel, navController) }
|
||||||
composable(Routes.TRANSACTIONS) { TransactionsScreen(viewModel, navController) }
|
composable(Routes.TRANSACTIONS) { TransactionsScreen(viewModel, navController) }
|
||||||
|
composable(Routes.REPORTS) { ReportsScreen(viewModel, navController) }
|
||||||
composable(Routes.SETTINGS) { SettingsScreen(viewModel, navController) }
|
composable(Routes.SETTINGS) { SettingsScreen(viewModel, navController) }
|
||||||
composable(Routes.CATEGORIES) { CategoriesScreen(viewModel, navController) }
|
composable(Routes.CATEGORIES) { CategoriesScreen(viewModel, navController) }
|
||||||
composable(Routes.RULES) { RulesScreen(viewModel, navController) }
|
composable(Routes.RULES) { RulesScreen(viewModel, navController) }
|
||||||
composable(Routes.RECURRING) { RecurringScreen(viewModel, navController) }
|
composable(Routes.RECURRING) { RecurringScreen(viewModel, navController) }
|
||||||
composable(Routes.NTFY) { NtfyScreen(viewModel, navController) }
|
composable(Routes.NTFY) { NtfyScreen(viewModel, navController) }
|
||||||
composable(Routes.BACKUP) { BackupScreen(viewModel, navController) }
|
composable(Routes.BACKUP) { BackupScreen(viewModel, navController) }
|
||||||
|
composable(Routes.THEME) { ThemeScreen(viewModel, navController) }
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = "${Routes.ACCOUNT_DETAIL}/{accountId}",
|
route = "${Routes.ACCOUNT_DETAIL}/{accountId}",
|
||||||
|
|||||||
@@ -30,32 +30,53 @@ import com.conta.app.data.model.UpdateCategorizationRuleRequest
|
|||||||
import com.conta.app.data.model.UpdateCategoryRequest
|
import com.conta.app.data.model.UpdateCategoryRequest
|
||||||
import com.conta.app.data.model.UpdateRecurringTransactionRequest
|
import com.conta.app.data.model.UpdateRecurringTransactionRequest
|
||||||
import com.conta.app.data.model.UpdateTransactionRequest
|
import com.conta.app.data.model.UpdateTransactionRequest
|
||||||
|
import com.conta.app.data.local.applyLocalBalances
|
||||||
|
import com.conta.app.data.local.computeStatistics
|
||||||
|
import com.conta.app.data.local.computeSummary
|
||||||
|
import com.conta.app.data.local.toCategory
|
||||||
|
import com.conta.app.data.local.toTransactionWithAccount
|
||||||
import com.conta.app.data.network.Session
|
import com.conta.app.data.network.Session
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class AppViewModel : ViewModel() {
|
class AppViewModel : ViewModel() {
|
||||||
|
|
||||||
private val repo = ServiceLocator.repository
|
private val repo = ServiceLocator.repository
|
||||||
|
private val store = ServiceLocator.store
|
||||||
|
|
||||||
private val _summary = MutableStateFlow<Summary?>(null)
|
// ---------- Flujos desde el espejo local (Room) ----------
|
||||||
val summary: StateFlow<Summary?> = _summary.asStateFlow()
|
|
||||||
|
|
||||||
private val _statistics = MutableStateFlow<Statistics?>(null)
|
val accounts: StateFlow<List<AccountInfo>> = combine(store.accounts, store.transactions) { acc, tx ->
|
||||||
val statistics: StateFlow<Statistics?> = _statistics.asStateFlow()
|
applyLocalBalances(acc, tx)
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
private val _accounts = MutableStateFlow<List<AccountInfo>>(emptyList())
|
val transactions: StateFlow<List<TransactionWithAccount>> = store.transactions.map { list ->
|
||||||
val accounts: StateFlow<List<AccountInfo>> = _accounts.asStateFlow()
|
list.map { it.toTransactionWithAccount() }
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
private val _transactions = MutableStateFlow<List<TransactionWithAccount>>(emptyList())
|
val categories: StateFlow<List<Category>> = store.categories.map { list ->
|
||||||
val transactions: StateFlow<List<TransactionWithAccount>> = _transactions.asStateFlow()
|
list.map { it.toCategory() }
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
private val _categories = MutableStateFlow<List<Category>>(emptyList())
|
/** Null mientras no hay datos ni conexión (se usa para la pantalla de "No conectado"). */
|
||||||
val categories: StateFlow<List<Category>> = _categories.asStateFlow()
|
private val _connected = MutableStateFlow(false)
|
||||||
|
val hasConnected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||||
|
|
||||||
|
val summary: StateFlow<Summary?> = combine(store.accounts, store.transactions, _connected) { acc, tx, connected ->
|
||||||
|
if (!connected && acc.isEmpty() && tx.isEmpty()) null
|
||||||
|
else computeSummary(applyLocalBalances(acc, tx))
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
|
val statistics: StateFlow<Statistics?> = store.transactions.map { computeStatistics(it) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
private val _rules = MutableStateFlow<List<CategorizationRule>>(emptyList())
|
private val _rules = MutableStateFlow<List<CategorizationRule>>(emptyList())
|
||||||
val rules: StateFlow<List<CategorizationRule>> = _rules.asStateFlow()
|
val rules: StateFlow<List<CategorizationRule>> = _rules.asStateFlow()
|
||||||
@@ -75,6 +96,21 @@ class AppViewModel : ViewModel() {
|
|||||||
private val _message = MutableStateFlow<String?>(null)
|
private val _message = MutableStateFlow<String?>(null)
|
||||||
val message: StateFlow<String?> = _message.asStateFlow()
|
val message: StateFlow<String?> = _message.asStateFlow()
|
||||||
|
|
||||||
|
val isOnline: StateFlow<Boolean> = combine(
|
||||||
|
ServiceLocator.connectivity.hasNetwork,
|
||||||
|
ServiceLocator.sync.serverReachable,
|
||||||
|
) { hasNetwork, reachable -> hasNetwork && reachable }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||||
|
|
||||||
|
val isSyncing: StateFlow<Boolean> = ServiceLocator.sync.isSyncing
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||||
|
|
||||||
|
val pendingCount: StateFlow<Int> = store.pendingCount
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
|
||||||
|
|
||||||
|
val lastSyncAt: StateFlow<String?> = ServiceLocator.sync.lastSyncedAt
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
fun consumeMessage() {
|
fun consumeMessage() {
|
||||||
_message.value = null
|
_message.value = null
|
||||||
}
|
}
|
||||||
@@ -87,6 +123,7 @@ class AppViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val saved = ServiceLocator.settings.baseUrl.first()
|
val saved = ServiceLocator.settings.baseUrl.first()
|
||||||
Session.baseUrl = saved
|
Session.baseUrl = saved
|
||||||
|
refreshCached()
|
||||||
if (saved.isNotBlank()) refreshAll()
|
if (saved.isNotBlank()) refreshAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,56 +138,56 @@ class AppViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshCached() {
|
||||||
|
_rules.value = repo.listRules()
|
||||||
|
_recurring.value = repo.listRecurring()
|
||||||
|
_notices.value = repo.listNotices()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trae datos del servidor al espejo local y cachea reglas/recurrentes/avisos. */
|
||||||
|
private suspend fun pullFromServer() {
|
||||||
|
try {
|
||||||
|
repo.refreshFromServer()
|
||||||
|
_connected.value = true
|
||||||
|
refreshCached()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun refreshAll() {
|
fun refreshAll() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_loading.value = true
|
_loading.value = true
|
||||||
runRequest { _summary.value = repo.getSummary() }
|
pullFromServer()
|
||||||
runRequest { _transactions.value = repo.listTransactions() }
|
repo.syncNow()
|
||||||
runRequest { _accounts.value = repo.listAccounts() }
|
refreshCached()
|
||||||
runRequest { _categories.value = repo.listCategories() }
|
|
||||||
runRequest { _rules.value = repo.listRules() }
|
|
||||||
runRequest { _recurring.value = repo.listRecurring() }
|
|
||||||
runRequest { _notices.value = repo.listNotices() }
|
|
||||||
runRequest { _statistics.value = repo.getStatistics() }
|
|
||||||
_loading.value = false
|
_loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshDashboard() {
|
fun refreshDashboard() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runRequest { _summary.value = repo.getSummary() }
|
pullFromServer()
|
||||||
runRequest { _transactions.value = repo.listTransactions() }
|
repo.syncNow()
|
||||||
runRequest { _notices.value = repo.listNotices() }
|
refreshCached()
|
||||||
runRequest { _statistics.value = repo.getStatistics() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshAccounts() {
|
fun refreshAccounts() = refreshDashboard()
|
||||||
|
fun refreshCategories() = refreshDashboard()
|
||||||
|
fun refreshRules() = refreshDashboard()
|
||||||
|
fun refreshRecurring() = refreshDashboard()
|
||||||
|
|
||||||
|
/** Sincronización manual (botón en Ajustes). */
|
||||||
|
fun forceSync() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runRequest { _summary.value = repo.getSummary() }
|
pullFromServer()
|
||||||
runRequest { _accounts.value = repo.listAccounts() }
|
repo.syncNow()
|
||||||
|
refreshCached()
|
||||||
|
if (isOnline.value) showMessage("Sincronizado")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshCategories() {
|
// ---------- Cuentas (requieren conexión) ----------
|
||||||
viewModelScope.launch {
|
|
||||||
runRequest { _categories.value = repo.listCategories() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshRules() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
runRequest { _rules.value = repo.listRules() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshRecurring() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
runRequest { _recurring.value = repo.listRecurring() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Cuentas ----------
|
|
||||||
|
|
||||||
fun createAccount(body: CreateAccountRequest, onDone: () -> Unit = {}) {
|
fun createAccount(body: CreateAccountRequest, onDone: () -> Unit = {}) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -216,7 +253,7 @@ class AppViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Transacciones ----------
|
// ---------- Movimientos (local-first) ----------
|
||||||
|
|
||||||
fun createTransaction(body: CreateTransactionRequest, onDone: () -> Unit = {}) {
|
fun createTransaction(body: CreateTransactionRequest, onDone: () -> Unit = {}) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -273,7 +310,7 @@ class AppViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Categorías ----------
|
// ---------- Categorías / Reglas / Recurrentes (requieren conexión) ----------
|
||||||
|
|
||||||
fun createCategory(body: CreateCategoryRequest, onDone: () -> Unit = {}) {
|
fun createCategory(body: CreateCategoryRequest, onDone: () -> Unit = {}) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -309,8 +346,6 @@ class AppViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Reglas ----------
|
|
||||||
|
|
||||||
fun createRule(body: CreateCategorizationRuleRequest, onDone: (CategorizationRuleCreated?) -> Unit) {
|
fun createRule(body: CreateCategorizationRuleRequest, onDone: (CategorizationRuleCreated?) -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_busy.value = true
|
_busy.value = true
|
||||||
@@ -352,8 +387,6 @@ class AppViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Recurrentes ----------
|
|
||||||
|
|
||||||
fun createRecurring(body: CreateRecurringTransactionRequest, onDone: () -> Unit = {}) {
|
fun createRecurring(body: CreateRecurringTransactionRequest, onDone: () -> Unit = {}) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_busy.value = true
|
_busy.value = true
|
||||||
@@ -473,4 +506,10 @@ class AppViewModel : ViewModel() {
|
|||||||
refreshAll()
|
refreshAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setThemeId(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
ServiceLocator.settings.setThemeId(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import com.conta.app.ui.util.formatMonth
|
|||||||
import com.conta.app.ui.util.formatMoneyCompact
|
import com.conta.app.ui.util.formatMoneyCompact
|
||||||
import com.conta.app.ui.util.resolveCategoryColor
|
import com.conta.app.ui.util.resolveCategoryColor
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MonthlyBarChart(stats: List<MonthlyStat>, modifier: Modifier = Modifier) {
|
fun MonthlyBarChart(stats: List<MonthlyStat>, modifier: Modifier = Modifier) {
|
||||||
@@ -193,3 +194,71 @@ fun CategoryBars(stats: List<CategoryStat>, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Barras de gasto por día del mes (espejo del "Gasto Diario" del frontend). */
|
||||||
|
@Composable
|
||||||
|
fun DailyExpenseChart(values: List<Double>, modifier: Modifier = Modifier) {
|
||||||
|
val barColor = MaterialTheme.colorScheme.error
|
||||||
|
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val chartHeight = 220.dp
|
||||||
|
|
||||||
|
Column(modifier) {
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(chartHeight),
|
||||||
|
) {
|
||||||
|
val gridColor = textColor.copy(alpha = 0.15f)
|
||||||
|
val maxValue = values.maxOrNull() ?: 0.0
|
||||||
|
val scale = if (maxValue <= 0.0) 1.0 else maxValue
|
||||||
|
|
||||||
|
// Líneas de referencia (0, 25, 50, 75, 100 %)
|
||||||
|
for (frac in listOf(0f, 0.25f, 0.5f, 0.75f, 1f)) {
|
||||||
|
val y = size.height * (1f - frac)
|
||||||
|
drawLine(
|
||||||
|
color = gridColor,
|
||||||
|
start = Offset(0f, y),
|
||||||
|
end = Offset(size.width, y),
|
||||||
|
strokeWidth = 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.isEmpty()) return@Canvas
|
||||||
|
|
||||||
|
val slotWidth = size.width / values.size
|
||||||
|
val barWidth = (slotWidth * 0.55f).coerceAtLeast(2f)
|
||||||
|
val gap = slotWidth * 0.15f
|
||||||
|
|
||||||
|
values.forEachIndexed { i, total ->
|
||||||
|
if (total <= 0.0) return@forEachIndexed
|
||||||
|
val barH = (total / scale).toFloat() * size.height
|
||||||
|
val top = size.height - barH
|
||||||
|
drawRoundRect(
|
||||||
|
color = barColor,
|
||||||
|
topLeft = Offset(i * slotWidth + gap, top),
|
||||||
|
size = Size(barWidth, barH),
|
||||||
|
cornerRadius = CornerRadius(3f, 3f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Etiquetas de días (cada 5 para no saturar)
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
val step = max(1, (values.size.toDouble() / 7.0).roundToInt())
|
||||||
|
values.forEachIndexed { i, _ ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (i % step == 0 || i == values.size - 1) {
|
||||||
|
Text(
|
||||||
|
text = "${i + 1}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = textColor,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.conta.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CloudOff
|
||||||
|
import androidx.compose.material.icons.filled.Sync
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** Banner superior mostrado cuando hay cambios pendientes o no hay conexión. */
|
||||||
|
@Composable
|
||||||
|
fun OfflineBanner(pendingCount: Int, isOnline: Boolean, isSyncing: Boolean, onSync: () -> Unit) {
|
||||||
|
val text = when {
|
||||||
|
isSyncing -> "Sincronizando cambios..."
|
||||||
|
pendingCount > 0 -> "Modo offline · $pendingCount cambios pendientes de sincronizar"
|
||||||
|
else -> "Sin conexión · los cambios se guardarán localmente"
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = if (pendingCount > 0) MaterialTheme.colorScheme.tertiaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (pendingCount > 0) Icons.Filled.CloudOff else Icons.Filled.Sync,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (pendingCount > 0 && !isSyncing) {
|
||||||
|
TextButton(onClick = onSync) {
|
||||||
|
Text("Sincronizar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tarjeta de estado de sincronización (Ajustes). */
|
||||||
|
@Composable
|
||||||
|
fun SyncStatusCard(
|
||||||
|
isOnline: Boolean,
|
||||||
|
isSyncing: Boolean,
|
||||||
|
pendingCount: Int,
|
||||||
|
lastSyncAt: String?,
|
||||||
|
onSync: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(14.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isOnline) Icons.Filled.Sync else Icons.Filled.CloudOff,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Sincronización",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
|
||||||
|
Text(
|
||||||
|
text = if (isOnline) "En línea" else "Sin conexión",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (pendingCount > 0) "$pendingCount cambios pendientes"
|
||||||
|
else "Al día",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
lastSyncAt?.let {
|
||||||
|
Text(
|
||||||
|
text = "Última sincronización: ${formatSyncTime(it)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (pendingCount > 0) {
|
||||||
|
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
|
||||||
|
androidx.compose.material3.Button(onClick = onSync, enabled = !isSyncing) {
|
||||||
|
Icon(Icons.Filled.Sync, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
|
||||||
|
Text(if (isSyncing) "Sincronizando..." else "Sincronizar ahora")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatSyncTime(iso: String): String {
|
||||||
|
val dt = runCatching { LocalDateTime.parse(iso) }.getOrNull() ?: return iso
|
||||||
|
return dt.format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm", Locale.getDefault()))
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ fun AccountFormScreen(viewModel: AppViewModel, navController: NavController, acc
|
|||||||
viewModel.updateAccount(
|
viewModel.updateAccount(
|
||||||
account.id,
|
account.id,
|
||||||
UpdateAccountRequest(
|
UpdateAccountRequest(
|
||||||
|
name = name.trim(),
|
||||||
creditLimit = parseAmount(creditLimit),
|
creditLimit = parseAmount(creditLimit),
|
||||||
interestRate = parseAmount(interestRate),
|
interestRate = parseAmount(interestRate),
|
||||||
maturityDate = maturityDate.ifBlank { null },
|
maturityDate = maturityDate.ifBlank { null },
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import com.conta.app.data.model.CreateCategoryRequest
|
|||||||
import com.conta.app.data.model.UpdateCategoryRequest
|
import com.conta.app.data.model.UpdateCategoryRequest
|
||||||
import com.conta.app.ui.AppViewModel
|
import com.conta.app.ui.AppViewModel
|
||||||
import com.conta.app.ui.components.SectionHeader
|
import com.conta.app.ui.components.SectionHeader
|
||||||
import com.conta.app.ui.util.CATEGORY_COLOR_PALETTE
|
import com.conta.app.ui.util.categoryColorPalette
|
||||||
import com.conta.app.ui.util.CATEGORY_ICON_NAMES
|
import com.conta.app.ui.util.CATEGORY_ICON_NAMES
|
||||||
import com.conta.app.ui.util.categoryIcon
|
import com.conta.app.ui.util.categoryIcon
|
||||||
|
|
||||||
@@ -186,7 +186,8 @@ fun CategoryFormScreen(viewModel: AppViewModel, navController: NavController, ca
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
CATEGORY_COLOR_PALETTE.forEachIndexed { index, colorValue ->
|
val palette = categoryColorPalette()
|
||||||
|
palette.forEachIndexed { index, colorValue ->
|
||||||
val selected = color == index.toString()
|
val selected = color == index.toString()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package com.conta.app.ui.screens.reports
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.conta.app.ui.AppViewModel
|
||||||
|
import com.conta.app.ui.components.DailyExpenseChart
|
||||||
|
import com.conta.app.ui.components.EmptyState
|
||||||
|
import com.conta.app.ui.components.LoadingScreen
|
||||||
|
import com.conta.app.ui.util.currentMonthIso
|
||||||
|
import com.conta.app.ui.util.daysInMonthIso
|
||||||
|
import com.conta.app.ui.util.formatMoney
|
||||||
|
import com.conta.app.ui.util.formatMonthFull
|
||||||
|
import com.conta.app.ui.util.shiftMonthIso
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ReportsScreen(viewModel: AppViewModel, navController: NavController) {
|
||||||
|
val transactions by viewModel.transactions.collectAsStateWithLifecycle()
|
||||||
|
val summary by viewModel.summary.collectAsStateWithLifecycle()
|
||||||
|
val loading by viewModel.loading.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var month by remember { mutableStateOf(currentMonthIso()) }
|
||||||
|
val isCurrentMonth = month == currentMonthIso()
|
||||||
|
|
||||||
|
val days = daysInMonthIso(month)
|
||||||
|
val values = remember(transactions, month) {
|
||||||
|
val sums = DoubleArray(days)
|
||||||
|
for (tx in transactions) {
|
||||||
|
if (tx.transactionType != "expense" || !tx.date.startsWith(month)) continue
|
||||||
|
val day = tx.date.substringAfterLast('-', "").toIntOrNull() ?: continue
|
||||||
|
if (day in 1..days) sums[day - 1] += tx.amount
|
||||||
|
}
|
||||||
|
sums.toList()
|
||||||
|
}
|
||||||
|
val totalMonth = values.sum()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
|
||||||
|
) { padding ->
|
||||||
|
when {
|
||||||
|
summary == null && loading -> LoadingScreen(Modifier.padding(padding))
|
||||||
|
summary == null -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
EmptyState("No conectado. Configura la URL del servidor en Ajustes.")
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
androidx.compose.material3.Button(onClick = { viewModel.refreshAll() }) {
|
||||||
|
Text("Reintentar")
|
||||||
|
}
|
||||||
|
androidx.compose.material3.OutlinedButton(onClick = { navController.navigate(com.conta.app.ui.Routes.SETTINGS) }) {
|
||||||
|
Text("Ir a Ajustes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Reportes",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Gasto Diario del Mes",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = { month = shiftMonthIso(month, -1) }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Mes anterior")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = formatMonthFull(month),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = { month = shiftMonthIso(month, 1) },
|
||||||
|
enabled = !isCurrentMonth,
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Mes siguiente")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidx.compose.foundation.layout.Spacer(Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Total del mes: ${formatMoney(totalMonth)}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (transactions.isEmpty() && !loading) {
|
||||||
|
EmptyState("Sin transacciones")
|
||||||
|
} else if (totalMonth == 0.0 && !loading) {
|
||||||
|
EmptyState("Sin gastos en ${formatMonthFull(month)}")
|
||||||
|
} else {
|
||||||
|
androidx.compose.foundation.layout.Spacer(Modifier.size(16.dp))
|
||||||
|
DailyExpenseChart(values, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidx.compose.foundation.layout.Spacer(Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
|||||||
import androidx.compose.material.icons.filled.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.Http
|
import androidx.compose.material.icons.filled.Http
|
||||||
import androidx.compose.material.icons.filled.NotificationsActive
|
import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.material.icons.filled.Save
|
||||||
import androidx.compose.material.icons.filled.Sync
|
import androidx.compose.material.icons.filled.Sync
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -47,6 +48,7 @@ import com.conta.app.ServiceLocator
|
|||||||
import com.conta.app.ui.AppViewModel
|
import com.conta.app.ui.AppViewModel
|
||||||
import com.conta.app.ui.Routes
|
import com.conta.app.ui.Routes
|
||||||
import com.conta.app.ui.components.SectionHeader
|
import com.conta.app.ui.components.SectionHeader
|
||||||
|
import com.conta.app.ui.components.SyncStatusCard
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -61,6 +63,10 @@ fun SettingsScreen(viewModel: AppViewModel, navController: NavController) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val summary by viewModel.summary.collectAsStateWithLifecycle()
|
val summary by viewModel.summary.collectAsStateWithLifecycle()
|
||||||
|
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
|
||||||
|
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
|
||||||
|
val pendingCount by viewModel.pendingCount.collectAsStateWithLifecycle()
|
||||||
|
val lastSyncAt by viewModel.lastSyncAt.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
|
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
|
||||||
@@ -116,6 +122,16 @@ fun SettingsScreen(viewModel: AppViewModel, navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
|
||||||
|
SectionHeader("Sincronización")
|
||||||
|
SyncStatusCard(
|
||||||
|
isOnline = isOnline,
|
||||||
|
isSyncing = isSyncing,
|
||||||
|
pendingCount = pendingCount,
|
||||||
|
lastSyncAt = lastSyncAt,
|
||||||
|
onSync = { viewModel.forceSync() },
|
||||||
|
)
|
||||||
|
|
||||||
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
|
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
|
||||||
SectionHeader("Notificaciones")
|
SectionHeader("Notificaciones")
|
||||||
SettingsItemCard(
|
SettingsItemCard(
|
||||||
@@ -125,6 +141,15 @@ fun SettingsScreen(viewModel: AppViewModel, navController: NavController) {
|
|||||||
onClick = { navController.navigate(Routes.NTFY) },
|
onClick = { navController.navigate(Routes.NTFY) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
|
||||||
|
SectionHeader("Apariencia")
|
||||||
|
SettingsItemCard(
|
||||||
|
icon = Icons.Filled.Palette,
|
||||||
|
title = "Tema de colores",
|
||||||
|
subtitle = "Catppuccin, Gruvbox, Nord, Monokai y más",
|
||||||
|
onClick = { navController.navigate(Routes.THEME) },
|
||||||
|
)
|
||||||
|
|
||||||
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
|
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
|
||||||
SectionHeader("Datos")
|
SectionHeader("Datos")
|
||||||
SettingsItemCard(
|
SettingsItemCard(
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.conta.app.ui.screens.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.conta.app.ServiceLocator
|
||||||
|
import com.conta.app.ui.AppViewModel
|
||||||
|
import com.conta.app.ui.components.SectionHeader
|
||||||
|
import com.conta.app.ui.theme.THEME_OPTIONS
|
||||||
|
import com.conta.app.ui.theme.ThemeSpec
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ThemeScreen(viewModel: AppViewModel, navController: NavController) {
|
||||||
|
val themeId by remember { ServiceLocator.settings.themeId }.collectAsStateWithLifecycle(initialValue = "default")
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Apariencia", fontWeight = FontWeight.Bold) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Volver")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
SectionHeader("Tema")
|
||||||
|
Text(
|
||||||
|
text = "Elige el tema de colores de la aplicación. El cambio se aplica de inmediato y se guarda en este dispositivo.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 12.dp))
|
||||||
|
|
||||||
|
THEME_OPTIONS.forEach { spec ->
|
||||||
|
ThemeRow(
|
||||||
|
spec = spec,
|
||||||
|
selected = themeId == spec.id,
|
||||||
|
onClick = { viewModel.setThemeId(spec.id) },
|
||||||
|
)
|
||||||
|
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
|
||||||
|
}
|
||||||
|
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThemeRow(spec: ThemeSpec, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
ThemeDot(spec.primary)
|
||||||
|
ThemeDot(spec.secondary)
|
||||||
|
ThemeDot(spec.background, border = true)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = spec.label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThemeDot(color: Color, border: Boolean = false) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.background(color, CircleShape)
|
||||||
|
.then(
|
||||||
|
if (border) Modifier.border(1.dp, MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
|
||||||
|
else Modifier,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package com.conta.app.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
data class ThemeSpec(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val isDark: Boolean,
|
||||||
|
val primary: Color,
|
||||||
|
val secondary: Color,
|
||||||
|
val error: Color,
|
||||||
|
val warning: Color,
|
||||||
|
val success: Color,
|
||||||
|
val info: Color,
|
||||||
|
val background: Color,
|
||||||
|
val paper: Color,
|
||||||
|
val textPrimary: Color,
|
||||||
|
val textSecondary: Color,
|
||||||
|
val divider: Color,
|
||||||
|
val categoryColors: List<Color>,
|
||||||
|
)
|
||||||
|
|
||||||
|
val THEME_OPTIONS: List<ThemeSpec> = listOf(
|
||||||
|
ThemeSpec(
|
||||||
|
id = "default",
|
||||||
|
label = "Predeterminado",
|
||||||
|
isDark = false,
|
||||||
|
primary = Color(0xFF1565C0),
|
||||||
|
secondary = Color(0xFFE53935),
|
||||||
|
error = Color(0xFFD32F2F),
|
||||||
|
warning = Color(0xFFF57C00),
|
||||||
|
success = Color(0xFF2E7D32),
|
||||||
|
info = Color(0xFF0288D1),
|
||||||
|
background = Color(0xFFF5F5F5),
|
||||||
|
paper = Color(0xFFFFFFFF),
|
||||||
|
textPrimary = Color(0xDE000000),
|
||||||
|
textSecondary = Color(0x99000000),
|
||||||
|
divider = Color(0x1F000000),
|
||||||
|
categoryColors = listOf(
|
||||||
|
Color(0xFF1976D2), Color(0xFF7B1FA2), Color(0xFF00897B), Color(0xFFF9A825),
|
||||||
|
Color(0xFFE53935), Color(0xFF5D4037), Color(0xFF43A047), Color(0xFF6A1B9A),
|
||||||
|
Color(0xFFC2185B), Color(0xFF303F9F), Color(0xFF00ACC1), Color(0xFF7CB342),
|
||||||
|
Color(0xFFFFB300), Color(0xFFD81B60),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ThemeSpec(
|
||||||
|
id = "catppuccin-latte",
|
||||||
|
label = "Catppuccin Latte (claro)",
|
||||||
|
isDark = false,
|
||||||
|
primary = Color(0xFF1E66F5),
|
||||||
|
secondary = Color(0xFF8839EF),
|
||||||
|
error = Color(0xFFD20F39),
|
||||||
|
warning = Color(0xFFDF8E1D),
|
||||||
|
success = Color(0xFF40A02B),
|
||||||
|
info = Color(0xFF04A5E5),
|
||||||
|
background = Color(0xFFEFF1F5),
|
||||||
|
paper = Color(0xFFCCD0DA),
|
||||||
|
textPrimary = Color(0xFF4C4F69),
|
||||||
|
textSecondary = Color(0xFF6C6F85),
|
||||||
|
divider = Color(0xFFACB0BE),
|
||||||
|
categoryColors = listOf(
|
||||||
|
Color(0xFF1E66F5), Color(0xFF8839EF), Color(0xFF179299), Color(0xFFDF8E1D),
|
||||||
|
Color(0xFFD20F39), Color(0xFFFE640B), Color(0xFF40A02B), Color(0xFF7287FD),
|
||||||
|
Color(0xFFE64553), Color(0xFF04A5E5), Color(0xFFDD7878), Color(0xFFEA76CB),
|
||||||
|
Color(0xFF209FB5), Color(0xFF8C8FA1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ThemeSpec(
|
||||||
|
id = "catppuccin-mocha",
|
||||||
|
label = "Catppuccin Mocha (oscuro)",
|
||||||
|
isDark = true,
|
||||||
|
primary = Color(0xFF89B4FA),
|
||||||
|
secondary = Color(0xFFCBA6F7),
|
||||||
|
error = Color(0xFFF38BA8),
|
||||||
|
warning = Color(0xFFF9E2AF),
|
||||||
|
success = Color(0xFFA6E3A1),
|
||||||
|
info = Color(0xFF89DCEB),
|
||||||
|
background = Color(0xFF1E1E2E),
|
||||||
|
paper = Color(0xFF313244),
|
||||||
|
textPrimary = Color(0xFFCDD6F4),
|
||||||
|
textSecondary = Color(0xFFA6ADC8),
|
||||||
|
divider = Color(0xFF585B70),
|
||||||
|
categoryColors = listOf(
|
||||||
|
Color(0xFF89B4FA), Color(0xFFCBA6F7), Color(0xFF94E2D5), Color(0xFFF9E2AF),
|
||||||
|
Color(0xFFF38BA8), Color(0xFFFAB387), Color(0xFFA6E3A1), Color(0xFFB4BEFE),
|
||||||
|
Color(0xFFEBA0AC), Color(0xFF89DCEB), Color(0xFFF2CDCD), Color(0xFFF5C2E7),
|
||||||
|
Color(0xFF74C7EC), Color(0xFFA6ADC8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ThemeSpec(
|
||||||
|
id = "gruvbox-light",
|
||||||
|
label = "Gruvbox Claro",
|
||||||
|
isDark = false,
|
||||||
|
primary = Color(0xFF076678),
|
||||||
|
secondary = Color(0xFF8F3F71),
|
||||||
|
error = Color(0xFFCC241D),
|
||||||
|
warning = Color(0xFFB57614),
|
||||||
|
success = Color(0xFF79740E),
|
||||||
|
info = Color(0xFF427B58),
|
||||||
|
background = Color(0xFFFBF1C7),
|
||||||
|
paper = Color(0xFFEBDDB2),
|
||||||
|
textPrimary = Color(0xFF282828),
|
||||||
|
textSecondary = Color(0xFF928374),
|
||||||
|
divider = Color(0xFFD5C4A1),
|
||||||
|
categoryColors = listOf(
|
||||||
|
Color(0xFF076678), Color(0xFF8F3F71), Color(0xFF79740E), Color(0xFFB57614),
|
||||||
|
Color(0xFFCC241D), Color(0xFFAF3A03), Color(0xFF98971A), Color(0xFF427B58),
|
||||||
|
Color(0xFFB16286), Color(0xFF9D0006), Color(0xFF7C6F64), Color(0xFFD65D0E),
|
||||||
|
Color(0xFF689D6A), Color(0xFF504945),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ThemeSpec(
|
||||||
|
id = "gruvbox-dark",
|
||||||
|
label = "Gruvbox Oscuro",
|
||||||
|
isDark = true,
|
||||||
|
primary = Color(0xFF83A598),
|
||||||
|
secondary = Color(0xFFD3869B),
|
||||||
|
error = Color(0xFFFB4934),
|
||||||
|
warning = Color(0xFFFABD2F),
|
||||||
|
success = Color(0xFFB8BB26),
|
||||||
|
info = Color(0xFF8EC07C),
|
||||||
|
background = Color(0xFF282828),
|
||||||
|
paper = Color(0xFF3C3836),
|
||||||
|
textPrimary = Color(0xFFEBDDB2),
|
||||||
|
textSecondary = Color(0xFFBDAE93),
|
||||||
|
divider = Color(0xFF665C54),
|
||||||
|
categoryColors = listOf(
|
||||||
|
Color(0xFF83A598), Color(0xFFD3869B), Color(0xFF8EC07C), Color(0xFFFABD2F),
|
||||||
|
Color(0xFFFB4934), Color(0xFFD65D0E), Color(0xFFB8BB26), Color(0xFF458588),
|
||||||
|
Color(0xFFB16286), Color(0xFFCC241D), Color(0xFF98971A), Color(0xFF689D6A),
|
||||||
|
Color(0xFFA89984), Color(0xFFD79921),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ThemeSpec(
|
||||||
|
id = "everforest",
|
||||||
|
label = "Everforest",
|
||||||
|
isDark = true,
|
||||||
|
primary = Color(0xFF7FBBB3),
|
||||||
|
secondary = Color(0xFFD699B6),
|
||||||
|
error = Color(0xFFE67E80),
|
||||||
|
warning = Color(0xFFE69875),
|
||||||
|
success = Color(0xFFA7C080),
|
||||||
|
info = Color(0xFF83C092),
|
||||||
|
background = Color(0xFF2D353B),
|
||||||
|
paper = Color(0xFF343F44),
|
||||||
|
textPrimary = Color(0xFFD3C6AA),
|
||||||
|
textSecondary = Color(0xFF859289),
|
||||||
|
divider = Color(0xFF4F585E),
|
||||||
|
categoryColors = listOf(
|
||||||
|
Color(0xFF7FBBB3), Color(0xFFD699B6), Color(0xFFA7C080), Color(0xFFE69875),
|
||||||
|
Color(0xFFE67E80), Color(0xFFD3C6AA), Color(0xFF83C092), Color(0xFF899C97),
|
||||||
|
Color(0xFF6A916D), Color(0xFF9DA9A0), Color(0xFFA8B370), Color(0xFFD3869B),
|
||||||
|
Color(0xFF5C7C6A), Color(0xFFC4B28A),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ThemeSpec(
|
||||||
|
id = "nord",
|
||||||
|
label = "Nord",
|
||||||
|
isDark = true,
|
||||||
|
primary = Color(0xFF88C0D0),
|
||||||
|
secondary = Color(0xFFB48EAD),
|
||||||
|
error = Color(0xFFBF616A),
|
||||||
|
warning = Color(0xFFD08770),
|
||||||
|
success = Color(0xFFA3BE8C),
|
||||||
|
info = Color(0xFF81A1C1),
|
||||||
|
background = Color(0xFF2E3440),
|
||||||
|
paper = Color(0xFF3B4252),
|
||||||
|
textPrimary = Color(0xFFECEFF4),
|
||||||
|
textSecondary = Color(0xFFD8DEE9),
|
||||||
|
divider = Color(0xFF4C566A),
|
||||||
|
categoryColors = listOf(
|
||||||
|
Color(0xFF88C0D0), Color(0xFFB48EAD), Color(0xFFA3BE8C), Color(0xFFEBCB8B),
|
||||||
|
Color(0xFFBF616A), Color(0xFFD08770), Color(0xFF81A1C1), Color(0xFF8FBCBB),
|
||||||
|
Color(0xFF5E81AC), Color(0xFFD8DEE9), Color(0xFF4C566A), Color(0xFF63B3AD),
|
||||||
|
Color(0xFFC98076), Color(0xFF7EB4D8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ThemeSpec(
|
||||||
|
id = "rose-pine-moon",
|
||||||
|
label = "Rosé Pine Moon",
|
||||||
|
isDark = true,
|
||||||
|
primary = Color(0xFF9CCFD8),
|
||||||
|
secondary = Color(0xFFC4A7E7),
|
||||||
|
error = Color(0xFFEB6F92),
|
||||||
|
warning = Color(0xFFF6C177),
|
||||||
|
success = Color(0xFF3E8FB0),
|
||||||
|
info = Color(0xFFEA9A97),
|
||||||
|
background = Color(0xFF232136),
|
||||||
|
paper = Color(0xFF2A273F),
|
||||||
|
textPrimary = Color(0xFFE0DEF4),
|
||||||
|
textSecondary = Color(0xFF908CAA),
|
||||||
|
divider = Color(0xFF393552),
|
||||||
|
categoryColors = listOf(
|
||||||
|
Color(0xFF9CCFD8), Color(0xFFC4A7E7), Color(0xFF3E8FB0), Color(0xFFEA9A97),
|
||||||
|
Color(0xFFEB6F92), Color(0xFFF6C177), Color(0xFF56949F), Color(0xFF908CAA),
|
||||||
|
Color(0xFF31748F), Color(0xFF907AA9), Color(0xFFE0DEF4), Color(0xFF44415A),
|
||||||
|
Color(0xFFD9D6E0), Color(0xFF2A273F),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ThemeSpec(
|
||||||
|
id = "monokai",
|
||||||
|
label = "Monokai",
|
||||||
|
isDark = true,
|
||||||
|
primary = Color(0xFF66D9EF),
|
||||||
|
secondary = Color(0xFFAE81FF),
|
||||||
|
error = Color(0xFFF92672),
|
||||||
|
warning = Color(0xFFFD971F),
|
||||||
|
success = Color(0xFFA6E22E),
|
||||||
|
info = Color(0xFFE6DB74),
|
||||||
|
background = Color(0xFF272822),
|
||||||
|
paper = Color(0xFF37372E),
|
||||||
|
textPrimary = Color(0xFFF8F8F2),
|
||||||
|
textSecondary = Color(0xFFCFCFC2),
|
||||||
|
divider = Color(0xFF49483E),
|
||||||
|
categoryColors = listOf(
|
||||||
|
Color(0xFF66D9EF), Color(0xFFAE81FF), Color(0xFFA6E22E), Color(0xFFE6DB74),
|
||||||
|
Color(0xFFF92672), Color(0xFFFD971F), Color(0xFFA1EFE4), Color(0xFFF8F8F2),
|
||||||
|
Color(0xFF75715E), Color(0xFFE69F66), Color(0xFFCC6633), Color(0xFFBD93F9),
|
||||||
|
Color(0xFF50FA7B), Color(0xFFFF79C6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ThemeSpec(
|
||||||
|
id = "ayu-dark",
|
||||||
|
label = "Ayu Dark",
|
||||||
|
isDark = true,
|
||||||
|
primary = Color(0xFFFF8F40),
|
||||||
|
secondary = Color(0xFFD2A6FF),
|
||||||
|
error = Color(0xFFF07178),
|
||||||
|
warning = Color(0xFFFFB454),
|
||||||
|
success = Color(0xFFAAD94C),
|
||||||
|
info = Color(0xFF59C2FF),
|
||||||
|
background = Color(0xFF0B0E14),
|
||||||
|
paper = Color(0xFF141923),
|
||||||
|
textPrimary = Color(0xFFD9D7CE),
|
||||||
|
textSecondary = Color(0xFF626A73),
|
||||||
|
divider = Color(0xFF2B333E),
|
||||||
|
categoryColors = listOf(
|
||||||
|
Color(0xFFFF8F40), Color(0xFFD2A6FF), Color(0xFFAAD94C), Color(0xFFFFB454),
|
||||||
|
Color(0xFFF07178), Color(0xFF59C2FF), Color(0xFFE6B673), Color(0xFF95E6CB),
|
||||||
|
Color(0xFFF29E74), Color(0xFFD4BFFF), Color(0xFF73D0FF), Color(0xFFB8CC52),
|
||||||
|
Color(0xFFF7B2A1), Color(0xFF36A3D9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun themeById(id: String): ThemeSpec = THEME_OPTIONS.firstOrNull { it.id == id } ?: THEME_OPTIONS.first()
|
||||||
@@ -1,82 +1,106 @@
|
|||||||
package com.conta.app.ui.theme
|
package com.conta.app.ui.theme
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.conta.app.ServiceLocator
|
||||||
|
|
||||||
val PrimaryLight = Color(0xFF1565C0)
|
/** Aplica el esquema de colores según el tema persistido en Ajustes. */
|
||||||
val PrimaryDark = Color(0xFF90CAF9)
|
|
||||||
val Accent = Color(0xFF00796B)
|
|
||||||
val AccentDark = Color(0xFF80CBC4)
|
|
||||||
val Tertiary = Color(0xFF6A1B9A)
|
|
||||||
val TertiaryDark = Color(0xFFCE93D8)
|
|
||||||
|
|
||||||
private val LightColors = lightColorScheme(
|
|
||||||
primary = PrimaryLight,
|
|
||||||
onPrimary = Color.White,
|
|
||||||
primaryContainer = Color(0xFFD6E3FF),
|
|
||||||
onPrimaryContainer = Color(0xFF0B2A56),
|
|
||||||
secondary = Accent,
|
|
||||||
onSecondary = Color.White,
|
|
||||||
secondaryContainer = Color(0xFFB2DFDB),
|
|
||||||
onSecondaryContainer = Color(0xFF00332E),
|
|
||||||
tertiary = Tertiary,
|
|
||||||
onTertiary = Color.White,
|
|
||||||
tertiaryContainer = Color(0xFFE1BEE7),
|
|
||||||
onTertiaryContainer = Color(0xFF310742),
|
|
||||||
error = Color(0xFFD32F2F),
|
|
||||||
onError = Color.White,
|
|
||||||
errorContainer = Color(0xFFFFDAD6),
|
|
||||||
onErrorContainer = Color(0xFF410002),
|
|
||||||
background = Color(0xFFF5F6F8),
|
|
||||||
onBackground = Color(0xFF191C20),
|
|
||||||
surface = Color(0xFFFAFAFA),
|
|
||||||
onSurface = Color(0xFF191C20),
|
|
||||||
surfaceVariant = Color(0xFFE7E8EC),
|
|
||||||
onSurfaceVariant = Color(0xFF43474E),
|
|
||||||
outline = Color(0xFF73777F),
|
|
||||||
outlineVariant = Color(0xFFC4C6CC),
|
|
||||||
surfaceTint = PrimaryLight,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val DarkColors = darkColorScheme(
|
|
||||||
primary = PrimaryDark,
|
|
||||||
onPrimary = Color(0xFF0B2A56),
|
|
||||||
primaryContainer = Color(0xFF1E3A5F),
|
|
||||||
onPrimaryContainer = Color(0xFFD6E3FF),
|
|
||||||
secondary = AccentDark,
|
|
||||||
onSecondary = Color(0xFF00332E),
|
|
||||||
secondaryContainer = Color(0xFF00504A),
|
|
||||||
onSecondaryContainer = Color(0xFFB2DFDB),
|
|
||||||
tertiary = TertiaryDark,
|
|
||||||
onTertiary = Color(0xFF310742),
|
|
||||||
tertiaryContainer = Color(0xFF4A1584),
|
|
||||||
onTertiaryContainer = Color(0xFFE1BEE7),
|
|
||||||
error = Color(0xFFFFB4AB),
|
|
||||||
onError = Color(0xFF690005),
|
|
||||||
errorContainer = Color(0xFF93000A),
|
|
||||||
onErrorContainer = Color(0xFFFFDAD6),
|
|
||||||
background = Color(0xFF0F1216),
|
|
||||||
onBackground = Color(0xFFE2E2E6),
|
|
||||||
surface = Color(0xFF15181D),
|
|
||||||
onSurface = Color(0xFFE2E2E6),
|
|
||||||
surfaceVariant = Color(0xFF2A2E35),
|
|
||||||
onSurfaceVariant = Color(0xFFC4C6CC),
|
|
||||||
outline = Color(0xFF8E929A),
|
|
||||||
outlineVariant = Color(0xFF2A2E35),
|
|
||||||
surfaceTint = PrimaryDark,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ContaTheme(
|
fun ContaTheme(content: @Composable () -> Unit) {
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
val themeId by ServiceLocator.settings.themeId.collectAsStateWithLifecycle(initialValue = "default")
|
||||||
content: @Composable () -> Unit,
|
val spec = themeById(themeId)
|
||||||
) {
|
val colorScheme = if (spec.isDark) spec.toDarkColorScheme() else spec.toLightColorScheme()
|
||||||
|
CompositionLocalThemeSpec(spec) {
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = if (darkTheme) DarkColors else LightColors,
|
colorScheme = colorScheme,
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CompositionLocal que expone el ThemeSpec activo (para colores de categorías, etc.). */
|
||||||
|
val LocalThemeSpec = staticCompositionLocalOf<ThemeSpec> { THEME_OPTIONS.first() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompositionLocalThemeSpec(spec: ThemeSpec, content: @Composable () -> Unit) {
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(LocalThemeSpec provides spec, content = content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ThemeSpec.toLightColorScheme() = lightColorScheme(
|
||||||
|
primary = primary,
|
||||||
|
onPrimary = onColor(primary),
|
||||||
|
primaryContainer = lightContainer(primary),
|
||||||
|
onPrimaryContainer = onColor(lightContainer(primary)),
|
||||||
|
secondary = secondary,
|
||||||
|
onSecondary = onColor(secondary),
|
||||||
|
secondaryContainer = lightContainer(secondary),
|
||||||
|
onSecondaryContainer = onColor(lightContainer(secondary)),
|
||||||
|
tertiary = info,
|
||||||
|
onTertiary = onColor(info),
|
||||||
|
tertiaryContainer = lightContainer(info),
|
||||||
|
onTertiaryContainer = onColor(lightContainer(info)),
|
||||||
|
error = error,
|
||||||
|
onError = onColor(error),
|
||||||
|
errorContainer = lightContainer(error),
|
||||||
|
onErrorContainer = onColor(lightContainer(error)),
|
||||||
|
background = background,
|
||||||
|
onBackground = textPrimary,
|
||||||
|
surface = paper,
|
||||||
|
onSurface = textPrimary,
|
||||||
|
surfaceVariant = divider,
|
||||||
|
onSurfaceVariant = textSecondary,
|
||||||
|
outline = textSecondary,
|
||||||
|
outlineVariant = divider,
|
||||||
|
surfaceTint = primary,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun ThemeSpec.toDarkColorScheme() = darkColorScheme(
|
||||||
|
primary = primary,
|
||||||
|
onPrimary = onColor(primary),
|
||||||
|
primaryContainer = darkContainer(primary),
|
||||||
|
onPrimaryContainer = onColor(darkContainer(primary)),
|
||||||
|
secondary = secondary,
|
||||||
|
onSecondary = onColor(secondary),
|
||||||
|
secondaryContainer = darkContainer(secondary),
|
||||||
|
onSecondaryContainer = onColor(darkContainer(secondary)),
|
||||||
|
tertiary = info,
|
||||||
|
onTertiary = onColor(info),
|
||||||
|
tertiaryContainer = darkContainer(info),
|
||||||
|
onTertiaryContainer = onColor(darkContainer(info)),
|
||||||
|
error = error,
|
||||||
|
onError = onColor(error),
|
||||||
|
errorContainer = darkContainer(error),
|
||||||
|
onErrorContainer = onColor(darkContainer(error)),
|
||||||
|
background = background,
|
||||||
|
onBackground = textPrimary,
|
||||||
|
surface = paper,
|
||||||
|
onSurface = textPrimary,
|
||||||
|
surfaceVariant = divider,
|
||||||
|
onSurfaceVariant = textSecondary,
|
||||||
|
outline = textSecondary,
|
||||||
|
outlineVariant = divider,
|
||||||
|
surfaceTint = primary,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Color de contraste (blanco/negro) para texto sobre un fondo dado. */
|
||||||
|
private fun onColor(bg: Color): Color = if (bg.luminance() > 0.5f) Color.Black else Color.White
|
||||||
|
|
||||||
|
private fun Color.luminance(): Float =
|
||||||
|
0.2126f * red + 0.7152f * green + 0.0722f * blue
|
||||||
|
|
||||||
|
private fun lightContainer(base: Color): Color = base.blend(Color.White, t = 0.75f)
|
||||||
|
|
||||||
|
private fun darkContainer(base: Color): Color = base.blend(Color.Black, t = 0.65f)
|
||||||
|
|
||||||
|
private fun Color.blend(other: Color, t: Float = 0.7f): Color = Color(
|
||||||
|
red = red * (1 - t) + other.red * t,
|
||||||
|
green = green * (1 - t) + other.green * t,
|
||||||
|
blue = blue * (1 - t) + other.blue * t,
|
||||||
|
alpha = 1f,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
package com.conta.app.ui.util
|
package com.conta.app.ui.util
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import com.conta.app.ui.theme.LocalThemeSpec
|
||||||
|
|
||||||
/** Paleta por defecto para categorías (misma que el frontend). */
|
/** Paleta de colores de categorías según el tema activo (misma que el frontend). */
|
||||||
val CATEGORY_COLOR_PALETTE = listOf(
|
@Composable
|
||||||
Color(0xFF1976D2), Color(0xFF7B1FA2), Color(0xFF00897B), Color(0xFFF9A825),
|
fun categoryColorPalette(): List<Color> = LocalThemeSpec.current.categoryColors
|
||||||
Color(0xFFE53935), Color(0xFF5D4037), Color(0xFF43A047), Color(0xFF6A1B9A),
|
|
||||||
Color(0xFFC2185B), Color(0xFF303F9F), Color(0xFF00ACC1), Color(0xFF7CB342),
|
|
||||||
Color(0xFFFFB300), Color(0xFFD81B60),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
fun resolveCategoryColor(color: String?, fallback: Color): Color {
|
fun resolveCategoryColor(color: String?, fallback: Color): Color {
|
||||||
if (color.isNullOrBlank()) return fallback
|
if (color.isNullOrBlank()) return fallback
|
||||||
// Si es un índice de la paleta (0-13), se resuelve contra la paleta.
|
val palette = categoryColorPalette()
|
||||||
|
// Si es un índice de la paleta (0-13), se resuelve contra la paleta del tema.
|
||||||
val idx = color.toIntOrNull()
|
val idx = color.toIntOrNull()
|
||||||
if (idx != null && idx in 0 until CATEGORY_COLOR_PALETTE.size) {
|
if (idx != null && idx in 0 until palette.size) {
|
||||||
return CATEGORY_COLOR_PALETTE[idx]
|
return palette[idx]
|
||||||
}
|
}
|
||||||
// Si es un color hex (##RRGGBB o #RRGGBB).
|
// Si es un color hex (##RRGGBB o #RRGGBB).
|
||||||
val hex = color.trim().removePrefix("##").removePrefix("#")
|
val hex = color.trim().removePrefix("##").removePrefix("#")
|
||||||
|
|||||||
@@ -35,6 +35,37 @@ fun formatMonth(monthStr: String): String {
|
|||||||
return "$label $year"
|
return "$label $year"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "2024-01" -> "Enero 2024" */
|
||||||
|
fun formatMonthFull(monthStr: String): String {
|
||||||
|
val parts = monthStr.split("-")
|
||||||
|
if (parts.size != 2) return monthStr
|
||||||
|
val month = parts[1].toIntOrNull() ?: return monthStr
|
||||||
|
val year = parts[0]
|
||||||
|
val label = LocalDate.of(year.toIntOrNull() ?: 1970, month, 1)
|
||||||
|
.month.getDisplayName(TextStyle.FULL, Locale.getDefault())
|
||||||
|
return "$label $year"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2024-01" -> año y mes en forma "2024-01" (igual pero validado). */
|
||||||
|
fun currentMonthIso(): String = LocalDate.now().toString().substring(0, 7)
|
||||||
|
|
||||||
|
/** Desplaza un mes "yyyy-MM" por delta y devuelve "yyyy-MM". */
|
||||||
|
fun shiftMonthIso(month: String, delta: Int): String {
|
||||||
|
val parts = month.split("-")
|
||||||
|
val year = parts.getOrNull(0)?.toIntOrNull() ?: LocalDate.now().year
|
||||||
|
val m = parts.getOrNull(1)?.toIntOrNull() ?: (LocalDate.now().monthValue)
|
||||||
|
val date = LocalDate.of(year, m, 1).plusMonths(delta.toLong())
|
||||||
|
return "${date.year}-${date.monthValue.toString().padStart(2, '0')}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Número de días de un mes "yyyy-MM". */
|
||||||
|
fun daysInMonthIso(month: String): Int {
|
||||||
|
val parts = month.split("-")
|
||||||
|
val year = parts.getOrNull(0)?.toIntOrNull() ?: LocalDate.now().year
|
||||||
|
val m = parts.getOrNull(1)?.toIntOrNull() ?: 1
|
||||||
|
return LocalDate.of(year, m, 1).lengthOfMonth()
|
||||||
|
}
|
||||||
|
|
||||||
/** Formatea la cantidad con el prefijo de tipo de transacción: +, -, o sin signo. */
|
/** Formatea la cantidad con el prefijo de tipo de transacción: +, -, o sin signo. */
|
||||||
fun signedAmount(amount: Double, type: String): String = when (type) {
|
fun signedAmount(amount: Double, type: String): String = when (type) {
|
||||||
"income" -> "+${formatMoney(amount)}"
|
"income" -> "+${formatMoney(amount)}"
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ plugins {
|
|||||||
id "org.jetbrains.kotlin.android" version "2.0.21" apply false
|
id "org.jetbrains.kotlin.android" version "2.0.21" apply false
|
||||||
id "org.jetbrains.kotlin.plugin.compose" version "2.0.21" apply false
|
id "org.jetbrains.kotlin.plugin.compose" version "2.0.21" apply false
|
||||||
id "org.jetbrains.kotlin.plugin.serialization" version "2.0.21" apply false
|
id "org.jetbrains.kotlin.plugin.serialization" version "2.0.21" apply false
|
||||||
|
id "com.google.devtools.ksp" version "2.0.21-1.0.28" apply false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user