App Conta (Android): backend de registro-contable con Compose/Material3 + .gitignore
This commit is contained in:
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Local configuration (Sensitive)
|
||||||
|
local.properties
|
||||||
|
.env
|
||||||
|
*.properties.local
|
||||||
|
|
||||||
|
# Keystores and signing keys
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
*.jks.pem
|
||||||
|
*.keystore.pem
|
||||||
|
|
||||||
|
# Android Studio / IntelliJ
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Kotlin
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# Build artifacts / APKs (subir via release, no al repo)
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Profiling
|
||||||
|
*.hprof
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
id "com.android.application" version "8.5.2" apply false
|
||||||
|
id "org.jetbrains.kotlin.android" version "2.0.21" apply false
|
||||||
|
id "org.jetbrains.kotlin.plugin.compose" version "2.0.21" apply false
|
||||||
|
id "org.jetbrains.kotlin.plugin.serialization" version "2.0.21" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Conta-app"
|
||||||
|
include ":app"
|
||||||
Reference in New Issue
Block a user