2 Commits
18 changed files with 1738 additions and 80 deletions
+7 -2
View File
@@ -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 6 versionCode 8
versionName "1.0.5" 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")
+22 -1
View File
@@ -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"
}
}
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBalance import androidx.compose.material.icons.filled.AccountBalance
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material.icons.filled.SwapHoriz
@@ -42,12 +43,14 @@ import com.conta.app.ui.screens.categories.CategoriesScreen
import com.conta.app.ui.screens.dashboard.DashboardScreen import com.conta.app.ui.screens.dashboard.DashboardScreen
import com.conta.app.ui.screens.recurring.RecurringFormScreen import com.conta.app.ui.screens.recurring.RecurringFormScreen
import com.conta.app.ui.screens.recurring.RecurringScreen import com.conta.app.ui.screens.recurring.RecurringScreen
import com.conta.app.ui.screens.reports.ReportsScreen
import com.conta.app.ui.screens.rules.RuleFormScreen import com.conta.app.ui.screens.rules.RuleFormScreen
import com.conta.app.ui.screens.rules.RulesScreen import com.conta.app.ui.screens.rules.RulesScreen
import com.conta.app.ui.screens.settings.BackupScreen import com.conta.app.ui.screens.settings.BackupScreen
import com.conta.app.ui.screens.settings.NtfyScreen import com.conta.app.ui.screens.settings.NtfyScreen
import com.conta.app.ui.screens.settings.SettingsScreen import com.conta.app.ui.screens.settings.SettingsScreen
import com.conta.app.ui.screens.settings.ThemeScreen import com.conta.app.ui.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
@@ -58,6 +61,7 @@ object Routes {
const val ACCOUNT_FORM = "account_form" const val ACCOUNT_FORM = "account_form"
const val TRANSACTIONS = "transactions" const val TRANSACTIONS = "transactions"
const val TRANSACTION_FORM = "transaction_form" const val TRANSACTION_FORM = "transaction_form"
const val REPORTS = "reports"
const val CATEGORIES = "categories" const val CATEGORIES = "categories"
const val CATEGORY_FORM = "category_form" const val CATEGORY_FORM = "category_form"
const val RULES = "rules" const val RULES = "rules"
@@ -93,6 +97,7 @@ private val bottomItems = listOf(
BottomNavItem(Routes.DASHBOARD, "Inicio", Icons.Filled.Home), BottomNavItem(Routes.DASHBOARD, "Inicio", Icons.Filled.Home),
BottomNavItem(Routes.ACCOUNTS, "Cuentas", Icons.Filled.AccountBalance), BottomNavItem(Routes.ACCOUNTS, "Cuentas", Icons.Filled.AccountBalance),
BottomNavItem(Routes.TRANSACTIONS, "Movimientos", Icons.Filled.SwapHoriz), BottomNavItem(Routes.TRANSACTIONS, "Movimientos", Icons.Filled.SwapHoriz),
BottomNavItem(Routes.REPORTS, "Reportes", Icons.Filled.BarChart),
BottomNavItem(Routes.SETTINGS, "Ajustes", Icons.Filled.Settings), BottomNavItem(Routes.SETTINGS, "Ajustes", Icons.Filled.Settings),
) )
@@ -102,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 {
@@ -115,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 {
@@ -147,6 +166,7 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) {
composable(Routes.DASHBOARD) { DashboardScreen(viewModel, navController) } composable(Routes.DASHBOARD) { DashboardScreen(viewModel, navController) }
composable(Routes.ACCOUNTS) { AccountsScreen(viewModel, navController) } composable(Routes.ACCOUNTS) { AccountsScreen(viewModel, navController) }
composable(Routes.TRANSACTIONS) { TransactionsScreen(viewModel, navController) } composable(Routes.TRANSACTIONS) { TransactionsScreen(viewModel, navController) }
composable(Routes.REPORTS) { ReportsScreen(viewModel, navController) }
composable(Routes.SETTINGS) { SettingsScreen(viewModel, navController) } composable(Routes.SETTINGS) { SettingsScreen(viewModel, navController) }
composable(Routes.CATEGORIES) { CategoriesScreen(viewModel, navController) } composable(Routes.CATEGORIES) { CategoriesScreen(viewModel, navController) }
composable(Routes.RULES) { RulesScreen(viewModel, navController) } composable(Routes.RULES) { RulesScreen(viewModel, navController) }
@@ -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
@@ -28,6 +28,7 @@ import com.conta.app.ui.util.formatMonth
import com.conta.app.ui.util.formatMoneyCompact import com.conta.app.ui.util.formatMoneyCompact
import com.conta.app.ui.util.resolveCategoryColor import com.conta.app.ui.util.resolveCategoryColor
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt
@Composable @Composable
fun MonthlyBarChart(stats: List<MonthlyStat>, modifier: Modifier = Modifier) { fun MonthlyBarChart(stats: List<MonthlyStat>, modifier: Modifier = Modifier) {
@@ -193,3 +194,71 @@ fun CategoryBars(stats: List<CategoryStat>, modifier: Modifier = Modifier) {
} }
} }
} }
/** Barras de gasto por día del mes (espejo del "Gasto Diario" del frontend). */
@Composable
fun DailyExpenseChart(values: List<Double>, modifier: Modifier = Modifier) {
val barColor = MaterialTheme.colorScheme.error
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
val chartHeight = 220.dp
Column(modifier) {
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(chartHeight),
) {
val gridColor = textColor.copy(alpha = 0.15f)
val maxValue = values.maxOrNull() ?: 0.0
val scale = if (maxValue <= 0.0) 1.0 else maxValue
// Líneas de referencia (0, 25, 50, 75, 100 %)
for (frac in listOf(0f, 0.25f, 0.5f, 0.75f, 1f)) {
val y = size.height * (1f - frac)
drawLine(
color = gridColor,
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = 1f,
)
}
if (values.isEmpty()) return@Canvas
val slotWidth = size.width / values.size
val barWidth = (slotWidth * 0.55f).coerceAtLeast(2f)
val gap = slotWidth * 0.15f
values.forEachIndexed { i, total ->
if (total <= 0.0) return@forEachIndexed
val barH = (total / scale).toFloat() * size.height
val top = size.height - barH
drawRoundRect(
color = barColor,
topLeft = Offset(i * slotWidth + gap, top),
size = Size(barWidth, barH),
cornerRadius = CornerRadius(3f, 3f),
)
}
}
// Etiquetas de días (cada 5 para no saturar)
Row(modifier = Modifier.fillMaxWidth()) {
val step = max(1, (values.size.toDouble() / 7.0).roundToInt())
values.forEachIndexed { i, _ ->
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center,
) {
if (i % step == 0 || i == values.size - 1) {
Text(
text = "${i + 1}",
style = MaterialTheme.typography.labelSmall,
color = textColor,
maxLines = 1,
)
}
}
}
}
}
}
@@ -0,0 +1,130 @@
package com.conta.app.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
/** Banner superior mostrado cuando hay cambios pendientes o no hay conexión. */
@Composable
fun OfflineBanner(pendingCount: Int, isOnline: Boolean, isSyncing: Boolean, onSync: () -> Unit) {
val text = when {
isSyncing -> "Sincronizando cambios..."
pendingCount > 0 -> "Modo offline · $pendingCount cambios pendientes de sincronizar"
else -> "Sin conexión · los cambios se guardarán localmente"
}
Surface(
color = if (pendingCount > 0) MaterialTheme.colorScheme.tertiaryContainer
else MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = if (pendingCount > 0) Icons.Filled.CloudOff else Icons.Filled.Sync,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (pendingCount > 0 && !isSyncing) {
TextButton(onClick = onSync) {
Text("Sincronizar")
}
}
}
}
}
/** Tarjeta de estado de sincronización (Ajustes). */
@Composable
fun SyncStatusCard(
isOnline: Boolean,
isSyncing: Boolean,
pendingCount: Int,
lastSyncAt: String?,
onSync: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
imageVector = if (isOnline) Icons.Filled.Sync else Icons.Filled.CloudOff,
contentDescription = null,
tint = if (isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
)
Text(
"Sincronización",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
Text(
text = if (isOnline) "En línea" else "Sin conexión",
style = MaterialTheme.typography.bodySmall,
color = if (isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
)
Text(
text = if (pendingCount > 0) "$pendingCount cambios pendientes"
else "Al día",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
lastSyncAt?.let {
Text(
text = "Última sincronización: ${formatSyncTime(it)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (pendingCount > 0) {
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
androidx.compose.material3.Button(onClick = onSync, enabled = !isSyncing) {
Icon(Icons.Filled.Sync, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
Text(if (isSyncing) "Sincronizando..." else "Sincronizar ahora")
}
}
}
}
}
private fun formatSyncTime(iso: String): String {
val dt = runCatching { LocalDateTime.parse(iso) }.getOrNull() ?: return iso
return dt.format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm", Locale.getDefault()))
}
@@ -0,0 +1,161 @@
package com.conta.app.ui.screens.reports
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.components.DailyExpenseChart
import com.conta.app.ui.components.EmptyState
import com.conta.app.ui.components.LoadingScreen
import com.conta.app.ui.util.currentMonthIso
import com.conta.app.ui.util.daysInMonthIso
import com.conta.app.ui.util.formatMoney
import com.conta.app.ui.util.formatMonthFull
import com.conta.app.ui.util.shiftMonthIso
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReportsScreen(viewModel: AppViewModel, navController: NavController) {
val transactions by viewModel.transactions.collectAsStateWithLifecycle()
val summary by viewModel.summary.collectAsStateWithLifecycle()
val loading by viewModel.loading.collectAsStateWithLifecycle()
var month by remember { mutableStateOf(currentMonthIso()) }
val isCurrentMonth = month == currentMonthIso()
val days = daysInMonthIso(month)
val values = remember(transactions, month) {
val sums = DoubleArray(days)
for (tx in transactions) {
if (tx.transactionType != "expense" || !tx.date.startsWith(month)) continue
val day = tx.date.substringAfterLast('-', "").toIntOrNull() ?: continue
if (day in 1..days) sums[day - 1] += tx.amount
}
sums.toList()
}
val totalMonth = values.sum()
Scaffold(
contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
) { padding ->
when {
summary == null && loading -> LoadingScreen(Modifier.padding(padding))
summary == null -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
EmptyState("No conectado. Configura la URL del servidor en Ajustes.")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
androidx.compose.material3.Button(onClick = { viewModel.refreshAll() }) {
Text("Reintentar")
}
androidx.compose.material3.OutlinedButton(onClick = { navController.navigate(com.conta.app.ui.Routes.SETTINGS) }) {
Text("Ir a Ajustes")
}
}
}
}
else -> Column(
modifier = Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
Text(
"Reportes",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Gasto Diario del Mes",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { month = shiftMonthIso(month, -1) }) {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Mes anterior")
}
Text(
text = formatMonthFull(month),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 4.dp),
maxLines = 1,
)
IconButton(
onClick = { month = shiftMonthIso(month, 1) },
enabled = !isCurrentMonth,
) {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Mes siguiente")
}
}
}
androidx.compose.foundation.layout.Spacer(Modifier.size(8.dp))
Text(
text = "Total del mes: ${formatMoney(totalMonth)}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (transactions.isEmpty() && !loading) {
EmptyState("Sin transacciones")
} else if (totalMonth == 0.0 && !loading) {
EmptyState("Sin gastos en ${formatMonthFull(month)}")
} else {
androidx.compose.foundation.layout.Spacer(Modifier.size(16.dp))
DailyExpenseChart(values, modifier = Modifier.fillMaxWidth())
}
}
}
androidx.compose.foundation.layout.Spacer(Modifier.size(24.dp))
}
}
}
}
@@ -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(
@@ -35,6 +35,37 @@ fun formatMonth(monthStr: String): String {
return "$label $year" return "$label $year"
} }
/** "2024-01" -> "Enero 2024" */
fun formatMonthFull(monthStr: String): String {
val parts = monthStr.split("-")
if (parts.size != 2) return monthStr
val month = parts[1].toIntOrNull() ?: return monthStr
val year = parts[0]
val label = LocalDate.of(year.toIntOrNull() ?: 1970, month, 1)
.month.getDisplayName(TextStyle.FULL, Locale.getDefault())
return "$label $year"
}
/** "2024-01" -> año y mes en forma "2024-01" (igual pero validado). */
fun currentMonthIso(): String = LocalDate.now().toString().substring(0, 7)
/** Desplaza un mes "yyyy-MM" por delta y devuelve "yyyy-MM". */
fun shiftMonthIso(month: String, delta: Int): String {
val parts = month.split("-")
val year = parts.getOrNull(0)?.toIntOrNull() ?: LocalDate.now().year
val m = parts.getOrNull(1)?.toIntOrNull() ?: (LocalDate.now().monthValue)
val date = LocalDate.of(year, m, 1).plusMonths(delta.toLong())
return "${date.year}-${date.monthValue.toString().padStart(2, '0')}"
}
/** Número de días de un mes "yyyy-MM". */
fun daysInMonthIso(month: String): Int {
val parts = month.split("-")
val year = parts.getOrNull(0)?.toIntOrNull() ?: LocalDate.now().year
val m = parts.getOrNull(1)?.toIntOrNull() ?: 1
return LocalDate.of(year, m, 1).lengthOfMonth()
}
/** Formatea la cantidad con el prefijo de tipo de transacción: +, -, o sin signo. */ /** Formatea la cantidad con el prefijo de tipo de transacción: +, -, o sin signo. */
fun signedAmount(amount: Double, type: String): String = when (type) { fun signedAmount(amount: Double, type: String): String = when (type) {
"income" -> "+${formatMoney(amount)}" "income" -> "+${formatMoney(amount)}"
+1
View File
@@ -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
} }