v1.1.0: modo offline local-first (Room + outbox) con sincronización automática
This commit is contained in:
+7
-2
@@ -3,6 +3,7 @@ plugins {
|
||||
id "org.jetbrains.kotlin.android"
|
||||
id "org.jetbrains.kotlin.plugin.compose"
|
||||
id "org.jetbrains.kotlin.plugin.serialization"
|
||||
id "com.google.devtools.ksp"
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -13,8 +14,8 @@ android {
|
||||
applicationId "com.conta.app"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 7
|
||||
versionName "1.0.6"
|
||||
versionCode 8
|
||||
versionName "1.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -67,6 +68,10 @@ dependencies {
|
||||
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
|
||||
|
||||
@@ -4,13 +4,18 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import com.conta.app.data.AppRepository
|
||||
import com.conta.app.data.SettingsRepository
|
||||
import com.conta.app.data.local.ContaDatabase
|
||||
import com.conta.app.data.local.LocalStore
|
||||
import com.conta.app.data.network.ApiService
|
||||
import com.conta.app.data.network.ConnectivityObserver
|
||||
import com.conta.app.data.network.NetworkModule
|
||||
import com.conta.app.data.network.Session
|
||||
import com.conta.app.data.sync.SyncEngine
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ContaApp : Application() {
|
||||
@@ -28,6 +33,13 @@ class ContaApp : Application() {
|
||||
scope.launch {
|
||||
Session.baseUrl = ServiceLocator.settings.baseUrl.first()
|
||||
}
|
||||
scope.launch {
|
||||
ServiceLocator.connectivity.hasNetwork.collectLatest { online ->
|
||||
if (online) {
|
||||
ServiceLocator.repository.syncNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,12 +48,21 @@ object ServiceLocator {
|
||||
lateinit var settings: SettingsRepository
|
||||
private set
|
||||
|
||||
lateinit var connectivity: ConnectivityObserver
|
||||
private set
|
||||
|
||||
val api: ApiService get() = NetworkModule.api
|
||||
|
||||
val repository: AppRepository by lazy { AppRepository(api) }
|
||||
val store: LocalStore by lazy { LocalStore(ContaDatabase.get(ContaApp.instance)) }
|
||||
|
||||
val sync: SyncEngine by lazy { SyncEngine(api, store, connectivity, NetworkModule.json) }
|
||||
|
||||
val repository: AppRepository by lazy { AppRepository(api, store, sync, NetworkModule.json) }
|
||||
|
||||
fun init(context: Context) {
|
||||
if (::settings.isInitialized) return
|
||||
settings = SettingsRepository(context.applicationContext)
|
||||
connectivity = ConnectivityObserver(context.applicationContext)
|
||||
connectivity.start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
package com.conta.app.data
|
||||
|
||||
import com.conta.app.data.local.LocalStore
|
||||
import com.conta.app.data.local.LocalTransaction
|
||||
import com.conta.app.data.local.OpType
|
||||
import com.conta.app.data.local.SyncState
|
||||
import com.conta.app.data.local.applyLocalBalances
|
||||
import com.conta.app.data.local.computeStatistics
|
||||
import com.conta.app.data.local.computeSummary
|
||||
import com.conta.app.data.local.nowIso
|
||||
import com.conta.app.data.local.toCategory
|
||||
import com.conta.app.data.local.toLocal
|
||||
import com.conta.app.data.local.toTransactionWithAccount
|
||||
import com.conta.app.data.local.withCategory
|
||||
import com.conta.app.data.model.Account
|
||||
import com.conta.app.data.model.AccountInfo
|
||||
import com.conta.app.data.model.BackupData
|
||||
@@ -29,13 +41,25 @@ import com.conta.app.data.model.UpdateCategoryRequest
|
||||
import com.conta.app.data.model.UpdateRecurringTransactionRequest
|
||||
import com.conta.app.data.model.UpdateTransactionRequest
|
||||
import com.conta.app.data.network.ApiService
|
||||
import com.conta.app.data.sync.SyncEngine
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import java.io.File
|
||||
|
||||
/** Convierte los errores de red/HTTP en mensajes legibles en español. */
|
||||
class AppRepository(private val api: ApiService) {
|
||||
/**
|
||||
* Capa de datos local-first: los movimientos se guardan primero en el espejo local
|
||||
* (outbox) y se sincronizan con el servidor. Cuentas/categorías/reglas/recurrentes
|
||||
* se leen del espejo cuando están disponibles y se editan solo con conexión.
|
||||
*/
|
||||
class AppRepository(
|
||||
private val api: ApiService,
|
||||
private val store: LocalStore,
|
||||
private val sync: SyncEngine,
|
||||
private val json: Json,
|
||||
) {
|
||||
|
||||
private inline fun <T> safe(block: () -> T): T {
|
||||
return try {
|
||||
@@ -54,11 +78,183 @@ class AppRepository(private val api: ApiService) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listAccounts(): List<AccountInfo> = safe { api.listAccounts() }
|
||||
// ---------- Lecturas locales ----------
|
||||
|
||||
suspend fun listAccounts(): List<AccountInfo> =
|
||||
applyLocalBalances(store.snapshotAccounts(), store.snapshotTransactions())
|
||||
|
||||
suspend fun listAccountTransactions(id: Long): List<TransactionWithAccount> =
|
||||
listTransactions().filter { it.accountId == id || it.toAccountId == id }
|
||||
|
||||
suspend fun listTransactions(): List<TransactionWithAccount> =
|
||||
store.snapshotTransactions().map { it.toTransactionWithAccount() }
|
||||
|
||||
suspend fun listCategories(): List<Category> =
|
||||
store.snapshotCategories().map { it.toCategory() }
|
||||
|
||||
suspend fun getSummary(): Summary = computeSummary(listAccounts())
|
||||
|
||||
suspend fun getStatistics(): Statistics = computeStatistics(store.snapshotTransactions())
|
||||
|
||||
suspend fun listRules(): List<CategorizationRule> =
|
||||
store.getCache(CACHE_RULES)?.let { runCatching { json.decodeFromString<List<CategorizationRule>>(it) }.getOrNull() }
|
||||
?: emptyList()
|
||||
|
||||
suspend fun listRecurring(): List<RecurringTransactionWithNames> =
|
||||
store.getCache(CACHE_RECURRING)?.let { runCatching { json.decodeFromString<List<RecurringTransactionWithNames>>(it) }.getOrNull() }
|
||||
?: emptyList()
|
||||
|
||||
suspend fun listNotices(): List<Notice> =
|
||||
store.getCache(CACHE_NOTICES)?.let { runCatching { json.decodeFromString<List<Notice>>(it) }.getOrNull() }
|
||||
?: emptyList()
|
||||
|
||||
/** Refresca el espejo desde el servidor y cachea reglas/recurrentes/avisos. */
|
||||
suspend fun refreshFromServer() {
|
||||
safe {
|
||||
sync.refreshMirror()
|
||||
store.putCache(CACHE_RULES, json.encodeToString(api.listRules()))
|
||||
store.putCache(CACHE_RECURRING, json.encodeToString(api.listRecurring()))
|
||||
store.putCache(CACHE_NOTICES, json.encodeToString(api.listNotices()))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Movimientos: local-first (outbox) ----------
|
||||
|
||||
suspend fun createTransaction(body: CreateTransactionRequest): Transaction {
|
||||
val id = store.nextTempId()
|
||||
val now = nowIso()
|
||||
val accountName = store.snapshotAccounts().firstOrNull { it.id == body.accountId }?.name ?: ""
|
||||
val category = store.snapshotCategories().firstOrNull { it.id == body.categoryId }
|
||||
val local = LocalTransaction(
|
||||
id = id,
|
||||
accountId = body.accountId,
|
||||
accountName = accountName,
|
||||
transactionType = body.transactionType,
|
||||
amount = body.amount,
|
||||
description = body.description,
|
||||
date = body.date,
|
||||
categoryId = body.categoryId,
|
||||
createdAt = now,
|
||||
syncState = SyncState.PENDING_CREATE,
|
||||
).withCategory(category)
|
||||
store.insertPendingCreate(local, json.encodeToString(body))
|
||||
sync.requestSync()
|
||||
return local.toTransaction()
|
||||
}
|
||||
|
||||
suspend fun createTransfer(body: TransferRequest): List<Transaction> {
|
||||
val id = store.nextTempId()
|
||||
val now = nowIso()
|
||||
val accounts = store.snapshotAccounts().associateBy { it.id }
|
||||
val category = store.snapshotCategories().firstOrNull { it.id == body.categoryId }
|
||||
val local = LocalTransaction(
|
||||
id = id,
|
||||
accountId = body.fromAccountId,
|
||||
accountName = accounts[body.fromAccountId]?.name ?: "",
|
||||
toAccountId = body.toAccountId,
|
||||
toAccountName = accounts[body.toAccountId]?.name,
|
||||
transactionType = "transfer",
|
||||
amount = body.amount,
|
||||
description = body.description,
|
||||
date = body.date,
|
||||
categoryId = body.categoryId,
|
||||
createdAt = now,
|
||||
syncState = SyncState.PENDING_CREATE,
|
||||
).withCategory(category)
|
||||
store.insertPendingTransfer(local, json.encodeToString(body))
|
||||
sync.requestSync()
|
||||
return listOf(local.toTransaction())
|
||||
}
|
||||
|
||||
suspend fun updateTransaction(id: Long, body: UpdateTransactionRequest): Transaction {
|
||||
if (id < 0) {
|
||||
// Aún no subida: se edita localmente (merge en el op pendiente).
|
||||
val local = store.snapshotTransactions().firstOrNull { it.id == id } ?: return localPlaceholder(id)
|
||||
val updated = local.copy(
|
||||
accountId = body.accountId ?: local.accountId,
|
||||
transactionType = body.transactionType ?: local.transactionType,
|
||||
amount = body.amount ?: local.amount,
|
||||
description = body.description ?: local.description,
|
||||
date = body.date ?: local.date,
|
||||
categoryId = body.categoryId?.value ?: local.categoryId,
|
||||
).withCategory(store.snapshotCategories().firstOrNull { it.id == body.categoryId?.value })
|
||||
val opType = if (updated.transactionType == "transfer") OpType.CREATE_TRANSFER else OpType.CREATE_TRANSACTION
|
||||
val payload = if (updated.transactionType == "transfer") {
|
||||
json.encodeToString(
|
||||
TransferRequest(
|
||||
fromAccountId = updated.accountId,
|
||||
toAccountId = updated.toAccountId ?: updated.accountId,
|
||||
amount = updated.amount,
|
||||
description = updated.description,
|
||||
date = updated.date,
|
||||
categoryId = updated.categoryId,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
json.encodeToString(
|
||||
CreateTransactionRequest(
|
||||
accountId = updated.accountId,
|
||||
transactionType = updated.transactionType,
|
||||
amount = updated.amount,
|
||||
description = updated.description,
|
||||
date = updated.date,
|
||||
categoryId = updated.categoryId,
|
||||
),
|
||||
)
|
||||
}
|
||||
store.updatePendingCreate(updated, payload, opType)
|
||||
sync.requestSync()
|
||||
return updated.toTransaction()
|
||||
}
|
||||
store.enqueueUpdate(
|
||||
store.snapshotTransactions().firstOrNull { it.id == id } ?: return localPlaceholder(id),
|
||||
json.encodeToString(body),
|
||||
)
|
||||
sync.requestSync()
|
||||
return localPlaceholder(id)
|
||||
}
|
||||
|
||||
suspend fun updateTransactionToTransfer(id: Long, body: TransferRequest): List<Transaction> {
|
||||
if (id < 0) {
|
||||
val local = store.snapshotTransactions().firstOrNull { it.id == id } ?: return emptyList()
|
||||
val accounts = store.snapshotAccounts().associateBy { it.id }
|
||||
val updated = local.copy(
|
||||
accountId = body.fromAccountId,
|
||||
accountName = accounts[body.fromAccountId]?.name ?: local.accountName,
|
||||
toAccountId = body.toAccountId,
|
||||
toAccountName = accounts[body.toAccountId]?.name,
|
||||
transactionType = "transfer",
|
||||
amount = body.amount,
|
||||
description = body.description,
|
||||
date = body.date,
|
||||
categoryId = body.categoryId,
|
||||
).withCategory(store.snapshotCategories().firstOrNull { it.id == body.categoryId })
|
||||
store.updatePendingCreate(updated, json.encodeToString(body), OpType.CREATE_TRANSFER)
|
||||
sync.requestSync()
|
||||
return listOf(updated.toTransaction())
|
||||
}
|
||||
store.enqueueUpdateToTransfer(
|
||||
store.snapshotTransactions().firstOrNull { it.id == id } ?: return emptyList(),
|
||||
json.encodeToString(body),
|
||||
)
|
||||
sync.requestSync()
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
suspend fun deleteTransaction(id: Long) {
|
||||
if (id < 0) {
|
||||
store.removeLocal(id)
|
||||
} else {
|
||||
store.enqueueDelete(id)
|
||||
sync.requestSync()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Cuentas / categorías / reglas / recurrentes (requieren conexión) ----------
|
||||
|
||||
suspend fun createAccount(body: CreateAccountRequest): Account = safe { api.createAccount(body) }
|
||||
suspend fun updateAccount(id: Long, body: UpdateAccountRequest): Account = safe { api.updateAccount(id, body) }
|
||||
suspend fun deleteAccount(id: Long) = safe { api.deleteAccount(id) }
|
||||
suspend fun listAccountTransactions(id: Long): List<TransactionWithAccount> = safe { api.listAccountTransactions(id) }
|
||||
|
||||
suspend fun importStatement(accountId: Long, file: File): StatementImportResult =
|
||||
safe { api.importStatement(accountId, file.toMultipart("statement.pdf")) }
|
||||
@@ -66,34 +262,20 @@ class AppRepository(private val api: ApiService) {
|
||||
suspend fun importSavingsStatement(accountId: Long, file: File): StatementImportResult =
|
||||
safe { api.importSavingsStatement(accountId, file.toMultipart("statement.pdf")) }
|
||||
|
||||
suspend fun listTransactions(): List<TransactionWithAccount> = safe { api.listTransactions() }
|
||||
suspend fun createTransaction(body: CreateTransactionRequest): Transaction = safe { api.createTransaction(body) }
|
||||
suspend fun createTransfer(body: TransferRequest): List<Transaction> = safe { api.createTransfer(body) }
|
||||
suspend fun updateTransaction(id: Long, body: UpdateTransactionRequest): Transaction = safe { api.updateTransaction(id, body) }
|
||||
suspend fun updateTransactionToTransfer(id: Long, body: TransferRequest): List<Transaction> =
|
||||
safe { api.updateTransactionToTransfer(id, body) }
|
||||
suspend fun deleteTransaction(id: Long) = safe { api.deleteTransaction(id) }
|
||||
|
||||
suspend fun listCategories(): List<Category> = safe { api.listCategories() }
|
||||
suspend fun createCategory(body: CreateCategoryRequest): Category = safe { api.createCategory(body) }
|
||||
suspend fun updateCategory(id: Long, body: UpdateCategoryRequest): Category = safe { api.updateCategory(id, body) }
|
||||
suspend fun deleteCategory(id: Long) = safe { api.deleteCategory(id) }
|
||||
|
||||
suspend fun listRules(): List<CategorizationRule> = safe { api.listRules() }
|
||||
suspend fun createRule(body: CreateCategorizationRuleRequest): CategorizationRuleCreated = safe { api.createRule(body) }
|
||||
suspend fun updateRule(id: Long, body: UpdateCategorizationRuleRequest): CategorizationRule = safe { api.updateRule(id, body) }
|
||||
suspend fun deleteRule(id: Long) = safe { api.deleteRule(id) }
|
||||
|
||||
suspend fun getSummary(): Summary = safe { api.getSummary() }
|
||||
suspend fun getStatistics(): Statistics = safe { api.getStatistics() }
|
||||
|
||||
suspend fun listRecurring(): List<RecurringTransactionWithNames> = safe { api.listRecurring() }
|
||||
suspend fun createRecurring(body: CreateRecurringTransactionRequest): RecurringTransactionWithNames =
|
||||
safe { api.createRecurring(body) }
|
||||
suspend fun updateRecurring(id: Long, body: UpdateRecurringTransactionRequest): RecurringTransactionWithNames =
|
||||
safe { api.updateRecurring(id, body) }
|
||||
suspend fun createRecurring(body: CreateRecurringTransactionRequest): RecurringTransactionWithNames = safe { api.createRecurring(body) }
|
||||
suspend fun updateRecurring(id: Long, body: UpdateRecurringTransactionRequest): RecurringTransactionWithNames = safe { api.updateRecurring(id, body) }
|
||||
suspend fun deleteRecurring(id: Long) = safe { api.deleteRecurring(id) }
|
||||
|
||||
// ---------- Otros ----------
|
||||
|
||||
suspend fun exportBackup(): BackupData = safe { api.exportBackup() }
|
||||
suspend fun importBackup(body: BackupData) = safe { api.importBackup(body) }
|
||||
|
||||
@@ -101,11 +283,40 @@ class AppRepository(private val api: ApiService) {
|
||||
suspend fun saveNtfySettings(body: NtfySettingsRequest): NtfySettings = safe { api.saveNtfySettings(body) }
|
||||
suspend fun testNtfy(): NtfyTestResult = safe { api.testNtfy() }
|
||||
|
||||
suspend fun listNotices(): List<Notice> = safe { api.listNotices() }
|
||||
suspend fun dismissNotice(fingerprint: String) = safe { api.dismissNotice(DismissNoticeRequest(fingerprint)) }
|
||||
|
||||
suspend fun syncNow(): Boolean = sync.syncNow()
|
||||
|
||||
private fun localPlaceholder(id: Long): Transaction = Transaction(
|
||||
id = id,
|
||||
accountId = 0,
|
||||
transactionType = "expense",
|
||||
amount = 0.0,
|
||||
description = "",
|
||||
date = "",
|
||||
createdAt = "",
|
||||
)
|
||||
|
||||
private fun LocalTransaction.toTransaction(): Transaction = Transaction(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
toAccountId = toAccountId,
|
||||
transactionType = transactionType,
|
||||
amount = amount,
|
||||
description = description,
|
||||
date = date,
|
||||
categoryId = categoryId,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
private fun File.toMultipart(name: String): MultipartBody.Part =
|
||||
MultipartBody.Part.createFormData("file", name, asRequestBody("application/pdf".toMediaType()))
|
||||
|
||||
private companion object {
|
||||
const val CACHE_RULES = "rules"
|
||||
const val CACHE_RECURRING = "recurring"
|
||||
const val CACHE_NOTICES = "notices"
|
||||
}
|
||||
}
|
||||
|
||||
class ApiError(message: String) : Exception(message)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ import com.conta.app.ui.screens.settings.BackupScreen
|
||||
import com.conta.app.ui.screens.settings.NtfyScreen
|
||||
import com.conta.app.ui.screens.settings.SettingsScreen
|
||||
import com.conta.app.ui.screens.settings.ThemeScreen
|
||||
import com.conta.app.ui.components.OfflineBanner
|
||||
import com.conta.app.ui.screens.transactions.TransactionFormScreen
|
||||
import com.conta.app.ui.screens.transactions.TransactionsScreen
|
||||
|
||||
@@ -106,9 +107,13 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val message by viewModel.message.collectAsStateWithLifecycle()
|
||||
val busy by viewModel.busy.collectAsStateWithLifecycle()
|
||||
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
|
||||
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
|
||||
val pendingCount by viewModel.pendingCount.collectAsStateWithLifecycle()
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
val showBottomBar = bottomItems.any { it.route == currentRoute }
|
||||
val showOfflineBanner = !isOnline || pendingCount > 0
|
||||
|
||||
LaunchedEffect(message) {
|
||||
message?.let {
|
||||
@@ -119,6 +124,16 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) {
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
if (showOfflineBanner) {
|
||||
OfflineBanner(
|
||||
pendingCount = pendingCount,
|
||||
isOnline = isOnline,
|
||||
isSyncing = isSyncing,
|
||||
onSync = { viewModel.forceSync() },
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
if (showBottomBar) {
|
||||
NavigationBar {
|
||||
|
||||
@@ -30,32 +30,53 @@ import com.conta.app.data.model.UpdateCategorizationRuleRequest
|
||||
import com.conta.app.data.model.UpdateCategoryRequest
|
||||
import com.conta.app.data.model.UpdateRecurringTransactionRequest
|
||||
import com.conta.app.data.model.UpdateTransactionRequest
|
||||
import com.conta.app.data.local.applyLocalBalances
|
||||
import com.conta.app.data.local.computeStatistics
|
||||
import com.conta.app.data.local.computeSummary
|
||||
import com.conta.app.data.local.toCategory
|
||||
import com.conta.app.data.local.toTransactionWithAccount
|
||||
import com.conta.app.data.network.Session
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
class AppViewModel : ViewModel() {
|
||||
|
||||
private val repo = ServiceLocator.repository
|
||||
private val store = ServiceLocator.store
|
||||
|
||||
private val _summary = MutableStateFlow<Summary?>(null)
|
||||
val summary: StateFlow<Summary?> = _summary.asStateFlow()
|
||||
// ---------- Flujos desde el espejo local (Room) ----------
|
||||
|
||||
private val _statistics = MutableStateFlow<Statistics?>(null)
|
||||
val statistics: StateFlow<Statistics?> = _statistics.asStateFlow()
|
||||
val accounts: StateFlow<List<AccountInfo>> = combine(store.accounts, store.transactions) { acc, tx ->
|
||||
applyLocalBalances(acc, tx)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private val _accounts = MutableStateFlow<List<AccountInfo>>(emptyList())
|
||||
val accounts: StateFlow<List<AccountInfo>> = _accounts.asStateFlow()
|
||||
val transactions: StateFlow<List<TransactionWithAccount>> = store.transactions.map { list ->
|
||||
list.map { it.toTransactionWithAccount() }
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private val _transactions = MutableStateFlow<List<TransactionWithAccount>>(emptyList())
|
||||
val transactions: StateFlow<List<TransactionWithAccount>> = _transactions.asStateFlow()
|
||||
val categories: StateFlow<List<Category>> = store.categories.map { list ->
|
||||
list.map { it.toCategory() }
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
private val _categories = MutableStateFlow<List<Category>>(emptyList())
|
||||
val categories: StateFlow<List<Category>> = _categories.asStateFlow()
|
||||
/** Null mientras no hay datos ni conexión (se usa para la pantalla de "No conectado"). */
|
||||
private val _connected = MutableStateFlow(false)
|
||||
val hasConnected: StateFlow<Boolean> = _connected.asStateFlow()
|
||||
|
||||
val summary: StateFlow<Summary?> = combine(store.accounts, store.transactions, _connected) { acc, tx, connected ->
|
||||
if (!connected && acc.isEmpty() && tx.isEmpty()) null
|
||||
else computeSummary(applyLocalBalances(acc, tx))
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
val statistics: StateFlow<Statistics?> = store.transactions.map { computeStatistics(it) }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
private val _rules = MutableStateFlow<List<CategorizationRule>>(emptyList())
|
||||
val rules: StateFlow<List<CategorizationRule>> = _rules.asStateFlow()
|
||||
@@ -75,6 +96,21 @@ class AppViewModel : ViewModel() {
|
||||
private val _message = MutableStateFlow<String?>(null)
|
||||
val message: StateFlow<String?> = _message.asStateFlow()
|
||||
|
||||
val isOnline: StateFlow<Boolean> = combine(
|
||||
ServiceLocator.connectivity.hasNetwork,
|
||||
ServiceLocator.sync.serverReachable,
|
||||
) { hasNetwork, reachable -> hasNetwork && reachable }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
val isSyncing: StateFlow<Boolean> = ServiceLocator.sync.isSyncing
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
val pendingCount: StateFlow<Int> = store.pendingCount
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
|
||||
|
||||
val lastSyncAt: StateFlow<String?> = ServiceLocator.sync.lastSyncedAt
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
fun consumeMessage() {
|
||||
_message.value = null
|
||||
}
|
||||
@@ -87,6 +123,7 @@ class AppViewModel : ViewModel() {
|
||||
viewModelScope.launch {
|
||||
val saved = ServiceLocator.settings.baseUrl.first()
|
||||
Session.baseUrl = saved
|
||||
refreshCached()
|
||||
if (saved.isNotBlank()) refreshAll()
|
||||
}
|
||||
}
|
||||
@@ -101,60 +138,56 @@ class AppViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshCached() {
|
||||
_rules.value = repo.listRules()
|
||||
_recurring.value = repo.listRecurring()
|
||||
_notices.value = repo.listNotices()
|
||||
}
|
||||
|
||||
/** Trae datos del servidor al espejo local y cachea reglas/recurrentes/avisos. */
|
||||
private suspend fun pullFromServer() {
|
||||
try {
|
||||
repo.refreshFromServer()
|
||||
_connected.value = true
|
||||
refreshCached()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshAll() {
|
||||
viewModelScope.launch {
|
||||
_loading.value = true
|
||||
runRequest { _summary.value = repo.getSummary() }
|
||||
runRequest { _transactions.value = repo.listTransactions() }
|
||||
runRequest { _accounts.value = repo.listAccounts() }
|
||||
runRequest { _categories.value = repo.listCategories() }
|
||||
runRequest { _rules.value = repo.listRules() }
|
||||
runRequest { _recurring.value = repo.listRecurring() }
|
||||
runRequest { _notices.value = repo.listNotices() }
|
||||
runRequest { _statistics.value = repo.getStatistics() }
|
||||
pullFromServer()
|
||||
repo.syncNow()
|
||||
refreshCached()
|
||||
_loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshDashboard() {
|
||||
viewModelScope.launch {
|
||||
runRequest { _summary.value = repo.getSummary() }
|
||||
runRequest { _transactions.value = repo.listTransactions() }
|
||||
runRequest { _accounts.value = repo.listAccounts() }
|
||||
runRequest { _notices.value = repo.listNotices() }
|
||||
runRequest { _statistics.value = repo.getStatistics() }
|
||||
pullFromServer()
|
||||
repo.syncNow()
|
||||
refreshCached()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshAccounts() {
|
||||
fun refreshAccounts() = refreshDashboard()
|
||||
fun refreshCategories() = refreshDashboard()
|
||||
fun refreshRules() = refreshDashboard()
|
||||
fun refreshRecurring() = refreshDashboard()
|
||||
|
||||
/** Sincronización manual (botón en Ajustes). */
|
||||
fun forceSync() {
|
||||
viewModelScope.launch {
|
||||
runRequest { _summary.value = repo.getSummary() }
|
||||
runRequest { _accounts.value = repo.listAccounts() }
|
||||
runRequest { _transactions.value = repo.listTransactions() }
|
||||
runRequest { _statistics.value = repo.getStatistics() }
|
||||
runRequest { _notices.value = repo.listNotices() }
|
||||
pullFromServer()
|
||||
repo.syncNow()
|
||||
refreshCached()
|
||||
if (isOnline.value) showMessage("Sincronizado")
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshCategories() {
|
||||
viewModelScope.launch {
|
||||
runRequest { _categories.value = repo.listCategories() }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshRules() {
|
||||
viewModelScope.launch {
|
||||
runRequest { _rules.value = repo.listRules() }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshRecurring() {
|
||||
viewModelScope.launch {
|
||||
runRequest { _recurring.value = repo.listRecurring() }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Cuentas ----------
|
||||
// ---------- Cuentas (requieren conexión) ----------
|
||||
|
||||
fun createAccount(body: CreateAccountRequest, onDone: () -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
@@ -220,7 +253,7 @@ class AppViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Transacciones ----------
|
||||
// ---------- Movimientos (local-first) ----------
|
||||
|
||||
fun createTransaction(body: CreateTransactionRequest, onDone: () -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
@@ -277,7 +310,7 @@ class AppViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Categorías ----------
|
||||
// ---------- Categorías / Reglas / Recurrentes (requieren conexión) ----------
|
||||
|
||||
fun createCategory(body: CreateCategoryRequest, onDone: () -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
@@ -313,8 +346,6 @@ class AppViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Reglas ----------
|
||||
|
||||
fun createRule(body: CreateCategorizationRuleRequest, onDone: (CategorizationRuleCreated?) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
_busy.value = true
|
||||
@@ -356,8 +387,6 @@ class AppViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Recurrentes ----------
|
||||
|
||||
fun createRecurring(body: CreateRecurringTransactionRequest, onDone: () -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
_busy.value = true
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import com.conta.app.ServiceLocator
|
||||
import com.conta.app.ui.AppViewModel
|
||||
import com.conta.app.ui.Routes
|
||||
import com.conta.app.ui.components.SectionHeader
|
||||
import com.conta.app.ui.components.SyncStatusCard
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -62,6 +63,10 @@ fun SettingsScreen(viewModel: AppViewModel, navController: NavController) {
|
||||
}
|
||||
|
||||
val summary by viewModel.summary.collectAsStateWithLifecycle()
|
||||
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
|
||||
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
|
||||
val pendingCount by viewModel.pendingCount.collectAsStateWithLifecycle()
|
||||
val lastSyncAt by viewModel.lastSyncAt.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
|
||||
@@ -117,6 +122,16 @@ fun SettingsScreen(viewModel: AppViewModel, navController: NavController) {
|
||||
}
|
||||
}
|
||||
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
|
||||
SectionHeader("Sincronización")
|
||||
SyncStatusCard(
|
||||
isOnline = isOnline,
|
||||
isSyncing = isSyncing,
|
||||
pendingCount = pendingCount,
|
||||
lastSyncAt = lastSyncAt,
|
||||
onSync = { viewModel.forceSync() },
|
||||
)
|
||||
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
|
||||
SectionHeader("Notificaciones")
|
||||
SettingsItemCard(
|
||||
|
||||
@@ -3,4 +3,5 @@ plugins {
|
||||
id "org.jetbrains.kotlin.android" version "2.0.21" apply false
|
||||
id "org.jetbrains.kotlin.plugin.compose" version "2.0.21" apply false
|
||||
id "org.jetbrains.kotlin.plugin.serialization" version "2.0.21" apply false
|
||||
id "com.google.devtools.ksp" version "2.0.21-1.0.28" apply false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user