diff --git a/app/build.gradle b/app/build.gradle index 86133b8..fb59ab9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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") diff --git a/app/src/main/java/com/conta/app/ContaApp.kt b/app/src/main/java/com/conta/app/ContaApp.kt index 0b0c2b7..b927b2e 100644 --- a/app/src/main/java/com/conta/app/ContaApp.kt +++ b/app/src/main/java/com/conta/app/ContaApp.kt @@ -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() } } diff --git a/app/src/main/java/com/conta/app/data/AppRepository.kt b/app/src/main/java/com/conta/app/data/AppRepository.kt index 5c8f004..8cc7fd8 100644 --- a/app/src/main/java/com/conta/app/data/AppRepository.kt +++ b/app/src/main/java/com/conta/app/data/AppRepository.kt @@ -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 safe(block: () -> T): T { return try { @@ -54,11 +78,183 @@ class AppRepository(private val api: ApiService) { } } - suspend fun listAccounts(): List = safe { api.listAccounts() } + // ---------- Lecturas locales ---------- + + suspend fun listAccounts(): List = + applyLocalBalances(store.snapshotAccounts(), store.snapshotTransactions()) + + suspend fun listAccountTransactions(id: Long): List = + listTransactions().filter { it.accountId == id || it.toAccountId == id } + + suspend fun listTransactions(): List = + store.snapshotTransactions().map { it.toTransactionWithAccount() } + + suspend fun listCategories(): List = + store.snapshotCategories().map { it.toCategory() } + + suspend fun getSummary(): Summary = computeSummary(listAccounts()) + + suspend fun getStatistics(): Statistics = computeStatistics(store.snapshotTransactions()) + + suspend fun listRules(): List = + store.getCache(CACHE_RULES)?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } + ?: emptyList() + + suspend fun listRecurring(): List = + store.getCache(CACHE_RECURRING)?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } + ?: emptyList() + + suspend fun listNotices(): List = + store.getCache(CACHE_NOTICES)?.let { runCatching { json.decodeFromString>(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 { + 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 { + 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 = 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 = safe { api.listTransactions() } - suspend fun createTransaction(body: CreateTransactionRequest): Transaction = safe { api.createTransaction(body) } - suspend fun createTransfer(body: TransferRequest): List = 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 = - safe { api.updateTransactionToTransfer(id, body) } - suspend fun deleteTransaction(id: Long) = safe { api.deleteTransaction(id) } - - suspend fun listCategories(): List = 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 = 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 = 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 = 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) diff --git a/app/src/main/java/com/conta/app/data/local/ContaDatabase.kt b/app/src/main/java/com/conta/app/data/local/ContaDatabase.kt new file mode 100644 index 0000000..cec3377 --- /dev/null +++ b/app/src/main/java/com/conta/app/data/local/ContaDatabase.kt @@ -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 } + } + } +} diff --git a/app/src/main/java/com/conta/app/data/local/Daos.kt b/app/src/main/java/com/conta/app/data/local/Daos.kt new file mode 100644 index 0000000..57193c2 --- /dev/null +++ b/app/src/main/java/com/conta/app/data/local/Daos.kt @@ -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> + + @Query("SELECT * FROM accounts ORDER BY accountType, name") + suspend fun getAll(): List + + @Query("SELECT * FROM accounts WHERE id = :id") + suspend fun findById(id: Long): LocalAccount? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(accounts: List) + + @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> + + @Query("SELECT * FROM categories ORDER BY name") + suspend fun getAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(categories: List) + + @Query("DELETE FROM categories") + suspend fun clear() +} + +@Dao +interface TransactionDao { + @Query("SELECT * FROM transactions ORDER BY date DESC, id DESC") + fun observeAll(): Flow> + + @Query("SELECT * FROM transactions ORDER BY date DESC, id DESC") + suspend fun getAll(): List + + @Query("SELECT * FROM transactions WHERE id = :id") + suspend fun findById(id: Long): LocalTransaction? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(transactions: List) + + @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> + + @Query("SELECT * FROM pending_ops ORDER BY opId ASC") + suspend fun getAll(): List + + @Query("SELECT COUNT(*) FROM pending_ops") + fun observeCount(): Flow + + @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) +} diff --git a/app/src/main/java/com/conta/app/data/local/LocalCalculations.kt b/app/src/main/java/com/conta/app/data/local/LocalCalculations.kt new file mode 100644 index 0000000..38e6edd --- /dev/null +++ b/app/src/main/java/com/conta/app/data/local/LocalCalculations.kt @@ -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 = 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, transactions: List): Map { + val balances = HashMap() + 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, transactions: List): List { + 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): 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 { + 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, 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 { 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) +} diff --git a/app/src/main/java/com/conta/app/data/local/LocalEntities.kt b/app/src/main/java/com/conta/app/data/local/LocalEntities.kt new file mode 100644 index 0000000..529aa6f --- /dev/null +++ b/app/src/main/java/com/conta/app/data/local/LocalEntities.kt @@ -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" +} diff --git a/app/src/main/java/com/conta/app/data/local/LocalStore.kt b/app/src/main/java/com/conta/app/data/local/LocalStore.kt new file mode 100644 index 0000000..b5f415a --- /dev/null +++ b/app/src/main/java/com/conta/app/data/local/LocalStore.kt @@ -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> = db.accountDao().observeAll() + val categories: Flow> = db.categoryDao().observeAll() + val transactions: Flow> = db.transactionDao().observeAll() + val pendingCount: Flow = db.pendingOpDao().observeCount() + + suspend fun snapshotAccounts(): List = db.accountDao().getAll() + suspend fun snapshotTransactions(): List = db.transactionDao().getAll() + suspend fun snapshotCategories(): List = db.categoryDao().getAll() + + suspend fun pendingOps(): List = 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, + categories: List, + transactions: List, + ) { + 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, +) diff --git a/app/src/main/java/com/conta/app/data/network/ConnectivityObserver.kt b/app/src/main/java/com/conta/app/data/network/ConnectivityObserver.kt new file mode 100644 index 0000000..a6826ce --- /dev/null +++ b/app/src/main/java/com/conta/app/data/network/ConnectivityObserver.kt @@ -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 = _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 + } +} diff --git a/app/src/main/java/com/conta/app/data/sync/SyncEngine.kt b/app/src/main/java/com/conta/app/data/sync/SyncEngine.kt new file mode 100644 index 0000000..8956a92 --- /dev/null +++ b/app/src/main/java/com/conta/app/data/sync/SyncEngine.kt @@ -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 = _isSyncing.asStateFlow() + + private val _serverReachable = MutableStateFlow(false) + val serverReachable: StateFlow = _serverReachable.asStateFlow() + + private val _lastSyncedAt = MutableStateFlow(null) + val lastSyncedAt: StateFlow = _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(op.payloadJson) + api.createTransaction(body).toLocal() + } else { + val body = json.decodeFromString(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(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(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): 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" + } +} diff --git a/app/src/main/java/com/conta/app/ui/AppRoot.kt b/app/src/main/java/com/conta/app/ui/AppRoot.kt index 89b159f..240274a 100644 --- a/app/src/main/java/com/conta/app/ui/AppRoot.kt +++ b/app/src/main/java/com/conta/app/ui/AppRoot.kt @@ -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 { diff --git a/app/src/main/java/com/conta/app/ui/AppViewModel.kt b/app/src/main/java/com/conta/app/ui/AppViewModel.kt index dae0a8e..f5d28cb 100644 --- a/app/src/main/java/com/conta/app/ui/AppViewModel.kt +++ b/app/src/main/java/com/conta/app/ui/AppViewModel.kt @@ -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(null) - val summary: StateFlow = _summary.asStateFlow() + // ---------- Flujos desde el espejo local (Room) ---------- - private val _statistics = MutableStateFlow(null) - val statistics: StateFlow = _statistics.asStateFlow() + val accounts: StateFlow> = combine(store.accounts, store.transactions) { acc, tx -> + applyLocalBalances(acc, tx) + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - private val _accounts = MutableStateFlow>(emptyList()) - val accounts: StateFlow> = _accounts.asStateFlow() + val transactions: StateFlow> = store.transactions.map { list -> + list.map { it.toTransactionWithAccount() } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - private val _transactions = MutableStateFlow>(emptyList()) - val transactions: StateFlow> = _transactions.asStateFlow() + val categories: StateFlow> = store.categories.map { list -> + list.map { it.toCategory() } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - private val _categories = MutableStateFlow>(emptyList()) - val categories: StateFlow> = _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 = _connected.asStateFlow() + + val summary: StateFlow = 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 = store.transactions.map { computeStatistics(it) } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) private val _rules = MutableStateFlow>(emptyList()) val rules: StateFlow> = _rules.asStateFlow() @@ -75,6 +96,21 @@ class AppViewModel : ViewModel() { private val _message = MutableStateFlow(null) val message: StateFlow = _message.asStateFlow() + val isOnline: StateFlow = combine( + ServiceLocator.connectivity.hasNetwork, + ServiceLocator.sync.serverReachable, + ) { hasNetwork, reachable -> hasNetwork && reachable } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val isSyncing: StateFlow = ServiceLocator.sync.isSyncing + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val pendingCount: StateFlow = store.pendingCount + .stateIn(viewModelScope, SharingStarted.Eagerly, 0) + + val lastSyncAt: StateFlow = 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 diff --git a/app/src/main/java/com/conta/app/ui/components/SyncStatus.kt b/app/src/main/java/com/conta/app/ui/components/SyncStatus.kt new file mode 100644 index 0000000..d934c2e --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/components/SyncStatus.kt @@ -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())) +} diff --git a/app/src/main/java/com/conta/app/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/conta/app/ui/screens/settings/SettingsScreen.kt index 2957bb6..51615f8 100644 --- a/app/src/main/java/com/conta/app/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/conta/app/ui/screens/settings/SettingsScreen.kt @@ -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( diff --git a/build.gradle b/build.gradle index b2cf666..4ce7bb0 100644 --- a/build.gradle +++ b/build.gradle @@ -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 }