App Conta (Android): backend de registro-contable con Compose/Material3 + .gitignore

This commit is contained in:
2026-08-04 12:24:44 -06:00
commit e55ce75c9b
52 changed files with 6752 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
plugins {
id "com.android.application"
id "org.jetbrains.kotlin.android"
id "org.jetbrains.kotlin.plugin.compose"
id "org.jetbrains.kotlin.plugin.serialization"
}
android {
namespace "com.conta.app"
compileSdk 34
defaultConfig {
applicationId "com.conta.app"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
applicationVariants.all { variant ->
variant.outputs.all { output ->
def version = variant.versionName
def name = variant.buildType.name
outputFileName = "Conta-app-v${version}-${name}.apk"
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose true
buildConfig true
}
}
dependencies {
def composeBom = "androidx.compose:compose-bom:2024.10.01"
implementation platform(composeBom)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.material:material-icons-core")
implementation("androidx.navigation:navigation-compose:2.8.3")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
debugImplementation("androidx.compose.ui:ui-tooling")
}
+2
View File
@@ -0,0 +1,2 @@
# Add project specific ProGuard rules here.
-keepattributes *Annotation*
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".ContaApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.ContaApp"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.ContaApp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,47 @@
package com.conta.app
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.network.ApiService
import com.conta.app.data.network.NetworkModule
import com.conta.app.data.network.Session
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class ContaApp : Application() {
companion object {
lateinit var instance: ContaApp
private set
}
override fun onCreate() {
super.onCreate()
instance = this
ServiceLocator.init(this)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
scope.launch {
Session.baseUrl = ServiceLocator.settings.baseUrl.first()
}
}
}
object ServiceLocator {
lateinit var settings: SettingsRepository
private set
val api: ApiService get() = NetworkModule.api
val repository: AppRepository by lazy { AppRepository(api) }
fun init(context: Context) {
if (::settings.isInitialized) return
settings = SettingsRepository(context.applicationContext)
}
}
@@ -0,0 +1,20 @@
package com.conta.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.conta.app.ui.AppRoot
import com.conta.app.ui.theme.ContaTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ContaTheme {
AppRoot()
}
}
}
}
@@ -0,0 +1,111 @@
package com.conta.app.data
import com.conta.app.data.model.Account
import com.conta.app.data.model.AccountInfo
import com.conta.app.data.model.BackupData
import com.conta.app.data.model.CategorizationRule
import com.conta.app.data.model.CategorizationRuleCreated
import com.conta.app.data.model.Category
import com.conta.app.data.model.CreateAccountRequest
import com.conta.app.data.model.CreateCategorizationRuleRequest
import com.conta.app.data.model.CreateCategoryRequest
import com.conta.app.data.model.CreateRecurringTransactionRequest
import com.conta.app.data.model.CreateTransactionRequest
import com.conta.app.data.model.DismissNoticeRequest
import com.conta.app.data.model.NtfySettings
import com.conta.app.data.model.NtfySettingsRequest
import com.conta.app.data.model.NtfyTestResult
import com.conta.app.data.model.Notice
import com.conta.app.data.model.RecurringTransactionWithNames
import com.conta.app.data.model.StatementImportResult
import com.conta.app.data.model.Statistics
import com.conta.app.data.model.Summary
import com.conta.app.data.model.Transaction
import com.conta.app.data.model.TransactionWithAccount
import com.conta.app.data.model.TransferRequest
import com.conta.app.data.model.UpdateAccountRequest
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.network.ApiService
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) {
private inline fun <T> safe(block: () -> T): T {
return try {
block()
} catch (e: retrofit2.HttpException) {
throw ApiError(
when (e.code()) {
400 -> "Solicitud inválida (verifica los datos)"
404 -> "Recurso no encontrado"
500 -> "Error interno del servidor"
else -> "Error del servidor (${e.code()})"
}
)
} catch (e: java.io.IOException) {
throw ApiError("No se pudo conectar al servidor. Verifica la URL en Ajustes.")
}
}
suspend fun listAccounts(): List<AccountInfo> = safe { api.listAccounts() }
suspend fun createAccount(body: CreateAccountRequest): Account = safe { api.createAccount(body) }
suspend fun updateAccount(id: Long, body: UpdateAccountRequest): Account = safe { api.updateAccount(id, body) }
suspend fun deleteAccount(id: Long) = safe { api.deleteAccount(id) }
suspend fun listAccountTransactions(id: Long): List<TransactionWithAccount> = safe { api.listAccountTransactions(id) }
suspend fun importStatement(accountId: Long, file: File): StatementImportResult =
safe { api.importStatement(accountId, file.toMultipart("statement.pdf")) }
suspend fun importSavingsStatement(accountId: Long, file: File): StatementImportResult =
safe { api.importSavingsStatement(accountId, file.toMultipart("statement.pdf")) }
suspend fun listTransactions(): List<TransactionWithAccount> = safe { api.listTransactions() }
suspend fun createTransaction(body: CreateTransactionRequest): Transaction = safe { api.createTransaction(body) }
suspend fun createTransfer(body: TransferRequest): List<Transaction> = safe { api.createTransfer(body) }
suspend fun updateTransaction(id: Long, body: UpdateTransactionRequest): Transaction = safe { api.updateTransaction(id, body) }
suspend fun updateTransactionToTransfer(id: Long, body: TransferRequest): List<Transaction> =
safe { api.updateTransactionToTransfer(id, body) }
suspend fun deleteTransaction(id: Long) = safe { api.deleteTransaction(id) }
suspend fun listCategories(): List<Category> = safe { api.listCategories() }
suspend fun createCategory(body: CreateCategoryRequest): Category = safe { api.createCategory(body) }
suspend fun updateCategory(id: Long, body: UpdateCategoryRequest): Category = safe { api.updateCategory(id, body) }
suspend fun deleteCategory(id: Long) = safe { api.deleteCategory(id) }
suspend fun listRules(): List<CategorizationRule> = safe { api.listRules() }
suspend fun createRule(body: CreateCategorizationRuleRequest): CategorizationRuleCreated = safe { api.createRule(body) }
suspend fun updateRule(id: Long, body: UpdateCategorizationRuleRequest): CategorizationRule = safe { api.updateRule(id, body) }
suspend fun deleteRule(id: Long) = safe { api.deleteRule(id) }
suspend fun getSummary(): Summary = safe { api.getSummary() }
suspend fun getStatistics(): Statistics = safe { api.getStatistics() }
suspend fun listRecurring(): List<RecurringTransactionWithNames> = safe { api.listRecurring() }
suspend fun createRecurring(body: CreateRecurringTransactionRequest): RecurringTransactionWithNames =
safe { api.createRecurring(body) }
suspend fun updateRecurring(id: Long, body: UpdateRecurringTransactionRequest): RecurringTransactionWithNames =
safe { api.updateRecurring(id, body) }
suspend fun deleteRecurring(id: Long) = safe { api.deleteRecurring(id) }
suspend fun exportBackup(): BackupData = safe { api.exportBackup() }
suspend fun importBackup(body: BackupData) = safe { api.importBackup(body) }
suspend fun getNtfySettings(): NtfySettings = safe { api.getNtfySettings() }
suspend fun saveNtfySettings(body: NtfySettingsRequest): NtfySettings = safe { api.saveNtfySettings(body) }
suspend fun testNtfy(): NtfyTestResult = safe { api.testNtfy() }
suspend fun listNotices(): List<Notice> = safe { api.listNotices() }
suspend fun dismissNotice(fingerprint: String) = safe { api.dismissNotice(DismissNoticeRequest(fingerprint)) }
private fun File.toMultipart(name: String): MultipartBody.Part =
MultipartBody.Part.createFormData("file", name, asRequestBody("application/pdf".toMediaType()))
}
class ApiError(message: String) : Exception(message)
@@ -0,0 +1,23 @@
package com.conta.app.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore(name = "conta_prefs")
class SettingsRepository(private val context: Context) {
private object Keys {
val BASE_URL = stringPreferencesKey("base_url")
}
val baseUrl: Flow<String> = context.dataStore.data.map { it[Keys.BASE_URL] ?: "" }
suspend fun setBaseUrl(url: String) {
context.dataStore.edit { it[Keys.BASE_URL] = url.trim() }
}
}
@@ -0,0 +1,428 @@
package com.conta.app.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// Wrapper para poder enviar un null explícito en campos "tri-estado"
// (ausente / null / valor). El backend usa Option<Option<T>>.
@Serializable
@JvmInline
value class NullableLong(val value: Long?)
@Serializable
data class Account(
val id: Long,
val name: String,
@SerialName("account_type") val accountType: String,
val balance: Double,
@SerialName("initial_balance") val initialBalance: Double = 0.0,
@SerialName("credit_limit") val creditLimit: Double? = null,
@SerialName("interest_rate") val interestRate: Double? = null,
@SerialName("maturity_date") val maturityDate: String? = null,
@SerialName("payment_account_id") val paymentAccountId: Long? = null,
@SerialName("cut_day") val cutDay: Int? = null,
@SerialName("payment_day") val paymentDay: Int? = null,
@SerialName("min_payment_pct") val minPaymentPct: Double? = null,
@SerialName("min_payment_min") val minPaymentMin: Double? = null,
@SerialName("late_fee") val lateFee: Double? = null,
@SerialName("created_at") val createdAt: String,
)
@Serializable
data class AccountInfo(
val id: Long,
val name: String,
@SerialName("account_type") val accountType: String,
val balance: Double,
@SerialName("initial_balance") val initialBalance: Double = 0.0,
@SerialName("credit_limit") val creditLimit: Double? = null,
@SerialName("interest_rate") val interestRate: Double? = null,
@SerialName("maturity_date") val maturityDate: String? = null,
@SerialName("payment_account_id") val paymentAccountId: Long? = null,
@SerialName("cut_day") val cutDay: Int? = null,
@SerialName("payment_day") val paymentDay: Int? = null,
@SerialName("min_payment_pct") val minPaymentPct: Double? = null,
@SerialName("min_payment_min") val minPaymentMin: Double? = null,
@SerialName("late_fee") val lateFee: Double? = null,
@SerialName("created_at") val createdAt: String,
val debt: Double = 0.0,
@SerialName("credit_available") val creditAvailable: Double? = null,
@SerialName("saldo_a_favor") val saldoAFavor: Double = 0.0,
@SerialName("statement_balance") val statementBalance: Double? = null,
@SerialName("min_payment") val minPayment: Double? = null,
@SerialName("next_cut_date") val nextCutDate: String? = null,
@SerialName("next_payment_date") val nextPaymentDate: String? = null,
val overdue: Boolean = false,
) {
fun toAccount(): Account = Account(
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,
)
}
@Serializable
data class CreateAccountRequest(
val name: String,
@SerialName("account_type") val accountType: String,
@SerialName("initial_balance") val initialBalance: Double? = null,
@SerialName("credit_limit") val creditLimit: Double? = null,
@SerialName("interest_rate") val interestRate: Double? = null,
@SerialName("maturity_date") val maturityDate: String? = null,
@SerialName("payment_account_id") val paymentAccountId: Long? = null,
@SerialName("cut_day") val cutDay: Int? = null,
@SerialName("payment_day") val paymentDay: Int? = null,
@SerialName("min_payment_pct") val minPaymentPct: Double? = null,
@SerialName("min_payment_min") val minPaymentMin: Double? = null,
@SerialName("late_fee") val lateFee: Double? = null,
)
@Serializable
data class UpdateAccountRequest(
val name: String? = null,
@SerialName("credit_limit") val creditLimit: Double? = null,
@SerialName("interest_rate") val interestRate: Double? = null,
@SerialName("maturity_date") val maturityDate: String? = null,
@SerialName("payment_account_id") val paymentAccountId: Long? = null,
@SerialName("cut_day") val cutDay: Int? = null,
@SerialName("payment_day") val paymentDay: Int? = null,
@SerialName("min_payment_pct") val minPaymentPct: Double? = null,
@SerialName("min_payment_min") val minPaymentMin: Double? = null,
@SerialName("late_fee") val lateFee: Double? = null,
)
@Serializable
data class Transaction(
val id: Long,
@SerialName("account_id") val accountId: Long,
@SerialName("to_account_id") val toAccountId: Long? = null,
@SerialName("transaction_type") val transactionType: String,
val amount: Double,
val description: String,
val date: String,
@SerialName("category_id") val categoryId: Long? = null,
@SerialName("created_at") val createdAt: String,
)
@Serializable
data class TransactionWithAccount(
val id: Long,
@SerialName("account_id") val accountId: Long,
@SerialName("account_name") val accountName: String,
@SerialName("to_account_id") val toAccountId: Long? = null,
@SerialName("to_account_name") val toAccountName: String? = null,
@SerialName("transaction_type") val transactionType: String,
val amount: Double,
val description: String,
val date: String,
@SerialName("category_id") val categoryId: Long? = null,
@SerialName("category_name") val categoryName: String? = null,
@SerialName("category_icon") val categoryIcon: String? = null,
@SerialName("category_color") val categoryColor: String? = null,
@SerialName("created_at") val createdAt: String,
)
@Serializable
data class CreateTransactionRequest(
@SerialName("account_id") val accountId: Long,
@SerialName("transaction_type") val transactionType: String,
val amount: Double,
val description: String,
val date: String,
@SerialName("category_id") val categoryId: Long? = null,
)
@Serializable
data class UpdateTransactionRequest(
@SerialName("account_id") val accountId: Long? = null,
@SerialName("transaction_type") val transactionType: String? = null,
val amount: Double? = null,
val description: String? = null,
val date: String? = null,
@SerialName("category_id") val categoryId: NullableLong? = null,
)
@Serializable
data class TransferRequest(
@SerialName("from_account_id") val fromAccountId: Long,
@SerialName("to_account_id") val toAccountId: Long,
val amount: Double,
val description: String,
val date: String,
@SerialName("category_id") val categoryId: Long? = null,
)
@Serializable
data class StatementImportResult(
val imported: Int,
val skipped: Int,
val payments: Int,
val errors: List<String> = emptyList(),
)
@Serializable
data class Summary(
@SerialName("total_balance") val totalBalance: Double,
@SerialName("total_savings") val totalSavings: Double,
@SerialName("total_credit") val totalCredit: Double,
@SerialName("total_time_deposits") val totalTimeDeposits: Double,
val accounts: List<AccountInfo> = emptyList(),
)
@Serializable
data class Category(
val id: Long,
val name: String,
@SerialName("category_type") val categoryType: String,
val icon: String? = null,
val color: String? = null,
@SerialName("created_at") val createdAt: String,
)
@Serializable
data class CreateCategoryRequest(
val name: String,
@SerialName("category_type") val categoryType: String,
val icon: String? = null,
val color: String? = null,
)
@Serializable
data class UpdateCategoryRequest(
val name: String? = null,
val icon: String? = null,
val color: String? = null,
)
@Serializable
data class CategorizationRule(
val id: Long,
val pattern: String,
@SerialName("category_id") val categoryId: Long,
@SerialName("category_name") val categoryName: String? = null,
@SerialName("category_icon") val categoryIcon: String? = null,
@SerialName("category_color") val categoryColor: String? = null,
@SerialName("created_at") val createdAt: String,
)
@Serializable
data class CreateCategorizationRuleRequest(
val pattern: String,
@SerialName("category_id") val categoryId: Long,
)
@Serializable
data class CategorizationRuleCreated(
val id: Long,
val pattern: String,
@SerialName("category_id") val categoryId: Long,
@SerialName("category_name") val categoryName: String? = null,
@SerialName("category_icon") val categoryIcon: String? = null,
@SerialName("category_color") val categoryColor: String? = null,
@SerialName("created_at") val createdAt: String,
val applied: Int = 0,
)
@Serializable
data class UpdateCategorizationRuleRequest(
val pattern: String? = null,
@SerialName("category_id") val categoryId: Long? = null,
)
@Serializable
data class RecurringTransaction(
val id: Long,
@SerialName("account_id") val accountId: Long,
@SerialName("transaction_type") val transactionType: String,
val amount: Double,
val description: String,
@SerialName("day_of_month") val dayOfMonth: Int,
@SerialName("category_id") val categoryId: Long? = null,
val active: Int,
@SerialName("created_at") val createdAt: String,
)
@Serializable
data class RecurringTransactionWithNames(
val id: Long,
@SerialName("account_id") val accountId: Long,
@SerialName("account_name") val accountName: String? = null,
@SerialName("transaction_type") val transactionType: String,
val amount: Double,
val description: String,
@SerialName("day_of_month") val dayOfMonth: Int,
@SerialName("category_id") val categoryId: Long? = null,
@SerialName("category_name") val categoryName: String? = null,
@SerialName("category_icon") val categoryIcon: String? = null,
@SerialName("category_color") val categoryColor: String? = null,
val active: Int,
@SerialName("created_at") val createdAt: String,
)
@Serializable
data class CreateRecurringTransactionRequest(
@SerialName("account_id") val accountId: Long,
@SerialName("transaction_type") val transactionType: String,
val amount: Double,
val description: String,
@SerialName("day_of_month") val dayOfMonth: Int,
@SerialName("category_id") val categoryId: Long? = null,
)
@Serializable
data class UpdateRecurringTransactionRequest(
@SerialName("account_id") val accountId: Long? = null,
@SerialName("transaction_type") val transactionType: String? = null,
val amount: Double? = null,
val description: String? = null,
@SerialName("day_of_month") val dayOfMonth: Int? = null,
@SerialName("category_id") val categoryId: NullableLong? = null,
val active: Boolean? = null,
)
@Serializable
data class CategoryStat(
@SerialName("category_id") val categoryId: Long? = null,
val name: String,
val color: String? = null,
val icon: String? = null,
val total: Double,
)
@Serializable
data class MonthlyStat(
val month: String,
val income: Double,
val expense: Double,
)
@Serializable
data class Statistics(
@SerialName("expenses_by_category") val expensesByCategory: List<CategoryStat> = emptyList(),
val monthly: List<MonthlyStat> = emptyList(),
)
@Serializable
data class CategorizationRuleRaw(
val id: Long,
val pattern: String,
@SerialName("category_id") val categoryId: Long,
@SerialName("created_at") val createdAt: String,
)
@Serializable
data class CardStateRecord(
@SerialName("card_id") val cardId: Long,
@SerialName("last_interest_date") val lastInterestDate: String? = null,
@SerialName("last_cut_date") val lastCutDate: String? = null,
@SerialName("last_due_date") val lastDueDate: String? = null,
@SerialName("last_statement_balance") val lastStatementBalance: Double? = null,
@SerialName("due_processed") val dueProcessed: Int,
@SerialName("late_fee_charged") val lateFeeCharged: Int,
)
@Serializable
data class BackupData(
val version: Int,
@SerialName("exported_at") val exportedAt: String,
val accounts: List<Account> = emptyList(),
val categories: List<Category> = emptyList(),
val transactions: List<Transaction> = emptyList(),
@SerialName("categorization_rules") val categorizationRules: List<CategorizationRuleRaw> = emptyList(),
@SerialName("recurring_transactions") val recurringTransactions: List<RecurringTransaction> = emptyList(),
@SerialName("card_state") val cardState: List<CardStateRecord> = emptyList(),
val ntfy: NtfyConfig? = null,
)
@Serializable
data class BackupImportResult(
val restored: Boolean,
val counts: BackupCounts = BackupCounts(),
)
@Serializable
data class BackupCounts(
val accounts: Int = 0,
val categories: Int = 0,
val transactions: Int = 0,
val rules: Int = 0,
val recurring: Int = 0,
)
@Serializable
data class Notice(
val fingerprint: String,
@SerialName("notice_type") val noticeType: String,
val message: String,
val severity: String,
val icon: String,
)
@Serializable
data class DismissNoticeRequest(val fingerprint: String)
@Serializable
data class NtfyConfig(
val enabled: Boolean = false,
@SerialName("server_url") val serverUrl: String = "",
val topic: String = "",
val username: String = "",
val password: String = "",
@SerialName("deposit_expired") val depositExpired: Boolean = true,
@SerialName("deposit_upcoming") val depositUpcoming: Boolean = true,
@SerialName("card_overdue") val cardOverdue: Boolean = true,
@SerialName("card_payment") val cardPayment: Boolean = true,
@SerialName("card_debt") val cardDebt: Boolean = true,
@SerialName("month_end_reminder") val monthEndReminder: Boolean = true,
@SerialName("absence_reminder") val absenceReminder: Boolean = true,
)
@Serializable
data class NtfySettings(
val enabled: Boolean = false,
@SerialName("server_url") val serverUrl: String = "",
val topic: String = "",
val username: String = "",
@SerialName("has_password") val hasPassword: Boolean = false,
@SerialName("deposit_expired") val depositExpired: Boolean = true,
@SerialName("deposit_upcoming") val depositUpcoming: Boolean = true,
@SerialName("card_overdue") val cardOverdue: Boolean = true,
@SerialName("card_payment") val cardPayment: Boolean = true,
@SerialName("card_debt") val cardDebt: Boolean = true,
@SerialName("month_end_reminder") val monthEndReminder: Boolean = true,
@SerialName("absence_reminder") val absenceReminder: Boolean = true,
)
@Serializable
data class NtfySettingsRequest(
val enabled: Boolean,
@SerialName("server_url") val serverUrl: String,
val topic: String,
val username: String,
val password: String? = null,
@SerialName("deposit_expired") val depositExpired: Boolean,
@SerialName("deposit_upcoming") val depositUpcoming: Boolean,
@SerialName("card_overdue") val cardOverdue: Boolean,
@SerialName("card_payment") val cardPayment: Boolean,
@SerialName("card_debt") val cardDebt: Boolean,
@SerialName("month_end_reminder") val monthEndReminder: Boolean,
@SerialName("absence_reminder") val absenceReminder: Boolean,
)
@Serializable
data class NtfyTestResult(
val ok: Boolean,
val error: String? = null,
)
@@ -0,0 +1,147 @@
package com.conta.app.data.network
import com.conta.app.data.model.Account
import com.conta.app.data.model.AccountInfo
import com.conta.app.data.model.BackupData
import com.conta.app.data.model.BackupImportResult
import com.conta.app.data.model.CategorizationRule
import com.conta.app.data.model.CategorizationRuleCreated
import com.conta.app.data.model.Category
import com.conta.app.data.model.CreateAccountRequest
import com.conta.app.data.model.CreateCategorizationRuleRequest
import com.conta.app.data.model.CreateCategoryRequest
import com.conta.app.data.model.CreateRecurringTransactionRequest
import com.conta.app.data.model.CreateTransactionRequest
import com.conta.app.data.model.DismissNoticeRequest
import com.conta.app.data.model.NtfySettings
import com.conta.app.data.model.NtfySettingsRequest
import com.conta.app.data.model.NtfyTestResult
import com.conta.app.data.model.Notice
import com.conta.app.data.model.RecurringTransactionWithNames
import com.conta.app.data.model.StatementImportResult
import com.conta.app.data.model.Statistics
import com.conta.app.data.model.Summary
import com.conta.app.data.model.Transaction
import com.conta.app.data.model.TransactionWithAccount
import com.conta.app.data.model.TransferRequest
import com.conta.app.data.model.UpdateAccountRequest
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 okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
interface ApiService {
@GET("api/accounts")
suspend fun listAccounts(): List<AccountInfo>
@POST("api/accounts")
suspend fun createAccount(@Body body: CreateAccountRequest): Account
@PUT("api/accounts/{id}")
suspend fun updateAccount(@Path("id") id: Long, @Body body: UpdateAccountRequest): Account
@DELETE("api/accounts/{id}")
suspend fun deleteAccount(@Path("id") id: Long)
@GET("api/accounts/{id}/transactions")
suspend fun listAccountTransactions(@Path("id") id: Long): List<TransactionWithAccount>
@Multipart
@POST("api/accounts/{id}/statement")
suspend fun importStatement(@Path("id") id: Long, @Part file: MultipartBody.Part): StatementImportResult
@Multipart
@POST("api/accounts/{id}/savings-statement")
suspend fun importSavingsStatement(@Path("id") id: Long, @Part file: MultipartBody.Part): StatementImportResult
@GET("api/transactions")
suspend fun listTransactions(): List<TransactionWithAccount>
@POST("api/transactions")
suspend fun createTransaction(@Body body: CreateTransactionRequest): Transaction
@POST("api/transactions/transfer")
suspend fun createTransfer(@Body body: TransferRequest): List<Transaction>
@PUT("api/transactions/{id}/transfer")
suspend fun updateTransactionToTransfer(@Path("id") id: Long, @Body body: TransferRequest): List<Transaction>
@PUT("api/transactions/{id}")
suspend fun updateTransaction(@Path("id") id: Long, @Body body: UpdateTransactionRequest): Transaction
@DELETE("api/transactions/{id}")
suspend fun deleteTransaction(@Path("id") id: Long)
@GET("api/categories")
suspend fun listCategories(): List<Category>
@POST("api/categories")
suspend fun createCategory(@Body body: CreateCategoryRequest): Category
@PUT("api/categories/{id}")
suspend fun updateCategory(@Path("id") id: Long, @Body body: UpdateCategoryRequest): Category
@DELETE("api/categories/{id}")
suspend fun deleteCategory(@Path("id") id: Long)
@GET("api/rules")
suspend fun listRules(): List<CategorizationRule>
@POST("api/rules")
suspend fun createRule(@Body body: CreateCategorizationRuleRequest): CategorizationRuleCreated
@PUT("api/rules/{id}")
suspend fun updateRule(@Path("id") id: Long, @Body body: UpdateCategorizationRuleRequest): CategorizationRule
@DELETE("api/rules/{id}")
suspend fun deleteRule(@Path("id") id: Long)
@GET("api/summary")
suspend fun getSummary(): Summary
@GET("api/statistics")
suspend fun getStatistics(): Statistics
@GET("api/recurring")
suspend fun listRecurring(): List<RecurringTransactionWithNames>
@POST("api/recurring")
suspend fun createRecurring(@Body body: CreateRecurringTransactionRequest): RecurringTransactionWithNames
@PUT("api/recurring/{id}")
suspend fun updateRecurring(@Path("id") id: Long, @Body body: UpdateRecurringTransactionRequest): RecurringTransactionWithNames
@DELETE("api/recurring/{id}")
suspend fun deleteRecurring(@Path("id") id: Long)
@GET("api/backup")
suspend fun exportBackup(): BackupData
@POST("api/backup")
suspend fun importBackup(@Body body: BackupData): BackupImportResult
@GET("api/ntfy/settings")
suspend fun getNtfySettings(): NtfySettings
@PUT("api/ntfy/settings")
suspend fun saveNtfySettings(@Body body: NtfySettingsRequest): NtfySettings
@POST("api/ntfy/test")
suspend fun testNtfy(): NtfyTestResult
@GET("api/notices")
suspend fun listNotices(): List<Notice>
@POST("api/notices/dismiss")
suspend fun dismissNotice(@Body body: DismissNoticeRequest)
}
@@ -0,0 +1,82 @@
package com.conta.app.data.network
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
/** Sesión global con la URL base actual (en memoria, actualizada desde DataStore). */
object Session {
@Volatile
var baseUrl: String = ""
}
/**
* Normaliza lo que el usuario escribe en un URL base válido:
* - Añade http:// si falta el esquema.
* - Elimina la barra final y cualquier ruta.
* - Devuelve "" si está vacío.
*/
fun normalizeBaseUrl(raw: String): String {
var s = raw.trim()
if (s.isEmpty()) return ""
if (!s.startsWith("http://") && !s.startsWith("https://")) {
s = "http://$s"
}
while (s.endsWith("/")) s = s.dropLast(1)
val schemeEnd = s.indexOf("://") + 3
val pathStart = s.indexOf('/', schemeEnd)
if (pathStart != -1) s = s.substring(0, pathStart)
return s
}
/**
* Reescribe el host de cada petición hacia la URL base configurada.
* Permite cambiar el servidor sin reconstruir Retrofit.
*/
class BaseUrlInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
val base = Session.baseUrl
if (base.isNotBlank()) {
val url = request.url
val newUrl = base + url.encodedPath + (if (url.query != null) "?${url.query}" else "")
request = request.newBuilder().url(newUrl).build()
}
return chain.proceed(request)
}
}
object NetworkModule {
val json = Json {
ignoreUnknownKeys = true
explicitNulls = true
encodeDefaults = false
coerceInputValues = true
}
val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(BaseUrlInterceptor())
.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC })
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}
val api: ApiService by lazy {
Retrofit.Builder()
.baseUrl("http://localhost/")
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(ApiService::class.java)
}
}
@@ -0,0 +1,230 @@
package com.conta.app.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBalance
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.conta.app.ui.screens.accounts.AccountDetailScreen
import com.conta.app.ui.screens.accounts.AccountFormScreen
import com.conta.app.ui.screens.accounts.AccountsScreen
import com.conta.app.ui.screens.categories.CategoryFormScreen
import com.conta.app.ui.screens.categories.CategoriesScreen
import com.conta.app.ui.screens.dashboard.DashboardScreen
import com.conta.app.ui.screens.recurring.RecurringFormScreen
import com.conta.app.ui.screens.recurring.RecurringScreen
import com.conta.app.ui.screens.rules.RuleFormScreen
import com.conta.app.ui.screens.rules.RulesScreen
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.transactions.TransactionFormScreen
import com.conta.app.ui.screens.transactions.TransactionsScreen
object Routes {
const val DASHBOARD = "dashboard"
const val ACCOUNTS = "accounts"
const val ACCOUNT_DETAIL = "account_detail"
const val ACCOUNT_FORM = "account_form"
const val TRANSACTIONS = "transactions"
const val TRANSACTION_FORM = "transaction_form"
const val CATEGORIES = "categories"
const val CATEGORY_FORM = "category_form"
const val RULES = "rules"
const val RULE_FORM = "rule_form"
const val RECURRING = "recurring"
const val RECURRING_FORM = "recurring_form"
const val SETTINGS = "settings"
const val NTFY = "ntfy"
const val BACKUP = "backup"
fun accountDetail(id: Long) = "$ACCOUNT_DETAIL/$id"
fun accountForm(id: Long? = null) =
if (id == null) ACCOUNT_FORM else "$ACCOUNT_FORM?accountId=$id"
fun transactionForm(transactionId: Long? = null, preset: String? = null): String {
val params = listOfNotNull(
transactionId?.let { "transactionId=$it" },
preset?.let { "preset=$it" },
).joinToString("&")
return if (params.isEmpty()) TRANSACTION_FORM else "$TRANSACTION_FORM?$params"
}
fun categoryForm(id: Long? = null) =
if (id == null) CATEGORY_FORM else "$CATEGORY_FORM?categoryId=$id"
fun ruleForm(id: Long? = null) =
if (id == null) RULE_FORM else "$RULE_FORM?ruleId=$id"
fun recurringForm(id: Long? = null) =
if (id == null) RECURRING_FORM else "$RECURRING_FORM?recurringId=$id"
}
data class BottomNavItem(val route: String, val label: String, val icon: ImageVector)
private val bottomItems = listOf(
BottomNavItem(Routes.DASHBOARD, "Inicio", Icons.Filled.Home),
BottomNavItem(Routes.ACCOUNTS, "Cuentas", Icons.Filled.AccountBalance),
BottomNavItem(Routes.TRANSACTIONS, "Movimientos", Icons.Filled.SwapHoriz),
BottomNavItem(Routes.SETTINGS, "Ajustes", Icons.Filled.Settings),
)
@Composable
fun AppRoot(viewModel: AppViewModel = viewModel()) {
val navController = rememberNavController()
val snackbarHostState = remember { SnackbarHostState() }
val message by viewModel.message.collectAsStateWithLifecycle()
val busy by viewModel.busy.collectAsStateWithLifecycle()
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val showBottomBar = bottomItems.any { it.route == currentRoute }
LaunchedEffect(message) {
message?.let {
snackbarHostState.showSnackbar(it)
viewModel.consumeMessage()
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (showBottomBar) {
NavigationBar {
bottomItems.forEach { item ->
NavigationBarItem(
selected = currentRoute == item.route,
onClick = {
navController.navigate(item.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
)
}
}
}
},
) { innerPadding ->
Box(Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = Routes.DASHBOARD,
modifier = Modifier.padding(innerPadding),
) {
composable(Routes.DASHBOARD) { DashboardScreen(viewModel, navController) }
composable(Routes.ACCOUNTS) { AccountsScreen(viewModel, navController) }
composable(Routes.TRANSACTIONS) { TransactionsScreen(viewModel, navController) }
composable(Routes.SETTINGS) { SettingsScreen(viewModel, navController) }
composable(Routes.CATEGORIES) { CategoriesScreen(viewModel, navController) }
composable(Routes.RULES) { RulesScreen(viewModel, navController) }
composable(Routes.RECURRING) { RecurringScreen(viewModel, navController) }
composable(Routes.NTFY) { NtfyScreen(viewModel, navController) }
composable(Routes.BACKUP) { BackupScreen(viewModel, navController) }
composable(
route = "${Routes.ACCOUNT_DETAIL}/{accountId}",
arguments = listOf(navArgument("accountId") { type = NavType.LongType }),
) { entry ->
val accountId = entry.arguments?.getLong("accountId") ?: return@composable
AccountDetailScreen(viewModel, navController, accountId)
}
composable(
route = "${Routes.ACCOUNT_FORM}?accountId={accountId}",
arguments = listOf(navArgument("accountId") {
type = NavType.LongType
defaultValue = -1L
}),
) { entry ->
val id = entry.arguments?.getLong("accountId")?.takeIf { it > 0 }
AccountFormScreen(viewModel, navController, id)
}
composable(
route = "${Routes.TRANSACTION_FORM}?transactionId={transactionId}&preset={preset}",
arguments = listOf(
navArgument("transactionId") {
type = NavType.LongType
defaultValue = -1L
},
navArgument("preset") {
type = NavType.StringType
defaultValue = ""
},
),
) { entry ->
val id = entry.arguments?.getLong("transactionId")?.takeIf { it > 0 }
val preset = entry.arguments?.getString("preset").orEmpty()
TransactionFormScreen(viewModel, navController, id, preset)
}
composable(
route = "${Routes.CATEGORY_FORM}?categoryId={categoryId}",
arguments = listOf(navArgument("categoryId") {
type = NavType.LongType
defaultValue = -1L
}),
) { entry ->
val id = entry.arguments?.getLong("categoryId")?.takeIf { it > 0 }
CategoryFormScreen(viewModel, navController, id)
}
composable(
route = "${Routes.RULE_FORM}?ruleId={ruleId}",
arguments = listOf(navArgument("ruleId") {
type = NavType.LongType
defaultValue = -1L
}),
) { entry ->
val id = entry.arguments?.getLong("ruleId")?.takeIf { it > 0 }
RuleFormScreen(viewModel, navController, id)
}
composable(
route = "${Routes.RECURRING_FORM}?recurringId={recurringId}",
arguments = listOf(navArgument("recurringId") {
type = NavType.LongType
defaultValue = -1L
}),
) { entry ->
val id = entry.arguments?.getLong("recurringId")?.takeIf { it > 0 }
RecurringFormScreen(viewModel, navController, id)
}
}
if (busy) {
LinearProgressIndicator(Modifier.align(Alignment.TopCenter))
}
}
}
}
@@ -0,0 +1,467 @@
package com.conta.app.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.conta.app.ServiceLocator
import com.conta.app.data.model.AccountInfo
import com.conta.app.data.model.BackupData
import com.conta.app.data.model.BackupImportResult
import com.conta.app.data.model.CategorizationRule
import com.conta.app.data.model.CategorizationRuleCreated
import com.conta.app.data.model.Category
import com.conta.app.data.model.CreateAccountRequest
import com.conta.app.data.model.CreateCategorizationRuleRequest
import com.conta.app.data.model.CreateCategoryRequest
import com.conta.app.data.model.CreateRecurringTransactionRequest
import com.conta.app.data.model.CreateTransactionRequest
import com.conta.app.data.model.NtfySettings
import com.conta.app.data.model.NtfySettingsRequest
import com.conta.app.data.model.NtfyTestResult
import com.conta.app.data.model.Notice
import com.conta.app.data.model.RecurringTransactionWithNames
import com.conta.app.data.model.StatementImportResult
import com.conta.app.data.model.Statistics
import com.conta.app.data.model.Summary
import com.conta.app.data.model.Transaction
import com.conta.app.data.model.TransactionWithAccount
import com.conta.app.data.model.TransferRequest
import com.conta.app.data.model.UpdateAccountRequest
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.network.Session
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
class AppViewModel : ViewModel() {
private val repo = ServiceLocator.repository
private val _summary = MutableStateFlow<Summary?>(null)
val summary: StateFlow<Summary?> = _summary.asStateFlow()
private val _statistics = MutableStateFlow<Statistics?>(null)
val statistics: StateFlow<Statistics?> = _statistics.asStateFlow()
private val _accounts = MutableStateFlow<List<AccountInfo>>(emptyList())
val accounts: StateFlow<List<AccountInfo>> = _accounts.asStateFlow()
private val _transactions = MutableStateFlow<List<TransactionWithAccount>>(emptyList())
val transactions: StateFlow<List<TransactionWithAccount>> = _transactions.asStateFlow()
private val _categories = MutableStateFlow<List<Category>>(emptyList())
val categories: StateFlow<List<Category>> = _categories.asStateFlow()
private val _rules = MutableStateFlow<List<CategorizationRule>>(emptyList())
val rules: StateFlow<List<CategorizationRule>> = _rules.asStateFlow()
private val _recurring = MutableStateFlow<List<RecurringTransactionWithNames>>(emptyList())
val recurring: StateFlow<List<RecurringTransactionWithNames>> = _recurring.asStateFlow()
private val _notices = MutableStateFlow<List<Notice>>(emptyList())
val notices: StateFlow<List<Notice>> = _notices.asStateFlow()
private val _loading = MutableStateFlow(false)
val loading: StateFlow<Boolean> = _loading.asStateFlow()
private val _busy = MutableStateFlow(false)
val busy: StateFlow<Boolean> = _busy.asStateFlow()
private val _message = MutableStateFlow<String?>(null)
val message: StateFlow<String?> = _message.asStateFlow()
fun consumeMessage() {
_message.value = null
}
fun showMessage(msg: String) {
_message.value = msg
}
private suspend fun <T> runRequest(block: suspend () -> T): Boolean {
return try {
block()
true
} catch (e: Exception) {
_message.value = e.message ?: "Error inesperado"
false
}
}
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() }
_loading.value = false
}
}
fun refreshDashboard() {
viewModelScope.launch {
runRequest { _summary.value = repo.getSummary() }
runRequest { _transactions.value = repo.listTransactions() }
runRequest { _notices.value = repo.listNotices() }
runRequest { _statistics.value = repo.getStatistics() }
}
}
fun refreshAccounts() {
viewModelScope.launch {
runRequest { _summary.value = repo.getSummary() }
runRequest { _accounts.value = repo.listAccounts() }
}
}
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 ----------
fun createAccount(body: CreateAccountRequest, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.createAccount(body) }) {
onDone()
refreshAccounts()
}
_busy.value = false
}
}
fun updateAccount(id: Long, body: UpdateAccountRequest, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.updateAccount(id, body) }) {
onDone()
refreshAccounts()
}
_busy.value = false
}
}
fun deleteAccount(id: Long, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.deleteAccount(id) }) {
_message.value = "Cuenta eliminada"
onDone()
refreshAll()
}
_busy.value = false
}
}
fun importStatement(accountId: Long, file: File, onResult: (StatementImportResult?) -> Unit) {
viewModelScope.launch {
_busy.value = true
val result = try {
repo.importStatement(accountId, file)
} catch (e: Exception) {
_message.value = e.message ?: "Error al importar"
null
}
onResult(result)
if (result != null) refreshDashboard()
_busy.value = false
}
}
fun importSavingsStatement(accountId: Long, file: File, onResult: (StatementImportResult?) -> Unit) {
viewModelScope.launch {
_busy.value = true
val result = try {
repo.importSavingsStatement(accountId, file)
} catch (e: Exception) {
_message.value = e.message ?: "Error al importar"
null
}
onResult(result)
if (result != null) refreshDashboard()
_busy.value = false
}
}
// ---------- Transacciones ----------
fun createTransaction(body: CreateTransactionRequest, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.createTransaction(body) }) {
onDone()
refreshDashboard()
}
_busy.value = false
}
}
fun createTransfer(body: TransferRequest, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.createTransfer(body) }) {
onDone()
refreshDashboard()
}
_busy.value = false
}
}
fun updateTransaction(id: Long, body: UpdateTransactionRequest, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.updateTransaction(id, body) }) {
onDone()
refreshDashboard()
}
_busy.value = false
}
}
fun updateTransactionToTransfer(id: Long, body: TransferRequest, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.updateTransactionToTransfer(id, body) }) {
onDone()
refreshDashboard()
}
_busy.value = false
}
}
fun deleteTransaction(id: Long, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.deleteTransaction(id) }) {
onDone()
refreshDashboard()
}
_busy.value = false
}
}
// ---------- Categorías ----------
fun createCategory(body: CreateCategoryRequest, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.createCategory(body) }) {
onDone()
refreshCategories()
}
_busy.value = false
}
}
fun updateCategory(id: Long, body: UpdateCategoryRequest, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.updateCategory(id, body) }) {
onDone()
refreshCategories()
}
_busy.value = false
}
}
fun deleteCategory(id: Long, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.deleteCategory(id) }) {
_message.value = "Categoría eliminada"
onDone()
refreshCategories()
}
_busy.value = false
}
}
// ---------- Reglas ----------
fun createRule(body: CreateCategorizationRuleRequest, onDone: (CategorizationRuleCreated?) -> Unit) {
viewModelScope.launch {
_busy.value = true
val created = try {
repo.createRule(body)
} catch (e: Exception) {
_message.value = e.message ?: "Error al crear la regla"
null
}
if (created != null) {
refreshRules()
onDone(created)
} else {
onDone(null)
}
_busy.value = false
}
}
fun updateRule(id: Long, body: UpdateCategorizationRuleRequest, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.updateRule(id, body) }) {
onDone()
refreshRules()
}
_busy.value = false
}
}
fun deleteRule(id: Long, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.deleteRule(id) }) {
onDone()
refreshRules()
}
_busy.value = false
}
}
// ---------- Recurrentes ----------
fun createRecurring(body: CreateRecurringTransactionRequest, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.createRecurring(body) }) {
onDone()
refreshRecurring()
}
_busy.value = false
}
}
fun updateRecurring(id: Long, body: UpdateRecurringTransactionRequest, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.updateRecurring(id, body) }) {
onDone()
refreshRecurring()
}
_busy.value = false
}
}
fun deleteRecurring(id: Long, onDone: () -> Unit = {}) {
viewModelScope.launch {
_busy.value = true
if (runRequest { repo.deleteRecurring(id) }) {
onDone()
refreshRecurring()
}
_busy.value = false
}
}
// ---------- Avisos ----------
fun dismissNotice(fingerprint: String) {
viewModelScope.launch {
runRequest { repo.dismissNotice(fingerprint) }
_notices.value = _notices.value.filter { it.fingerprint != fingerprint }
}
}
// ---------- Ntfy / Backup ----------
fun getNtfySettings(onResult: (NtfySettings?) -> Unit) {
viewModelScope.launch {
val result = try {
repo.getNtfySettings()
} catch (e: Exception) {
_message.value = e.message ?: "Error al cargar configuración"
null
}
onResult(result)
}
}
fun saveNtfySettings(body: NtfySettingsRequest, onResult: (NtfySettings?) -> Unit) {
viewModelScope.launch {
_busy.value = true
val result = try {
repo.saveNtfySettings(body)
} catch (e: Exception) {
_message.value = e.message ?: "Error al guardar configuración"
null
}
if (result != null) _message.value = "Configuración guardada"
onResult(result)
_busy.value = false
}
}
fun testNtfy(onResult: (NtfyTestResult?) -> Unit) {
viewModelScope.launch {
_busy.value = true
val result = try {
repo.testNtfy()
} catch (e: Exception) {
NtfyTestResult(ok = false, error = e.message ?: "Error de conexión")
}
onResult(result)
_busy.value = false
}
}
fun exportBackup(onResult: (BackupData?) -> Unit) {
viewModelScope.launch {
val result = try {
repo.exportBackup()
} catch (e: Exception) {
_message.value = e.message ?: "Error al exportar"
null
}
onResult(result)
}
}
fun importBackup(data: BackupData, onResult: (BackupImportResult?) -> Unit) {
viewModelScope.launch {
_busy.value = true
val result = try {
repo.importBackup(data)
} catch (e: Exception) {
_message.value = e.message ?: "Error al importar"
null
}
if (result != null) refreshAll()
onResult(result)
_busy.value = false
}
}
fun setBaseUrl(url: String) {
val normalized = com.conta.app.data.network.normalizeBaseUrl(url)
Session.baseUrl = normalized
viewModelScope.launch {
ServiceLocator.settings.setBaseUrl(normalized)
refreshAll()
}
}
}
@@ -0,0 +1,182 @@
package com.conta.app.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.compose.ui.unit.sp
/** Calculadora simple para escribir montos con operaciones. */
@Composable
fun CalculatorDialog(
onResult: (String) -> Unit,
onDismiss: () -> Unit,
) {
var display by remember { mutableStateOf("0") }
var accumulator by remember { mutableStateOf(0.0) }
var pendingOp by remember { mutableStateOf<String?>(null) }
var fresh by remember { mutableStateOf(true) }
fun pressDigit(d: String) {
display = if (fresh || display == "0") d else display + d
fresh = false
}
fun pressDot() {
if (fresh || display == "0") {
display = "0."
fresh = false
} else if (!display.contains('.')) {
display += "."
}
}
fun pressOp(op: String) {
val current = display.toDoubleOrNull() ?: 0.0
if (pendingOp == null) {
accumulator = current
} else {
accumulator = applyOp(accumulator, current, pendingOp)
}
pendingOp = op
display = trimNum(accumulator)
fresh = true
}
fun pressEquals() {
val current = display.toDoubleOrNull() ?: 0.0
if (pendingOp != null) {
accumulator = applyOp(accumulator, current, pendingOp)
pendingOp = null
display = trimNum(accumulator)
}
fresh = true
}
fun pressClear() {
display = "0"
accumulator = 0.0
pendingOp = null
fresh = true
}
fun pressSign() {
display = if (display.startsWith("-")) display.drop(1) else "-$display"
}
fun pressPercent() {
val v = display.toDoubleOrNull() ?: 0.0
display = trimNum(v / 100.0)
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Calculadora") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
contentAlignment = Alignment.CenterEnd,
) {
Text(
text = display,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
color = MaterialTheme.colorScheme.primary,
)
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
CalcKey("C") { pressClear() }
CalcKey("±") { pressSign() }
CalcKey("%") { pressPercent() }
CalcKey("÷") { pressOp("/") }
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
CalcKey("7") { pressDigit("7") }
CalcKey("8") { pressDigit("8") }
CalcKey("9") { pressDigit("9") }
CalcKey("×") { pressOp("*") }
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
CalcKey("4") { pressDigit("4") }
CalcKey("5") { pressDigit("5") }
CalcKey("6") { pressDigit("6") }
CalcKey("-") { pressOp("-") }
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
CalcKey("1") { pressDigit("1") }
CalcKey("2") { pressDigit("2") }
CalcKey("3") { pressDigit("3") }
CalcKey("+") { pressOp("+") }
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
CalcKey("0") { pressDigit("0") }
CalcKey(".") { pressDot() }
CalcKey("=", isEquals = true) { pressEquals() }
}
}
}
},
confirmButton = {
TextButton(onClick = {
onResult(display)
}) { Text("Usar monto") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancelar") }
},
)
}
private fun applyOp(a: Double, b: Double, op: String?): Double = when (op) {
"+" -> a + b
"-" -> a - b
"*" -> a * b
"/" -> if (b == 0.0) a else a / b
else -> b
}
private fun trimNum(v: Double): String {
if (v == v.toLong().toDouble()) {
return v.toLong().toString()
}
return String.format("%.2f", v).trimEnd('0').trimEnd('.')
}
@Composable
private fun androidx.compose.foundation.layout.RowScope.CalcKey(label: String, isEquals: Boolean = false, onClick: () -> Unit) {
TextButton(
onClick = onClick,
modifier = Modifier
.weight(1f)
.padding(horizontal = 2.dp),
) {
Text(
text = label,
fontSize = 20.sp,
color = if (isEquals) MaterialTheme.colorScheme.primary
else if (label in listOf("C", "±", "%", "÷", "×", "-", "+")) MaterialTheme.colorScheme.secondary
else MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
)
}
}
@@ -0,0 +1,120 @@
package com.conta.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.conta.app.ui.util.categoryIcon
import com.conta.app.ui.util.resolveCategoryColor
@Composable
fun CategoryLabel(
icon: String?,
color: String?,
name: String?,
modifier: Modifier = Modifier,
iconSize: Int = 20,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
val tint = resolveCategoryColor(color, MaterialTheme.colorScheme.primary)
Box(
modifier = Modifier
.size(iconSize.dp)
.background(tint.copy(alpha = 0.15f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = categoryIcon(icon),
contentDescription = null,
tint = tint,
modifier = Modifier.size((iconSize - 6).dp),
)
}
if (name != null) {
Text(
text = name,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
fun CategoryAvatar(
icon: String?,
color: String?,
modifier: Modifier = Modifier,
size: Int = 40,
) {
val tint = resolveCategoryColor(color, MaterialTheme.colorScheme.primary)
Box(
modifier = modifier
.size(size.dp)
.background(tint.copy(alpha = 0.15f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = categoryIcon(icon),
contentDescription = null,
tint = tint,
modifier = Modifier.size((size * 0.55).dp),
)
}
}
@Composable
fun CategoryLabelFor(
icon: String?,
color: String?,
name: String?,
modifier: Modifier = Modifier,
) {
val tint = resolveCategoryColor(color, MaterialTheme.colorScheme.onSurfaceVariant)
if (name.isNullOrBlank()) {
Text(
text = "Sin categoría",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
imageVector = categoryIcon(icon),
contentDescription = null,
tint = tint,
modifier = Modifier.size(16.dp),
)
Text(
text = name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -0,0 +1,195 @@
package com.conta.app.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.conta.app.data.model.CategoryStat
import com.conta.app.data.model.MonthlyStat
import com.conta.app.ui.util.formatMonth
import com.conta.app.ui.util.formatMoneyCompact
import com.conta.app.ui.util.resolveCategoryColor
import kotlin.math.max
@Composable
fun MonthlyBarChart(stats: List<MonthlyStat>, modifier: Modifier = Modifier) {
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
val incomeColor = MaterialTheme.colorScheme.tertiary
val expenseColor = MaterialTheme.colorScheme.error
val chartHeight = 180.dp
val labelHeight = 20.dp
Column(modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
LegendDot(incomeColor, "Ingresos")
LegendDot(expenseColor, "Gastos")
}
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(chartHeight)
) {
val gridColor = textColor.copy(alpha = 0.15f)
val maxValue = stats.maxOfOrNull { max(it.income, it.expense) } ?: 0.0
val scale = if (maxValue <= 0.0) 1.0 else maxValue
// Líneas de referencia (0, 50, 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 (stats.isEmpty()) return@Canvas
val slotWidth = size.width / stats.size
val barWidth = slotWidth * 0.30f
val gap = slotWidth * 0.10f
stats.forEachIndexed { i, stat ->
val cx = slotWidth * i + slotWidth / 2
val incomeH = (stat.income / scale).toFloat() * size.height
val expenseH = (stat.expense / scale).toFloat() * size.height
val incomeTop = size.height - incomeH
val expenseTop = size.height - expenseH
drawRoundRect(
color = incomeColor,
topLeft = Offset(cx - barWidth - gap / 2, incomeTop),
size = Size(barWidth, incomeH),
cornerRadius = CornerRadius(3f, 3f),
)
drawRoundRect(
color = expenseColor,
topLeft = Offset(cx + gap / 2, expenseTop),
size = Size(barWidth, expenseH),
cornerRadius = CornerRadius(3f, 3f),
)
}
}
// Etiquetas de meses
Row(modifier = Modifier.fillMaxWidth()) {
stats.forEach { stat ->
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center,
) {
Text(
text = formatMonth(stat.month),
style = MaterialTheme.typography.labelSmall,
color = textColor,
textAlign = TextAlign.Center,
maxLines = 1,
)
}
}
}
}
}
@Composable
private fun LegendDot(color: Color, label: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(Modifier.size(10.dp).background(color, RoundedCornerShape(3.dp)))
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
fun CategoryBars(stats: List<CategoryStat>, modifier: Modifier = Modifier) {
if (stats.isEmpty()) {
Text(
"Sin gastos este mes",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp),
)
return
}
val maxTotal = stats.maxOfOrNull { it.total } ?: 0.0
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
stats.take(8).forEach { stat ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
CategoryAvatar(
icon = stat.icon,
color = stat.color,
size = 34,
)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 10.dp),
) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = stat.name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
maxLines = 1,
)
Text(
text = formatMoneyCompact(stat.total),
style = MaterialTheme.typography.bodySmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
}
Box(
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.height(6.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(3.dp),
),
) {
val frac = if (maxTotal > 0) (stat.total / maxTotal).toFloat() else 0f
Box(
modifier = Modifier
.fillMaxWidth(frac)
.height(6.dp)
.background(
color = resolveCategoryColor(stat.color, MaterialTheme.colorScheme.primary),
shape = RoundedCornerShape(3.dp),
),
)
}
}
}
}
}
}
@@ -0,0 +1,119 @@
package com.conta.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun LoadingScreen(modifier: Modifier = Modifier) {
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
@Composable
fun ErrorText(message: String, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Filled.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(40.dp),
)
Text(
message,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
}
@Composable
fun EmptyState(message: String, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 40.dp),
contentAlignment = Alignment.Center,
) {
Text(
message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@Composable
fun SectionHeader(title: String, modifier: Modifier = Modifier) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
modifier = modifier.padding(bottom = 8.dp),
)
}
@Composable
fun ConfirmDialog(
title: String,
message: String,
confirmText: String = "Eliminar",
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(message) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(confirmText, color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancelar")
}
},
)
}
@Composable
fun CategoryDot(color: Color, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(12.dp)
.background(color, CircleShape),
)
}
@@ -0,0 +1,115 @@
package com.conta.app.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Calculate
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
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.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.foundation.text.KeyboardOptions
import com.conta.app.ui.util.formatDate
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
/** Campo de texto para montos, con validación de formato. */
@Composable
fun MoneyField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
withCalculator: Boolean = false,
onOpenCalculator: (() -> Unit)? = null,
) {
OutlinedTextField(
value = value,
onValueChange = { input ->
val filtered = input.filter { it.isDigit() || it == '.' || it == ',' }
onValueChange(filtered)
},
label = { Text(label) },
prefix = { Text("$ ") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = modifier,
trailingIcon = if (withCalculator && onOpenCalculator != null) {
{
IconButton(onClick = onOpenCalculator) {
Icon(Icons.Filled.Calculate, contentDescription = "Calculadora")
}
}
} else null,
)
}
/** Campo de fecha con selector M3. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateField(
value: String,
onDateChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
) {
var showPicker by remember { mutableStateOf(false) }
OutlinedTextField(
value = if (value.isBlank()) "" else formatDate(value),
onValueChange = {},
label = { Text(label) },
readOnly = true,
trailingIcon = {
IconButton(onClick = { showPicker = true }) {
Icon(Icons.Filled.CalendarMonth, contentDescription = "Seleccionar fecha")
}
},
modifier = modifier,
)
if (showPicker) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = value.toIsoMillis() ?: System.currentTimeMillis()
)
DatePickerDialog(
onDismissRequest = { showPicker = false },
confirmButton = {
TextButton(onClick = {
showPicker = false
datePickerState.selectedDateMillis?.let { millis ->
onDateChange(
Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate().toString()
)
}
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showPicker = false }) { Text("Cancelar") }
},
) {
DatePicker(state = datePickerState, showModeToggle = false)
}
}
}
private fun String.toIsoMillis(): Long? {
return try {
LocalDate.parse(this).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli()
} catch (e: Exception) {
null
}
}
@@ -0,0 +1,107 @@
package com.conta.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.TrendingDown
import androidx.compose.material.icons.automirrored.filled.TrendingUp
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.conta.app.data.model.TransactionWithAccount
import com.conta.app.ui.util.formatDate
import com.conta.app.ui.util.signedAmount
import com.conta.app.ui.util.transactionTypeLabel
private fun typeColor(type: String, isDark: Boolean): Color = when (type) {
"income" -> if (isDark) Color(0xFFA5D6A7) else Color(0xFF2E7D32)
"expense" -> if (isDark) Color(0xFFEF9A9A) else Color(0xFFC62828)
else -> if (isDark) Color(0xFF90CAF9) else Color(0xFF1565C0)
}
@Composable
fun TransactionRow(
transaction: TransactionWithAccount,
modifier: Modifier = Modifier,
showAccount: Boolean = true,
onClick: (() -> Unit)? = null,
) {
val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f
val color = typeColor(transaction.transactionType, isDark)
val icon = when (transaction.transactionType) {
"income" -> Icons.AutoMirrored.Filled.TrendingUp
"expense" -> Icons.AutoMirrored.Filled.TrendingDown
else -> Icons.Filled.SwapHoriz
}
Row(
modifier = modifier
.fillMaxWidth()
.let { if (onClick != null) it.clickable(onClick = onClick) else it }
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.background(color.copy(alpha = 0.12f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(22.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = transaction.description.ifBlank { transactionTypeLabel(transaction.transactionType) },
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = buildString {
append(formatDate(transaction.date))
if (showAccount && transaction.accountName.isNotBlank()) {
append(" · ")
if (transaction.transactionType == "transfer" && !transaction.toAccountName.isNullOrBlank()) {
append(transaction.accountName, " → ", transaction.toAccountName)
} else {
append(transaction.accountName)
}
}
if (!transaction.categoryName.isNullOrBlank()) {
append(" · ", transaction.categoryName)
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = signedAmount(transaction.amount, transaction.transactionType),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = color,
)
}
}
@@ -0,0 +1,304 @@
package com.conta.app.ui.screens.accounts
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBalance
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CreditCard
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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.ContaApp
import com.conta.app.data.model.AccountInfo
import com.conta.app.data.model.StatementImportResult
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.Routes
import com.conta.app.ui.components.EmptyState
import com.conta.app.ui.components.LoadingScreen
import com.conta.app.ui.components.TransactionRow
import com.conta.app.ui.util.accountTypeLabel
import com.conta.app.ui.util.contentUriToFile
import com.conta.app.ui.util.formatMoney
import kotlinx.coroutines.launch
private fun typeIcon(type: String): ImageVector = when (type) {
"credit_card" -> Icons.Filled.CreditCard
"time_deposit" -> Icons.Filled.Schedule
else -> Icons.Filled.AccountBalance
}
private fun typeColor(type: String): Color = when (type) {
"credit_card" -> Color(0xFFC62828)
"time_deposit" -> Color(0xFF6A1B9A)
else -> Color(0xFF2E7D32)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountDetailScreen(viewModel: AppViewModel, navController: NavController, accountId: Long) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val transactions by viewModel.transactions.collectAsStateWithLifecycle()
val loading by viewModel.loading.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
val account = accounts.find { it.id == accountId }
val accountTransactions = transactions.filter {
it.accountId == accountId || it.toAccountId == accountId
}
val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
) { uri ->
uri ?: return@rememberLauncherForActivityResult
if (account == null) return@rememberLauncherForActivityResult
val isSavings = account.accountType == "savings"
scope.launch {
try {
val file = contentUriToFile(ContaApp.instance, uri)
val onResult: (StatementImportResult?) -> Unit = { result ->
if (result != null) {
viewModel.showMessage(
"Importados ${result.imported} movimientos, ${result.payments} pagos, ${result.skipped} omitidos" +
if (result.errors.isNotEmpty()) "\nErrores: ${result.errors.size}" else "",
)
}
}
if (isSavings) {
viewModel.importSavingsStatement(account.id, file, onResult)
} else {
viewModel.importStatement(account.id, file, onResult)
}
} catch (e: Exception) {
viewModel.showMessage(e.message ?: "Error al importar")
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(account?.name ?: "Cuenta", fontWeight = FontWeight.Bold) },
)
},
floatingActionButton = {
FloatingActionButton(onClick = {
navController.navigate(Routes.transactionForm(preset = "expense"))
}) {
Icon(Icons.Filled.Add, contentDescription = "Nueva transacción")
}
},
) { padding ->
if (account == null) {
if (loading) {
LoadingScreen(Modifier.padding(padding))
} else {
EmptyState("Cuenta no encontrada", Modifier.padding(padding))
}
return@Scaffold
}
LazyColumn(
modifier = Modifier
.padding(padding)
.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
AccountHeader(account)
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (account.accountType == "savings" || account.accountType == "credit_card") {
Button(
onClick = { importLauncher.launch("application/pdf") },
modifier = Modifier.weight(1f),
) {
Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
Text(if (account.accountType == "savings") "Importar estado" else "Importar tarjeta")
}
}
OutlinedButton(
onClick = { navController.navigate(Routes.accountForm(account.id)) },
modifier = Modifier.weight(1f),
) {
Icon(Icons.Filled.Edit, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
Text("Editar")
}
}
}
item {
Text(
"Movimientos",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp),
)
}
if (accountTransactions.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
EmptyState("Sin movimientos en esta cuenta")
}
}
} else {
items(accountTransactions, key = { it.id }) { tx ->
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column {
TransactionRow(
tx,
onClick = {
navController.navigate(Routes.transactionForm(transactionId = tx.id))
},
)
}
}
}
}
item { androidx.compose.foundation.layout.Spacer(Modifier.size(80.dp)) }
}
}
}
@Composable
private fun AccountHeader(account: AccountInfo) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(
modifier = Modifier
.size(48.dp)
.background(typeColor(account.accountType).copy(alpha = 0.15f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
typeIcon(account.accountType),
contentDescription = null,
tint = typeColor(account.accountType),
modifier = Modifier.size(26.dp),
)
}
Column {
Text(
accountTypeLabel(account.accountType),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
formatMoney(account.balance),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
when (account.accountType) {
"credit_card" -> {
DetailLine("Límite de crédito", account.creditLimit?.let { formatMoney(it) })
DetailLine("Deuda total", formatMoney(account.debt))
DetailLine("Disponible", account.creditAvailable?.let { formatMoney(it) })
DetailLine("Saldo del estado de cuenta", account.statementBalance?.let { formatMoney(it) })
DetailLine("Pago mínimo", account.minPayment?.let { formatMoney(it) })
DetailLine("Próximo corte", account.nextCutDate)
DetailLine("Próximo pago", account.nextPaymentDate)
if (account.overdue) {
Text(
"Pago VENCIDO",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error,
)
}
}
"time_deposit" -> {
DetailLine("Tasa de interés", account.interestRate?.let { "$it%" })
DetailLine("Vencimiento", account.maturityDate)
DetailLine("Saldo inicial", formatMoney(account.initialBalance))
}
else -> {
DetailLine("Saldo inicial", formatMoney(account.initialBalance))
if (account.paymentAccountId != null) {
DetailLine("Cuenta de pago vinculada", "Sí")
}
}
}
}
}
}
@Composable
private fun DetailLine(label: String, value: String?) {
if (value == null) return
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 3.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
value,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -0,0 +1,318 @@
package com.conta.app.ui.screens.accounts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.conta.app.data.model.CreateAccountRequest
import com.conta.app.data.model.UpdateAccountRequest
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.components.DateField
import com.conta.app.ui.components.MoneyField
import com.conta.app.ui.util.accountTypeLabel
import com.conta.app.ui.util.parseAmount
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountFormScreen(viewModel: AppViewModel, navController: NavController, accountId: Long?) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val editing = accountId != null
val account = accounts.find { it.id == accountId }
var name by remember { mutableStateOf("") }
var accountType by remember { mutableStateOf("savings") }
var initialBalance by remember { mutableStateOf("") }
var creditLimit by remember { mutableStateOf("") }
var interestRate by remember { mutableStateOf("") }
var maturityDate by remember { mutableStateOf("") }
var paymentAccountId by remember { mutableStateOf<Long?>(null) }
var cutDay by remember { mutableStateOf("") }
var paymentDay by remember { mutableStateOf("") }
var minPaymentPct by remember { mutableStateOf("") }
var minPaymentMin by remember { mutableStateOf("") }
var lateFee by remember { mutableStateOf("") }
var initialized by remember { mutableStateOf(false) }
LaunchedEffect(account) {
if (editing && account != null && !initialized) {
name = account.name
accountType = account.accountType
creditLimit = account.creditLimit?.toString() ?: ""
interestRate = account.interestRate?.toString() ?: ""
maturityDate = account.maturityDate ?: ""
paymentAccountId = account.paymentAccountId
cutDay = account.cutDay?.toString() ?: ""
paymentDay = account.paymentDay?.toString() ?: ""
minPaymentPct = account.minPaymentPct?.toString() ?: ""
minPaymentMin = account.minPaymentMin?.toString() ?: ""
lateFee = account.lateFee?.toString() ?: ""
initialized = true
}
}
fun save() {
if (name.isBlank()) {
viewModel.showMessage("Escribe un nombre para la cuenta")
return
}
if (editing && account != null) {
viewModel.updateAccount(
account.id,
UpdateAccountRequest(
creditLimit = parseAmount(creditLimit),
interestRate = parseAmount(interestRate),
maturityDate = maturityDate.ifBlank { null },
paymentAccountId = paymentAccountId,
cutDay = cutDay.toIntOrNull()?.takeIf { it in 1..31 },
paymentDay = paymentDay.toIntOrNull()?.takeIf { it in 1..31 },
minPaymentPct = parseAmount(minPaymentPct),
minPaymentMin = parseAmount(minPaymentMin),
lateFee = parseAmount(lateFee),
),
) { navController.popBackStack() }
} else {
viewModel.createAccount(
CreateAccountRequest(
name = name.trim(),
accountType = accountType,
initialBalance = parseAmount(initialBalance),
creditLimit = parseAmount(creditLimit),
interestRate = parseAmount(interestRate),
maturityDate = maturityDate.ifBlank { null },
paymentAccountId = paymentAccountId,
cutDay = cutDay.toIntOrNull()?.takeIf { it in 1..31 },
paymentDay = paymentDay.toIntOrNull()?.takeIf { it in 1..31 },
minPaymentPct = parseAmount(minPaymentPct),
minPaymentMin = parseAmount(minPaymentMin),
lateFee = parseAmount(lateFee),
),
) { navController.popBackStack() }
}
}
val savingsAccounts = accounts.filter { it.accountType == "savings" && it.id != accountId }
val isCredit = accountType == "credit_card"
val isTimeDeposit = accountType == "time_deposit"
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (editing) "Editar cuenta" else "Nueva cuenta", fontWeight = FontWeight.Bold) },
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Nombre") },
)
TypeDropdown(
value = accountType,
onSelect = { accountType = it },
enabled = !editing,
)
if (!editing) {
MoneyField(
value = initialBalance,
onValueChange = { initialBalance = it },
label = when (accountType) {
"credit_card" -> "Deuda actual"
"time_deposit" -> "Saldo inicial"
else -> "Saldo actual"
},
modifier = Modifier.fillMaxWidth(),
)
}
if (isCredit) {
MoneyField(creditLimit, { creditLimit = it }, "Límite de crédito", Modifier.fillMaxWidth())
MoneyField(interestRate, { interestRate = it }, "Tasa de interés anual (%)", Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = cutDay,
onValueChange = { if (it.length <= 2) cutDay = it.filter { c -> c.isDigit() } },
modifier = Modifier.weight(1f),
singleLine = true,
label = { Text("Día de corte") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = paymentDay,
onValueChange = { if (it.length <= 2) paymentDay = it.filter { c -> c.isDigit() } },
modifier = Modifier.weight(1f),
singleLine = true,
label = { Text("Día de pago") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MoneyField(minPaymentPct, { minPaymentPct = it }, "Pago mínimo (%)", Modifier.weight(1f))
MoneyField(minPaymentMin, { minPaymentMin = it }, "Pago mínimo ($)", Modifier.weight(1f))
}
MoneyField(lateFee, { lateFee = it }, "Recargo por mora", Modifier.fillMaxWidth())
PaymentAccountDropdown(
selectedId = paymentAccountId,
accounts = savingsAccounts,
onSelect = { paymentAccountId = it },
)
}
if (isTimeDeposit) {
MoneyField(interestRate, { interestRate = it }, "Tasa de interés (%)", Modifier.fillMaxWidth())
DateField(
value = maturityDate,
onDateChange = { maturityDate = it },
label = "Fecha de vencimiento",
modifier = Modifier.fillMaxWidth(),
)
}
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedButton(onClick = { navController.popBackStack() }, modifier = Modifier.weight(1f)) {
Text("Cancelar")
}
Button(onClick = { save() }, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
Text(if (editing) "Guardar cambios" else "Crear cuenta")
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 24.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TypeDropdown(value: String, onSelect: (String) -> Unit, enabled: Boolean) {
var expanded by remember { mutableStateOf(false) }
val options = listOf("savings", "credit_card", "time_deposit")
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { if (enabled) expanded = !expanded },
) {
OutlinedTextField(
value = accountTypeLabel(value),
onValueChange = {},
readOnly = true,
enabled = enabled,
label = { Text("Tipo de cuenta") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(accountTypeLabel(option)) },
onClick = {
onSelect(option)
expanded = false
},
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PaymentAccountDropdown(
selectedId: Long?,
accounts: List<com.conta.app.data.model.AccountInfo>,
onSelect: (Long?) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
value = accounts.find { it.id == selectedId }?.name ?: "Sin cuenta asociada",
onValueChange = {},
readOnly = true,
label = { Text("Cuenta de pago asociada") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
DropdownMenuItem(
text = { Text("Sin cuenta asociada") },
onClick = {
onSelect(null)
expanded = false
},
)
accounts.forEach { account ->
DropdownMenuItem(
text = { Text(account.name) },
onClick = {
onSelect(account.id)
expanded = false
},
)
}
}
}
}
@@ -0,0 +1,227 @@
package com.conta.app.ui.screens.accounts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.AccountBalance
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CreditCard
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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.data.model.AccountInfo
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.Routes
import com.conta.app.ui.components.EmptyState
import com.conta.app.ui.components.LoadingScreen
import com.conta.app.ui.util.accountTypeLabel
import com.conta.app.ui.util.formatMoney
private fun typeIcon(type: String): ImageVector = when (type) {
"credit_card" -> Icons.Filled.CreditCard
"time_deposit" -> Icons.Filled.Schedule
else -> Icons.Filled.AccountBalance
}
private fun typeColor(type: String): Color = when (type) {
"credit_card" -> Color(0xFFC62828)
"time_deposit" -> Color(0xFF6A1B9A)
else -> Color(0xFF2E7D32)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountsScreen(viewModel: AppViewModel, navController: NavController) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val loading by viewModel.loading.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Cuentas", fontWeight = FontWeight.Bold) },
actions = {
androidx.compose.material3.IconButton(onClick = { viewModel.refreshAccounts() }) {
Icon(Icons.Filled.Sync, contentDescription = "Actualizar")
}
},
)
},
floatingActionButton = {
FloatingActionButton(onClick = { navController.navigate(Routes.accountForm()) }) {
Icon(Icons.Filled.Add, contentDescription = "Nueva cuenta")
}
},
) { padding ->
if (accounts.isEmpty() && loading) {
LoadingScreen(Modifier.padding(padding))
return@Scaffold
}
if (accounts.isEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
EmptyState("No hay cuentas todavía")
androidx.compose.material3.Button(onClick = { navController.navigate(Routes.accountForm()) }) {
Text("Crear cuenta")
}
}
return@Scaffold
}
val groups = listOf("savings", "credit_card", "time_deposit")
LazyColumn(
modifier = Modifier
.padding(padding)
.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
groups.forEach { type ->
val typeAccounts = accounts.filter { it.accountType == type }
if (typeAccounts.isEmpty()) return@forEach
item {
Text(
text = accountTypeLabel(type),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
}
items(typeAccounts, key = { it.id }) { account ->
AccountCard(account) {
navController.navigate(Routes.accountDetail(account.id))
}
}
}
item { androidx.compose.foundation.layout.Spacer(Modifier.size(80.dp)) }
}
}
}
@Composable
private fun AccountCard(account: AccountInfo, onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Row(
modifier = Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(44.dp)
.background(typeColor(account.accountType).copy(alpha = 0.12f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
typeIcon(account.accountType),
contentDescription = null,
tint = typeColor(account.accountType),
modifier = Modifier.size(24.dp),
)
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = account.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
if (account.overdue) {
Icon(Icons.Filled.Warning, contentDescription = "Pago vencido", tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
}
}
Text(
text = when (account.accountType) {
"credit_card" -> {
val parts = mutableListOf<String>()
if (account.creditAvailable != null) {
parts += "Disponible ${formatMoney(account.creditAvailable)}"
}
parts += "Deuda ${formatMoney(account.debt)}"
parts.joinToString(" · ")
}
"time_deposit" -> {
val parts = mutableListOf<String>()
if (account.interestRate != null) parts += "${account.interestRate}%"
if (!account.maturityDate.isNullOrBlank()) parts += "Vence ${account.maturityDate}"
parts.joinToString(" · ")
}
else -> {
val parts = mutableListOf<String>()
if (account.paymentAccountId != null) parts += "Pago con cuenta vinculada"
parts.joinToString(" · ")
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = formatMoney(account.balance),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = when (account.accountType) {
"credit_card" -> if (account.debt > 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.primary
},
)
if (account.accountType == "credit_card" && !account.nextPaymentDate.isNullOrBlank()) {
Text(
text = "Pago ${account.nextPaymentDate}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,158 @@
package com.conta.app.ui.screens.categories
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.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.Routes
import com.conta.app.ui.components.CategoryAvatar
import com.conta.app.ui.components.ConfirmDialog
import com.conta.app.ui.components.EmptyState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoriesScreen(viewModel: AppViewModel, navController: NavController) {
val categories by viewModel.categories.collectAsStateWithLifecycle()
var typeFilter by remember { mutableStateOf<String?>(null) }
var deleting by remember { mutableStateOf<com.conta.app.data.model.Category?>(null) }
val filtered = categories.filter { typeFilter == null || it.categoryType == typeFilter }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Categorías", fontWeight = FontWeight.Bold) },
actions = {
androidx.compose.material3.IconButton(onClick = { viewModel.refreshCategories() }) {
Icon(Icons.Filled.Sync, contentDescription = "Actualizar")
}
},
)
},
floatingActionButton = {
FloatingActionButton(onClick = { navController.navigate(Routes.categoryForm()) }) {
Icon(Icons.Filled.Add, contentDescription = "Nueva categoría")
}
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize(),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = typeFilter == null,
onClick = { typeFilter = null },
label = { Text("Todas") },
)
FilterChip(
selected = typeFilter == "expense",
onClick = { typeFilter = if (typeFilter == "expense") null else "expense" },
label = { Text("Gastos") },
)
FilterChip(
selected = typeFilter == "income",
onClick = { typeFilter = if (typeFilter == "income") null else "income" },
label = { Text("Ingresos") },
)
}
if (filtered.isEmpty()) {
EmptyState("No hay categorías", Modifier.weight(1f))
} else {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(filtered, key = { it.id }) { category ->
Card(
onClick = {
navController.navigate(Routes.categoryForm(category.id))
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CategoryAvatar(icon = category.icon, color = category.color, size = 38)
Column(modifier = Modifier.weight(1f)) {
Text(
category.name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
if (category.categoryType == "income") "Ingreso" else "Gasto",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { deleting = category }) {
Icon(
Icons.Filled.Delete,
contentDescription = "Eliminar",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
}
}
}
deleting?.let { category ->
ConfirmDialog(
title = "Eliminar categoría",
message = "¿Eliminar \"${category.name}\"? Las transacciones asociadas quedarán sin categoría.",
onConfirm = {
viewModel.deleteCategory(category.id)
deleting = null
},
onDismiss = { deleting = null },
)
}
}
@@ -0,0 +1,264 @@
package com.conta.app.ui.screens.categories
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.data.model.CreateCategoryRequest
import com.conta.app.data.model.UpdateCategoryRequest
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.components.SectionHeader
import com.conta.app.ui.util.CATEGORY_COLOR_PALETTE
import com.conta.app.ui.util.CATEGORY_ICON_NAMES
import com.conta.app.ui.util.categoryIcon
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryFormScreen(viewModel: AppViewModel, navController: NavController, categoryId: Long?) {
val categories by viewModel.categories.collectAsStateWithLifecycle()
val editing = categoryId != null
val category = categories.find { it.id == categoryId }
var name by remember { mutableStateOf("") }
var categoryType by remember { mutableStateOf("expense") }
var icon by remember { mutableStateOf<String?>(null) }
var color by remember { mutableStateOf<String?>(null) }
var initialized by remember { mutableStateOf(false) }
LaunchedEffect(category) {
if (editing && category != null && !initialized) {
name = category.name
categoryType = category.categoryType
icon = category.icon
color = category.color
initialized = true
}
}
fun save() {
if (name.isBlank()) {
viewModel.showMessage("Escribe un nombre para la categoría")
return
}
val done: () -> Unit = { navController.popBackStack() }
if (editing && category != null) {
viewModel.updateCategory(
category.id,
UpdateCategoryRequest(
name = name.trim(),
icon = icon,
color = color,
),
done,
)
} else {
viewModel.createCategory(
CreateCategoryRequest(
name = name.trim(),
categoryType = categoryType,
icon = icon,
color = color,
),
done,
)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (editing) "Editar categoría" else "Nueva categoría", fontWeight = FontWeight.Bold) },
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Nombre") },
)
if (!editing) {
TypeDropdownCategory(
value = categoryType,
onSelect = { categoryType = it },
)
}
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 16.dp))
SectionHeader("Icono")
LazyVerticalGrid(
columns = GridCells.Fixed(6),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
items(CATEGORY_ICON_NAMES) { iconName ->
val selected = icon == iconName
val tint = MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.size(44.dp)
.background(
if (selected) tint.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
RoundedCornerShape(12.dp),
)
.then(
if (selected) Modifier.border(2.dp, tint, RoundedCornerShape(12.dp))
else Modifier,
),
contentAlignment = Alignment.Center,
) {
IconButton(onClick = { icon = iconName }) {
Icon(
categoryIcon(iconName),
contentDescription = iconName,
tint = if (selected) tint else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 16.dp))
SectionHeader("Color")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
CATEGORY_COLOR_PALETTE.forEachIndexed { index, colorValue ->
val selected = color == index.toString()
Box(
modifier = Modifier
.size(38.dp)
.background(colorValue, CircleShape)
.then(
if (selected) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
else Modifier,
),
contentAlignment = Alignment.Center,
) {
IconButton(onClick = { color = index.toString() }, modifier = Modifier.size(38.dp)) {
if (selected) {
Icon(
Icons.Filled.Check,
contentDescription = null,
tint = androidx.compose.ui.graphics.Color.White,
modifier = Modifier.size(18.dp),
)
}
}
}
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedButton(onClick = { navController.popBackStack() }, modifier = Modifier.weight(1f)) {
Text("Cancelar")
}
Button(onClick = { save() }, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
Text(if (editing) "Guardar cambios" else "Crear categoría")
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 24.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TypeDropdownCategory(value: String, onSelect: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
val options = listOf("expense" to "Gasto", "income" to "Ingreso")
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
value = options.find { it.first == value }?.second ?: value,
onValueChange = {},
readOnly = true,
label = { Text("Tipo") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option.second) },
onClick = {
onSelect(option.first)
expanded = false
},
)
}
}
}
}
@@ -0,0 +1,385 @@
package com.conta.app.ui.screens.dashboard
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.CreditCard
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Savings
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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.data.model.Notice
import com.conta.app.data.model.Summary
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.Routes
import com.conta.app.ui.components.CategoryBars
import com.conta.app.ui.components.EmptyState
import com.conta.app.ui.components.LoadingScreen
import com.conta.app.ui.components.MonthlyBarChart
import com.conta.app.ui.components.SectionHeader
import com.conta.app.ui.components.TransactionRow
import com.conta.app.ui.util.accountTypeLabel
import com.conta.app.ui.util.formatMoney
import com.conta.app.ui.util.formatMoneyCompact
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(viewModel: AppViewModel, navController: NavController) {
val summary by viewModel.summary.collectAsStateWithLifecycle()
val statistics by viewModel.statistics.collectAsStateWithLifecycle()
val transactions by viewModel.transactions.collectAsStateWithLifecycle()
val notices by viewModel.notices.collectAsStateWithLifecycle()
val loading by viewModel.loading.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Conta", fontWeight = FontWeight.Bold) },
actions = {
IconButton(onClick = { viewModel.refreshDashboard() }) {
Icon(Icons.Filled.Sync, contentDescription = "Actualizar")
}
},
)
},
floatingActionButton = {
FloatingActionButton(onClick = { navController.navigate(Routes.transactionForm(preset = "expense")) }) {
Icon(Icons.Filled.Add, contentDescription = "Nueva transacción")
}
},
) { 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.")
androidx.compose.material3.Button(onClick = { navController.navigate(Routes.SETTINGS) }) {
Text("Ir a Ajustes")
}
}
}
else -> DashboardContent(
summary = summary!!,
notices = notices,
recentTransactions = transactions.take(10),
onDismissNotice = viewModel::dismissNotice,
onOpenAllTransactions = { navController.navigate(Routes.TRANSACTIONS) },
statisticsContent = {
if (statistics != null) {
SectionHeader("Ingresos y gastos (6 meses)")
MonthlyBarChart(statistics!!.monthly)
Spacer16()
SectionHeader("Gastos por categoría (mes actual)")
CategoryBars(statistics!!.expensesByCategory)
}
},
modifier = Modifier
.padding(padding)
.verticalScroll(rememberScrollState()),
)
}
}
}
@Composable
private fun Spacer16() = androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 16.dp))
@Composable
private fun DashboardContent(
summary: Summary,
notices: List<Notice>,
recentTransactions: List<com.conta.app.data.model.TransactionWithAccount>,
onDismissNotice: (String) -> Unit,
onOpenAllTransactions: () -> Unit,
statisticsContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.padding(horizontal = 16.dp)) {
Text("Resumen financiero", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SummaryCard(
label = "Saldo total",
value = formatMoney(summary.totalBalance),
color = MaterialTheme.colorScheme.primary,
icon = Icons.Filled.AccountBalanceWallet,
modifier = Modifier.weight(1f),
)
SummaryCard(
label = "Ahorros",
value = formatMoney(summary.totalSavings),
color = MaterialTheme.colorScheme.secondary,
icon = Icons.Filled.Savings,
modifier = Modifier.weight(1f),
)
}
androidx.compose.foundation.layout.Spacer(Modifier.size(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SummaryCard(
label = "Deuda tarjetas",
value = formatMoney(summary.totalCredit),
color = MaterialTheme.colorScheme.error,
icon = Icons.Filled.CreditCard,
modifier = Modifier.weight(1f),
)
SummaryCard(
label = "Depósitos a plazo",
value = formatMoney(summary.totalTimeDeposits),
color = MaterialTheme.colorScheme.tertiary,
icon = Icons.Filled.Schedule,
modifier = Modifier.weight(1f),
)
}
androidx.compose.foundation.layout.Spacer(Modifier.size(20.dp))
if (notices.isNotEmpty()) {
NoticesCard(notices, onDismissNotice)
androidx.compose.foundation.layout.Spacer(Modifier.size(20.dp))
}
SectionHeader("Cuentas")
AccountSummaryList(summary)
androidx.compose.foundation.layout.Spacer(Modifier.size(20.dp))
statisticsContent()
androidx.compose.foundation.layout.Spacer(Modifier.size(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
SectionHeader("Últimos movimientos")
TextButtonLink(onClick = onOpenAllTransactions)
}
if (recentTransactions.isEmpty()) {
EmptyState("Aún no hay transacciones")
} else {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column {
recentTransactions.forEachIndexed { i, tx ->
TransactionRow(tx, showAccount = false)
if (i < recentTransactions.lastIndex) {
androidx.compose.material3.HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
)
}
}
}
}
}
androidx.compose.foundation.layout.Spacer(Modifier.size(24.dp))
}
}
@Composable
private fun TextButtonLink(onClick: () -> Unit) {
androidx.compose.material3.TextButton(onClick = onClick) {
Text("Ver todas")
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, modifier = Modifier.size(16.dp))
}
}
@Composable
private fun SummaryCard(
label: String,
value: String,
color: Color,
icon: ImageVector,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(14.dp)) {
Box(
modifier = Modifier
.size(34.dp)
.background(color.copy(alpha = 0.12f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(18.dp))
}
androidx.compose.foundation.layout.Spacer(Modifier.size(10.dp))
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = color,
maxLines = 1,
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
@Composable
private fun NoticesCard(notices: List<Notice>, onDismiss: (String) -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
),
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
Icons.Filled.NotificationsActive,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
Text(
"Avisos",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
}
androidx.compose.foundation.layout.Spacer(Modifier.size(8.dp))
notices.forEach { notice ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = when (notice.icon) {
"credit_card" -> Icons.Filled.CreditCard
"calendar" -> Icons.Filled.Event
else -> Icons.Filled.NotificationsActive
},
contentDescription = null,
tint = if (notice.severity == "warning") MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
Text(
text = notice.message,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { onDismiss(notice.fingerprint) }, modifier = Modifier.size(28.dp)) {
Icon(Icons.Filled.CheckCircle, contentDescription = "Descartar", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
}
}
}
}
}
}
@Composable
private fun AccountSummaryList(summary: Summary) {
val groups = listOf("savings", "credit_card", "time_deposit")
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column {
groups.forEachIndexed { gi, type ->
val typeAccounts = summary.accounts.filter { it.accountType == type }
if (typeAccounts.isEmpty()) return@forEachIndexed
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
.padding(horizontal = 14.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = accountTypeLabel(type),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
typeAccounts.forEach { account ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = account.name,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
maxLines = 1,
)
Text(
text = formatMoneyCompact(account.balance),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
}
}
if (gi < groups.lastIndex) {
androidx.compose.material3.HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f))
}
}
if (summary.accounts.isEmpty()) {
EmptyState("No hay cuentas creadas")
}
}
}
}
@@ -0,0 +1,344 @@
package com.conta.app.ui.screens.recurring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.conta.app.data.model.CreateRecurringTransactionRequest
import com.conta.app.data.model.NullableLong
import com.conta.app.data.model.UpdateRecurringTransactionRequest
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.components.CategoryAvatar
import com.conta.app.ui.components.MoneyField
import com.conta.app.ui.util.accountTypeLabel
import com.conta.app.ui.util.formatMoney
import com.conta.app.ui.util.parseAmount
import com.conta.app.ui.util.transactionTypeLabel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringFormScreen(viewModel: AppViewModel, navController: NavController, recurringId: Long?) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val categories by viewModel.categories.collectAsStateWithLifecycle()
val recurrings by viewModel.recurring.collectAsStateWithLifecycle()
val editing = recurringId != null
val original = recurrings.find { it.id == recurringId }
var tab by remember { mutableStateOf("expense") }
var accountId by remember { mutableStateOf<Long?>(null) }
var description by remember { mutableStateOf("") }
var amount by remember { mutableStateOf("") }
var dayOfMonth by remember { mutableStateOf("") }
var categoryId by remember { mutableStateOf<Long?>(null) }
var active by remember { mutableStateOf(true) }
var initialized by remember { mutableStateOf(false) }
LaunchedEffect(original) {
if (editing && original != null && !initialized) {
tab = original.transactionType
accountId = original.accountId
description = original.description
amount = original.amount.toString()
dayOfMonth = original.dayOfMonth.toString()
categoryId = original.categoryId
active = original.active == 1
initialized = true
}
}
val filteredCategories = categories.filter { it.categoryType == tab }
fun save() {
if (accountId == null) {
viewModel.showMessage("Selecciona una cuenta")
return
}
val parsedAmount = parseAmount(amount)
if (parsedAmount == null || parsedAmount <= 0) {
viewModel.showMessage("Escribe un monto válido mayor a cero")
return
}
val day = dayOfMonth.toIntOrNull()
if (day == null || day !in 1..31) {
viewModel.showMessage("El día del mes debe estar entre 1 y 31")
return
}
val done: () -> Unit = { navController.popBackStack() }
if (editing && original != null) {
viewModel.updateRecurring(
original.id,
UpdateRecurringTransactionRequest(
accountId = accountId,
transactionType = tab,
amount = parsedAmount,
description = description.trim(),
dayOfMonth = day,
categoryId = categoryId?.let { NullableLong(it) } ?: if (original.categoryId != null) NullableLong(null) else null,
active = active,
),
done,
)
} else {
viewModel.createRecurring(
CreateRecurringTransactionRequest(
accountId = accountId ?: 0L,
transactionType = tab,
amount = parsedAmount,
description = description.trim(),
dayOfMonth = day,
categoryId = categoryId,
),
done,
)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (editing) "Editar recurrente" else "Nuevo recurrente", fontWeight = FontWeight.Bold) },
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
TabRow(selectedTabIndex = if (tab == "expense") 0 else 1) {
Tab(
selected = tab == "expense",
onClick = { tab = "expense" },
text = { Text("Gasto") },
)
Tab(
selected = tab == "income",
onClick = { tab = "income" },
text = { Text("Ingreso") },
)
}
androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
RecurringAccountDropdown(
selectedId = accountId,
accounts = accounts,
onSelect = { accountId = it },
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Descripción") },
)
MoneyField(
value = amount,
onValueChange = { amount = it },
label = "Monto",
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = dayOfMonth,
onValueChange = { if (it.length <= 2) dayOfMonth = it.filter { c -> c.isDigit() } },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Día del mes (1-31)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
RecurringCategoryDropdown(
selectedId = categoryId,
categories = filteredCategories,
onSelect = { categoryId = it },
)
if (editing) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Activo", style = MaterialTheme.typography.bodyMedium)
Switch(checked = active, onCheckedChange = { active = it })
}
}
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedButton(onClick = { navController.popBackStack() }, modifier = Modifier.weight(1f)) {
Text("Cancelar")
}
Button(onClick = { save() }, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
Text(if (editing) "Guardar cambios" else "Crear recurrente")
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 24.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RecurringAccountDropdown(
selectedId: Long?,
accounts: List<com.conta.app.data.model.AccountInfo>,
onSelect: (Long) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val selected = accounts.find { it.id == selectedId }
val options = listOf("savings", "credit_card", "time_deposit")
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
value = selected?.name ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Cuenta") },
placeholder = { Text("Seleccionar") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { type ->
val typeList = accounts.filter { it.accountType == type }
if (typeList.isNotEmpty()) {
DropdownMenuItem(
text = {
Text(
accountTypeLabel(type),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
},
enabled = false,
onClick = {},
)
typeList.forEach { account ->
DropdownMenuItem(
text = { Text("${account.name} (${formatMoney(account.balance)})") },
onClick = {
onSelect(account.id)
expanded = false
},
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RecurringCategoryDropdown(
selectedId: Long?,
categories: List<com.conta.app.data.model.Category>,
onSelect: (Long?) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val selected = categories.find { it.id == selectedId }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
value = selected?.name ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Categoría") },
placeholder = { Text("Sin categoría") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
DropdownMenuItem(
text = { Text("Sin categoría") },
onClick = {
onSelect(null)
expanded = false
},
)
categories.forEach { category ->
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CategoryAvatar(icon = category.icon, color = category.color, size = 20)
Text(category.name)
}
},
onClick = {
onSelect(category.id)
expanded = false
},
)
}
}
}
}
@@ -0,0 +1,167 @@
package com.conta.app.ui.screens.recurring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.data.model.RecurringTransactionWithNames
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.Routes
import com.conta.app.ui.components.CategoryAvatar
import com.conta.app.ui.components.ConfirmDialog
import com.conta.app.ui.components.EmptyState
import com.conta.app.ui.util.formatMoney
import com.conta.app.ui.util.signedAmount
import com.conta.app.ui.util.transactionTypeLabel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringScreen(viewModel: AppViewModel, navController: NavController) {
val recurrings by viewModel.recurring.collectAsStateWithLifecycle()
var deleting by remember { mutableStateOf<RecurringTransactionWithNames?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Recurrentes", fontWeight = FontWeight.Bold) },
actions = {
IconButton(onClick = { viewModel.refreshRecurring() }) {
Icon(Icons.Filled.Sync, contentDescription = "Actualizar")
}
},
)
},
floatingActionButton = {
FloatingActionButton(onClick = { navController.navigate(Routes.recurringForm()) }) {
Icon(Icons.Filled.Add, contentDescription = "Nuevo recurrente")
}
},
) { padding ->
if (recurrings.isEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
EmptyState("No hay transacciones recurrentes")
}
return@Scaffold
}
LazyColumn(
modifier = Modifier
.padding(padding)
.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(recurrings, key = { it.id }) { item ->
Card(
onClick = { navController.navigate(Routes.recurringForm(item.id)) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CategoryAvatar(icon = item.categoryIcon, color = item.categoryColor, size = 38)
Column(modifier = Modifier.weight(1f)) {
Text(
item.description,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
"${transactionTypeLabel(item.transactionType)} · ${item.accountName ?: "Cuenta #${item.accountId}"} · Día ${item.dayOfMonth}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
signedAmount(item.amount, item.transactionType),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = if (item.transactionType == "expense") MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (item.active == 1) "Activo" else "Inactivo",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Switch(
checked = item.active == 1,
onCheckedChange = { checked ->
viewModel.updateRecurring(
item.id,
com.conta.app.data.model.UpdateRecurringTransactionRequest(active = checked),
)
},
modifier = Modifier.size(40.dp),
)
}
}
IconButton(onClick = { deleting = item }) {
Icon(
Icons.Filled.Delete,
contentDescription = "Eliminar",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
}
deleting?.let { item ->
ConfirmDialog(
title = "Eliminar recurrente",
message = "¿Eliminar \"${item.description}\"?",
onConfirm = {
viewModel.deleteRecurring(item.id)
deleting = null
},
onDismiss = { deleting = null },
)
}
}
@@ -0,0 +1,236 @@
package com.conta.app.ui.screens.rules
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.data.model.CreateCategorizationRuleRequest
import com.conta.app.data.model.UpdateCategorizationRuleRequest
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.components.CategoryAvatar
import com.conta.app.ui.util.transactionTypeLabel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RuleFormScreen(viewModel: AppViewModel, navController: NavController, ruleId: Long?) {
val rules by viewModel.rules.collectAsStateWithLifecycle()
val categories by viewModel.categories.collectAsStateWithLifecycle()
val editing = ruleId != null
val rule = rules.find { it.id == ruleId }
var pattern by remember { mutableStateOf("") }
var categoryId by remember { mutableStateOf<Long?>(null) }
var initialized by remember { mutableStateOf(false) }
var resultMessage by remember { mutableStateOf<String?>(null) }
LaunchedEffect(rule) {
if (editing && rule != null && !initialized) {
pattern = rule.pattern
categoryId = rule.categoryId
initialized = true
}
}
fun save() {
if (pattern.isBlank()) {
viewModel.showMessage("Escribe un patrón de búsqueda")
return
}
if (categoryId == null) {
viewModel.showMessage("Selecciona una categoría")
return
}
if (editing && rule != null) {
viewModel.updateRule(
rule.id,
UpdateCategorizationRuleRequest(
pattern = pattern.trim(),
categoryId = categoryId,
),
) { navController.popBackStack() }
} else {
viewModel.createRule(
CreateCategorizationRuleRequest(
pattern = pattern.trim(),
categoryId = categoryId!!,
),
) { created ->
resultMessage = created?.let {
"Regla creada. Aplicada a ${it.applied} transacciones."
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (editing) "Editar regla" else "Nueva regla", fontWeight = FontWeight.Bold) },
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedTextField(
value = pattern,
onValueChange = { pattern = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Patrón (expresión regular)") },
placeholder = { Text("Ej.: mercado|supermercado") },
)
Text(
"Se busca este patrón en la descripción de cada transacción; si coincide, se asigna la categoría automáticamente.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
RuleCategoryDropdown(
selectedId = categoryId,
categories = categories,
onSelect = { categoryId = it },
)
}
}
resultMessage?.let {
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Text(
it,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp),
)
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedButton(onClick = { navController.popBackStack() }, modifier = Modifier.weight(1f)) {
Text("Cancelar")
}
Button(onClick = { save() }, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
Text(if (editing) "Guardar cambios" else "Crear regla")
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 24.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RuleCategoryDropdown(
selectedId: Long?,
categories: List<com.conta.app.data.model.Category>,
onSelect: (Long?) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val selected = categories.find { it.id == selectedId }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
value = selected?.name ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Categoría") },
placeholder = { Text("Seleccionar categoría") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
listOf("expense", "income").forEach { type ->
val typeCategories = categories.filter { it.categoryType == type }
if (typeCategories.isNotEmpty()) {
DropdownMenuItem(
text = {
Text(
transactionTypeLabel(type),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
},
enabled = false,
onClick = {},
)
typeCategories.forEach { category ->
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CategoryAvatar(icon = category.icon, color = category.color, size = 20)
Text(category.name)
}
},
onClick = {
onSelect(category.id)
expanded = false
},
)
}
}
}
}
}
}
@@ -0,0 +1,144 @@
package com.conta.app.ui.screens.rules
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.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.FontStyle
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.data.model.CategorizationRule
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.Routes
import com.conta.app.ui.components.CategoryAvatar
import com.conta.app.ui.components.ConfirmDialog
import com.conta.app.ui.components.EmptyState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RulesScreen(viewModel: AppViewModel, navController: NavController) {
val rules by viewModel.rules.collectAsStateWithLifecycle()
var deleting by remember { mutableStateOf<CategorizationRule?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Reglas de categorización", fontWeight = FontWeight.Bold) },
actions = {
IconButton(onClick = { viewModel.refreshRules() }) {
Icon(Icons.Filled.Sync, contentDescription = "Actualizar")
}
},
)
},
floatingActionButton = {
FloatingActionButton(onClick = { navController.navigate(Routes.ruleForm()) }) {
Icon(Icons.Filled.Add, contentDescription = "Nueva regla")
}
},
) { padding ->
if (rules.isEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
EmptyState("No hay reglas todavía")
Text(
"Las reglas asignan una categoría automáticamente según la descripción de la transacción.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
return@Scaffold
}
LazyColumn(
modifier = Modifier
.padding(padding)
.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(rules, key = { it.id }) { rule ->
Card(
onClick = { navController.navigate(Routes.ruleForm(rule.id)) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CategoryAvatar(icon = rule.categoryIcon, color = rule.categoryColor, size = 38)
Column(modifier = Modifier.weight(1f)) {
Text(
rule.pattern,
style = MaterialTheme.typography.titleSmall,
fontStyle = FontStyle.Italic,
fontWeight = FontWeight.SemiBold,
)
Text(
"→ ${rule.categoryName ?: "Categoría #${rule.categoryId}"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { deleting = rule }) {
Icon(
Icons.Filled.Delete,
contentDescription = "Eliminar",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
}
deleting?.let { rule ->
ConfirmDialog(
title = "Eliminar regla",
message = "¿Eliminar la regla \"${rule.pattern}\"?",
onConfirm = {
viewModel.deleteRule(rule.id)
deleting = null
},
onDismiss = { deleting = null },
)
}
}
@@ -0,0 +1,179 @@
package com.conta.app.ui.screens.settings
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.filled.Download
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.conta.app.data.model.BackupData
import com.conta.app.data.network.NetworkModule
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.components.SectionHeader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BackupScreen(viewModel: AppViewModel, navController: NavController) {
var lastExport by remember { mutableStateOf<BackupData?>(null) }
val scope = rememberCoroutineScope()
val exportLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json"),
) { uri: Uri? ->
uri ?: return@rememberLauncherForActivityResult
viewModel.exportBackup { data ->
if (data != null) {
val text = NetworkModule.json.encodeToString(data)
try {
contextResolver(uri) { out ->
out.use { it.write(text.toByteArray()) }
}
lastExport = data
viewModel.showMessage("Copia de seguridad exportada")
} catch (e: Exception) {
viewModel.showMessage("No se pudo guardar el archivo: ${e.message}")
}
}
}
}
val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
) { uri: Uri? ->
uri ?: return@rememberLauncherForActivityResult
scope.launch {
try {
val text = readTextFromUri(uri)
val data = NetworkModule.json.decodeFromString<BackupData>(text)
viewModel.importBackup(data) { result ->
if (result != null) {
viewModel.showMessage(
"Restaurado: ${result.counts.accounts} cuentas, ${result.counts.transactions} movimientos",
)
}
}
} catch (e: Exception) {
viewModel.showMessage("Archivo de copia inválido: ${e.message}")
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Copia de seguridad", fontWeight = FontWeight.Bold) },
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
SectionHeader("Exportar")
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(14.dp)) {
Text(
"Guarda todos los datos (cuentas, categorías, movimientos, reglas, recurrentes y configuración ntfy) en un archivo JSON.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 10.dp))
Button(
onClick = { exportLauncher.launch("conta-backup.json") },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Download, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
Text("Exportar copia de seguridad")
}
if (lastExport != null) {
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween) {
Text("Última exportación", style = MaterialTheme.typography.bodySmall)
Text(
lastExport!!.exportedAt,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
SectionHeader("Importar")
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(14.dp)) {
Text(
"Restaura un respaldo previo. Los datos existentes se conservan y se añaden los del archivo.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 10.dp))
OutlinedButton(
onClick = { importLauncher.launch("application/json") },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Upload, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
Text("Importar copia de seguridad")
}
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 24.dp))
}
}
}
private fun contextResolver(uri: Uri, write: (java.io.OutputStream) -> Unit) {
val resolver = com.conta.app.ContaApp.instance.contentResolver
resolver.openOutputStream(uri)?.let { write(it) } ?: throw IllegalStateException("No se pudo abrir el archivo")
}
private suspend fun readTextFromUri(uri: Uri): String = withContext(Dispatchers.IO) {
val resolver = com.conta.app.ContaApp.instance.contentResolver
resolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
?: throw IllegalStateException("No se pudo leer el archivo")
}
@@ -0,0 +1,238 @@
package com.conta.app.ui.screens.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.conta.app.data.model.NtfySettingsRequest
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.components.SectionHeader
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NtfyScreen(viewModel: AppViewModel, navController: NavController) {
var enabled by remember { mutableStateOf(false) }
var serverUrl by remember { mutableStateOf("") }
var topic by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var hasPassword by remember { mutableStateOf(false) }
var depositExpired by remember { mutableStateOf(true) }
var depositUpcoming by remember { mutableStateOf(true) }
var cardOverdue by remember { mutableStateOf(true) }
var cardPayment by remember { mutableStateOf(true) }
var cardDebt by remember { mutableStateOf(true) }
var monthEndReminder by remember { mutableStateOf(true) }
var absenceReminder by remember { mutableStateOf(true) }
var loaded by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.getNtfySettings { settings ->
if (settings != null) {
enabled = settings.enabled
serverUrl = settings.serverUrl
topic = settings.topic
username = settings.username
hasPassword = settings.hasPassword
depositExpired = settings.depositExpired
depositUpcoming = settings.depositUpcoming
cardOverdue = settings.cardOverdue
cardPayment = settings.cardPayment
cardDebt = settings.cardDebt
monthEndReminder = settings.monthEndReminder
absenceReminder = settings.absenceReminder
}
loaded = true
}
}
fun save() {
viewModel.saveNtfySettings(
NtfySettingsRequest(
enabled = enabled,
serverUrl = serverUrl.trim(),
topic = topic.trim(),
username = username.trim(),
password = password.ifBlank { null },
depositExpired = depositExpired,
depositUpcoming = depositUpcoming,
cardOverdue = cardOverdue,
cardPayment = cardPayment,
cardDebt = cardDebt,
monthEndReminder = monthEndReminder,
absenceReminder = absenceReminder,
),
onResult = {
if (it != null) {
password = ""
hasPassword = it.hasPassword
}
},
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Notificaciones ntfy", fontWeight = FontWeight.Bold) },
)
},
) { padding ->
if (!loaded) {
com.conta.app.ui.components.LoadingScreen(Modifier.padding(padding))
return@Scaffold
}
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
SectionHeader("General")
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(14.dp), verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Activar notificaciones", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
Text(
"Usa ntfy.sh u otro servidor ntfy",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = enabled, onCheckedChange = { enabled = it })
}
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Servidor (https://ntfy.sh)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
OutlinedTextField(
value = topic,
onValueChange = { topic = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Topic") },
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Usuario (opcional)") },
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(if (hasPassword) "Contraseña (déjalo vacío para mantener)" else "Contraseña (opcional)") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 16.dp))
SectionHeader("Recordatorios")
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column {
NotifySwitch("Depósito a plazo vencido", depositExpired) { depositExpired = it }
NotifySwitch("Depósito a plazo por vencer", depositUpcoming) { depositUpcoming = it }
NotifySwitch("Tarjeta con pago vencido", cardOverdue) { cardOverdue = it }
NotifySwitch("Pago de tarjeta próximo", cardPayment) { cardPayment = it }
NotifySwitch("Deuda de tarjeta", cardDebt) { cardDebt = it }
NotifySwitch("Cierre de mes", monthEndReminder) { monthEndReminder = it }
NotifySwitch("Recordatorio de ausencia", absenceReminder) { absenceReminder = it }
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 16.dp))
Button(
onClick = { save() },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.NotificationsActive, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
Text("Guardar configuración")
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
Button(
onClick = {
viewModel.testNtfy { result ->
if (result != null) {
if (result.ok) viewModel.showMessage("Notificación de prueba enviada")
else viewModel.showMessage(result.error ?: "Error al enviar la prueba")
}
}
},
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Send, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
Text("Enviar notificación de prueba")
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 24.dp))
}
}
}
@Composable
private fun NotifySwitch(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween,
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@@ -0,0 +1,186 @@
package com.conta.app.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.KeyboardArrowRight
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Http
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.conta.app.BuildConfig
import com.conta.app.ServiceLocator
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.Routes
import com.conta.app.ui.components.SectionHeader
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(viewModel: AppViewModel, navController: NavController) {
val storedUrl by remember { ServiceLocator.settings.baseUrl }.collectAsStateWithLifecycle(initialValue = "")
var url by remember { mutableStateOf("") }
LaunchedEffect(storedUrl) {
if (storedUrl.isNotEmpty() && url != storedUrl) {
url = storedUrl
}
}
val summary by viewModel.summary.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Ajustes", fontWeight = FontWeight.Bold) },
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
SectionHeader("Servidor")
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(Icons.Filled.Http, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Text(
"URL del backend",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
OutlinedTextField(
value = url,
onValueChange = { url = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("http://192.168.x.x:3344") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.setBaseUrl(url) }) {
Icon(Icons.Filled.Save, contentDescription = null, modifier = androidx.compose.ui.Modifier.padding(end = 6.dp))
Text("Guardar")
}
OutlinedButton(onClick = { viewModel.refreshAll() }) {
Icon(Icons.Filled.Sync, contentDescription = null, modifier = androidx.compose.ui.Modifier.padding(end = 6.dp))
Text("Probar")
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 8.dp))
Text(
text = if (summary != null) "Conectado y con datos" else "Sin conexión aún",
style = MaterialTheme.typography.bodySmall,
color = if (summary != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
)
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
SectionHeader("Notificaciones")
SettingsItemCard(
icon = Icons.Filled.NotificationsActive,
title = "Notificaciones ntfy",
subtitle = "Recordatorios de depósitos, tarjetas y cierres de mes",
onClick = { navController.navigate(Routes.NTFY) },
)
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 20.dp))
SectionHeader("Datos")
SettingsItemCard(
icon = Icons.Filled.Cloud,
title = "Copia de seguridad",
subtitle = "Exportar e importar todos los datos",
onClick = { navController.navigate(Routes.BACKUP) },
)
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 24.dp))
Text(
text = "Conta v${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
}
@Composable
private fun SettingsItemCard(
icon: ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit,
) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Row(
modifier = Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,558 @@
package com.conta.app.ui.screens.transactions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.lazy.LazyRow
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.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.data.model.CreateTransactionRequest
import com.conta.app.data.model.NullableLong
import com.conta.app.data.model.RecurringTransactionWithNames
import com.conta.app.data.model.TransferRequest
import com.conta.app.data.model.UpdateTransactionRequest
import com.conta.app.ui.AppViewModel
import com.conta.app.ui.components.CalculatorDialog
import com.conta.app.ui.components.DateField
import com.conta.app.ui.components.MoneyField
import com.conta.app.ui.util.accountTypeLabel
import com.conta.app.ui.util.dayOfMonth
import com.conta.app.ui.util.formatMoney
import com.conta.app.ui.util.parseAmount
import com.conta.app.ui.util.todayIso
import com.conta.app.ui.util.transactionTypeLabel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionFormScreen(
viewModel: AppViewModel,
navController: NavController,
transactionId: Long?,
preset: String,
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val categories by viewModel.categories.collectAsStateWithLifecycle()
val transactions by viewModel.transactions.collectAsStateWithLifecycle()
val recurrings by viewModel.recurring.collectAsStateWithLifecycle()
val rules by viewModel.rules.collectAsStateWithLifecycle()
val editing = transactionId != null
val original = transactions.find { it.id == transactionId }
var tab by remember { mutableStateOf(original?.transactionType ?: if (preset == "income" || preset == "transfer") preset else "expense") }
var accountId by remember { mutableStateOf<Long?>(null) }
var fromAccountId by remember { mutableStateOf<Long?>(null) }
var toAccountId by remember { mutableStateOf<Long?>(null) }
var amount by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var date by remember { mutableStateOf(todayIso()) }
var categoryId by remember { mutableStateOf<Long?>(null) }
var appliedRecurringId by remember { mutableStateOf<Long?>(null) }
var categoryAuto by remember { mutableStateOf(false) }
var showCalc by remember { mutableStateOf(false) }
var initialized by remember { mutableStateOf(false) }
LaunchedEffect(original) {
if (editing && original != null && !initialized) {
tab = original.transactionType
if (original.transactionType == "transfer") {
fromAccountId = original.accountId
toAccountId = original.toAccountId
} else {
accountId = original.accountId
}
amount = original.amount.toString()
description = original.description
date = original.date
categoryId = original.categoryId
initialized = true
}
}
val isTransfer = tab == "transfer"
val isEditingTransfer = editing && original?.transactionType == "transfer"
val filteredCategories = categories.filter {
it.categoryType == if (isTransfer) "expense" else tab
}
val typeAccounts = listOf("savings", "credit_card", "time_deposit")
val typed = description.trim().lowercase()
val descriptionSuggestion: RecurringTransactionWithNames? =
if (!editing && typed.length >= 3) {
recurrings.find {
it.active == 1 &&
it.id != appliedRecurringId &&
it.transactionType == (if (isTransfer) "expense" else tab) &&
(typed.contains(it.description.lowercase()) || it.description.lowercase().contains(typed))
}
} else null
val dueToday = if (!editing) {
recurrings.filter { it.active == 1 && it.dayOfMonth == dayOfMonth(date) && it.id != appliedRecurringId }
} else emptyList()
fun applyRecurring(r: RecurringTransactionWithNames) {
tab = r.transactionType
accountId = r.accountId
fromAccountId = null
toAccountId = null
amount = r.amount.toString()
description = r.description
categoryId = r.categoryId
appliedRecurringId = r.id
categoryAuto = false
}
fun onDescriptionChange(value: String) {
description = value
appliedRecurringId = null
val expectedType = if (isTransfer) "expense" else tab
var matched: Long? = null
for (rule in rules) {
val re = try { Regex(rule.pattern) } catch (e: Exception) { continue }
if (re.containsMatchIn(value)) {
matched = rule.categoryId
break
}
}
if (matched != null) {
val cat = categories.find { it.id == matched }
if (cat != null && cat.categoryType == expectedType) {
categoryAuto = true
categoryId = matched
return
}
}
if (categoryAuto) {
categoryAuto = false
categoryId = null
}
}
fun save() {
if (isTransfer) {
if (fromAccountId == null || toAccountId == null) {
viewModel.showMessage("Selecciona cuenta de origen y destino")
return
}
if (fromAccountId == toAccountId) {
viewModel.showMessage("Las cuentas deben ser diferentes")
return
}
} else if (accountId == null) {
viewModel.showMessage("Selecciona una cuenta")
return
}
val parsedAmount = parseAmount(amount)
if (parsedAmount == null || parsedAmount <= 0) {
viewModel.showMessage("Escribe un monto válido mayor a cero")
return
}
val done: () -> Unit = { navController.popBackStack() }
if (editing && original != null) {
if (isTransfer) {
viewModel.updateTransactionToTransfer(
original.id,
TransferRequest(
fromAccountId = fromAccountId ?: original.accountId,
toAccountId = toAccountId ?: 0L,
amount = parsedAmount,
description = description.trim(),
date = date,
categoryId = categoryId,
),
done,
)
} else {
viewModel.updateTransaction(
original.id,
UpdateTransactionRequest(
accountId = accountId,
transactionType = tab,
amount = parsedAmount,
description = description.trim(),
date = date,
categoryId = categoryId?.let { NullableLong(it) } ?: if (original.categoryId != null) NullableLong(null) else null,
),
done,
)
}
} else {
if (isTransfer) {
viewModel.createTransfer(
TransferRequest(
fromAccountId = fromAccountId ?: 0L,
toAccountId = toAccountId ?: 0L,
amount = parsedAmount,
description = description.trim(),
date = date,
categoryId = categoryId,
),
done,
)
} else {
viewModel.createTransaction(
CreateTransactionRequest(
accountId = accountId ?: 0L,
transactionType = tab,
amount = parsedAmount,
description = description.trim(),
date = date,
categoryId = categoryId,
),
done,
)
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (editing) "Editar transacción" else "Nueva transacción", fontWeight = FontWeight.Bold) },
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
TabRow(selectedTabIndex = when (tab) {
"expense" -> 0
"income" -> 1
else -> 2
}) {
Tab(
selected = tab == "expense",
onClick = {
tab = "expense"
categoryAuto = false
categoryId = null
if (editing && isEditingTransfer) accountId = original.accountId
},
text = { Text("Gasto") },
)
Tab(
selected = tab == "income",
onClick = {
tab = "income"
categoryAuto = false
categoryId = null
if (editing && isEditingTransfer) accountId = original.accountId
},
text = { Text("Ingreso") },
)
Tab(
selected = isTransfer,
onClick = {
tab = "transfer"
categoryAuto = false
categoryId = null
if (editing && !isEditingTransfer && original != null) {
fromAccountId = original.accountId
toAccountId = original.toAccountId
}
},
text = { Text("Transferencia") },
)
}
androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
if (descriptionSuggestion != null) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f)),
) {
Row(
modifier = Modifier.padding(10.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Recurrente sugerido",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"${descriptionSuggestion.description} · ${formatMoney(descriptionSuggestion.amount)} el día ${descriptionSuggestion.dayOfMonth}",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
)
}
TextButton(onClick = { applyRecurring(descriptionSuggestion) }) { Text("Usar") }
}
}
androidx.compose.foundation.layout.Spacer(Modifier.size(8.dp))
}
if (dueToday.isNotEmpty()) {
Text(
"Vencen el día ${dayOfMonth(date)}:",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(vertical = 4.dp)) {
items(dueToday.size) { i ->
val r = dueToday[i]
AssistChip(
onClick = { applyRecurring(r) },
label = { Text("${r.description} · ${formatMoney(r.amount)}") },
)
}
}
androidx.compose.foundation.layout.Spacer(Modifier.size(4.dp))
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (isTransfer) {
AccountDropdown(
label = "Desde (origen)",
selectedId = fromAccountId,
accounts = accounts,
excludeId = toAccountId,
onSelect = { fromAccountId = it },
)
AccountDropdown(
label = "Hacia (destino)",
selectedId = toAccountId,
accounts = accounts,
excludeId = fromAccountId,
onSelect = { toAccountId = it },
)
} else {
AccountDropdown(
label = "Cuenta",
selectedId = accountId,
accounts = accounts,
excludeId = null,
onSelect = { accountId = it },
)
}
OutlinedTextField(
value = description,
onValueChange = { onDescriptionChange(it) },
modifier = Modifier.fillMaxWidth(),
label = { Text("Descripción") },
)
CategoryDropdown(
selectedId = categoryId,
categories = filteredCategories,
onSelect = {
categoryAuto = false
categoryId = it
},
)
MoneyField(
value = amount,
onValueChange = { amount = it },
label = "Monto",
modifier = Modifier.fillMaxWidth(),
withCalculator = true,
onOpenCalculator = { showCalc = true },
)
DateField(
value = date,
onDateChange = { date = it },
label = "Fecha",
modifier = Modifier.fillMaxWidth(),
)
}
}
androidx.compose.foundation.layout.Spacer(Modifier.size(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedButton(onClick = { navController.popBackStack() }, modifier = Modifier.weight(1f)) {
Text("Cancelar")
}
Button(onClick = { save() }, modifier = Modifier.weight(1f)) {
Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.padding(end = 6.dp))
Text(if (editing) "Guardar" else "Agregar")
}
}
androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 24.dp))
}
}
if (showCalc) {
CalculatorDialog(
onDismiss = { showCalc = false },
onResult = { result ->
showCalc = false
if (result != null) amount = result
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AccountDropdown(
label: String,
selectedId: Long?,
accounts: List<com.conta.app.data.model.AccountInfo>,
excludeId: Long?,
onSelect: (Long) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val selected = accounts.find { it.id == selectedId }
val options = listOf("savings", "credit_card", "time_deposit")
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
value = selected?.name ?: "",
onValueChange = {},
readOnly = true,
label = { Text(label) },
placeholder = { Text("Seleccionar") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { type ->
val typeList = accounts.filter { it.accountType == type && it.id != excludeId }
if (typeList.isNotEmpty()) {
DropdownMenuItem(
text = {
Text(
accountTypeLabel(type),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
},
enabled = false,
onClick = {},
)
typeList.forEach { account ->
DropdownMenuItem(
text = { Text("${account.name} (${formatMoney(account.balance)})") },
onClick = {
onSelect(account.id)
expanded = false
},
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CategoryDropdown(
selectedId: Long?,
categories: List<com.conta.app.data.model.Category>,
onSelect: (Long?) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val selected = categories.find { it.id == selectedId }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
value = selected?.name ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Categoría") },
placeholder = { Text("Sin categoría") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
DropdownMenuItem(
text = { Text("Sin categoría") },
onClick = {
onSelect(null)
expanded = false
},
)
categories.forEach { category ->
DropdownMenuItem(
text = {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
com.conta.app.ui.components.CategoryAvatar(
icon = category.icon,
color = category.color,
size = 20,
)
Text(category.name)
}
},
onClick = {
onSelect(category.id)
expanded = false
},
)
}
}
}
}
@@ -0,0 +1,153 @@
package com.conta.app.ui.screens.transactions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.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.Routes
import com.conta.app.ui.components.EmptyState
import com.conta.app.ui.components.LoadingScreen
import com.conta.app.ui.components.TransactionRow
import com.conta.app.ui.util.formatMoney
import com.conta.app.ui.util.transactionTypeLabel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionsScreen(viewModel: AppViewModel, navController: NavController) {
val transactions by viewModel.transactions.collectAsStateWithLifecycle()
val loading by viewModel.loading.collectAsStateWithLifecycle()
var filter by remember { mutableStateOf<String?>(null) }
val filtered = transactions
.sortedWith(compareByDescending<com.conta.app.data.model.TransactionWithAccount> { it.date }.thenByDescending { it.id })
.filter { filter == null || it.transactionType == filter }
val totalIncome = filtered.filter { it.transactionType == "income" }.sumOf { it.amount }
val totalExpense = filtered.filter { it.transactionType == "expense" }.sumOf { it.amount }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Movimientos", fontWeight = FontWeight.Bold) },
actions = {
IconButton(onClick = { viewModel.refreshDashboard() }) {
Icon(Icons.Filled.Sync, contentDescription = "Actualizar")
}
},
)
},
floatingActionButton = {
FloatingActionButton(onClick = { navController.navigate(Routes.transactionForm()) }) {
Icon(Icons.Filled.Add, contentDescription = "Nueva transacción")
}
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize(),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (filter == null) {
"Ingresos ${formatMoney(totalIncome)} · Gastos ${formatMoney(totalExpense)}"
} else {
transactionTypeLabel(filter!!)
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
}
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = filter == null,
onClick = { filter = null },
label = { Text("Todos") },
)
FilterChip(
selected = filter == "expense",
onClick = { filter = if (filter == "expense") null else "expense" },
label = { Text("Gastos") },
)
FilterChip(
selected = filter == "income",
onClick = { filter = if (filter == "income") null else "income" },
label = { Text("Ingresos") },
)
FilterChip(
selected = filter == "transfer",
onClick = { filter = if (filter == "transfer") null else "transfer" },
label = { Text("Transferencias") },
)
}
if (filtered.isEmpty() && loading) {
LoadingScreen(Modifier.weight(1f))
} else if (filtered.isEmpty()) {
EmptyState("Sin movimientos", Modifier.weight(1f))
} else {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(filtered, key = { it.id }) { tx ->
Card(
modifier = Modifier.fillMaxSize(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
TransactionRow(
tx,
showAccount = true,
onClick = {
navController.navigate(Routes.transactionForm(transactionId = tx.id))
},
)
}
}
item { androidx.compose.foundation.layout.Spacer(Modifier.padding(bottom = 80.dp)) }
}
}
}
}
}
@@ -0,0 +1,82 @@
package com.conta.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
val PrimaryLight = Color(0xFF1565C0)
val PrimaryDark = Color(0xFF90CAF9)
val Accent = Color(0xFF00796B)
val AccentDark = Color(0xFF80CBC4)
val Tertiary = Color(0xFF6A1B9A)
val TertiaryDark = Color(0xFFCE93D8)
private val LightColors = lightColorScheme(
primary = PrimaryLight,
onPrimary = Color.White,
primaryContainer = Color(0xFFD6E3FF),
onPrimaryContainer = Color(0xFF0B2A56),
secondary = Accent,
onSecondary = Color.White,
secondaryContainer = Color(0xFFB2DFDB),
onSecondaryContainer = Color(0xFF00332E),
tertiary = Tertiary,
onTertiary = Color.White,
tertiaryContainer = Color(0xFFE1BEE7),
onTertiaryContainer = Color(0xFF310742),
error = Color(0xFFD32F2F),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFF5F6F8),
onBackground = Color(0xFF191C20),
surface = Color(0xFFFAFAFA),
onSurface = Color(0xFF191C20),
surfaceVariant = Color(0xFFE7E8EC),
onSurfaceVariant = Color(0xFF43474E),
outline = Color(0xFF73777F),
outlineVariant = Color(0xFFC4C6CC),
surfaceTint = PrimaryLight,
)
private val DarkColors = darkColorScheme(
primary = PrimaryDark,
onPrimary = Color(0xFF0B2A56),
primaryContainer = Color(0xFF1E3A5F),
onPrimaryContainer = Color(0xFFD6E3FF),
secondary = AccentDark,
onSecondary = Color(0xFF00332E),
secondaryContainer = Color(0xFF00504A),
onSecondaryContainer = Color(0xFFB2DFDB),
tertiary = TertiaryDark,
onTertiary = Color(0xFF310742),
tertiaryContainer = Color(0xFF4A1584),
onTertiaryContainer = Color(0xFFE1BEE7),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF0F1216),
onBackground = Color(0xFFE2E2E6),
surface = Color(0xFF15181D),
onSurface = Color(0xFFE2E2E6),
surfaceVariant = Color(0xFF2A2E35),
onSurfaceVariant = Color(0xFFC4C6CC),
outline = Color(0xFF8E929A),
outlineVariant = Color(0xFF2A2E35),
surfaceTint = PrimaryDark,
)
@Composable
fun ContaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
content = content,
)
}
@@ -0,0 +1,30 @@
package com.conta.app.ui.util
import androidx.compose.ui.graphics.Color
/** Paleta por defecto para categorías (misma que el frontend). */
val CATEGORY_COLOR_PALETTE = listOf(
Color(0xFF1976D2), Color(0xFF7B1FA2), Color(0xFF00897B), Color(0xFFF9A825),
Color(0xFFE53935), Color(0xFF5D4037), Color(0xFF43A047), Color(0xFF6A1B9A),
Color(0xFFC2185B), Color(0xFF303F9F), Color(0xFF00ACC1), Color(0xFF7CB342),
Color(0xFFFFB300), Color(0xFFD81B60),
)
fun resolveCategoryColor(color: String?, fallback: Color): Color {
if (color.isNullOrBlank()) return fallback
// Si es un índice de la paleta (0-13), se resuelve contra la paleta.
val idx = color.toIntOrNull()
if (idx != null && idx in 0 until CATEGORY_COLOR_PALETTE.size) {
return CATEGORY_COLOR_PALETTE[idx]
}
// Si es un color hex (##RRGGBB o #RRGGBB).
val hex = color.trim().removePrefix("##").removePrefix("#")
if (hex.length == 6) {
return try {
Color(("FF$hex").toLong(16))
} catch (e: NumberFormatException) {
fallback
}
}
return fallback
}
@@ -0,0 +1,15 @@
package com.conta.app.ui.util
import android.content.Context
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
suspend fun contentUriToFile(context: Context, uri: Uri): File = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, "import_${System.currentTimeMillis()}.pdf")
context.contentResolver.openInputStream(uri)?.use { input ->
file.outputStream().use { output -> input.copyTo(output) }
} ?: throw IllegalStateException("No se pudo leer el archivo seleccionado")
file
}
@@ -0,0 +1,66 @@
package com.conta.app.ui.util
import java.text.DecimalFormat
import java.text.NumberFormat
import java.time.LocalDate
import java.time.format.TextStyle
import java.util.Locale
private val moneyFormat: NumberFormat = NumberFormat.getNumberInstance(Locale.getDefault()).apply {
minimumFractionDigits = 2
maximumFractionDigits = 2
}
private val moneyCompact: DecimalFormat = DecimalFormat("#,##0")
fun formatMoney(value: Double): String = "$${moneyFormat.format(value)}"
fun formatMoneyCompact(value: Double): String = "$${moneyCompact.format(value)}"
/** "2024-01-05" -> "05/01/2024" */
fun formatDate(dateStr: String?): String {
if (dateStr.isNullOrBlank()) return ""
val parts = dateStr.split("-")
return if (parts.size == 3) "${parts[2]}/${parts[1]}/${parts[0]}" else dateStr
}
/** "2024-01" -> "ene 2024" */
fun formatMonth(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.SHORT, Locale.getDefault())
return "$label $year"
}
/** Formatea la cantidad con el prefijo de tipo de transacción: +, -, o sin signo. */
fun signedAmount(amount: Double, type: String): String = when (type) {
"income" -> "+${formatMoney(amount)}"
"expense" -> "-${formatMoney(amount)}"
else -> formatMoney(amount)
}
fun todayIso(): String = LocalDate.now().toString()
fun parseAmount(text: String): Double? = text.trim().replace(',', '.').toDoubleOrNull()
fun accountTypeLabel(type: String): String = when (type) {
"savings" -> "Ahorro"
"credit_card" -> "Tarjeta"
"time_deposit" -> "Depósito a Plazo"
else -> type
}
fun transactionTypeLabel(type: String): String = when (type) {
"income" -> "Ingreso"
"expense" -> "Gasto"
"transfer" -> "Transferencia"
else -> type
}
fun dayOfMonth(dateIso: String): Int {
val parts = dateIso.split("-")
return parts.getOrNull(2)?.toIntOrNull() ?: LocalDate.now().dayOfMonth
}
@@ -0,0 +1,80 @@
package com.conta.app.ui.util
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBalance
import androidx.compose.material.icons.filled.AttachMoney
import androidx.compose.material.icons.filled.BeachAccess
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Business
import androidx.compose.material.icons.filled.CardGiftcard
import androidx.compose.material.icons.filled.Category
import androidx.compose.material.icons.filled.Checkroom
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material.icons.filled.DirectionsCar
import androidx.compose.material.icons.filled.ElectricBolt
import androidx.compose.material.icons.filled.Fastfood
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material.icons.filled.Flight
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.LocalGroceryStore
import androidx.compose.material.icons.filled.LocalHospital
import androidx.compose.material.icons.filled.LocalLaundryService
import androidx.compose.material.icons.filled.LocalTaxi
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Payments
import androidx.compose.material.icons.filled.Pets
import androidx.compose.material.icons.filled.PhoneIphone
import androidx.compose.material.icons.filled.Savings
import androidx.compose.material.icons.filled.School
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material.icons.filled.TrendingUp
import androidx.compose.material.icons.filled.VideogameAsset
import androidx.compose.material.icons.filled.Water
import androidx.compose.material.icons.filled.Weekend
import androidx.compose.material.icons.filled.Work
import androidx.compose.ui.graphics.vector.ImageVector
/** Iconos disponibles para las categorías (mismos nombres que el frontend). */
val CATEGORY_ICON_NAMES = listOf(
"Fastfood", "ShoppingCart", "LocalGroceryStore", "DirectionsCar", "Home",
"LocalHospital", "School", "Flight", "FitnessCenter", "PhoneIphone",
"Pets", "Build", "CardGiftcard", "Work", "AccountBalance", "TrendingUp",
"Payments", "Computer", "Checkroom", "Weekend", "Water",
"LocalLaundryService", "BeachAccess", "AttachMoney", "Savings",
"Business", "VideogameAsset", "MusicNote", "LocalTaxi", "ElectricBolt",
)
private val iconMap: Map<String, ImageVector> = mapOf(
"Fastfood" to Icons.Filled.Fastfood,
"ShoppingCart" to Icons.Filled.ShoppingCart,
"LocalGroceryStore" to Icons.Filled.LocalGroceryStore,
"DirectionsCar" to Icons.Filled.DirectionsCar,
"Home" to Icons.Filled.Home,
"LocalHospital" to Icons.Filled.LocalHospital,
"School" to Icons.Filled.School,
"Flight" to Icons.Filled.Flight,
"FitnessCenter" to Icons.Filled.FitnessCenter,
"PhoneIphone" to Icons.Filled.PhoneIphone,
"Pets" to Icons.Filled.Pets,
"Build" to Icons.Filled.Build,
"CardGiftcard" to Icons.Filled.CardGiftcard,
"Work" to Icons.Filled.Work,
"AccountBalance" to Icons.Filled.AccountBalance,
"TrendingUp" to Icons.Filled.TrendingUp,
"Payments" to Icons.Filled.Payments,
"Computer" to Icons.Filled.Computer,
"Checkroom" to Icons.Filled.Checkroom,
"Weekend" to Icons.Filled.Weekend,
"Water" to Icons.Filled.Water,
"LocalLaundryService" to Icons.Filled.LocalLaundryService,
"BeachAccess" to Icons.Filled.BeachAccess,
"AttachMoney" to Icons.Filled.AttachMoney,
"Savings" to Icons.Filled.Savings,
"Business" to Icons.Filled.Business,
"VideogameAsset" to Icons.Filled.VideogameAsset,
"MusicNote" to Icons.Filled.MusicNote,
"LocalTaxi" to Icons.Filled.LocalTaxi,
"ElectricBolt" to Icons.Filled.ElectricBolt,
)
fun categoryIcon(name: String?): ImageVector = name?.let { iconMap[it] } ?: Icons.Filled.Category
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.5"
android:scaleY="0.5"
android:pivotX="54"
android:pivotY="54">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,28 L54,80 M42,38 L66,38 M42,70 L66,70 M42,54 L66,54" />
</group>
<group android:scaleX="0.4"
android:scaleY="0.4"
android:pivotX="54"
android:pivotY="54"
android:translateX="16"
android:translateY="-14">
<path
android:fillColor="#4FC3F7"
android:pathData="M54,34c-11.05,0 -20,8.95 -20,20s8.95,20 20,20 20,-8.95 20,-20 -8.95,-20 -20,-20zM54,66c-6.63,0 -12,-5.37 -12,-12s5.37,-12 12,-12 12,5.37 12,12 -5.37,12 -12,12z" />
</group>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1E3A5F</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Conta</string>
</resources>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.ContaApp" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowBackground">@android:color/white</item>
</style>
</resources>