Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5d1f2a9f1 |
+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 7
|
versionCode 8
|
||||||
versionName "1.0.6"
|
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)
|
||||||
|
|||||||
@@ -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.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.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
|
||||||
|
|
||||||
@@ -106,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 {
|
||||||
@@ -119,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 {
|
||||||
|
|||||||
@@ -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,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() {
|
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 { _accounts.value = repo.listAccounts() }
|
refreshCached()
|
||||||
runRequest { _notices.value = repo.listNotices() }
|
|
||||||
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()
|
||||||
runRequest { _transactions.value = repo.listTransactions() }
|
refreshCached()
|
||||||
runRequest { _statistics.value = repo.getStatistics() }
|
if (isOnline.value) showMessage("Sincronizado")
|
||||||
runRequest { _notices.value = repo.listNotices() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -220,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 {
|
||||||
@@ -277,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 {
|
||||||
@@ -313,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
|
||||||
@@ -356,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
|
||||||
|
|||||||
@@ -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.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
|
||||||
@@ -62,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),
|
||||||
@@ -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))
|
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
|
||||||
SectionHeader("Notificaciones")
|
SectionHeader("Notificaciones")
|
||||||
SettingsItemCard(
|
SettingsItemCard(
|
||||||
|
|||||||
@@ -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