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