commit e55ce75c9b69b354457ad485c76f5fbcda4b68bb Author: Nino Date: Tue Aug 4 12:24:44 2026 -0600 App Conta (Android): backend de registro-contable con Compose/Material3 + .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e0dba2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# Local configuration (Sensitive) +local.properties +.env +*.properties.local + +# Keystores and signing keys +*.jks +*.keystore +*.jks.pem +*.keystore.pem + +# Android Studio / IntelliJ +.idea/ +*.iml +*.ipr +*.iws +captures/ +.externalNativeBuild/ +.cxx/ + +# Kotlin +.kotlin/ + +# Build artifacts / APKs (subir via release, no al repo) +*.apk +*.aab + +# Logs +*.log + +# OS files +.DS_Store +Thumbs.db + +# Profiling +*.hprof diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..dca25e8 --- /dev/null +++ b/app/build.gradle @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..e3737d1 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Add project specific ProGuard rules here. +-keepattributes *Annotation* diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..39ddbaa --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/conta/app/ContaApp.kt b/app/src/main/java/com/conta/app/ContaApp.kt new file mode 100644 index 0000000..0b0c2b7 --- /dev/null +++ b/app/src/main/java/com/conta/app/ContaApp.kt @@ -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) + } +} diff --git a/app/src/main/java/com/conta/app/MainActivity.kt b/app/src/main/java/com/conta/app/MainActivity.kt new file mode 100644 index 0000000..b58be97 --- /dev/null +++ b/app/src/main/java/com/conta/app/MainActivity.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/com/conta/app/data/AppRepository.kt b/app/src/main/java/com/conta/app/data/AppRepository.kt new file mode 100644 index 0000000..5c8f004 --- /dev/null +++ b/app/src/main/java/com/conta/app/data/AppRepository.kt @@ -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 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 = 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 = 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 = safe { api.listTransactions() } + suspend fun createTransaction(body: CreateTransactionRequest): Transaction = safe { api.createTransaction(body) } + suspend fun createTransfer(body: TransferRequest): List = safe { api.createTransfer(body) } + suspend fun updateTransaction(id: Long, body: UpdateTransactionRequest): Transaction = safe { api.updateTransaction(id, body) } + suspend fun updateTransactionToTransfer(id: Long, body: TransferRequest): List = + safe { api.updateTransactionToTransfer(id, body) } + suspend fun deleteTransaction(id: Long) = safe { api.deleteTransaction(id) } + + suspend fun listCategories(): List = safe { api.listCategories() } + suspend fun createCategory(body: CreateCategoryRequest): Category = safe { api.createCategory(body) } + suspend fun updateCategory(id: Long, body: UpdateCategoryRequest): Category = safe { api.updateCategory(id, body) } + suspend fun deleteCategory(id: Long) = safe { api.deleteCategory(id) } + + suspend fun listRules(): List = safe { api.listRules() } + suspend fun createRule(body: CreateCategorizationRuleRequest): CategorizationRuleCreated = safe { api.createRule(body) } + suspend fun updateRule(id: Long, body: UpdateCategorizationRuleRequest): CategorizationRule = safe { api.updateRule(id, body) } + suspend fun deleteRule(id: Long) = safe { api.deleteRule(id) } + + suspend fun getSummary(): Summary = safe { api.getSummary() } + suspend fun getStatistics(): Statistics = safe { api.getStatistics() } + + suspend fun listRecurring(): List = safe { api.listRecurring() } + suspend fun createRecurring(body: CreateRecurringTransactionRequest): RecurringTransactionWithNames = + safe { api.createRecurring(body) } + suspend fun updateRecurring(id: Long, body: UpdateRecurringTransactionRequest): RecurringTransactionWithNames = + safe { api.updateRecurring(id, body) } + suspend fun 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 = 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) diff --git a/app/src/main/java/com/conta/app/data/SettingsRepository.kt b/app/src/main/java/com/conta/app/data/SettingsRepository.kt new file mode 100644 index 0000000..97b77b7 --- /dev/null +++ b/app/src/main/java/com/conta/app/data/SettingsRepository.kt @@ -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 = context.dataStore.data.map { it[Keys.BASE_URL] ?: "" } + + suspend fun setBaseUrl(url: String) { + context.dataStore.edit { it[Keys.BASE_URL] = url.trim() } + } +} diff --git a/app/src/main/java/com/conta/app/data/model/Models.kt b/app/src/main/java/com/conta/app/data/model/Models.kt new file mode 100644 index 0000000..ac00fe3 --- /dev/null +++ b/app/src/main/java/com/conta/app/data/model/Models.kt @@ -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>. +@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 = 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 = 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 = emptyList(), + val monthly: List = 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 = emptyList(), + val categories: List = emptyList(), + val transactions: List = emptyList(), + @SerialName("categorization_rules") val categorizationRules: List = emptyList(), + @SerialName("recurring_transactions") val recurringTransactions: List = emptyList(), + @SerialName("card_state") val cardState: List = 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, +) diff --git a/app/src/main/java/com/conta/app/data/network/ApiService.kt b/app/src/main/java/com/conta/app/data/network/ApiService.kt new file mode 100644 index 0000000..fa49c5d --- /dev/null +++ b/app/src/main/java/com/conta/app/data/network/ApiService.kt @@ -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 + + @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 + + @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 + + @POST("api/transactions") + suspend fun createTransaction(@Body body: CreateTransactionRequest): Transaction + + @POST("api/transactions/transfer") + suspend fun createTransfer(@Body body: TransferRequest): List + + @PUT("api/transactions/{id}/transfer") + suspend fun updateTransactionToTransfer(@Path("id") id: Long, @Body body: TransferRequest): List + + @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 + + @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 + + @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 + + @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 + + @POST("api/notices/dismiss") + suspend fun dismissNotice(@Body body: DismissNoticeRequest) +} diff --git a/app/src/main/java/com/conta/app/data/network/NetworkModule.kt b/app/src/main/java/com/conta/app/data/network/NetworkModule.kt new file mode 100644 index 0000000..5210488 --- /dev/null +++ b/app/src/main/java/com/conta/app/data/network/NetworkModule.kt @@ -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) + } +} diff --git a/app/src/main/java/com/conta/app/ui/AppRoot.kt b/app/src/main/java/com/conta/app/ui/AppRoot.kt new file mode 100644 index 0000000..c18a8bc --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/AppRoot.kt @@ -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)) + } + } + } +} diff --git a/app/src/main/java/com/conta/app/ui/AppViewModel.kt b/app/src/main/java/com/conta/app/ui/AppViewModel.kt new file mode 100644 index 0000000..f58090e --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/AppViewModel.kt @@ -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(null) + val summary: StateFlow = _summary.asStateFlow() + + private val _statistics = MutableStateFlow(null) + val statistics: StateFlow = _statistics.asStateFlow() + + private val _accounts = MutableStateFlow>(emptyList()) + val accounts: StateFlow> = _accounts.asStateFlow() + + private val _transactions = MutableStateFlow>(emptyList()) + val transactions: StateFlow> = _transactions.asStateFlow() + + private val _categories = MutableStateFlow>(emptyList()) + val categories: StateFlow> = _categories.asStateFlow() + + private val _rules = MutableStateFlow>(emptyList()) + val rules: StateFlow> = _rules.asStateFlow() + + private val _recurring = MutableStateFlow>(emptyList()) + val recurring: StateFlow> = _recurring.asStateFlow() + + private val _notices = MutableStateFlow>(emptyList()) + val notices: StateFlow> = _notices.asStateFlow() + + private val _loading = MutableStateFlow(false) + val loading: StateFlow = _loading.asStateFlow() + + private val _busy = MutableStateFlow(false) + val busy: StateFlow = _busy.asStateFlow() + + private val _message = MutableStateFlow(null) + val message: StateFlow = _message.asStateFlow() + + fun consumeMessage() { + _message.value = null + } + + fun showMessage(msg: String) { + _message.value = msg + } + + private suspend fun 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() + } + } +} diff --git a/app/src/main/java/com/conta/app/ui/components/CalculatorDialog.kt b/app/src/main/java/com/conta/app/ui/components/CalculatorDialog.kt new file mode 100644 index 0000000..ee4ec40 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/components/CalculatorDialog.kt @@ -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(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, + ) + } +} diff --git a/app/src/main/java/com/conta/app/ui/components/CategoryLabel.kt b/app/src/main/java/com/conta/app/ui/components/CategoryLabel.kt new file mode 100644 index 0000000..689cb7a --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/components/CategoryLabel.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/conta/app/ui/components/Charts.kt b/app/src/main/java/com/conta/app/ui/components/Charts.kt new file mode 100644 index 0000000..3517a30 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/components/Charts.kt @@ -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, 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, 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), + ), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/conta/app/ui/components/Common.kt b/app/src/main/java/com/conta/app/ui/components/Common.kt new file mode 100644 index 0000000..b9a09b8 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/components/Common.kt @@ -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), + ) +} diff --git a/app/src/main/java/com/conta/app/ui/components/FormFields.kt b/app/src/main/java/com/conta/app/ui/components/FormFields.kt new file mode 100644 index 0000000..e86c4ae --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/components/FormFields.kt @@ -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 + } +} diff --git a/app/src/main/java/com/conta/app/ui/components/TransactionRow.kt b/app/src/main/java/com/conta/app/ui/components/TransactionRow.kt new file mode 100644 index 0000000..83bc417 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/components/TransactionRow.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/accounts/AccountDetailScreen.kt b/app/src/main/java/com/conta/app/ui/screens/accounts/AccountDetailScreen.kt new file mode 100644 index 0000000..1f01415 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/accounts/AccountDetailScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/accounts/AccountFormScreen.kt b/app/src/main/java/com/conta/app/ui/screens/accounts/AccountFormScreen.kt new file mode 100644 index 0000000..37d4f02 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/accounts/AccountFormScreen.kt @@ -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(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, + 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 + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/accounts/AccountsScreen.kt b/app/src/main/java/com/conta/app/ui/screens/accounts/AccountsScreen.kt new file mode 100644 index 0000000..faf693d --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/accounts/AccountsScreen.kt @@ -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() + if (account.creditAvailable != null) { + parts += "Disponible ${formatMoney(account.creditAvailable)}" + } + parts += "Deuda ${formatMoney(account.debt)}" + parts.joinToString(" · ") + } + "time_deposit" -> { + val parts = mutableListOf() + if (account.interestRate != null) parts += "${account.interestRate}%" + if (!account.maturityDate.isNullOrBlank()) parts += "Vence ${account.maturityDate}" + parts.joinToString(" · ") + } + else -> { + val parts = mutableListOf() + 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, + ) + } + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/categories/CategoriesScreen.kt b/app/src/main/java/com/conta/app/ui/screens/categories/CategoriesScreen.kt new file mode 100644 index 0000000..b7ce79c --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/categories/CategoriesScreen.kt @@ -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(null) } + var deleting by remember { mutableStateOf(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 }, + ) + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/categories/CategoryFormScreen.kt b/app/src/main/java/com/conta/app/ui/screens/categories/CategoryFormScreen.kt new file mode 100644 index 0000000..509cae4 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/categories/CategoryFormScreen.kt @@ -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(null) } + var color by remember { mutableStateOf(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 + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/com/conta/app/ui/screens/dashboard/DashboardScreen.kt new file mode 100644 index 0000000..ab40c07 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/dashboard/DashboardScreen.kt @@ -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, + recentTransactions: List, + 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, 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") + } + } + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/recurring/RecurringFormScreen.kt b/app/src/main/java/com/conta/app/ui/screens/recurring/RecurringFormScreen.kt new file mode 100644 index 0000000..a00d1f5 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/recurring/RecurringFormScreen.kt @@ -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(null) } + var description by remember { mutableStateOf("") } + var amount by remember { mutableStateOf("") } + var dayOfMonth by remember { mutableStateOf("") } + var categoryId by remember { mutableStateOf(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, + 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, + 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 + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/recurring/RecurringScreen.kt b/app/src/main/java/com/conta/app/ui/screens/recurring/RecurringScreen.kt new file mode 100644 index 0000000..e97f94d --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/recurring/RecurringScreen.kt @@ -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(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 }, + ) + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/rules/RuleFormScreen.kt b/app/src/main/java/com/conta/app/ui/screens/rules/RuleFormScreen.kt new file mode 100644 index 0000000..344e7e7 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/rules/RuleFormScreen.kt @@ -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(null) } + var initialized by remember { mutableStateOf(false) } + var resultMessage by remember { mutableStateOf(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, + 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 + }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/rules/RulesScreen.kt b/app/src/main/java/com/conta/app/ui/screens/rules/RulesScreen.kt new file mode 100644 index 0000000..21f9bf1 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/rules/RulesScreen.kt @@ -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(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 }, + ) + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/settings/BackupScreen.kt b/app/src/main/java/com/conta/app/ui/screens/settings/BackupScreen.kt new file mode 100644 index 0000000..daba5fe --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/settings/BackupScreen.kt @@ -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(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(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") +} diff --git a/app/src/main/java/com/conta/app/ui/screens/settings/NtfyScreen.kt b/app/src/main/java/com/conta/app/ui/screens/settings/NtfyScreen.kt new file mode 100644 index 0000000..7a700ac --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/settings/NtfyScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/conta/app/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..cedfef0 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/settings/SettingsScreen.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/transactions/TransactionFormScreen.kt b/app/src/main/java/com/conta/app/ui/screens/transactions/TransactionFormScreen.kt new file mode 100644 index 0000000..17800e7 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/transactions/TransactionFormScreen.kt @@ -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(null) } + var fromAccountId by remember { mutableStateOf(null) } + var toAccountId by remember { mutableStateOf(null) } + var amount by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var date by remember { mutableStateOf(todayIso()) } + var categoryId by remember { mutableStateOf(null) } + var appliedRecurringId by remember { mutableStateOf(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, + 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, + 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 + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/conta/app/ui/screens/transactions/TransactionsScreen.kt b/app/src/main/java/com/conta/app/ui/screens/transactions/TransactionsScreen.kt new file mode 100644 index 0000000..47e7fb4 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/screens/transactions/TransactionsScreen.kt @@ -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(null) } + + val filtered = transactions + .sortedWith(compareByDescending { 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)) } + } + } + } + } +} diff --git a/app/src/main/java/com/conta/app/ui/theme/Theme.kt b/app/src/main/java/com/conta/app/ui/theme/Theme.kt new file mode 100644 index 0000000..3d92496 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/theme/Theme.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/conta/app/ui/util/CategoryColors.kt b/app/src/main/java/com/conta/app/ui/util/CategoryColors.kt new file mode 100644 index 0000000..902cd29 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/util/CategoryColors.kt @@ -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 +} diff --git a/app/src/main/java/com/conta/app/ui/util/Files.kt b/app/src/main/java/com/conta/app/ui/util/Files.kt new file mode 100644 index 0000000..0591e2c --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/util/Files.kt @@ -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 +} diff --git a/app/src/main/java/com/conta/app/ui/util/Formatters.kt b/app/src/main/java/com/conta/app/ui/util/Formatters.kt new file mode 100644 index 0000000..4b86140 --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/util/Formatters.kt @@ -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 +} diff --git a/app/src/main/java/com/conta/app/ui/util/Icons.kt b/app/src/main/java/com/conta/app/ui/util/Icons.kt new file mode 100644 index 0000000..126a93f --- /dev/null +++ b/app/src/main/java/com/conta/app/ui/util/Icons.kt @@ -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 = 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 diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..481624d --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..4a548bf Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..22c4125 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..56bf2d8 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..f4d5b35 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..07b89e0 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..abfb877 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #1E3A5F + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d02d94c --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Conta + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..376254e --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..b2cf666 --- /dev/null +++ b/build.gradle @@ -0,0 +1,6 @@ +plugins { + id "com.android.application" version "8.5.2" apply false + id "org.jetbrains.kotlin.android" version "2.0.21" apply false + id "org.jetbrains.kotlin.plugin.compose" version "2.0.21" apply false + id "org.jetbrains.kotlin.plugin.serialization" version "2.0.21" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8f2e28c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..eb4597f --- /dev/null +++ b/settings.gradle @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Conta-app" +include ":app"