diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..6d6ca27 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,40 @@ +name: Android CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.9' + - name: Build debug + unit tests + # Usa ./gradlew si el repo tiene wrapper; si no, el gradle instalado por setup-gradle. + run: | + if [ -x ./gradlew ]; then G=./gradlew; else G=gradle; fi + $G assembleDebug testDebugUnitTest --stacktrace + - name: Test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: unit-test-reports + path: app/build/reports/tests/ + - name: Debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: app/build/outputs/apk/debug/*.apk diff --git a/README.md b/README.md index fae87e5..0b1afe8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# SpreadVE +# Codivisas App Android 100% nativa (Kotlin + Jetpack Compose) que muestra las tasas oficiales del Banco Central de Venezuela (BCV) y Binance P2P USDT/VES, e incluye convertidor, widgets, notificaciones y utilidades de pago. @@ -9,12 +9,12 @@ App Android 100% nativa (Kotlin + Jetpack Compose) que muestra las tasas oficial - Android SDK 35 - Dispositivo/emulador con API 26+ -## Cómo abrir y ejecutar +## Cómo abrir y ejecutar 1. Clona o abre esta carpeta en Android Studio. 2. Espera a que Gradle sincronice las dependencias. 3. Conecta un dispositivo o inicia un emulador. -4. Pulsa **Run** (▶) sobre el módulo `app`. +4. Pulsa **Run** (â–¶) sobre el módulo `app`. Desde terminal (con Gradle wrapper): @@ -25,7 +25,7 @@ Desde terminal (con Gradle wrapper): ## Fuentes de datos -| Mercado | Fuente | API | Autenticación | +| Mercado | Fuente | API | Autenticación | |---------|--------|-----|---------------| | BCV oficial | bcv.today | `GET https://bcv.today/api/v1/rate.json` | No | | Binance P2P | Binance (interno) | `POST https://p2p.binance.com/bapi/c2c/v2/friendly/c2c/adv/search` | No | @@ -38,20 +38,20 @@ Edita `app/src/main/java/com/codeonyx/bcvaqui/data/ApiConfig.kt`: ```kotlin const val BCV_RATE_URL = "https://bcv.today/api/v1/" -const val WIDGET_UPDATE_INTERVAL_MINUTES = 30L // mínimo WorkManager: 15 +const val WIDGET_UPDATE_INTERVAL_MINUTES = 30L // mínimo WorkManager: 15 const val DEFAULT_BACKGROUND_INTERVAL_HOURS = 4L ``` -Los intervalos de notificación se configuran desde **Ajustes** en la app (1, 2, 4, 6, 12, 24 h o desactivado). +Los intervalos de notificación se configuran desde **Ajustes** en la app (1, 2, 4, 6, 12, 24 h o desactivado). ## Funcionalidades ### Pantalla principal - Tasa grande con 2 decimales (`1 USD = 785.07 VES`) - Selector de moneda (USD, EUR, CNY, TRY, RUB) -- Botón **Copiar tasa** +- Botón **Copiar tasa** - Convertidor entre monedas BCV y VES -- Mensaje amigable sin internet + última tasa en caché +- Mensaje amigable sin internet + última tasa en caché ### Widget - Muestra la moneda preferida (`USD: 785.07 VES`) @@ -59,31 +59,31 @@ Los intervalos de notificación se configuran desde **Ajustes** en la app (1, 2, - Tap abre la app ### Notificaciones -- Cambios de tasa (subió/bajó) con valor anterior y nuevo -- Alertas de desviación configurable (ej. ±5% vs BCV) +- Cambios de tasa (subió/bajó) con valor anterior y nuevo +- Alertas de desviación configurable (ej. ±5% vs BCV) - WorkManager en segundo plano ## Estructura del proyecto ``` app/src/main/java/com/codeonyx/bcvaqui/ -├── data/ -│ ├── ApiConfig.kt # URLs e intervalos -│ ├── api/ # Retrofit clients -│ ├── model/ # DTOs y modelos -│ ├── local/ # DataStore -│ └── repository/ # Capa de datos -├── domain/FxCalculator.kt # Conversión y cálculos -├── ui/ # Compose screens -├── widget/ # Home screen widget -├── worker/ # WorkManager -└── notification/ # Notificaciones locales +├── data/ +│ ├── ApiConfig.kt # URLs e intervalos +│ ├── api/ # Retrofit clients +│ ├── model/ # DTOs y modelos +│ ├── local/ # DataStore +│ └── repository/ # Capa de datos +├── domain/FxCalculator.kt # Conversión y cálculos +├── ui/ # Compose screens +├── widget/ # Home screen widget +├── worker/ # WorkManager +└── notification/ # Notificaciones locales ``` ## Notas legales -Las tasas BCV provienen de fuentes públicas que reflejan datos del Banco Central de Venezuela. Binance P2P es referencia de mercado, **no oficial**. Esta app es informativa; verifica siempre antes de operaciones financieras. +Las tasas BCV provienen de fuentes públicas que reflejan datos del Banco Central de Venezuela. Binance P2P es referencia de mercado, **no oficial**. Esta app es informativa; verifica siempre antes de operaciones financieras. ## Licencia -Proyecto personal — libre para modificar y usar. +Proyecto personal — libre para modificar y usar. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 85701a3..b924596 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,13 +25,13 @@ android { applicationId = "com.codeonyx.bcvaqui" minSdk = 26 targetSdk = 35 - versionCode = 6 - versionName = "1.3.0" + versionCode = 7 + versionName = "1.3.1" } signingConfigs { - create("release") { - if (keystorePropertiesFile.exists()) { + if (keystorePropertiesFile.exists()) { + create("release") { storeFile = rootProject.file(prop("storeFile")) storePassword = prop("storePassword") keyAlias = prop("keyAlias") @@ -43,11 +43,9 @@ android { buildTypes { release { isMinifyEnabled = true - signingConfig = if (keystorePropertiesFile.exists()) { - signingConfigs.getByName("release") - } else { - signingConfigs.getByName("debug") - } + // Nunca firmar el release con la clave de debug: ese APK no podría + // actualizar el que los usuarios ya tienen instalado. + signingConfig = signingConfigs.findByName("release") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -76,6 +74,19 @@ android { } } +// Falla con un mensaje claro si se intenta empaquetar el release sin keystore. +gradle.taskGraph.whenReady { + val buildsRelease = allTasks.any { task -> + task.project == project && Regex("^(assemble|bundle|package|sign)Release.*").matches(task.name) + } + if (buildsRelease && !keystorePropertiesFile.exists()) { + throw GradleException( + "Falta keystore.properties en la raíz del proyecto. El release no se firma con la clave " + + "de debug. Copia keystore.properties.example, complétalo y vuelve a compilar." + ) + } +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -100,4 +111,6 @@ dependencies { implementation(libs.androidx.splashscreen) debugImplementation(libs.androidx.compose.ui.tooling.preview) + + testImplementation("junit:junit:4.13.2") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 34498a0..cc7ddff 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,8 @@ + android:theme="@style/Theme.BcvAqui.Splash" /> + + + - + - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/java/com/codeonyx/bcvaqui/BcvAquiApp.kt b/app/src/main/java/com/codeonyx/bcvaqui/BcvAquiApp.kt index 134e96e..5ecdc67 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/BcvAquiApp.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/BcvAquiApp.kt @@ -11,7 +11,6 @@ class BcvAquiApp : Application() { super.onCreate() createNotificationChannels() WorkScheduler.scheduleAllBackgroundWork(this) - WorkScheduler.enqueueImmediateRefresh(this) } private fun createNotificationChannels() { diff --git a/app/src/main/java/com/codeonyx/bcvaqui/MainActivity.kt b/app/src/main/java/com/codeonyx/bcvaqui/MainActivity.kt index 8dee23a..2862718 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/MainActivity.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/MainActivity.kt @@ -4,24 +4,69 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.SystemBarStyle +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.codeonyx.bcvaqui.data.local.ThemeModes +import com.codeonyx.bcvaqui.ui.appViewModel import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.lifecycleScope import com.codeonyx.bcvaqui.ui.BcvAquiNavHost import com.codeonyx.bcvaqui.ui.theme.BcvAquiTheme import com.codeonyx.bcvaqui.update.AppUpdateManager +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + // Paleta leída de forma síncrona: tema del splash y colores listos antes del primer frame. + val startPalette = com.codeonyx.bcvaqui.ui.theme.PaletteStore.read(applicationContext) + com.codeonyx.bcvaqui.ui.theme.ActivePalette.current = startPalette + setTheme(com.codeonyx.bcvaqui.ui.theme.PaletteManager.splashTheme(startPalette)) installSplashScreen() super.onCreate(savedInstanceState) enableEdgeToEdge() AppUpdateManager.resumePendingInstall(this) + lifecycleScope.launch { + // Ícono/splash acordes a la paleta guardada (p. ej. tras importar un respaldo). + val saved = com.codeonyx.bcvaqui.domain.AppPalette.from( + com.codeonyx.bcvaqui.data.local.PreferencesRepository(applicationContext).preferences.first().palette + ) + if (saved != com.codeonyx.bcvaqui.ui.theme.ActivePalette.current) { + com.codeonyx.bcvaqui.ui.theme.PaletteStore.write(applicationContext, saved) + } + com.codeonyx.bcvaqui.ui.theme.PaletteManager.sync(this@MainActivity, saved) + } lifecycleScope.launch { AppUpdateManager.checkForUpdate(this@MainActivity, notifyIfAvailable = true) } setContent { - BcvAquiTheme { + val viewModel = appViewModel() + val prefs by viewModel.preferences.collectAsStateWithLifecycle() + val systemDark = isSystemInDarkTheme() + val dark = when (prefs.themeMode) { + ThemeModes.LIGHT -> false + ThemeModes.DARK -> true + else -> systemDark + } + DisposableEffect(dark) { + val style = if (dark) { + SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) + } else { + SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT) + } + // La barra superior es azul en ambos temas: iconos de estado claros. + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), + navigationBarStyle = style + ) + onDispose { } + } + // Fuente inmediata (SharedPreferences), no el valor por defecto de DataStore al arrancar. + val palette = com.codeonyx.bcvaqui.ui.theme.ActivePalette.current + BcvAquiTheme(darkTheme = dark, palette = palette) { BcvAquiNavHost() } } diff --git a/app/src/main/java/com/codeonyx/bcvaqui/data/ApiConfig.kt b/app/src/main/java/com/codeonyx/bcvaqui/data/ApiConfig.kt index 95ba1ae..9631723 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/data/ApiConfig.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/data/ApiConfig.kt @@ -5,6 +5,8 @@ package com.codeonyx.bcvaqui.data */ object ApiConfig { const val BCV_RATE_URL = "https://bcv.today/api/v1/" + /** Respaldo de la tasa oficial BCV si bcv.today falla. */ + const val DOLAR_API_URL = "https://ve.dolarapi.com/" const val WORLD_FX_URL = "https://open.er-api.com/" const val BINANCE_P2P_URL = "https://p2p.binance.com/" const val WIDGET_UPDATE_INTERVAL_MINUTES = 30L @@ -12,7 +14,10 @@ object ApiConfig { const val HTTP_TIMEOUT_SECONDS = 30L /** Montos rápidos del widget convertidor (USD/EUR → Bs.). */ val WIDGET_CONVERT_PRESETS = listOf(50.0, 100.0, 200.0, 500.0) - /** Manifiesto de actualización (raw en GitHub). Actualizar en cada release. */ - const val UPDATE_MANIFEST_URL = + /** Manifiesto de actualización, servido desde codeonyx.online. Actualizar en cada release. */ + const val UPDATE_MANIFEST_URL = "https://codeonyx.online/codivisas/update.json" + /** Respaldo en GitHub mientras el repositorio siga público. */ + const val UPDATE_MANIFEST_FALLBACK_URL = "https://raw.githubusercontent.com/codeonyx-dev/spread-ve/main/update.json" + val UPDATE_MANIFEST_URLS = listOf(UPDATE_MANIFEST_URL, UPDATE_MANIFEST_FALLBACK_URL) } diff --git a/app/src/main/java/com/codeonyx/bcvaqui/data/api/FxApis.kt b/app/src/main/java/com/codeonyx/bcvaqui/data/api/FxApis.kt index 980fbc8..bbddc53 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/data/api/FxApis.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/data/api/FxApis.kt @@ -4,6 +4,8 @@ import com.codeonyx.bcvaqui.data.model.BcvRateResponse import com.codeonyx.bcvaqui.data.model.BinanceP2pResponse import com.codeonyx.bcvaqui.data.model.BinanceSearchRequest import com.codeonyx.bcvaqui.data.model.WorldRatesResponse +import com.codeonyx.bcvaqui.data.model.DolarApiQuote +import com.codeonyx.bcvaqui.data.model.DolarApiHistoryEntry import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST @@ -25,3 +27,20 @@ interface BinanceP2pApi { @POST("bapi/c2c/v2/friendly/c2c/adv/search") suspend fun searchAds(@Body body: BinanceSearchRequest): BinanceP2pResponse } + +/** ve.dolarapi.com: respaldo de la tasa oficial (USD y EUR). */ +interface DolarApi { + @GET("v1/dolares/oficial") + suspend fun getUsdOfficial(): DolarApiQuote + + @GET("v1/euros/oficial") + suspend fun getEurOfficial(): DolarApiQuote + + /** Histórico diario oficial; suele incluir la tasa del próximo día hábil ya publicada. */ + @GET("v1/historicos/dolares/oficial") + suspend fun getOfficialHistory(): List + + /** Histórico diario del promedio paralelo (referencia de mercado). */ + @GET("v1/historicos/dolares/paralelo") + suspend fun getParallelHistory(): List +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/data/api/NetworkModule.kt b/app/src/main/java/com/codeonyx/bcvaqui/data/api/NetworkModule.kt index cba096e..783f842 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/data/api/NetworkModule.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/data/api/NetworkModule.kt @@ -38,7 +38,7 @@ object NetworkModule { .addInterceptor { chain -> chain.proceed( chain.request().newBuilder() - .header("User-Agent", "SpreadVE/1.0 Android") + .header("User-Agent", "Codivisas/1.0 Android") .header("Accept", "application/json") .header("Cache-Control", "no-cache") .build() @@ -93,4 +93,13 @@ object NetworkModule { .build() .create(BinanceP2pApi::class.java) } + + val dolarApi: DolarApi by lazy { + Retrofit.Builder() + .baseUrl(ApiConfig.DOLAR_API_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(DolarApi::class.java) + } } diff --git a/app/src/main/java/com/codeonyx/bcvaqui/data/local/BackupManager.kt b/app/src/main/java/com/codeonyx/bcvaqui/data/local/BackupManager.kt new file mode 100644 index 0000000..a5df089 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/data/local/BackupManager.kt @@ -0,0 +1,54 @@ +package com.codeonyx.bcvaqui.data.local + +import android.content.Context +import com.codeonyx.bcvaqui.data.model.PaymentContact +import com.codeonyx.bcvaqui.domain.Debt +import com.codeonyx.bcvaqui.domain.SavingsGoal +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class SpreadBackup( + val version: Int = 1, + val createdAt: Long = 0, + val debts: List = emptyList(), + val goals: List = emptyList(), + val paymentContacts: List = emptyList(), + /** Paleta de colores (AppPalette.name). Null en respaldos antiguos: no se cambia. */ + val palette: String? = null +) + +/** Respaldo local en JSON (deudas, ahorros, métodos de pago y paleta) vía selector de archivos. */ +object BackupManager { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true; prettyPrint = true } + + suspend fun export(context: Context): String { + val fin = FinanceRepository(context) + val prefs = PreferencesRepository(context).preferences.first() + return json.encodeToString( + SpreadBackup.serializer(), + SpreadBackup(2, System.currentTimeMillis(), fin.debts.first(), fin.goals.first(), prefs.paymentContacts, prefs.palette) + ) + } + + /** Devuelve un resumen legible o lanza si el archivo no es válido. */ + suspend fun import(context: Context, raw: String): String { + val b = json.decodeFromString(SpreadBackup.serializer(), raw) + val fin = FinanceRepository(context) + fin.updateDebts { b.debts } + fin.updateGoals { b.goals } + PreferencesRepository(context).setPaymentContacts(b.paymentContacts) + val palette = b.palette?.let { com.codeonyx.bcvaqui.domain.AppPalette.entries.firstOrNull { p -> p.name == it } } + if (palette != null) { + PreferencesRepository(context).setPalette(palette.name) + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { + com.codeonyx.bcvaqui.ui.theme.PaletteStore.write(context, palette) + } + com.codeonyx.bcvaqui.ui.theme.PaletteManager.applyLauncherIcon(context, palette) + com.codeonyx.bcvaqui.widget.Widgets.updateAll(context) + } + val extra = palette?.let { " · paleta ${it.label}" } ?: "" + return "${b.debts.size} deudas, ${b.goals.size} metas y ${b.paymentContacts.size} métodos de pago$extra" + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/data/local/FinanceRepository.kt b/app/src/main/java/com/codeonyx/bcvaqui/data/local/FinanceRepository.kt new file mode 100644 index 0000000..c4696f4 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/data/local/FinanceRepository.kt @@ -0,0 +1,49 @@ +package com.codeonyx.bcvaqui.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.codeonyx.bcvaqui.domain.Debt +import com.codeonyx.bcvaqui.domain.SavingsGoal +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json + +/** Deudas y ahorros: DataStore local (en datastore/, excluido del respaldo). */ +private val Context.financeStore: DataStore by preferencesDataStore(name = "spreadve_finanzas") + +class FinanceRepository(private val context: Context) { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private val debtSer = ListSerializer(Debt.serializer()) + private val goalSer = ListSerializer(SavingsGoal.serializer()) + private val DEBTS = stringPreferencesKey("debts_json") + private val GOALS = stringPreferencesKey("goals_json") + private val LAST_REMIND = androidx.datastore.preferences.core.longPreferencesKey("last_reminder_day") + + val debts: Flow> = context.financeStore.data.map { decode(it[DEBTS], debtSer) } + val goals: Flow> = context.financeStore.data.map { decode(it[GOALS], goalSer) } + + private fun decode(raw: String?, ser: kotlinx.serialization.KSerializer>): List = + raw?.let { runCatching { json.decodeFromString(ser, it) }.getOrNull() }.orEmpty() + + suspend fun updateDebts(transform: (List) -> List) { + context.financeStore.edit { it[DEBTS] = json.encodeToString(debtSer, transform(decode(it[DEBTS], debtSer))) } + } + + suspend fun updateGoals(transform: (List) -> List) { + context.financeStore.edit { it[GOALS] = json.encodeToString(goalSer, transform(decode(it[GOALS], goalSer))) } + } + + /** Devuelve true (y marca el día) solo la primera vez que se llama en [epochDay]. */ + suspend fun claimReminderDay(epochDay: Long): Boolean { + var claimed = false + context.financeStore.edit { + if (it[LAST_REMIND] != epochDay) { it[LAST_REMIND] = epochDay; claimed = true } + } + return claimed + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/data/local/HistoryDiskCache.kt b/app/src/main/java/com/codeonyx/bcvaqui/data/local/HistoryDiskCache.kt new file mode 100644 index 0000000..b481777 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/data/local/HistoryDiskCache.kt @@ -0,0 +1,37 @@ +package com.codeonyx.bcvaqui.data.local + +import com.codeonyx.bcvaqui.data.model.BcvRateResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import java.io.File + +/** Guarda el histórico BCV en disco para verlo sin conexión (máx. [MAX_ENTRIES] días). */ +class HistoryDiskCache(private val file: File) { + private val json = Json { ignoreUnknownKeys = true } + private val serializer = ListSerializer(BcvRateResponse.serializer()) + + suspend fun read(): List = withContext(Dispatchers.IO) { + runCatching { + if (!file.exists()) emptyList() else json.decodeFromString(serializer, file.readText()) + }.getOrDefault(emptyList()) + } + + suspend fun write(entries: List) = withContext(Dispatchers.IO) { + runCatching { + file.parentFile?.mkdirs() + val tmp = File(file.parentFile, file.name + ".tmp") + tmp.writeText(json.encodeToString(serializer, entries.takeLast(MAX_ENTRIES))) + if (!tmp.renameTo(file)) { + file.delete() + tmp.renameTo(file) + } + } + Unit + } + + companion object { + const val MAX_ENTRIES = 400 + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/data/local/PreferencesRepository.kt b/app/src/main/java/com/codeonyx/bcvaqui/data/local/PreferencesRepository.kt index d4d8b63..4ab2cba 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/data/local/PreferencesRepository.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/data/local/PreferencesRepository.kt @@ -45,12 +45,46 @@ data class UserPreferences( val alertThresholdPercent: Double = 1.0, val widgetConvertAmount: Double = 100.0, /** Empieza en true para evitar un parpadeo antes de que DataStore cargue. */ - val hasSeenOnboarding: Boolean = true + val hasSeenOnboarding: Boolean = true, + val merchantMarginPercent: Double = 0.0, + /** "system", "light" o "dark". */ + val themeMode: String = ThemeModes.SYSTEM, + /** Operaciones recientes de la calculadora (más reciente primero). */ + val calculatorHistory: List = emptyList(), + val notifyBcvUsd: Boolean = true, + val notifyBcvEur: Boolean = false, + val notifyUsdt: Boolean = false, + val quietHoursEnabled: Boolean = false, + val quietStartHour: Int = 22, + val quietEndHour: Int = 7, + /** Avisar deudas próximas o vencidas. */ + val debtReminders: Boolean = true, + val autoUpdateCheck: Boolean = true, + val liteMode: Boolean = false, + /** Última carga exitosa de todas las fuentes (JSON de RatesSnapshot). */ + val ratesSnapshotJson: String? = null, + /** true si el último intento de actualizar por red falló (modo sin conexión). */ + val lastRefreshFailed: Boolean = false, + /** Paleta de colores elegida (ver AppPalette). */ + val palette: String = "AZUL" ) +object ThemeModes { + const val SYSTEM = "system" + const val LIGHT = "light" + const val DARK = "dark" +} + +private const val HISTORY_SEPARATOR = "\u001F" +private const val MAX_CALC_HISTORY = 10 + class PreferencesRepository(private val context: Context) { private val json = Json { ignoreUnknownKeys = true } + /** Caché en disco del histórico BCV (filesDir, excluido del respaldo como el resto de datos). */ + fun historyDiskCache(): HistoryDiskCache = + HistoryDiskCache(java.io.File(context.filesDir, "bcv_history.json")) + private object Keys { val SELECTED_CURRENCY = stringPreferencesKey("selected_currency") val NOTIFICATION_INTERVAL = intPreferencesKey("notification_interval_hours") @@ -68,6 +102,22 @@ class PreferencesRepository(private val context: Context) { val ALERT_THRESHOLD = doublePreferencesKey("alert_threshold_percent") val WIDGET_CONVERT_AMOUNT = doublePreferencesKey("widget_convert_amount") val HAS_SEEN_ONBOARDING = booleanPreferencesKey("has_seen_onboarding") + val THEME_MODE = stringPreferencesKey("theme_mode") + val CALC_HISTORY = stringPreferencesKey("calculator_history") + val MERCHANT_MARGIN = doublePreferencesKey("merchant_margin_percent") + val NOTIFY_USD = booleanPreferencesKey("notify_bcv_usd") + val NOTIFY_EUR = booleanPreferencesKey("notify_bcv_eur") + val NOTIFY_USDT = booleanPreferencesKey("notify_usdt") + val QUIET_ON = booleanPreferencesKey("quiet_hours_enabled") + val QUIET_START = intPreferencesKey("quiet_start_hour") + val QUIET_END = intPreferencesKey("quiet_end_hour") + val NOTIFY_BASELINES = stringPreferencesKey("notify_baselines_json") + val DEBT_REMINDERS = booleanPreferencesKey("debt_reminders") + val AUTO_UPDATE = booleanPreferencesKey("auto_update_check") + val LITE_MODE = booleanPreferencesKey("lite_mode") + val RATES_SNAPSHOT = stringPreferencesKey("rates_snapshot_json") + val REFRESH_FAILED = booleanPreferencesKey("last_refresh_failed") + val PALETTE = stringPreferencesKey("app_palette") } val preferences: Flow = context.dataStore.data.map { prefs -> @@ -94,10 +144,82 @@ class PreferencesRepository(private val context: Context) { variationTodayRate = prefs[Keys.VARIATION_TODAY], alertThresholdPercent = prefs[Keys.ALERT_THRESHOLD] ?: 1.0, widgetConvertAmount = prefs[Keys.WIDGET_CONVERT_AMOUNT] ?: 100.0, - hasSeenOnboarding = prefs[Keys.HAS_SEEN_ONBOARDING] ?: false + hasSeenOnboarding = prefs[Keys.HAS_SEEN_ONBOARDING] ?: false, + merchantMarginPercent = prefs[Keys.MERCHANT_MARGIN] ?: 0.0, + themeMode = prefs[Keys.THEME_MODE] ?: ThemeModes.SYSTEM, + calculatorHistory = prefs[Keys.CALC_HISTORY] + ?.split(HISTORY_SEPARATOR) + ?.filter { it.isNotBlank() } + .orEmpty(), + notifyBcvUsd = prefs[Keys.NOTIFY_USD] ?: true, + notifyBcvEur = prefs[Keys.NOTIFY_EUR] ?: false, + notifyUsdt = prefs[Keys.NOTIFY_USDT] ?: false, + quietHoursEnabled = prefs[Keys.QUIET_ON] ?: false, + quietStartHour = prefs[Keys.QUIET_START] ?: 22, + quietEndHour = prefs[Keys.QUIET_END] ?: 7, + debtReminders = prefs[Keys.DEBT_REMINDERS] ?: true, + autoUpdateCheck = prefs[Keys.AUTO_UPDATE] ?: true, + liteMode = prefs[Keys.LITE_MODE] ?: false, + ratesSnapshotJson = prefs[Keys.RATES_SNAPSHOT], + lastRefreshFailed = prefs[Keys.REFRESH_FAILED] ?: false, + palette = prefs[Keys.PALETTE] ?: "AZUL" ) } + suspend fun setNotifySource(key: String, on: Boolean) { + context.dataStore.edit { + when (key) { + "BCV_USD" -> it[Keys.NOTIFY_USD] = on + "BCV_EUR" -> it[Keys.NOTIFY_EUR] = on + "USDT" -> it[Keys.NOTIFY_USDT] = on + } + } + } + + suspend fun setQuietHours(enabled: Boolean, start: Int, end: Int) { + context.dataStore.edit { it[Keys.QUIET_ON] = enabled; it[Keys.QUIET_START] = start; it[Keys.QUIET_END] = end } + } + + suspend fun setDebtReminders(on: Boolean) { context.dataStore.edit { it[Keys.DEBT_REMINDERS] = on } } + suspend fun setPalette(id: String) { context.dataStore.edit { it[Keys.PALETTE] = id } } + suspend fun saveRatesSnapshot(jsonString: String) { + context.dataStore.edit { it[Keys.RATES_SNAPSHOT] = jsonString; it[Keys.REFRESH_FAILED] = false } + } + suspend fun setRefreshFailed(failed: Boolean) { context.dataStore.edit { it[Keys.REFRESH_FAILED] = failed } } + suspend fun setLiteMode(on: Boolean) { context.dataStore.edit { it[Keys.LITE_MODE] = on } } + suspend fun setAutoUpdateCheck(on: Boolean) { context.dataStore.edit { it[Keys.AUTO_UPDATE] = on } } + + suspend fun notifyBaselines(): Map = + context.dataStore.data.first()[Keys.NOTIFY_BASELINES] + ?.let { runCatching { json.decodeFromString>(it) }.getOrNull() }.orEmpty() + + suspend fun setNotifyBaselines(map: Map) { + context.dataStore.edit { it[Keys.NOTIFY_BASELINES] = json.encodeToString(map) } + } + + suspend fun setMerchantMarginPercent(percent: Double) { + context.dataStore.edit { it[Keys.MERCHANT_MARGIN] = percent.coerceIn(-90.0, 500.0) } + } + + suspend fun setThemeMode(mode: String) { + context.dataStore.edit { it[Keys.THEME_MODE] = mode } + } + + suspend fun addCalculatorHistory(entry: String) { + context.dataStore.edit { prefs -> + val current = prefs[Keys.CALC_HISTORY] + ?.split(HISTORY_SEPARATOR) + ?.filter { it.isNotBlank() } + .orEmpty() + val updated = (listOf(entry) + current.filter { it != entry }).take(MAX_CALC_HISTORY) + prefs[Keys.CALC_HISTORY] = updated.joinToString(HISTORY_SEPARATOR) + } + } + + suspend fun clearCalculatorHistory() { + context.dataStore.edit { it.remove(Keys.CALC_HISTORY) } + } + suspend fun setSelectedCurrency(code: String) { context.dataStore.edit { it[Keys.SELECTED_CURRENCY] = code } } @@ -141,13 +263,18 @@ class PreferencesRepository(private val context: Context) { suspend fun upsertPaymentContact(contact: PaymentContact) { val current = preferences.first().paymentContacts - val next = current.filterNot { it.id == contact.id } + contact - setPaymentContacts(next.sortedBy { it.displayName.lowercase() }) + var next = current.filterNot { it.id == contact.id } + contact + if (contact.isDefault) next = com.codeonyx.bcvaqui.data.model.PaymentMigration.setDefault(next, contact.id) + setPaymentContacts(com.codeonyx.bcvaqui.data.model.PaymentMigration.normalize(next.sortedBy { it.displayName.lowercase() })) } suspend fun deletePaymentContact(id: String) { val next = preferences.first().paymentContacts.filterNot { it.id == id } - setPaymentContacts(next) + setPaymentContacts(com.codeonyx.bcvaqui.data.model.PaymentMigration.normalize(next)) + } + + suspend fun setDefaultPaymentContact(id: String) { + setPaymentContacts(com.codeonyx.bcvaqui.data.model.PaymentMigration.setDefault(preferences.first().paymentContacts, id)) } suspend fun cacheRates( @@ -189,8 +316,6 @@ class PreferencesRepository(private val context: Context) { private fun decodePaymentContacts(jsonString: String?): List { if (jsonString.isNullOrBlank()) return emptyList() - return runCatching { - json.decodeFromString>(jsonString) - }.getOrDefault(emptyList()) + return com.codeonyx.bcvaqui.data.model.PaymentMigration.decode(jsonString) } } diff --git a/app/src/main/java/com/codeonyx/bcvaqui/data/model/FxModels.kt b/app/src/main/java/com/codeonyx/bcvaqui/data/model/FxModels.kt index ae69598..9561173 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/data/model/FxModels.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/data/model/FxModels.kt @@ -16,6 +16,21 @@ data class BcvRateResponse( val source: String? = null ) +@Serializable +data class DolarApiQuote( + val moneda: String? = null, + val fuente: String? = null, + val promedio: Double? = null, + val fechaActualizacion: String? = null +) + +@Serializable +data class DolarApiHistoryEntry( + val fuente: String? = null, + val promedio: Double? = null, + val fecha: String? = null +) + /** Entrada del histórico diario BCV (misma forma que rate.json). */ typealias BcvHistoryEntry = BcvRateResponse @@ -43,7 +58,13 @@ data class FxDashboard( val binanceUsdtVes: Double? = null, val binanceError: String? = null, val isFromCache: Boolean, - val errorMessage: String? = null + val errorMessage: String? = null, + /** Tasa BCV USD del próximo día hábil, si ya fue publicada. */ + val nextBcvUsd: com.codeonyx.bcvaqui.domain.NextRate? = null, + /** USDT/VES compra, venta y promedio (Binance P2P). */ + val usdtQuote: com.codeonyx.bcvaqui.domain.UsdtQuote? = null, + /** Momento (epoch ms) de la última carga exitosa mostrada. */ + val loadedAtMs: Long? = null ) @Serializable diff --git a/app/src/main/java/com/codeonyx/bcvaqui/data/model/PaymentModels.kt b/app/src/main/java/com/codeonyx/bcvaqui/data/model/PaymentModels.kt index cc69018..b43261d 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/data/model/PaymentModels.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/data/model/PaymentModels.kt @@ -6,7 +6,16 @@ import kotlinx.serialization.Serializable enum class PaymentType { PAGO_MOVIL, CUENTA_VE, - CUENTA_EXTRANJERA + CUENTA_EXTRANJERA, + BINANCE, + WALLY, + ZINLI, + CUENTA_CO, + ZELLE, + PAYPAL; + + /** Métodos que se cobran en dólares: al compartir se destaca el monto en USD. */ + val isUsd: Boolean get() = this == BINANCE || this == WALLY || this == ZINLI || this == ZELLE || this == PAYPAL } @Serializable @@ -27,7 +36,14 @@ data class PaymentContact( val currency: String = "", /** IBAN, SWIFT, routing, etc. */ val extraInfo: String = "", - val notes: String = "" + val notes: String = "", + /** Correo (Zelle, PayPal, Binance, Zinli, Wally). */ + val email: String = "", + /** Usuario / alias (Binance user, paypal.me, Wally). */ + val userHandle: String = "", + /** Tipo de cuenta (Ahorros / Corriente / Nequi / Daviplata). */ + val accountType: String = "", + val isDefault: Boolean = false ) { fun formatForCopy(): String = buildString { when (type) { @@ -55,6 +71,7 @@ data class PaymentContact( if (documentId.isNotBlank()) appendLine("- Documento: $documentId") if (extraInfo.isNotBlank()) appendLine("- $extraInfo") } + else -> append(com.codeonyx.bcvaqui.domain.ShareFormatter.methodBlock(this@PaymentContact, null, null)) } }.trim() @@ -69,6 +86,12 @@ data class PaymentContact( PaymentType.PAGO_MOVIL -> "Pago móvil" PaymentType.CUENTA_VE -> "Cuenta VE" PaymentType.CUENTA_EXTRANJERA -> "Cuenta extranjera" + PaymentType.BINANCE -> "Binance" + PaymentType.WALLY -> "Wally" + PaymentType.ZINLI -> "Zinli" + PaymentType.CUENTA_CO -> "Cuenta Colombia" + PaymentType.ZELLE -> "Zelle" + PaymentType.PAYPAL -> "PayPal" } fun subtitle(): String = when (type) { @@ -78,6 +101,12 @@ data class PaymentContact( .filter { it.isNotBlank() } .joinToString(" · ") .ifBlank { accountNumber } + PaymentType.BINANCE -> listOf(accountNumber, email, userHandle).firstOrNull { it.isNotBlank() }.orEmpty() + PaymentType.WALLY, PaymentType.ZINLI -> listOf(email, phone, userHandle).firstOrNull { it.isNotBlank() }.orEmpty() + PaymentType.ZELLE -> email.ifBlank { phone } + PaymentType.PAYPAL -> email.ifBlank { userHandle } + PaymentType.CUENTA_CO -> listOf(bankName, accountType, accountNumber.ifBlank { phone }) + .filter { it.isNotBlank() }.joinToString(" · ") } } @@ -116,3 +145,41 @@ object VenezuelanBanks { fun byCode(code: String): VenezuelanBank? = ALL.find { it.code == code } } + +/** Bancos y billeteras comunes en Colombia. */ +object ColombianBanks { + val ALL = listOf( + "Bancolombia", "Banco de Bogotá", "Davivienda", "BBVA Colombia", "Banco de Occidente", + "Banco Popular", "Banco AV Villas", "Banco Caja Social", "Scotiabank Colpatria", + "Banco Agrario", "Itaú", "Banco Falabella", "Nequi", "Daviplata" + ) + val ACCOUNT_TYPES = listOf("Ahorros", "Corriente") + fun isWallet(bank: String) = bank.equals("Nequi", true) || bank.equals("Daviplata", true) +} + +/** Lectura tolerante de los métodos guardados (migra datos viejos sin perderlos). */ +object PaymentMigration { + private val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true; coerceInputValues = true } + + fun decode(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + val array = runCatching { json.parseToJsonElement(raw) as? kotlinx.serialization.json.JsonArray } + .getOrNull() ?: return emptyList() + val contacts = array.mapNotNull { element -> + runCatching { json.decodeFromJsonElement(PaymentContact.serializer(), element) }.getOrNull() + } + return normalize(contacts) + } + + /** Garantiza un único predeterminado: el marcado, o el primer pago móvil, o el primero. */ + fun normalize(contacts: List): List { + if (contacts.isEmpty()) return contacts + val defaultId = contacts.firstOrNull { it.isDefault }?.id + ?: contacts.firstOrNull { it.type == PaymentType.PAGO_MOVIL }?.id + ?: contacts.first().id + return contacts.map { it.copy(isDefault = it.id == defaultId) } + } + + fun setDefault(contacts: List, id: String): List = + contacts.map { it.copy(isDefault = it.id == id) } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/data/model/UpdateManifest.kt b/app/src/main/java/com/codeonyx/bcvaqui/data/model/UpdateManifest.kt index f7875db..4b90c51 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/data/model/UpdateManifest.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/data/model/UpdateManifest.kt @@ -7,5 +7,7 @@ data class UpdateManifest( val versionCode: Int, val versionName: String, val apkUrl: String, - val releaseNotes: String? = null + val releaseNotes: String? = null, + /** SHA-256 (hex) del APK publicado. Si está, se verifica antes de instalar. */ + val sha256: String? = null ) diff --git a/app/src/main/java/com/codeonyx/bcvaqui/data/repository/BcvFallback.kt b/app/src/main/java/com/codeonyx/bcvaqui/data/repository/BcvFallback.kt new file mode 100644 index 0000000..21b8731 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/data/repository/BcvFallback.kt @@ -0,0 +1,20 @@ +package com.codeonyx.bcvaqui.data.repository + +import com.codeonyx.bcvaqui.data.model.BcvRateResponse +import com.codeonyx.bcvaqui.data.model.DolarApiQuote + +/** Convierte la respuesta de ve.dolarapi.com al formato de bcv.today. null si no trae USD. */ +object BcvFallback { + fun toBcvResponse(usd: DolarApiQuote?, eur: DolarApiQuote?): BcvRateResponse? { + val usdRate = usd?.promedio?.takeIf { it > 0 } ?: return null + val date = usd.fechaActualizacion?.take(10)?.takeIf { it.length == 10 } + return BcvRateResponse( + USD = usdRate, + EUR = eur?.promedio?.takeIf { it > 0 }, + updatedAt = usd.fechaActualizacion, + effectiveDate = date, + date = date, + source = "ve.dolarapi.com" + ) + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/data/repository/FxRepository.kt b/app/src/main/java/com/codeonyx/bcvaqui/data/repository/FxRepository.kt index c1fb1b6..b2baa1f 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/data/repository/FxRepository.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/data/repository/FxRepository.kt @@ -1,6 +1,7 @@ package com.codeonyx.bcvaqui.data.repository import com.codeonyx.bcvaqui.data.api.NetworkModule +import com.codeonyx.bcvaqui.data.local.HistoryDiskCache import com.codeonyx.bcvaqui.data.local.PreferencesRepository import com.codeonyx.bcvaqui.data.local.UserPreferences import com.codeonyx.bcvaqui.data.model.BcvRateResponse @@ -9,12 +10,17 @@ import com.codeonyx.bcvaqui.data.model.CurrencyRate import com.codeonyx.bcvaqui.data.model.FxDashboard import com.codeonyx.bcvaqui.data.model.WhereDestination import com.codeonyx.bcvaqui.domain.FxCalculator +import com.codeonyx.bcvaqui.domain.DayPoint +import com.codeonyx.bcvaqui.domain.MarketMath +import com.codeonyx.bcvaqui.domain.NextRate +import com.codeonyx.bcvaqui.domain.UsdtQuote import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first class FxRepository( - private val preferencesRepository: PreferencesRepository + private val preferencesRepository: PreferencesRepository, + private val historyDisk: HistoryDiskCache? = preferencesRepository.historyDiskCache() ) { val preferences = preferencesRepository.preferences private var historyCache: List = emptyList() @@ -33,13 +39,31 @@ class FxRepository( val bcvDeferred = async { fetchBcvRatesWithRetry() } val worldDeferred = async { fetchWorldRatesSafe() } val binanceDeferred = async { fetchBinanceUsdtVes() } + val sellDeferred = async { fetchBinanceSide("SELL") } + val nextDeferred = async { fetchNextRateSafe() } val bcvResult = bcvDeferred.await() val world = worldDeferred.await() val binance = binanceDeferred.await() + val sell = sellDeferred.await() + val next = nextDeferred.await() val bcvRates = bcvResult.rates val bcvUpdatedAt = bcvResult.updatedAt + val now = System.currentTimeMillis() + val merged = com.codeonyx.bcvaqui.domain.OfflineRates.merge( + com.codeonyx.bcvaqui.domain.OfflineRates.decode(prefs.ratesSnapshotJson), + com.codeonyx.bcvaqui.domain.RatesSnapshot( + bcvRates = bcvRates.values.toList(), + bcvUpdatedAt = bcvUpdatedAt, + worldRatesUsd = world.rates, + worldUpdatedAt = world.updatedAt, + usdtBuy = binance.rate, + usdtSell = sell.rate, + loadedAtMs = now + ) + ) + preferencesRepository.saveRatesSnapshot(com.codeonyx.bcvaqui.domain.OfflineRates.encode(merged)) preferencesRepository.cacheRates( rates = bcvRates, updatedAt = bcvUpdatedAt, @@ -51,15 +75,21 @@ class FxRepository( FxDashboard( bcvRates = bcvRates, bcvUpdatedAt = bcvUpdatedAt, - worldRatesUsd = world.rates, - worldRatesUpdatedAt = world.updatedAt, - binanceUsdtVes = binance.rate, + worldRatesUsd = merged.worldRatesUsd, + worldRatesUpdatedAt = merged.worldUpdatedAt, + binanceUsdtVes = binance.rate ?: merged.usdtBuy, binanceError = binance.error, - isFromCache = false + isFromCache = false, + nextBcvUsd = next, + usdtQuote = if (binance.rate != null || sell.rate != null) { + UsdtQuote(binance.rate, sell.rate, System.currentTimeMillis()) + } else null, + loadedAtMs = now ) } }.recoverCatching { error -> - if (cachedRates.isNotEmpty()) { + preferencesRepository.setRefreshFailed(true) + if (cachedRates.isNotEmpty() || com.codeonyx.bcvaqui.domain.OfflineRates.decode(prefs.ratesSnapshotJson) != null) { buildDashboardFromCache(prefs, cachedRates, error.message) } else { throw error @@ -67,6 +97,53 @@ class FxRepository( } } + private suspend fun fetchNextRateSafe(): NextRate? = runCatching { + val today = java.time.LocalDate.now(java.time.ZoneId.of("America/Caracas")).toString() + MarketMath.nextRate(officialSeries(force = true), today) + }.getOrNull() + + private var officialCache: List = emptyList() + private var parallelCache: List = emptyList() + + private suspend fun officialSeries(force: Boolean = false): List { + if (!force && officialCache.isNotEmpty()) return officialCache + return runCatching { + NetworkModule.dolarApi.getOfficialHistory().toPoints().also { officialCache = it } + }.getOrDefault(officialCache) + } + + private fun List.toPoints(): List = + mapNotNull { e -> + val d = e.fecha?.take(10) ?: return@mapNotNull null + val v = e.promedio ?: return@mapNotNull null + DayPoint(d, v) + }.sortedBy { it.date } + + /** + * Series para gráficos: BCV (histórico en disco + ve.dolarapi.com como relleno) y paralelo/USDT. + * Las fechas futuras (tasa ya publicada del próximo día) se excluyen. + */ + suspend fun fetchMarketSeries(): MarketSeries = coroutineScope { + val today = java.time.LocalDate.now(java.time.ZoneId.of("America/Caracas")).toString() + val localDeferred = async { + runCatching { loadHistoryEntries(forceNetwork = false) }.getOrDefault(emptyList()) + .mapNotNull { r -> + val d = (r.effectiveDate ?: r.date ?: r.updatedAt)?.take(10) ?: return@mapNotNull null + r.USD?.let { DayPoint(d, it) } + } + } + val officialDeferred = async { officialSeries() } + val parallelDeferred = async { + if (parallelCache.isNotEmpty()) parallelCache else runCatching { + NetworkModule.dolarApi.getParallelHistory().toPoints().also { parallelCache = it } + }.getOrDefault(emptyList()) + } + val bcv = MarketMath.merge(localDeferred.await(), officialDeferred.await()) + .filter { it.date <= today } + val usdt = parallelDeferred.await().filter { it.date <= today } + MarketSeries(bcv = bcv, usdt = usdt, gap = MarketMath.gapSeries(bcv, usdt)) + } + /** Últimos [days] días del histórico BCV (más reciente al final). */ suspend fun fetchHistory(days: Int = 30): Result> { return runCatching { @@ -89,6 +166,14 @@ class FxRepository( kotlinx.coroutines.delay(1_000L * (attempt + 1)) } } + // Respaldo: ve.dolarapi.com (tasa oficial USD/EUR). + runCatching { + coroutineScope { + val usd = async { NetworkModule.dolarApi.getUsdOfficial() } + val eur = async { runCatching { NetworkModule.dolarApi.getEurOfficial() }.getOrNull() } + BcvFallback.toBcvResponse(usd.await(), eur.await()) + } + }.getOrNull()?.let { return parseBcvResponse(it) } throw lastError ?: IllegalStateException("Failed to fetch BCV rates") } @@ -102,8 +187,12 @@ class FxRepository( NetworkModule.bcvTodayApi.getHistory().also { historyCache = it historyCacheAtMs = System.currentTimeMillis() + historyDisk?.write(it) } - }.getOrElse { historyCache } + }.getOrElse { + if (historyCache.isEmpty()) historyCache = historyDisk?.read().orEmpty() + historyCache + } } /** bcv.today/rate.json a veces queda atrasado; el histórico suele tener la tasa del día. */ @@ -134,13 +223,15 @@ class FxRepository( } } - private suspend fun fetchBinanceUsdtVes(): BinanceFetchResult { + private suspend fun fetchBinanceUsdtVes(): BinanceFetchResult = fetchBinanceSide("BUY") + + private suspend fun fetchBinanceSide(tradeType: String): BinanceFetchResult { return runCatching { val response = NetworkModule.binanceP2pApi.searchAds( BinanceSearchRequest( asset = "USDT", fiat = "VES", - tradeType = "BUY", + tradeType = tradeType, page = 1, rows = 10, payTypes = emptyList() @@ -190,12 +281,21 @@ class FxRepository( cachedRates: Map, errorMessage: String? = null ): FxDashboard { + val snap = com.codeonyx.bcvaqui.domain.OfflineRates.decode(prefs.ratesSnapshotJson) + val rates = cachedRates.ifEmpty { com.codeonyx.bcvaqui.domain.OfflineRates.ratesMap(snap) } + val buy = prefs.lastKnownBinanceUsdtVes ?: snap?.usdtBuy return FxDashboard( - bcvRates = cachedRates, - bcvUpdatedAt = prefs.lastKnownUpdatedAt, - binanceUsdtVes = prefs.lastKnownBinanceUsdtVes, + bcvRates = rates, + bcvUpdatedAt = prefs.lastKnownUpdatedAt ?: snap?.bcvUpdatedAt, + worldRatesUsd = snap?.worldRatesUsd.orEmpty(), + worldRatesUpdatedAt = snap?.worldUpdatedAt, + binanceUsdtVes = buy, isFromCache = true, - errorMessage = errorMessage ?: "Sin conexión — mostrando última tasa guardada" + errorMessage = errorMessage ?: "Sin conexión — mostrando última tasa guardada", + usdtQuote = if (buy != null || snap?.usdtSell != null) { + UsdtQuote(buy, snap?.usdtSell, snap?.loadedAtMs ?: prefs.lastFetchEpochMs) + } else null, + loadedAtMs = (snap?.loadedAtMs ?: prefs.lastFetchEpochMs).takeIf { it > 0L } ) } @@ -218,3 +318,9 @@ class FxRepository( const val HISTORY_CACHE_TTL_MS = 15 * 60 * 1000L } } + +data class MarketSeries( + val bcv: List, + val usdt: List, + val gap: List +) diff --git a/app/src/main/java/com/codeonyx/bcvaqui/domain/AppPalette.kt b/app/src/main/java/com/codeonyx/bcvaqui/domain/AppPalette.kt new file mode 100644 index 0000000..8378d5b --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/domain/AppPalette.kt @@ -0,0 +1,54 @@ +package com.codeonyx.bcvaqui.domain + +/** + * Paletas de color de Codivisas. Valores ARGB puros (sin Android) para poder probarlos. + * [launcherAlias] es el nombre de la activity-alias del manifest que muestra el ícono de la paleta. + */ +enum class AppPalette( + val label: String, + val primary: Long, + val primaryDark: Long, + val primaryLight: Long, + val tertiary: Long, + val accent: Long, + val accentDark: Long, + val container: Long, + val darkPrimary: Long, + val darkTopBar: Long, + val darkBackground: Long, + val darkSurface: Long, + val launcherAlias: String, + /** Acento en modo oscuro (más brillante); por defecto igual a [accent]. */ + val darkAccent: Long = accent +) { + AZUL("Zafiro", 0xFF1E3A8A, 0xFF172554, 0xFF3B82F6, 0xFF6366F1, 0xFFFBBF24, 0xFFD97706, 0xFFDBEAFE, + 0xFF93C5FD, 0xFF0F1B3D, 0xFF0B1120, 0xFF111827, "LauncherAzul"), + ONIX("Ónix", 0xFF1A1A1A, 0xFF0D0D0D, 0xFFDC2626, 0xFF404040, 0xFFB91C1C, 0xFF8A0F1F, 0xFFFEE2E2, + 0xFFFF5A6A, 0xFF161618, 0xFF0E0E10, 0xFF1A1A1E, "LauncherOnix", darkAccent = 0xFFFF5A6A), + MORADO("Amatista", 0xFF4C1D95, 0xFF2E1065, 0xFF7C3AED, 0xFF8B5CF6, 0xFFC4B5FD, 0xFF6D28D9, 0xFFEDE9FE, + 0xFFC4B5FD, 0xFF1E0B3F, 0xFF0F0720, 0xFF1A1030, "LauncherMorado"), + ESMERALDA("Esmeralda", 0xFF065F46, 0xFF064E3B, 0xFF10B981, 0xFF059669, 0xFFFBBF24, 0xFFD97706, 0xFFD1FAE5, + 0xFF6EE7B7, 0xFF052E24, 0xFF03140F, 0xFF0B1F19, "LauncherEsmeralda"); + + companion object { + val DEFAULT = AZUL + /** Color de texto (blanco o casi negro) con mejor contraste sobre [bg]. */ + fun onColor(bg: Long): Long = if (contrast(0xFFFFFFFF, bg) >= contrast(0xFF0D0D0D, bg)) 0xFFFFFFFF else 0xFF0D0D0D + + fun from(id: String?): AppPalette = entries.firstOrNull { it.name == id } ?: DEFAULT + + /** Contraste WCAG entre dos colores ARGB. */ + fun contrast(a: Long, b: Long): Double { + val la = luminance(a); val lb = luminance(b) + return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05) + } + + fun luminance(c: Long): Double { + fun ch(v: Long): Double { + val s = v / 255.0 + return if (s <= 0.03928) s / 12.92 else Math.pow((s + 0.055) / 1.055, 2.4) + } + return 0.2126 * ch((c shr 16) and 0xFF) + 0.7152 * ch((c shr 8) and 0xFF) + 0.0722 * ch(c and 0xFF) + } + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/domain/CalcEngine.kt b/app/src/main/java/com/codeonyx/bcvaqui/domain/CalcEngine.kt new file mode 100644 index 0000000..89dade0 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/domain/CalcEngine.kt @@ -0,0 +1,150 @@ +package com.codeonyx.bcvaqui.domain + +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Locale + +/** + * Motor de la calculadora. Expresiones con coma decimal venezolana y los operadores + * + − × ÷ y % (postfijo): + * - "200 + 10%" = 220 (porcentaje del valor anterior) + * - "200 × 10%" = 20 + * - "15%" = 0,15 + */ +object CalcEngine { + const val PLUS = '+' + const val MINUS = '−' + const val TIMES = '×' + const val DIVIDE = '÷' + val OPERATORS = setOf(PLUS, MINUS, TIMES, DIVIDE) + + /** Evalúa la expresión. Ignora un operador o coma final (resultado en vivo). null si no es válida. */ + fun evaluate(expression: String): Double? { + var expr = expression.replace(" ", "").replace('-', MINUS).replace('*', TIMES).replace('/', DIVIDE) + while (expr.isNotEmpty() && (expr.last() in OPERATORS || expr.last() == ',')) expr = expr.dropLast(1) + if (expr.isEmpty()) return null + return try { + val parser = Parser(expr) + val value = parser.parseExpression() + if (!parser.atEnd() || value.isNaN() || value.isInfinite()) null else value + } catch (e: IllegalArgumentException) { + null + } catch (e: ArithmeticException) { + null + } + } + + /** Número con miles "." y hasta 4 decimales ",": 1234.5 -> "1.234,5". */ + fun format(value: Double, maxDecimals: Int = 4): String { + val symbols = DecimalFormatSymbols(Locale.US).apply { + groupingSeparator = '.' + decimalSeparator = ',' + } + val pattern = "#,##0" + if (maxDecimals > 0) "." + "#".repeat(maxDecimals) else "" + val df = DecimalFormat(pattern, symbols) + df.roundingMode = RoundingMode.HALF_UP + val rounded = BigDecimal.valueOf(value).setScale(maxDecimals, RoundingMode.HALF_UP) + val text = df.format(rounded) + return if (text == "-0") "0" else text + } + + private class Parser(private val s: String) { + private var i = 0 + fun atEnd() = i >= s.length + + private data class Term(val value: Double, val percentOnly: Boolean) + + fun parseExpression(): Double { + var left = parseTerm().let { if (it.percentOnly) it.value / 100.0 else it.value } + while (!atEnd() && (s[i] == PLUS || s[i] == MINUS)) { + val op = s[i++] + val right = parseTerm() + val r = if (right.percentOnly) left * right.value / 100.0 else right.value + left = if (op == PLUS) left + r else left - r + } + return left + } + + private fun parseTerm(): Term { + var (value, percent) = parseFactor() + var single = true + while (!atEnd() && (s[i] == TIMES || s[i] == DIVIDE)) { + if (percent) value /= 100.0 + percent = false + single = false + val op = s[i++] + val (rv, rp) = parseFactor() + val r = if (rp) rv / 100.0 else rv + value = if (op == TIMES) { + value * r + } else { + if (r == 0.0) throw ArithmeticException("División entre cero") + value / r + } + } + return Term(value, single && percent) + } + + private fun parseFactor(): Pair { + var sign = 1.0 + while (!atEnd() && s[i] == MINUS) { sign = -sign; i++ } + val start = i + while (!atEnd() && (s[i].isDigit() || s[i] == ',' || s[i] == '.')) i++ + require(i > start) { "Número esperado" } + val raw = s.substring(start, i).replace(".", "").replace(',', '.') + val number = raw.toDoubleOrNull() ?: throw IllegalArgumentException("Número inválido") + var percent = false + if (!atEnd() && s[i] == '%') { percent = true; i++ } + return sign * number to percent + } + } +} + +/** Estado editable de la calculadora (sin Android, fácil de probar). */ +data class CalcInput(val expression: String = "") { + private val lastNumber: String + get() = expression.takeLastWhile { it.isDigit() || it == ',' } + + fun digit(d: String): CalcInput { + if (expression.endsWith("%")) return CalcInput(expression + CalcEngine.TIMES + d) + val last = lastNumber + if (last == "0" && d.all { it == '0' }) return this + if (last == "0") return CalcInput(expression.dropLast(1) + d) + if (last.isEmpty() && d == "00") return CalcInput(expression + "0") + return CalcInput(expression + d) + } + + fun comma(): CalcInput { + if (lastNumber.contains(',')) return this + if (expression.endsWith("%")) return CalcInput(expression + CalcEngine.TIMES + "0,") + return if (lastNumber.isEmpty()) CalcInput(expression + "0,") else CalcInput(expression + ",") + } + + fun operator(op: Char): CalcInput { + if (expression.isEmpty()) return if (op == CalcEngine.MINUS) CalcInput("−") else this + val trimmed = if (expression.last() in CalcEngine.OPERATORS || expression.last() == ',') { + if (expression.length == 1) return this + expression.dropLast(1) + } else { + expression + } + return CalcInput(trimmed + op) + } + + fun percent(): CalcInput { + if (lastNumber.isEmpty() || expression.endsWith("%")) return this + return CalcInput(expression + "%") + } + + fun backspace(): CalcInput = CalcInput(expression.dropLast(1)) + + fun clear(): CalcInput = CalcInput("") + + /** Reemplaza todo por un valor (resultado anterior, conversión, historial). */ + fun withValue(value: Double): CalcInput = + CalcInput(CalcEngine.format(value).replace(".", "").let { if (value < 0) "−" + it.removePrefix("-") else it }) + + val result: Double? get() = CalcEngine.evaluate(expression) +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/domain/CopyTexts.kt b/app/src/main/java/com/codeonyx/bcvaqui/domain/CopyTexts.kt new file mode 100644 index 0000000..23b33bb --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/domain/CopyTexts.kt @@ -0,0 +1,22 @@ +package com.codeonyx.bcvaqui.domain + +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** Textos de los botones de copiar en Inicio. */ +object CopyTexts { + private val dateFmt = DateTimeFormatter.ofPattern("dd/MM/yyyy") + + /** "Tasa BCV 27/10/2026: 888,12 Bs." — la fecha sale de updatedAt (ISO) o de [today]. */ + fun rateOfDay(rateLabel: String, rate: Double, updatedAt: String?, today: LocalDate = LocalDate.now()): String { + val date = updatedAt?.take(10)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: today + return "Tasa $rateLabel ${date.format(dateFmt)}: ${FxCalculator.format2(rate)} Bs." + } + + /** "5,00 $ = 4.440,60 Bs." o "4.440,60 Bs. = 5,00 $" según la dirección. */ + fun conversion(input: Double, result: Double, symbol: String, toVes: Boolean): String = + if (toVes) "${FxCalculator.format2(input)} $symbol = ${FxCalculator.format2(result)} Bs." + else "${FxCalculator.format2(input)} Bs. = ${FxCalculator.format2(result)} $symbol" + + fun amountOnly(result: Double): String = FxCalculator.format2(result) +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/domain/DataAge.kt b/app/src/main/java/com/codeonyx/bcvaqui/domain/DataAge.kt new file mode 100644 index 0000000..914330e --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/domain/DataAge.kt @@ -0,0 +1,20 @@ +package com.codeonyx.bcvaqui.domain + +/** Texto corto de antigüedad de los datos: "hace 5 min", "hace 3 h", "hace 2 días". */ +object DataAge { + fun describe(epochMs: Long, nowMs: Long = System.currentTimeMillis()): String? { + if (epochMs <= 0L) return null + val minutes = ((nowMs - epochMs).coerceAtLeast(0L)) / 60_000L + return when { + minutes < 1 -> "hace instantes" + minutes < 60 -> "hace $minutes min" + minutes < 60 * 24 -> "hace ${minutes / 60} h" + minutes < 60 * 48 -> "hace 1 día" + else -> "hace ${minutes / (60 * 24)} días" + } + } + + /** true si los datos tienen más de [hours] horas. */ + fun isStale(epochMs: Long, hours: Long = 12, nowMs: Long = System.currentTimeMillis()): Boolean = + epochMs > 0L && nowMs - epochMs > hours * 3_600_000L +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/domain/FinancePlanner.kt b/app/src/main/java/com/codeonyx/bcvaqui/domain/FinancePlanner.kt new file mode 100644 index 0000000..c5e56ad --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/domain/FinancePlanner.kt @@ -0,0 +1,185 @@ +package com.codeonyx.bcvaqui.domain + +import kotlinx.serialization.Serializable +import java.time.LocalDate +import java.time.temporal.ChronoUnit +import kotlin.math.ceil +import kotlin.math.max + +enum class Frequency(val label: String, val plural: String) { + SEMANAL("Semanal", "semana"), + QUINCENAL("Quincenal", "quincena"), + MENSUAL("Mensual", "mes"); + + fun advance(date: LocalDate, steps: Long): LocalDate = when (this) { + SEMANAL -> date.plusWeeks(steps) + QUINCENAL -> date.plusDays(15 * steps) + MENSUAL -> date.plusMonths(steps) + } + + /** Períodos completos (al menos 1) entre [from] y [to]. */ + fun periodsBetween(from: LocalDate, to: LocalDate): Int { + if (!to.isAfter(from)) return 1 + val n = when (this) { + SEMANAL -> ChronoUnit.DAYS.between(from, to) / 7.0 + QUINCENAL -> ChronoUnit.DAYS.between(from, to) / 15.0 + MENSUAL -> { + val m = ChronoUnit.MONTHS.between(from, to) + (if (from.plusMonths(m).isBefore(to)) m + 1 else m).toDouble() + } + } + return max(1, ceil(n - 1e-9).toInt()) + } +} + +enum class Money { USD, VES } + +@Serializable +data class Movement( + val id: String, + val epochDay: Long, + val amount: Double, + val note: String = "" +) + +@Serializable +data class Debt( + val id: String, + /** true = "Debo"; false = "Me deben". */ + val iOwe: Boolean, + val person: String, + val amount: Double, + val currency: Money = Money.USD, + val startEpochDay: Long, + val dueEpochDay: Long? = null, + val installments: Int = 0, + val frequency: Frequency = Frequency.MENSUAL, + val notes: String = "", + val payments: List = emptyList(), + val archived: Boolean = false, + val markedPaid: Boolean = false, + val remindDaysBefore: Int = 1 +) + +@Serializable +data class SavingsGoal( + val id: String, + val name: String, + val emoji: String = "🎯", + val target: Double, + val currency: Money = Money.USD, + val createdEpochDay: Long, + val targetEpochDay: Long, + val frequency: Frequency = Frequency.MENSUAL, + val movements: List = emptyList(), + /** Tasa BCV al crear la meta (para mostrar la erosión de metas en Bs). */ + val rateAtCreation: Double? = null +) + +data class DebtStatus( + val paid: Double, + val remaining: Double, + val progress: Float, + val installmentAmount: Double?, + val nextDue: LocalDate?, + val nextAmount: Double, + val overdue: Boolean, + val daysToNext: Long?, + val settled: Boolean +) + +data class GoalStatus( + val saved: Double, + val remaining: Double, + val progress: Float, + val requiredPerPeriod: Double, + val periodsLeft: Int, + val expectedByNow: Double, + /** Positivo = atrasado esa cantidad; ≤0 = al día. */ + val behindBy: Double, + val projectedDate: LocalDate?, + val complete: Boolean +) + +object FinancePlanner { + fun installmentAmount(total: Double, count: Int): Double = + if (count <= 0) total else FxCalculator.round2(total / count) + + fun schedule(start: LocalDate, count: Int, frequency: Frequency): List = + (1..count).map { frequency.advance(start, it.toLong()) } + + fun debtStatus(debt: Debt, today: LocalDate): DebtStatus { + val paid = FxCalculator.round2(debt.payments.sumOf { it.amount }) + val remaining = FxCalculator.round2((debt.amount - paid).coerceAtLeast(0.0)) + val settled = debt.markedPaid || remaining <= 0.005 + val progress = if (settled) 1f else (paid / debt.amount).toFloat().coerceIn(0f, 1f) + val start = LocalDate.ofEpochDay(debt.startEpochDay) + var nextDue: LocalDate? = null + var nextAmount = remaining + var cuota: Double? = null + if (debt.installments > 0) { + cuota = installmentAmount(debt.amount, debt.installments) + val dates = schedule(start, debt.installments, debt.frequency) + // Cuotas cubiertas por lo abonado (con tolerancia de redondeo). + val covered = ((paid + 0.01) / cuota).toInt().coerceAtMost(debt.installments) + if (!settled && covered < debt.installments) { + nextDue = dates[covered] + val owedUpToNext = cuota * (covered + 1) + nextAmount = FxCalculator.round2(minOf(remaining, owedUpToNext - paid)) + } + } else if (!settled) { + nextDue = debt.dueEpochDay?.let { LocalDate.ofEpochDay(it) } + } + val overdue = !settled && nextDue != null && nextDue.isBefore(today) + return DebtStatus( + paid, remaining, progress, cuota, nextDue, if (settled) 0.0 else nextAmount, overdue, + nextDue?.let { ChronoUnit.DAYS.between(today, it) }, settled + ) + } + + fun toUsd(amount: Double, currency: Money, rate: Double): Double = + if (currency == Money.USD) amount else if (rate > 0) amount / rate else 0.0 + + fun toVes(amount: Double, currency: Money, rate: Double): Double = + if (currency == Money.VES) amount else amount * rate + + fun goalStatus(goal: SavingsGoal, today: LocalDate): GoalStatus { + val saved = FxCalculator.round2(goal.movements.sumOf { it.amount }.coerceAtLeast(0.0)) + val remaining = FxCalculator.round2((goal.target - saved).coerceAtLeast(0.0)) + val complete = remaining <= 0.005 + val created = LocalDate.ofEpochDay(goal.createdEpochDay) + val target = LocalDate.ofEpochDay(goal.targetEpochDay) + val periodsLeft = goal.frequency.periodsBetween(today, target) + val required = if (complete) 0.0 else FxCalculator.round2(remaining / periodsLeft) + val totalDays = ChronoUnit.DAYS.between(created, target).coerceAtLeast(1) + val elapsed = ChronoUnit.DAYS.between(created, today).coerceIn(0, totalDays) + val expected = FxCalculator.round2(goal.target * elapsed / totalDays) + val behind = FxCalculator.round2(expected - saved) + return GoalStatus( + saved, remaining, (saved / goal.target).toFloat().coerceIn(0f, 1f), required, periodsLeft, + expected, if (complete) 0.0 else behind, projectedCompletion(goal, saved, today), complete + ) + } + + /** Fecha estimada según el ritmo real (ahorro neto / días desde la creación). */ + fun projectedCompletion(goal: SavingsGoal, saved: Double, today: LocalDate): LocalDate? { + if (saved >= goal.target) return today + val days = ChronoUnit.DAYS.between(LocalDate.ofEpochDay(goal.createdEpochDay), today).coerceAtLeast(1) + if (saved <= 0) return null + val perDay = saved / days + val daysNeeded = ceil((goal.target - saved) / perDay).toLong() + return if (daysNeeded > 365L * 50) null else today.plusDays(daysNeeded) + } + + /** Pérdida % del valor en $ de un monto en Bs entre dos tasas. */ + fun erosionPercent(rateThen: Double?, rateNow: Double): Double? { + if (rateThen == null || rateThen <= 0 || rateNow <= 0) return null + return FxCalculator.round2((1 - rateThen / rateNow) * 100.0) + } + + /** Deudas que deben avisarse hoy: próximo pago dentro de [Debt.remindDaysBefore] días o vencido. */ + fun debtsToRemind(debts: List, today: LocalDate): List> = + debts.filter { !it.archived && it.remindDaysBefore >= 0 } + .map { it to debtStatus(it, today) } + .filter { (d, s) -> !s.settled && s.daysToNext != null && s.daysToNext <= d.remindDaysBefore } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/domain/MarketMath.kt b/app/src/main/java/com/codeonyx/bcvaqui/domain/MarketMath.kt new file mode 100644 index 0000000..f4ec3ad --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/domain/MarketMath.kt @@ -0,0 +1,82 @@ +package com.codeonyx.bcvaqui.domain + +import kotlin.math.abs + +/** Punto diario de una serie (fecha ISO yyyy-MM-dd). */ +data class DayPoint(val date: String, val value: Double) + +/** Tasa BCV ya publicada para un día hábil posterior a hoy. */ +data class NextRate(val date: String, val rate: Double) + +data class UsdtQuote(val buy: Double?, val sell: Double?, val atEpochMs: Long) { + val average: Double? + get() = when { + buy != null && sell != null -> FxCalculator.round2((buy + sell) / 2.0) + else -> buy ?: sell + } +} + +enum class Trend { UP, DOWN, FLAT } + +object MarketMath { + /** Primera tasa con fecha posterior a [today] (el BCV publica la del próximo día hábil por la tarde). */ + fun nextRate(history: List, today: String): NextRate? = + history.filter { it.date > today && it.value > 0 } + .minByOrNull { it.date } + ?.let { NextRate(it.date, it.value) } + + /** Últimos [days] puntos hasta [today] inclusive, ordenados por fecha. */ + fun window(points: List, days: Int, today: String? = null): List = + points.asSequence() + .filter { it.value > 0 && (today == null || it.date <= today) } + .distinctBy { it.date } + .sortedBy { it.date } + .toList() + .takeLast(days) + + /** Brecha % entre paralelo/USDT y BCV para las fechas que coinciden. */ + fun gapSeries(bcv: List, market: List): List { + val byDate = bcv.associate { it.date to it.value } + return market.mapNotNull { m -> + val o = byDate[m.date] ?: return@mapNotNull null + if (o <= 0) null else DayPoint(m.date, FxCalculator.round2((m.value - o) / o * 100.0)) + }.sortedBy { it.date } + } + + /** Combina dos fuentes por fecha; [primary] gana si ambas tienen el día. */ + fun merge(primary: List, secondary: List): List = + (secondary.associateBy { it.date } + primary.associateBy { it.date }).values.sortedBy { it.date } + + fun changePercent(points: List): Double? { + if (points.size < 2) return null + val first = points.first().value + val last = points.last().value + return if (first <= 0) null else FxCalculator.round2((last - first) / first * 100.0) + } + + fun trend(changePct: Double?, epsilon: Double = 0.01): Trend = when { + changePct == null || abs(changePct) < epsilon -> Trend.FLAT + changePct > 0 -> Trend.UP + else -> Trend.DOWN + } + + fun trendArrow(changePct: Double?): String = when (trend(changePct)) { + Trend.UP -> "▲" + Trend.DOWN -> "▼" + Trend.FLAT -> "■" + } + + /** Modo comerciante: aplica ganancia (+) o descuento (−) en % a un monto. */ + fun applyMargin(amount: Double, marginPercent: Double): Double = + FxCalculator.round2(amount * (1.0 + marginPercent / 100.0)) + + /** Tasa efectiva con margen, útil para cotizar en Bs. */ + fun marginRate(rate: Double, marginPercent: Double): Double = + FxCalculator.round2(rate * (1.0 + marginPercent / 100.0)) + + /** "2026-10-28" -> "28/10/2026". */ + fun shortDate(iso: String): String { + val p = iso.take(10).split("-") + return if (p.size == 3) "${p[2]}/${p[1]}/${p[0]}" else iso + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/domain/OfflineRates.kt b/app/src/main/java/com/codeonyx/bcvaqui/domain/OfflineRates.kt new file mode 100644 index 0000000..9cd4a96 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/domain/OfflineRates.kt @@ -0,0 +1,71 @@ +package com.codeonyx.bcvaqui.domain + +import com.codeonyx.bcvaqui.data.model.CurrencyRate +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** Última carga exitosa de todas las fuentes (BCV, tasas mundiales, USDT P2P) con su hora de carga. */ +@Serializable +data class RatesSnapshot( + val bcvRates: List = emptyList(), + val bcvUpdatedAt: String? = null, + val worldRatesUsd: Map = emptyMap(), + val worldUpdatedAt: String? = null, + val usdtBuy: Double? = null, + val usdtSell: Double? = null, + val loadedAtMs: Long = 0L +) + +/** Lógica pura del modo sin conexión (sin Android, probada en unit tests). */ +object OfflineRates { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + val CARACAS: TimeZone = TimeZone.getTimeZone("America/Caracas") + + fun encode(s: RatesSnapshot): String = json.encodeToString(RatesSnapshot.serializer(), s) + + fun decode(raw: String?): RatesSnapshot? { + if (raw.isNullOrBlank()) return null + return runCatching { json.decodeFromString(RatesSnapshot.serializer(), raw) }.getOrNull() + } + + /** + * Combina una carga nueva con la anterior: si una fuente falló (vacía/null) se conserva + * su último valor bueno, así nunca se pierde una tasa ya cargada. + */ + fun merge(previous: RatesSnapshot?, fresh: RatesSnapshot): RatesSnapshot { + if (previous == null) return fresh + return fresh.copy( + bcvRates = fresh.bcvRates.ifEmpty { previous.bcvRates }, + bcvUpdatedAt = if (fresh.bcvRates.isEmpty()) previous.bcvUpdatedAt else fresh.bcvUpdatedAt ?: previous.bcvUpdatedAt, + worldRatesUsd = fresh.worldRatesUsd.ifEmpty { previous.worldRatesUsd }, + worldUpdatedAt = if (fresh.worldRatesUsd.isEmpty()) previous.worldUpdatedAt else fresh.worldUpdatedAt, + usdtBuy = fresh.usdtBuy ?: previous.usdtBuy, + usdtSell = fresh.usdtSell ?: previous.usdtSell + ) + } + + /** dd/MM/yyyy hh:mm a en hora de Caracas. */ + fun formatLoadedAt(epochMs: Long?): String { + if (epochMs == null || epochMs <= 0L) return "—" + return SimpleDateFormat("dd/MM/yyyy hh:mm a", Locale.US).apply { timeZone = CARACAS }.format(Date(epochMs)) + } + + const val BANNER_TITLE = "Modo sin conexión" + + fun bannerText(epochMs: Long?): String = "$BANNER_TITLE · Última tasa cargada: ${formatLoadedAt(epochMs)}" + + fun lastLoadedLine(epochMs: Long?): String = "Última tasa cargada: ${formatLoadedAt(epochMs)}" + + /** Marca corta para widgets. */ + fun widgetMarker(epochMs: Long?): String = "⚠ Sin conexión · ${formatLoadedAt(epochMs)}" + + /** Sin conexión = la última actualización falló pero hay datos guardados que mostrar. */ + fun isOffline(lastRefreshFailed: Boolean, snapshot: RatesSnapshot?): Boolean = + lastRefreshFailed && snapshot != null && snapshot.loadedAtMs > 0L + + fun ratesMap(s: RatesSnapshot?): Map = s?.bcvRates.orEmpty().associateBy { it.code } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/domain/PriceTools.kt b/app/src/main/java/com/codeonyx/bcvaqui/domain/PriceTools.kt new file mode 100644 index 0000000..1ceb623 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/domain/PriceTools.kt @@ -0,0 +1,47 @@ +package com.codeonyx.bcvaqui.domain + +/** Cálculos de precios con impuestos venezolanos y brecha cambiaria. */ +object PriceTools { + const val IVA_PERCENT = 16.0 + const val IGTF_PERCENT = 3.0 + + data class TaxBreakdown( + val base: Double, + val iva: Double, + val igtf: Double, + val total: Double + ) + + /** + * @param amount monto escrito por el usuario. + * @param amountIncludesIva true si el monto ya trae IVA (se desglosa). + * @param applyIgtf true si se paga en divisas (IGTF 3 % sobre el total con IVA). + */ + fun taxes( + amount: Double, + amountIncludesIva: Boolean, + applyIva: Boolean = true, + applyIgtf: Boolean, + ivaPercent: Double = IVA_PERCENT, + igtfPercent: Double = IGTF_PERCENT + ): TaxBreakdown { + if (amount <= 0.0) return TaxBreakdown(0.0, 0.0, 0.0, 0.0) + val ivaRate = if (applyIva) ivaPercent / 100.0 else 0.0 + val base = if (amountIncludesIva) amount / (1 + ivaRate) else amount + val iva = base * ivaRate + val withIva = base + iva + val igtf = if (applyIgtf) withIva * igtfPercent / 100.0 else 0.0 + return TaxBreakdown( + base = FxCalculator.round2(base), + iva = FxCalculator.round2(iva), + igtf = FxCalculator.round2(igtf), + total = FxCalculator.round2(withIva + igtf) + ) + } + + /** Brecha % del mercado (USDT) sobre la tasa oficial. null si falta alguna. */ + fun gapPercent(official: Double?, market: Double?): Double? { + if (official == null || market == null || official <= 0.0 || market <= 0.0) return null + return FxCalculator.round2((market - official) / official * 100.0) + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/domain/RateAlertPolicy.kt b/app/src/main/java/com/codeonyx/bcvaqui/domain/RateAlertPolicy.kt new file mode 100644 index 0000000..7631c59 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/domain/RateAlertPolicy.kt @@ -0,0 +1,38 @@ +package com.codeonyx.bcvaqui.domain + +import kotlin.math.abs + +/** + * Decide si un cambio de tasa merece notificación. + * - BCV: la tasa oficial cambia una vez por publicación; se notifica cuando + * realmente cambia y supera el umbral del usuario (0 = cualquier cambio real). + * - USDT (Binance P2P): es volátil; se exige siempre un umbral mínimo para + * no notificar ruido minuto a minuto. + */ +object RateAlertPolicy { + /** Umbral mínimo para USDT aunque el usuario tenga 0 %. */ + const val MIN_USDT_THRESHOLD_PERCENT = 1.0 + + fun changePercent(oldRate: Double, newRate: Double): Double { + if (oldRate <= 0.0) return 0.0 + return (newRate - oldRate) / oldRate * 100.0 + } + + fun shouldNotify( + currency: String, + oldRate: Double, + newRate: Double, + thresholdPercent: Double + ): Boolean { + if (oldRate <= 0.0 || newRate <= 0.0) return false + // Ignora diferencias de redondeo (< 0,01 Bs.). + if (abs(FxCalculator.round2(newRate) - FxCalculator.round2(oldRate)) < 0.01) return false + val change = abs(changePercent(oldRate, newRate)) + val threshold = if (currency == RateSources.BINANCE_USDT) { + maxOf(thresholdPercent, MIN_USDT_THRESHOLD_PERCENT) + } else { + thresholdPercent.coerceAtLeast(0.0) + } + return change >= threshold + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/domain/RateNotifier.kt b/app/src/main/java/com/codeonyx/bcvaqui/domain/RateNotifier.kt new file mode 100644 index 0000000..55959eb --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/domain/RateNotifier.kt @@ -0,0 +1,65 @@ +package com.codeonyx.bcvaqui.domain + +import kotlin.math.abs + +/** Fuentes que pueden notificar, cada una con su propia línea base. */ +enum class NotifySource(val key: String, val title: String, val currency: String) { + BCV_USD("BCV_USD", "Dólar BCV", "USD"), + BCV_EUR("BCV_EUR", "Euro BCV", "EUR"), + USDT("USDT", "USDT Binance", RateSources.BINANCE_USDT) +} + +data class RateNotification( + val source: NotifySource, + val oldRate: Double, + val newRate: Double, + val changePercent: Double +) { + val up: Boolean get() = newRate > oldRate + /** "Dólar BCV subió 0,85 %" */ + val title: String get() = "${source.title} ${if (up) "subió" else "bajó"} ${FxCalculator.format2(abs(changePercent))} %" + /** "888,12 Bs. · antes 880,64 Bs." */ + val body: String get() = "${FxCalculator.format2(newRate)} Bs. · antes ${FxCalculator.format2(oldRate)} Bs." +} + +data class NotifyDecision( + val notifications: List, + /** Nuevas líneas base por fuente (se persisten siempre). */ + val baselines: Map +) + +/** + * Detección de cambios por fuente: cada fuente se compara SOLO contra su propio + * último valor. Cambiar la moneda seleccionada no afecta nada aquí. + */ +object RateNotifier { + fun isQuietHour(hour: Int, enabled: Boolean, start: Int, end: Int): Boolean { + if (!enabled || start == end) return false + return if (start < end) hour in start until end else hour >= start || hour < end + } + + fun decide( + current: Map, + baselines: Map, + enabled: Set, + thresholdPercent: Double, + quiet: Boolean + ): NotifyDecision { + val next = baselines.toMutableMap() + val out = mutableListOf() + for ((source, rate) in current) { + if (rate == null || rate <= 0.0) continue + val old = baselines[source.key] + if (old == null || source !in enabled) { + // Primera vez o fuente apagada: solo fija la base, sin avisar. + next[source.key] = rate + continue + } + if (!RateAlertPolicy.shouldNotify(source.currency, old, rate, thresholdPercent)) continue + if (quiet) continue // se mantiene la base: avisará al terminar las horas de silencio + out += RateNotification(source, old, rate, RateAlertPolicy.changePercent(old, rate)) + next[source.key] = rate + } + return NotifyDecision(out, next) + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/domain/ShareFormatter.kt b/app/src/main/java/com/codeonyx/bcvaqui/domain/ShareFormatter.kt new file mode 100644 index 0000000..e500bd0 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/domain/ShareFormatter.kt @@ -0,0 +1,133 @@ +package com.codeonyx.bcvaqui.domain + +import com.codeonyx.bcvaqui.data.model.ColombianBanks +import com.codeonyx.bcvaqui.data.model.PaymentContact +import com.codeonyx.bcvaqui.data.model.PaymentType + +/** Datos de una conversión a compartir. */ +data class ShareConversion( + /** Nombre visible de la tasa: BCV, Euro, Usdt o una etiqueta propia. */ + val rateLabel: String, + val currencyCode: String, + val foreignAmount: Double, + val vesAmount: Double, + /** Monto equivalente en USD (para Zelle, PayPal, Binance…). null = usar foreignAmount si es USD/USDT. */ + val usdAmount: Double? = null +) + +/** + * Desglose detallado para compartir: + * -Tasa: Usdt + * -Monto en Dolares: 43,00 + * -Monto Bs: 41.470,06 + * + * -Pago Movil: + * -Tlf: ... + */ +object ShareFormatter { + + fun rateLabelFor(currencyCode: String, customLabel: String? = null): String = when { + !customLabel.isNullOrBlank() -> customLabel.trim() + currencyCode == "USD" -> "BCV" + currencyCode == "EUR" -> "Euro" + currencyCode == RateSources.BINANCE_USDT -> "Usdt" + else -> currencyCode + } + + private fun amountLabel(currencyCode: String): String = when (currencyCode) { + "EUR" -> "Monto en Euros" + "USD", RateSources.BINANCE_USDT -> "Monto en Dolares" + else -> "Monto en $currencyCode" + } + + fun usdFor(conv: ShareConversion): Double? = conv.usdAmount + ?: conv.foreignAmount.takeIf { conv.currencyCode == "USD" || conv.currencyCode == RateSources.BINANCE_USDT } + + fun build(conv: ShareConversion, methods: List, copPerUsd: Double? = null): String { + val usd = usdFor(conv) + return buildString { + appendLine("-Tasa: ${conv.rateLabel}") + appendLine("-${amountLabel(conv.currencyCode)}: ${FxCalculator.format2(conv.foreignAmount)}") + append("-Monto Bs: ${FxCalculator.format2(conv.vesAmount)}") + methods.forEach { method -> + val block = methodBlock(method, usd, copPerUsd) + if (block.isNotBlank()) { + appendLine() + appendLine() + append(block) + } + } + } + } + + fun methodBlock(m: PaymentContact, usdAmount: Double?, copPerUsd: Double?): String { + val lines = mutableListOf() + fun add(label: String, value: String) { if (value.isNotBlank()) lines += "-$label: ${value.trim()}" } + val bank = when { + m.bankCode.isNotBlank() && m.bankName.isNotBlank() -> "${m.bankCode} - ${m.bankName}" + else -> m.bankName.ifBlank { m.bankCode } + } + when (m.type) { + PaymentType.PAGO_MOVIL -> { + lines += "-Pago Movil:" + add("Tlf", m.phone); add("CI", m.documentId); add("Banco", bank) + } + PaymentType.CUENTA_VE -> { + lines += "-Transferencia:" + add("Titular", m.displayName); add("Cuenta", m.accountNumber) + add("CI", m.documentId); add("Banco", bank) + } + PaymentType.CUENTA_EXTRANJERA -> { + lines += "-Cuenta extranjera:" + add("Titular", m.displayName); add("Cuenta/IBAN", m.accountNumber); add("Banco", m.bankName) + add("País", m.country); add("Moneda", m.currency); add("Documento", m.documentId); add("Datos", m.extraInfo) + } + PaymentType.ZELLE -> { + lines += "-Zelle:" + add("Nombre", m.displayName); add("Correo", m.email); add("Tlf", m.phone) + } + PaymentType.PAYPAL -> { + lines += "-PayPal:" + add("Nombre", m.displayName); add("Correo", m.email) + if (m.userHandle.isNotBlank()) add("Link", paypalMe(m.userHandle)) + } + PaymentType.BINANCE -> { + lines += "-Binance Pay:" + add("Pay ID", m.accountNumber); add("Correo", m.email); add("Usuario", m.userHandle); add("Nombre", m.displayName) + } + PaymentType.ZINLI -> { + lines += "-Zinli:" + add("Nombre", m.displayName); add("Correo", m.email); add("Tlf", m.phone) + } + PaymentType.WALLY -> { + lines += "-Wally:" + add("Nombre", m.displayName); add("Correo", m.email); add("Tlf", m.phone); add("Usuario", m.userHandle) + } + PaymentType.CUENTA_CO -> { + lines += if (ColombianBanks.isWallet(m.bankName)) "-${m.bankName.trim()}:" else "-Cuenta Colombia:" + if (!ColombianBanks.isWallet(m.bankName)) add("Banco", m.bankName) + add("Tipo", m.accountType); add("Número", m.accountNumber); add("Tlf", m.phone) + add("Titular", m.displayName); add("CC/NIT", m.documentId) + if (usdAmount != null && copPerUsd != null && copPerUsd > 0) { + add("Monto COP", formatCop(usdAmount * copPerUsd)) + } + } + } + if (m.type.isUsd && usdAmount != null) { + val unit = if (m.type == PaymentType.BINANCE) "USDT" else "USD" + lines += "-Monto a enviar: ${FxCalculator.format2(usdAmount)} $unit" + } + return if (lines.size <= 1 && !(m.type.isUsd && usdAmount != null)) "" else lines.joinToString("\n") + } + + fun paypalMe(handle: String): String { + val h = handle.trim().removePrefix("https://").removePrefix("http://").removePrefix("www.") + return if (h.startsWith("paypal.me/", ignoreCase = true)) h else "paypal.me/${h.removePrefix("@")}" + } + + /** COP sin decimales con punto de miles: 172.000 */ + fun formatCop(value: Double): String { + val rounded = Math.round(value) + return "%,d".format(java.util.Locale.US, rounded).replace(',', '.') + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/domain/SyncPolicy.kt b/app/src/main/java/com/codeonyx/bcvaqui/domain/SyncPolicy.kt new file mode 100644 index 0000000..262a6e0 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/domain/SyncPolicy.kt @@ -0,0 +1,38 @@ +package com.codeonyx.bcvaqui.domain + +import java.time.DayOfWeek +import java.time.Duration +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZonedDateTime + +/** Reglas puras de sincronización en segundo plano (testeables sin Android). */ +object SyncPolicy { + val CARACAS: ZoneId = ZoneId.of("America/Caracas") + + /** El BCV suele publicar la tasa del día hábil siguiente entre 15:00 y 18:00 (hora de Caracas). */ + val BCV_PUBLISH_CHECK: LocalTime = LocalTime.of(17, 30) + + const val WIDGET_INTERVAL_MINUTES = 60L + + /** + * Intervalo del sync periódico único, o null si no hace falta ninguno + * (sin widgets y sin notificaciones). + */ + fun periodicIntervalMinutes(hasWidgets: Boolean, notificationHours: Int): Long? { + val notif = if (notificationHours > 0) notificationHours * 60L else null + val widget = if (hasWidgets) WIDGET_INTERVAL_MINUTES else null + return listOfNotNull(notif, widget).minOrNull() + } + + /** Milisegundos hasta el próximo chequeo de publicación BCV (lunes a viernes 17:30 Caracas). */ + fun delayToNextBcvPublishMs(now: ZonedDateTime): Long { + val local = now.withZoneSameInstant(CARACAS) + var target = local.with(BCV_PUBLISH_CHECK).withSecond(0).withNano(0) + if (!target.isAfter(local)) target = target.plusDays(1) + while (target.dayOfWeek == DayOfWeek.SATURDAY || target.dayOfWeek == DayOfWeek.SUNDAY) { + target = target.plusDays(1) + } + return Duration.between(local, target).toMillis() + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/domain/VeNumber.kt b/app/src/main/java/com/codeonyx/bcvaqui/domain/VeNumber.kt new file mode 100644 index 0000000..09d575b --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/domain/VeNumber.kt @@ -0,0 +1,67 @@ +package com.codeonyx.bcvaqui.domain + +/** + * Entrada numérica en formato venezolano. + * + * Forma canónica del campo: sin separador de miles y con coma decimal (ej. "1234,56"). + * Acepta texto pegado como "Bs. 1.234,56", "1,234.56", "$ 50" o "1.500". + */ +object VeNumber { + private const val MAX_DECIMALS = 4 + + /** Convierte cualquier texto (tecleado o pegado) a la forma canónica del campo. */ + fun sanitize(raw: String, maxDecimals: Int = MAX_DECIMALS): String { + var cleaned = raw.filter { it.isDigit() || it == ',' || it == '.' } + // Punto tecleado después de una coma ya existente: se ignora. + if (cleaned.contains(',') && cleaned.endsWith('.')) cleaned = cleaned.dropLast(1) + if (cleaned.isEmpty()) return "" + val decimalIndex = decimalSeparatorIndex(cleaned) + return if (decimalIndex < 0) { + cleaned.filter(Char::isDigit).trimLeadingZeros() + } else { + val intPart = cleaned.substring(0, decimalIndex).filter(Char::isDigit).trimLeadingZeros() + val decPart = cleaned.substring(decimalIndex + 1).filter(Char::isDigit).take(maxDecimals) + (intPart.ifEmpty { "0" }) + "," + decPart + } + } + + /** Valor numérico del texto (canónico o pegado). Devuelve 0.0 si no es válido. */ + fun parse(raw: String): Double { + val canonical = sanitize(raw, maxDecimals = 8) + if (canonical.isEmpty()) return 0.0 + return canonical.replace(',', '.').toDoubleOrNull() ?: 0.0 + } + + /** Valor -> texto canónico editable (sin miles), ej. 1234.5 -> "1234,5". */ + fun toInput(value: Double): String { + if (value <= 0.0) return "" + val rounded = FxCalculator.round2(value) + val text = java.math.BigDecimal.valueOf(rounded).stripTrailingZeros().toPlainString() + return text.replace('.', ',') + } + + /** + * Decide qué carácter es el decimal: + * - Si hay coma y punto, el último que aparece es el decimal. + * - Solo comas: la última es decimal (formato venezolano). + * - Solo puntos: varios = miles; uno seguido de exactamente 3 dígitos en un + * texto pegado ("1.500") = miles; en otro caso es decimal. + */ + private fun decimalSeparatorIndex(text: String): Int { + val lastComma = text.lastIndexOf(',') + val lastDot = text.lastIndexOf('.') + if (lastComma >= 0 && lastDot >= 0) return maxOf(lastComma, lastDot) + if (lastComma >= 0) return lastComma + if (lastDot < 0) return -1 + val dots = text.count { it == '.' } + if (dots > 1) return -1 + val after = text.length - lastDot - 1 + val before = lastDot + return if (after == 3 && before in 1..3) -1 else lastDot + } + + private fun String.trimLeadingZeros(): String { + val trimmed = trimStart('0') + return if (trimmed.isEmpty() && isNotEmpty()) "0" else trimmed + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/notification/NotificationHelper.kt b/app/src/main/java/com/codeonyx/bcvaqui/notification/NotificationHelper.kt index dd58000..0a5eb1a 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/notification/NotificationHelper.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/notification/NotificationHelper.kt @@ -30,11 +30,53 @@ object NotificationHelper { showNotification(context, RATE_NOTIFICATION_ID, title, body) } + private const val DEBT_NOTIFICATION_ID = 1002 + + /** Recordatorio de deudas próximas o vencidas (mismo canal de notificaciones). */ + fun showDebtReminder(context: Context, title: String, body: String) { + runCatching { showNotification(context, DEBT_NOTIFICATION_ID, title, body) } + } + + private const val GROUP_RATES = "spreadve_rates" + private const val SUMMARY_ID = 1000 + + /** Una notificación por fuente (id estable = reemplaza, no duplica) y un resumen agrupado. */ + fun showRateNotifications(context: Context, items: List) { + if (items.isEmpty()) return + runCatching { + items.forEach { n -> + showNotification(context, RATE_NOTIFICATION_ID + 10 + n.source.ordinal, n.title, n.body, GROUP_RATES) + } + if (items.size > 1) { + val inbox = NotificationCompat.InboxStyle().also { st -> items.forEach { st.addLine(it.title) } } + val summary = NotificationCompat.Builder(context, BcvAquiApp.CHANNEL_RATE_CHANGES) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("Cambios de tasa") + .setContentText(items.joinToString(" · ") { it.source.title }) + .setStyle(inbox) + .setGroup(GROUP_RATES) + .setGroupSummary(true) + .setContentIntent(openApp(context, SUMMARY_ID)) + .setAutoCancel(true) + .build() + NotificationManagerCompat.from(context).notify(SUMMARY_ID, summary) + } + } + } + + private fun openApp(context: Context, requestCode: Int): PendingIntent { + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + return PendingIntent.getActivity(context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + private fun showNotification( context: Context, notificationId: Int, title: String, - body: String + body: String, + group: String? = null ) { val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP @@ -53,6 +95,7 @@ object NotificationHelper { .setStyle(NotificationCompat.BigTextStyle().bigText(body)) .setContentIntent(pendingIntent) .setAutoCancel(true) + .apply { group?.let { setGroup(it) } } .build() NotificationManagerCompat.from(context).notify(notificationId, notification) diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/MainViewModel.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/MainViewModel.kt index cf8417b..294a536 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/MainViewModel.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/MainViewModel.kt @@ -39,6 +39,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val _historyError = MutableStateFlow(null) val historyError: StateFlow = _historyError.asStateFlow() + private val _market = MutableStateFlow(null) + val market: StateFlow = _market.asStateFlow() + + private val _marketLoading = MutableStateFlow(false) + val marketLoading: StateFlow = _marketLoading.asStateFlow() + + fun loadMarket() { + if (_marketLoading.value) return + viewModelScope.launch { + _marketLoading.value = true + _market.value = repository.fetchMarketSeries() + _marketLoading.value = false + } + } + + fun setMerchantMargin(percent: Double) { + viewModelScope.launch { preferencesRepository.setMerchantMarginPercent(percent) } + } + init { refresh(forceNetwork = true) } @@ -90,7 +109,42 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + fun setPalette(palette: com.codeonyx.bcvaqui.domain.AppPalette) { + viewModelScope.launch { + com.codeonyx.bcvaqui.ui.theme.PaletteStore.write(getApplication(), palette) + preferencesRepository.setPalette(palette.name) + com.codeonyx.bcvaqui.ui.theme.PaletteManager.applyLauncherIcon(getApplication(), palette) + Widgets.updateAll(getApplication()) + } + } + + fun setThemeMode(mode: String) { + viewModelScope.launch { preferencesRepository.setThemeMode(mode) } + } + + fun addCalculatorHistory(entry: String) { + viewModelScope.launch { preferencesRepository.addCalculatorHistory(entry) } + } + + fun clearCalculatorHistory() { + viewModelScope.launch { preferencesRepository.clearCalculatorHistory() } + } + + val tourRequested = kotlinx.coroutines.flow.MutableStateFlow(false) + fun replayTour() { tourRequested.value = true } + + fun setNotifySource(key: String, on: Boolean) = viewModelScope.launch { + preferencesRepository.setNotifySource(key, on) + } + fun setQuietHours(enabled: Boolean, start: Int, end: Int) = viewModelScope.launch { + preferencesRepository.setQuietHours(enabled, start, end) + } + fun setDebtReminders(on: Boolean) = viewModelScope.launch { preferencesRepository.setDebtReminders(on) } + fun setLiteMode(on: Boolean) = viewModelScope.launch { preferencesRepository.setLiteMode(on) } + fun setAutoUpdateCheck(on: Boolean) = viewModelScope.launch { preferencesRepository.setAutoUpdateCheck(on) } + fun completeOnboarding() { + tourRequested.value = false viewModelScope.launch { preferencesRepository.setHasSeenOnboarding(true) } @@ -133,6 +187,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + fun setDefaultPaymentContact(id: String) { + viewModelScope.launch { preferencesRepository.setDefaultPaymentContact(id) } + } + fun savePaymentContact(contact: PaymentContact) { viewModelScope.launch { preferencesRepository.upsertPaymentContact(contact) diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/Navigation.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/Navigation.kt index eae1751..cbf1288 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/Navigation.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/Navigation.kt @@ -1,5 +1,6 @@ package com.codeonyx.bcvaqui.ui +import androidx.compose.ui.res.stringResource import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -31,6 +32,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector @@ -49,15 +51,21 @@ import com.codeonyx.bcvaqui.ui.settings.SettingsScreen import com.codeonyx.bcvaqui.ui.theme.BcvBlue import com.codeonyx.bcvaqui.ui.theme.SurfaceLight import com.codeonyx.bcvaqui.ui.tools.ComisionScreen -import com.codeonyx.bcvaqui.ui.tools.CompartirToolScreen import com.codeonyx.bcvaqui.ui.tools.DividirScreen import com.codeonyx.bcvaqui.ui.tools.HistorialScreen import com.codeonyx.bcvaqui.ui.tools.ToolRoutes +import com.codeonyx.bcvaqui.ui.theme.BrandColors +import com.codeonyx.bcvaqui.ui.tools.IvaIgtfScreen +import com.codeonyx.bcvaqui.ui.tools.BrechaScreen import com.codeonyx.bcvaqui.ui.tools.ToolsHubScreen -import com.codeonyx.bcvaqui.ui.tools.VariacionScreen +import com.codeonyx.bcvaqui.ui.home.CalculatorPanel import com.codeonyx.bcvaqui.ui.tools.agenda.AgendaPagosScreen import com.codeonyx.bcvaqui.ui.tools.toolSubtitle import com.codeonyx.bcvaqui.ui.tools.toolTitle +import com.codeonyx.bcvaqui.ui.tour.TourKeys +import com.codeonyx.bcvaqui.ui.tour.tourTarget +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle sealed class AppRoute( val route: String, @@ -83,6 +91,13 @@ fun BcvAquiNavHost() { currentDestination?.hierarchy?.any { d -> d.route == route.route } == true } val isHome = currentBottom == AppRoute.Home + val tourTargets = remember { androidx.compose.runtime.mutableStateMapOf() } + val prefs by viewModel.preferences.collectAsStateWithLifecycle() + val tourRequested by viewModel.tourRequested.collectAsStateWithLifecycle() + val showTour = tourRequested || !prefs.hasSeenOnboarding + var tourFocus by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(null) } + val lite = prefs.liteMode && !showTour + val visibleRoutes = if (lite) listOf(AppRoute.Home, AppRoute.Settings) else bottomRoutes val toolLeaf = currentDestination?.route?.takeIf { it != ToolRoutes.HUB && it != ToolRoutes.GRAPH && currentBottom == AppRoute.Tools @@ -95,10 +110,14 @@ fun BcvAquiNavHost() { null -> "app" } + androidx.compose.runtime.CompositionLocalProvider( + com.codeonyx.bcvaqui.ui.tour.LocalTourTargets provides tourTargets, + com.codeonyx.bcvaqui.ui.tour.LocalTourFocus provides tourFocus + ) { Box( modifier = Modifier .fillMaxSize() - .background(SurfaceLight) + .background(MaterialTheme.colorScheme.background) ) { Scaffold( containerColor = MaterialTheme.colorScheme.background, @@ -114,7 +133,7 @@ fun BcvAquiNavHost() { Icon( Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Volver", - tint = MaterialTheme.colorScheme.onPrimary + tint = BrandColors.onTopBar ) } } @@ -129,14 +148,14 @@ fun BcvAquiNavHost() { key == "home" -> { Column { Text( - text = "SpreadVE", + text = "Codivisas", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge ) Text( - text = "Tasas BCV y spread P2P", + text = "Tasas BCV y brecha P2P", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f) + color = BrandColors.onTopBarMuted ) } } @@ -150,14 +169,14 @@ fun BcvAquiNavHost() { else -> { Column { Text( - text = toolTitle(toolLeaf), + text = stringResource(toolTitle(toolLeaf)), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge ) Text( - text = toolSubtitle(toolLeaf), + text = stringResource(toolSubtitle(toolLeaf)), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f) + color = BrandColors.onTopBarMuted ) } } @@ -165,8 +184,10 @@ fun BcvAquiNavHost() { } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = BcvBlue, - titleContentColor = MaterialTheme.colorScheme.onPrimary + containerColor = BrandColors.topBar, + titleContentColor = BrandColors.onTopBar, + navigationIconContentColor = BrandColors.onTopBar, + actionIconContentColor = BrandColors.onTopBar ) ) }, @@ -175,9 +196,14 @@ fun BcvAquiNavHost() { containerColor = MaterialTheme.colorScheme.surface, tonalElevation = 8.dp ) { - bottomRoutes.forEach { route -> + visibleRoutes.forEach { route -> val selected = currentDestination?.hierarchy?.any { it.route == route.route } == true NavigationBarItem( + modifier = when (route) { + AppRoute.Tools -> Modifier.tourTarget(TourKeys.NAV_TOOLS) + AppRoute.Settings -> Modifier.tourTarget(TourKeys.NAV_SETTINGS) + else -> Modifier + }, selected = selected, onClick = { navController.navigate(route.route) { @@ -196,8 +222,8 @@ fun BcvAquiNavHost() { }, label = { Text(route.label) }, colors = NavigationBarItemDefaults.colors( - selectedIconColor = BcvBlue, - selectedTextColor = BcvBlue, + selectedIconColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, indicatorColor = MaterialTheme.colorScheme.primaryContainer, unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant @@ -211,6 +237,7 @@ fun BcvAquiNavHost() { modifier = Modifier .padding(padding) .fillMaxSize() + .tourTarget(if (currentBottom == AppRoute.Settings) TourKeys.SETTINGS_CONTENT else if (currentBottom == AppRoute.Tools) TourKeys.TOOLS_CONTENT else "content") .background( if (isHome) Color.Transparent else MaterialTheme.colorScheme.background ) @@ -225,7 +252,7 @@ fun BcvAquiNavHost() { popExitTransition = { Motion.tabExit() } ) { composable(AppRoute.Home.route) { - HomeScreen(viewModel = viewModel) + if (lite) com.codeonyx.bcvaqui.ui.home.LiteHomeScreen(viewModel) else HomeScreen(viewModel = viewModel) } navigation( startDestination = ToolRoutes.HUB, @@ -242,11 +269,16 @@ fun BcvAquiNavHost() { } toolComposable(ToolRoutes.DONDE) { DondeScreen(viewModel) } toolComposable(ToolRoutes.COMISION) { ComisionScreen() } - toolComposable(ToolRoutes.VARIACION) { VariacionScreen(viewModel) } + toolComposable(ToolRoutes.CALCULADORA) { CalculatorPanel(viewModel) } toolComposable(ToolRoutes.HISTORIAL) { HistorialScreen(viewModel) } toolComposable(ToolRoutes.DIVIDIR) { DividirScreen(viewModel) } - toolComposable(ToolRoutes.COMPARTIR) { CompartirToolScreen(viewModel) } toolComposable(ToolRoutes.AGENDA) { AgendaPagosScreen(viewModel) } + toolComposable(ToolRoutes.IVA_IGTF) { IvaIgtfScreen(viewModel) } + toolComposable(ToolRoutes.BRECHA) { BrechaScreen(viewModel) } + toolComposable(ToolRoutes.MERCADO) { com.codeonyx.bcvaqui.ui.tools.MercadoScreen(viewModel) } + toolComposable(ToolRoutes.DEUDAS) { com.codeonyx.bcvaqui.ui.tools.finance.DeudasScreen(viewModel) } + toolComposable(ToolRoutes.AHORROS) { com.codeonyx.bcvaqui.ui.tools.finance.AhorrosScreen(viewModel) } + toolComposable(ToolRoutes.COMERCIANTE) { com.codeonyx.bcvaqui.ui.tools.ComercianteScreen(viewModel) } } composable(AppRoute.Settings.route) { SettingsScreen( @@ -259,6 +291,33 @@ fun BcvAquiNavHost() { } } } + if (showTour) { + com.codeonyx.bcvaqui.ui.tour.TourOverlay( + targets = tourTargets, + onNavigate = { r -> + val dest = when (r) { "tools" -> AppRoute.Tools.route; "settings" -> AppRoute.Settings.route; else -> AppRoute.Home.route } + if (currentBottom?.route != dest) navController.navigate(dest) { + popUpTo(navController.graph.findStartDestination().id) { saveState = true } + launchSingleTop = true; restoreState = true + } + }, + onFocus = { tourFocus = it }, + onLite = { + viewModel.setLiteMode(true) + viewModel.completeOnboarding() + navController.navigate(AppRoute.Home.route) { + popUpTo(navController.graph.findStartDestination().id); launchSingleTop = true + } + }, + onFinish = { + viewModel.completeOnboarding() + navController.navigate(AppRoute.Home.route) { + popUpTo(navController.graph.findStartDestination().id); launchSingleTop = true + } + } + ) + } + } } } diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/donde/DondeScreen.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/donde/DondeScreen.kt index fb396d4..8b32818 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/donde/DondeScreen.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/donde/DondeScreen.kt @@ -1,5 +1,8 @@ package com.codeonyx.bcvaqui.ui.donde +import androidx.compose.material.icons.filled.Close +import androidx.lifecycle.compose.collectAsStateWithLifecycle + import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -78,9 +81,9 @@ import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable fun DondeScreen(viewModel: MainViewModel = appViewModel()) { - val dashboard by viewModel.dashboard.collectAsState() - val preferences by viewModel.preferences.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() + val dashboard by viewModel.dashboard.collectAsStateWithLifecycle() + val preferences by viewModel.preferences.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() val rates = dashboard?.bcvRates.orEmpty() val worldRates = dashboard?.worldRatesUsd.orEmpty() @@ -128,7 +131,7 @@ fun DondeScreen(viewModel: MainViewModel = appViewModel()) { LazyColumn( modifier = Modifier .fillMaxSize() - .background(SurfaceLight), + .background(MaterialTheme.colorScheme.background), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -151,7 +154,7 @@ fun DondeScreen(viewModel: MainViewModel = appViewModel()) { ) } IconButton(onClick = { showCurrencyPicker = true }) { - Icon(Icons.Default.Edit, contentDescription = "Elegir monedas", tint = BcvBlue) + Icon(Icons.Default.Edit, contentDescription = "Elegir monedas", tint = MaterialTheme.colorScheme.primary) } IconButton( onClick = { viewModel.refresh(forceNetwork = true) }, @@ -170,7 +173,7 @@ fun DondeScreen(viewModel: MainViewModel = appViewModel()) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(18.dp), - colors = CardDefaults.cardColors(containerColor = Color.White), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Column(modifier = Modifier.padding(14.dp)) { @@ -198,7 +201,7 @@ fun DondeScreen(viewModel: MainViewModel = appViewModel()) { Surface( shape = RoundedCornerShape(14.dp), - color = BcvBlue.copy(alpha = 0.06f), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.06f), modifier = Modifier.fillMaxWidth() ) { Row( @@ -210,7 +213,7 @@ fun DondeScreen(viewModel: MainViewModel = appViewModel()) { OutlinedTextField( value = amountInput, onValueChange = { raw -> - amountInput = raw.filter { c -> c.isDigit() || c == '.' } + amountInput = com.codeonyx.bcvaqui.domain.VeNumber.sanitize(raw) .let { cleaned -> val parts = cleaned.split('.') if (parts.size <= 1) cleaned @@ -219,16 +222,22 @@ fun DondeScreen(viewModel: MainViewModel = appViewModel()) { }, label = { Text(sourceDest?.chipLabel ?: sourceCurrency) }, suffix = { Text(sourceSymbol, color = MaterialTheme.colorScheme.onSurfaceVariant) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + trailingIcon = if (amountInput.isNotEmpty()) { { + androidx.compose.material3.IconButton(onClick = { amountInput = "" }) { + androidx.compose.material3.Icon(androidx.compose.material.icons.Icons.Default.Close, "Borrar monto") + } + } } else null, + textStyle = MaterialTheme.typography.titleMedium, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = androidx.compose.ui.text.input.ImeAction.Done), modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), singleLine = true, colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = BcvBlue, + focusedBorderColor = MaterialTheme.colorScheme.primary, unfocusedBorderColor = Color.Transparent, - focusedContainerColor = Color.White, - unfocusedContainerColor = Color.White, - focusedLabelColor = BcvBlue + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedLabelColor = MaterialTheme.colorScheme.primary ) ) } @@ -276,13 +285,13 @@ fun DondeScreen(viewModel: MainViewModel = appViewModel()) { Box( modifier = Modifier .fillMaxSize() - .background(SurfaceLight) + .background(MaterialTheme.colorScheme.background) .padding(24.dp), contentAlignment = Alignment.Center ) { Surface( shape = RoundedCornerShape(18.dp), - color = Color.White, + color = MaterialTheme.colorScheme.surface, shadowElevation = 2.dp ) { Column( @@ -391,7 +400,7 @@ private fun FlagBadge(flag: String, size: Dp) { modifier = Modifier .size(size) .clip(CircleShape) - .background(Color.White) + .background(MaterialTheme.colorScheme.surface) .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), CircleShape), contentAlignment = Alignment.Center ) { @@ -414,7 +423,7 @@ private fun CompactBaseChip( onClick: () -> Unit ) { val borderColor = if (selected) BcvBlue else MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) - val bg = if (selected) BcvBlue.copy(alpha = 0.12f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) + val bg = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier @@ -448,7 +457,7 @@ private fun CurrencyCheckRow( onClick: () -> Unit ) { val borderColor = if (selected) BcvBlue else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) - val bg = if (selected) BcvBlue.copy(alpha = 0.08f) else MaterialTheme.colorScheme.surface + val bg = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.08f) else MaterialTheme.colorScheme.surface Row( modifier = Modifier .fillMaxWidth() @@ -515,7 +524,7 @@ private fun ResultRow( onClick = onClick, enabled = !isBase, shape = RoundedCornerShape(16.dp), - color = if (isBase) Color(0xFFE8EEF8) else Color.White, + color = if (isBase) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) else MaterialTheme.colorScheme.surface, shadowElevation = if (isBase) 0.dp else 2.dp, modifier = Modifier.fillMaxWidth() ) { @@ -549,7 +558,7 @@ private fun ResultRow( Spacer(modifier = Modifier.width(8.dp)) Surface( shape = RoundedCornerShape(6.dp), - color = BcvBlue.copy(alpha = 0.15f) + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) ) { Text( text = "Base", @@ -566,7 +575,7 @@ private fun ResultRow( text = amountText, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = if (isBase) BcvBlue else MaterialTheme.colorScheme.onSurface, + color = if (isBase) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.End, maxLines = 1, softWrap = false @@ -576,7 +585,7 @@ private fun ResultRow( } private fun parseAmountInput(raw: String): Double = - raw.replace(",", "").toDoubleOrNull() ?: 0.0 + com.codeonyx.bcvaqui.domain.VeNumber.parse(raw) private fun formatInputAmount(value: Double): String { val rounded = FxCalculator.round2(value) diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/home/HomeComponents.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/home/HomeComponents.kt new file mode 100644 index 0000000..9fca297 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/home/HomeComponents.kt @@ -0,0 +1,422 @@ +package com.codeonyx.bcvaqui.ui.home + +import androidx.compose.runtime.mutableIntStateOf +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +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.Calculate +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.SwapVert +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +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.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.codeonyx.bcvaqui.data.model.PaymentContact +import com.codeonyx.bcvaqui.domain.FxCalculator +import com.codeonyx.bcvaqui.domain.RateSources +import com.codeonyx.bcvaqui.ui.MainViewModel +import com.codeonyx.bcvaqui.ui.ResultSpring +import com.codeonyx.bcvaqui.ui.appViewModel +import com.codeonyx.bcvaqui.ui.share.ReceiptData +import com.codeonyx.bcvaqui.ui.share.ReceiptShareHelper +import com.codeonyx.bcvaqui.ui.theme.BcvBlue +import com.codeonyx.bcvaqui.ui.theme.BcvBlueDark +import com.codeonyx.bcvaqui.ui.theme.BcvGold +import com.codeonyx.bcvaqui.ui.theme.BcvIndigo +import com.codeonyx.bcvaqui.ui.theme.currencyAccent +import androidx.compose.ui.res.stringResource +import com.codeonyx.bcvaqui.R +import com.codeonyx.bcvaqui.ui.theme.BrandColors +import com.codeonyx.bcvaqui.domain.DataAge +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.TrendingUp +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ShareWithPaymentSheet( + contacts: List, + defaultCopRate: Double?, + onDismiss: () -> Unit, + onShare: (List, Double?) -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val selected = remember(contacts) { + androidx.compose.runtime.mutableStateListOf().apply { + contacts.filter { it.isDefault }.forEach { add(it.id) } + } + } + val hasCo = contacts.any { it.id in selected && it.type == com.codeonyx.bcvaqui.data.model.PaymentType.CUENTA_CO } + var copInput by remember { mutableStateOf(com.codeonyx.bcvaqui.domain.VeNumber.toInput(defaultCopRate ?: 0.0)) } + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 28.dp) + ) { + Text( + text = stringResource(R.string.share_sheet_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(R.string.share_sheet_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(12.dp)) + contacts.forEach { contact -> + val checked = contact.id in selected + Surface( + onClick = { if (checked) selected.remove(contact.id) else selected.add(contact.id) }, + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = if (checked) 0.16f else 0.06f), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Row(modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material3.Checkbox(checked = checked, onCheckedChange = null) + Column(modifier = Modifier.weight(1f).padding(start = 6.dp)) { + Text(contact.displayName.ifBlank { contact.typeLabel() }, fontWeight = FontWeight.SemiBold) + Text( + "${contact.typeLabel()} · ${contact.subtitle()}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (contact.isDefault) { + Text( + stringResource(R.string.payment_default_badge), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + if (hasCo) { + Spacer(modifier = Modifier.height(8.dp)) + com.codeonyx.bcvaqui.ui.tools.ToolNumberField( + value = copInput, + onValueChange = { copInput = it }, + label = stringResource(R.string.share_cop_rate), + suffix = "COP", + helper = stringResource(R.string.share_cop_rate_help) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + androidx.compose.material3.Button( + onClick = { + val cop = com.codeonyx.bcvaqui.domain.VeNumber.parse(copInput).takeIf { it > 0 } + onShare(contacts.filter { it.id in selected }, cop) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + if (selected.isEmpty()) stringResource(R.string.share_only_conversion) + else stringResource(R.string.share_with_methods, selected.size) + ) + } + } + } +} + +@Composable +internal fun OfflineBanner( + message: String, + onRetry: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = Color(0xFFFBBF24) + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = com.codeonyx.bcvaqui.domain.OfflineRates.BANNER_TITLE, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = Color(0xFF78350F) + ) + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF92400E) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Surface( + onClick = onRetry, + shape = RoundedCornerShape(12.dp), + color = Color(0xFF78350F) + ) { + Text( + text = "Reintentar", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = Color.White + ) + } + } + } +} + +@Composable +internal fun CurrencyBadge( + code: String, + size: androidx.compose.ui.unit.Dp, + accent: Color +) { + Surface( + modifier = Modifier.size(size), + shape = CircleShape, + color = accent.copy(alpha = 0.15f), + border = androidx.compose.foundation.BorderStroke(1.5.dp, accent.copy(alpha = 0.4f)) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = FxCalculator.currencySymbol(code), + fontWeight = FontWeight.Bold, + fontSize = (size.value * 0.35f).sp, + color = accent + ) + } + } +} + +internal fun copyHomeText(context: Context, text: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("BCV monto", text)) +} + + +/** Estado de error a pantalla completa cuando no hay tasas. */ +@Composable +internal fun HomeErrorState(onRetry: () -> Unit) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = BrandColors.heroGradient + ) + ) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + shadowElevation = 6.dp + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.Refresh, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(36.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + "No pudimos cargar las tasas", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + "Revisa tu conexión e inténtalo nuevamente.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { onRetry() }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) + ) { + Text("Reintentar") + } + } + } + } +} + +private data class OnboardingPage(val title: String, val lines: List) + +private val onboardingPages = listOf( + OnboardingPage( + "Bienvenido a Codivisas", + listOf( + "Tasa oficial BCV para USD, EUR y otras monedas.", + "Referencia USDT de Binance P2P y la brecha con el BCV.", + "Desliza hacia abajo en Inicio para actualizar." + ) + ), + OnboardingPage( + "Sin anuncios, sin registro, sin rastreo", + listOf( + "No mostramos publicidad ni pedimos cuenta.", + "No usamos analíticas ni rastreadores: tus datos de pago se quedan en tu teléfono.", + "Herramientas extra (gráficos, modo comerciante, IVA…) están en la pestaña Herramientas." + ) + ), + OnboardingPage( + "Tasas solo referenciales", + listOf( + "Las tasas provienen de fuentes públicas (BCV, ve.dolarapi.com, Binance P2P) y pueden tener retraso.", + "Verifica siempre la tasa vigente antes de pagar o cobrar.", + "Tip: mantén pulsado el inicio de tu teléfono → Widgets → Codivisas para agregar un widget." + ) + ) +) + +@Composable +internal fun OnboardingDialog(onDone: () -> Unit) { + var page by remember { mutableIntStateOf(0) } + val current = onboardingPages[page] + val last = page == onboardingPages.lastIndex + AlertDialog( + onDismissRequest = {}, + title = { Text(current.title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + current.lines.forEach { Text("• $it") } + Text( + "${page + 1} de ${onboardingPages.size}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + dismissButton = { + if (page > 0) TextButton(onClick = { page-- }) { Text("Atrás") } + else TextButton(onClick = onDone) { Text("Saltar") } + }, + confirmButton = { + Button( + onClick = { if (last) onDone() else page++ }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) + ) { + Text(if (last) "Entendido" else "Siguiente") + } + } + ) +} + +/** Chips de "actualizado hace X" y brecha oficial vs. USDT sobre el fondo del hero. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun HomeStatusRow(lastFetchEpochMs: Long, gapPercent: Double?) { + val age = DataAge.describe(lastFetchEpochMs) + val stale = DataAge.isStale(lastFetchEpochMs) + val parts = buildList { + if (age != null) add(stringResource(R.string.home_updated_ago, age)) + if (gapPercent != null) add("Brecha ${FxCalculator.format2(gapPercent)} %") + } + if (parts.isEmpty()) return + Surface( + shape = RoundedCornerShape(12.dp), + color = if (stale) BcvGold.copy(alpha = 0.25f) else Color.White.copy(alpha = 0.12f) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Schedule, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = parts.joinToString(" · "), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun HeroChip(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String, highlight: Boolean = false) { + Surface( + shape = RoundedCornerShape(12.dp), + color = if (highlight) BcvGold.copy(alpha = 0.25f) else Color.White.copy(alpha = 0.12f) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = text, style = MaterialTheme.typography.labelSmall, color = Color.White) + } + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/home/HomeScreen.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/home/HomeScreen.kt index 3647b15..0aa5793 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/home/HomeScreen.kt @@ -1,5 +1,11 @@ package com.codeonyx.bcvaqui.ui.home +import com.codeonyx.bcvaqui.domain.CopyTexts +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.heightIn +import kotlinx.coroutines.launch +import com.codeonyx.bcvaqui.domain.ShareConversion +import com.codeonyx.bcvaqui.domain.ShareFormatter import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -51,7 +57,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -77,17 +83,27 @@ import com.codeonyx.bcvaqui.ui.appViewModel import com.codeonyx.bcvaqui.ui.share.ReceiptData import com.codeonyx.bcvaqui.ui.share.ReceiptShareHelper import com.codeonyx.bcvaqui.ui.theme.BcvBlue +import com.codeonyx.bcvaqui.R +import com.codeonyx.bcvaqui.ui.theme.BrandColors +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.ExperimentalFoundationApi +import com.codeonyx.bcvaqui.domain.VeNumber +import com.codeonyx.bcvaqui.domain.PriceTools +import androidx.compose.ui.res.stringResource +import androidx.compose.material.icons.filled.Close import com.codeonyx.bcvaqui.ui.theme.BcvBlueDark import com.codeonyx.bcvaqui.ui.theme.BcvGold import com.codeonyx.bcvaqui.ui.theme.BcvIndigo import com.codeonyx.bcvaqui.ui.theme.currencyAccent +import com.codeonyx.bcvaqui.ui.tour.TourKeys +import com.codeonyx.bcvaqui.ui.tour.tourTarget -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun HomeScreen(viewModel: MainViewModel = appViewModel()) { - val dashboard by viewModel.dashboard.collectAsState() - val preferences by viewModel.preferences.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() + val dashboard by viewModel.dashboard.collectAsStateWithLifecycle() + val preferences by viewModel.preferences.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() val context = LocalContext.current val rates = dashboard?.bcvRates.orEmpty() @@ -103,11 +119,20 @@ fun HomeScreen(viewModel: MainViewModel = appViewModel()) { var amountInput by remember { mutableStateOf("1") } var convertToVes by remember { mutableStateOf(true) } var currencyMenuExpanded by remember { mutableStateOf(false) } - var copyMenuExpanded by remember { mutableStateOf(false) } + val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() } + val snackScope = androidx.compose.runtime.rememberCoroutineScope() + val copiedMsg = stringResource(R.string.copied) + val copyAndNotify: (String) -> Unit = { text -> + copyHomeText(context, text) + snackScope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar("$copiedMsg: $text", duration = androidx.compose.material3.SnackbarDuration.Short) + } + } var showShareSheet by remember { mutableStateOf(false) } var showCalculator by remember { mutableStateOf(false) } - val amount = amountInput.toDoubleOrNull() ?: 0.0 + val amount = VeNumber.parse(amountInput) val result = if (convertToVes) { FxCalculator.round2(amount * selectedRate) } else if (selectedRate > 0) { @@ -141,9 +166,19 @@ fun HomeScreen(viewModel: MainViewModel = appViewModel()) { .takeUnless { selectedCurrency == RateSources.BINANCE_USDT } ) val copyText = ReceiptShareHelper.formatConversionLine(receiptData) + val usdBcv = rates["USD"]?.rateToVes + val shareConversion = ShareConversion( + rateLabel = ShareFormatter.rateLabelFor(selectedCurrency), + currencyCode = selectedCurrency, + foreignAmount = foreignAmount, + vesAmount = vesAmount, + usdAmount = if (selectedCurrency == "EUR" && usdBcv != null && usdBcv > 0) vesAmount / usdBcv else null + ) val copyRateText = "1 $selectedCurrency = ${FxCalculator.format2(selectedRate)} Bs. · $rateSourceLabel" val paymentContacts = preferences.paymentContacts + val resultPlain = FxCalculator.format2(result) + val resultWithUnit = if (convertToVes) "$resultPlain Bs." else "$resultPlain $selectedCurrency" PullToRefreshBox( isRefreshing = isLoading, @@ -152,40 +187,23 @@ fun HomeScreen(viewModel: MainViewModel = appViewModel()) { .fillMaxSize() .background( Brush.verticalGradient( - colors = listOf(BcvBlueDark, BcvBlue, BcvIndigo) + colors = BrandColors.heroGradient ) ) ) { - Box( - modifier = Modifier - .size(220.dp) - .align(Alignment.TopEnd) - .padding(top = 40.dp, end = 8.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.06f)) - ) - Box( - modifier = Modifier - .size(140.dp) - .align(Alignment.BottomStart) - .padding(bottom = 60.dp, start = 8.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.04f)) - ) Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp, vertical = 20.dp) + .padding(horizontal = 20.dp, vertical = 8.dp) ) { if (dashboard?.isFromCache == true) { OfflineBanner( - message = dashboard?.errorMessage - ?: "Sin conexión — mostrando última tasa guardada", + message = com.codeonyx.bcvaqui.domain.OfflineRates.lastLoadedLine(dashboard?.loadedAtMs ?: preferences.lastFetchEpochMs), onRetry = { viewModel.refresh(forceNetwork = true) } ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(8.dp)) } Row( @@ -222,9 +240,9 @@ fun HomeScreen(viewModel: MainViewModel = appViewModel()) { } } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(6.dp)) - Box { + Box(modifier = Modifier.tourTarget(TourKeys.RATE)) { Surface( modifier = Modifier .fillMaxWidth() @@ -234,15 +252,15 @@ fun HomeScreen(viewModel: MainViewModel = appViewModel()) { border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.15f)) ) { Row( - modifier = Modifier.padding(16.dp), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { - CurrencyBadge(code = selectedCurrency, size = 48.dp, accent = accent) + CurrencyBadge(code = selectedCurrency, size = 42.dp, accent = accent) Spacer(modifier = Modifier.width(14.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = FxCalculator.currencyLabel(selectedCurrency), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, color = Color.White, fontWeight = FontWeight.Bold ) @@ -335,18 +353,30 @@ fun HomeScreen(viewModel: MainViewModel = appViewModel()) { } } - Spacer(modifier = Modifier.height(28.dp)) + Spacer(modifier = Modifier.height(12.dp)) Surface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().tourTarget(TourKeys.AMOUNT), shape = MaterialTheme.shapes.large, - color = Color.White, + color = MaterialTheme.colorScheme.surface, shadowElevation = 6.dp ) { - Column(modifier = Modifier.padding(20.dp)) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { OutlinedTextField( value = amountInput, - onValueChange = { amountInput = it.filter { c -> c.isDigit() || c == '.' } }, + onValueChange = { amountInput = VeNumber.sanitize(it) }, + trailingIcon = if (amountInput.isNotEmpty()) { + { + IconButton(onClick = { amountInput = "" }) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.action_clear_amount) + ) + } + } + } else { + null + }, label = { Text( if (convertToVes) "Monto en $selectedCurrency" @@ -358,17 +388,18 @@ fun HomeScreen(viewModel: MainViewModel = appViewModel()) { shape = MaterialTheme.shapes.small, singleLine = true, colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = BcvBlue, - focusedLabelColor = BcvBlue, - cursorColor = BcvBlue + focusedBorderColor = MaterialTheme.colorScheme.primary, + focusedLabelColor = MaterialTheme.colorScheme.primary, + cursorColor = MaterialTheme.colorScheme.primary ) ) - Spacer(modifier = Modifier.height(14.dp)) + Spacer(modifier = Modifier.height(8.dp)) OutlinedButton( onClick = { convertToVes = !convertToVes }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().height(40.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), shape = MaterialTheme.shapes.small, border = androidx.compose.foundation.BorderStroke( 1.dp, @@ -391,11 +422,12 @@ fun HomeScreen(viewModel: MainViewModel = appViewModel()) { } } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(14.dp)) Column( modifier = Modifier .fillMaxWidth() + .tourTarget(TourKeys.RESULT) .animateContentSize(), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -405,15 +437,24 @@ fun HomeScreen(viewModel: MainViewModel = appViewModel()) { color = Color.White.copy(alpha = 0.6f), letterSpacing = 1.5.sp ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(2.dp)) Text( text = if (convertToVes) { "${FxCalculator.format2(animatedResult.toDouble())} Bs." } else { "${FxCalculator.format2(animatedResult.toDouble())} $selectedCurrency" }, - style = MaterialTheme.typography.displaySmall.copy(fontSize = 44.sp), + style = MaterialTheme.typography.displaySmall.copy(fontSize = 38.sp), color = BcvGold, + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .combinedClickable( + onClickLabel = "Copiar monto", + onLongClickLabel = "Copiar monto con moneda", + onClick = { copyAndNotify(CopyTexts.amountOnly(result)) }, + onLongClick = { copyAndNotify(resultWithUnit) } + ) + .padding(horizontal = 8.dp), fontWeight = FontWeight.Bold, textAlign = TextAlign.Center ) @@ -430,61 +471,41 @@ fun HomeScreen(viewModel: MainViewModel = appViewModel()) { ) } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(14.dp)) Column(modifier = Modifier.fillMaxWidth()) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier.fillMaxWidth().tourTarget(TourKeys.COPY), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Box(modifier = Modifier.weight(1f)) { - OutlinedButton( - onClick = { copyMenuExpanded = true }, - modifier = Modifier - .fillMaxWidth() - .height(52.dp), - shape = MaterialTheme.shapes.medium, - border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.5f)), - colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) - ) { - Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.width(6.dp)) - Text("Copiar", fontWeight = FontWeight.SemiBold) - } - DropdownMenu( - expanded = copyMenuExpanded, - onDismissRequest = { copyMenuExpanded = false } - ) { - DropdownMenuItem( - text = { Text("Copiar tasa") }, - onClick = { - copyMenuExpanded = false - copyToClipboard(context, copyRateText) - Toast.makeText(context, "Tasa copiada", Toast.LENGTH_SHORT).show() - } - ) - DropdownMenuItem( - text = { Text("Copiar conversión") }, - onClick = { - copyMenuExpanded = false - copyToClipboard(context, copyText) - Toast.makeText(context, "Conversión copiada", Toast.LENGTH_SHORT).show() - } - ) - } + HomeCopyButton(stringResource(R.string.copy_rate_of_day), Modifier.weight(1f)) { + copyAndNotify(CopyTexts.rateOfDay(ShareFormatter.rateLabelFor(selectedCurrency), selectedRate, receiptData.updatedAt)) } - + HomeCopyButton(stringResource(R.string.copy_conversion), Modifier.weight(1f)) { + copyAndNotify( + CopyTexts.conversion(amount, result, FxCalculator.currencySymbol(selectedCurrency), convertToVes) + ) + } + HomeCopyButton(stringResource(R.string.copy_amount_only), Modifier.weight(1f)) { + copyAndNotify(CopyTexts.amountOnly(result)) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth().tourTarget(TourKeys.SHARE)) { Button( onClick = { if (paymentContacts.isEmpty()) { - ReceiptShareHelper.share(context, receiptData) + ReceiptShareHelper.share( + context, receiptData, + fullText = ShareFormatter.build(shareConversion, emptyList()) + ) } else { showShareSheet = true } }, modifier = Modifier .weight(1f) - .height(52.dp), + .height(46.dp), shape = MaterialTheme.shapes.medium, colors = ButtonDefaults.buttonColors( containerColor = Color.White, @@ -498,10 +519,10 @@ fun HomeScreen(viewModel: MainViewModel = appViewModel()) { } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(10.dp)) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().tourTarget(TourKeys.STATUS), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -522,6 +543,12 @@ fun HomeScreen(viewModel: MainViewModel = appViewModel()) { } } + Spacer(modifier = Modifier.height(4.dp)) + HomeStatusRow( + lastFetchEpochMs = preferences.lastFetchEpochMs, + gapPercent = PriceTools.gapPercent(rates["USD"]?.rateToVes, binanceRate) + ) + if (dashboard?.isFromCache != true) { dashboard?.errorMessage?.let { Spacer(modifier = Modifier.height(8.dp)) @@ -532,68 +559,25 @@ fun HomeScreen(viewModel: MainViewModel = appViewModel()) { } if (!isLoading && rates.isEmpty()) { - Box( - modifier = Modifier - .matchParentSize() - .background( - Brush.verticalGradient( - colors = listOf(BcvBlueDark, BcvBlue, BcvIndigo) - ) - ) - .padding(24.dp), - contentAlignment = Alignment.Center - ) { - Surface( - shape = MaterialTheme.shapes.large, - color = Color.White, - shadowElevation = 6.dp - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - Icons.Default.Refresh, - contentDescription = null, - tint = BcvBlue, - modifier = Modifier.size(36.dp) - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - "No pudimos cargar las tasas", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(6.dp)) - Text( - "Revisa tu conexión e inténtalo nuevamente.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { viewModel.refresh(forceNetwork = true) }, - colors = ButtonDefaults.buttonColors(containerColor = BcvBlue) - ) { - Text("Reintentar") - } - } - } - } + HomeErrorState(onRetry = { viewModel.refresh(forceNetwork = true) }) } + androidx.compose.material3.SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp) + ) } if (showShareSheet) { ShareWithPaymentSheet( contacts = paymentContacts, + defaultCopRate = dashboard?.worldRatesUsd?.get("COP"), onDismiss = { showShareSheet = false }, - onShare = { contact -> + onShare = { methods, copRate -> showShareSheet = false ReceiptShareHelper.share( context = context, data = receiptData, - paymentText = contact?.formatForCopy() + fullText = ShareFormatter.build(shareConversion, methods, copRate) ) } ) @@ -607,159 +591,18 @@ fun HomeScreen(viewModel: MainViewModel = appViewModel()) { ) } - if (!preferences.hasSeenOnboarding) { - AlertDialog( - onDismissRequest = {}, - title = { Text("Bienvenido a SpreadVE") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text("• BCV: tasa oficial para USD, EUR y otras monedas.") - Text("• Binance P2P: referencia de mercado para USDT/VES.") - Text("• Dónde: compara cuánto representa un monto en varios países.") - Text( - "Puedes cambiar tu moneda principal y administrar tus datos de pago desde Ajustes.", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - confirmButton = { - Button( - onClick = { viewModel.completeOnboarding() }, - colors = ButtonDefaults.buttonColors(containerColor = BcvBlue) - ) { - Text("Entendido") - } - } - ) - } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ShareWithPaymentSheet( - contacts: List, - onDismiss: () -> Unit, - onShare: (PaymentContact?) -> Unit -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 28.dp) - ) { - Text( - text = "Compartir conversión", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Text( - text = "¿Quieres incluir datos de pago móvil o cuenta?", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(16.dp)) - - TextButton( - onClick = { onShare(null) }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Solo conversión") - } - - contacts.forEach { contact -> - Surface( - onClick = { onShare(contact) }, - shape = RoundedCornerShape(14.dp), - color = BcvBlue.copy(alpha = 0.06f), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - ) { - Column(modifier = Modifier.padding(14.dp)) { - Text(contact.displayName, fontWeight = FontWeight.SemiBold) - Text( - "${contact.typeLabel()} · ${contact.subtitle()}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } -} - -@Composable -private fun OfflineBanner( - message: String, - onRetry: () -> Unit -) { - Surface( - modifier = Modifier.fillMaxWidth(), +private fun HomeCopyButton(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + modifier = modifier.heightIn(min = 42.dp), shape = MaterialTheme.shapes.medium, - color = Color(0xFFFBBF24) + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.5f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) ) { - Row( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Sin conexión", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = Color(0xFF78350F) - ) - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = Color(0xFF92400E) - ) - } - Spacer(modifier = Modifier.width(8.dp)) - Surface( - onClick = onRetry, - shape = RoundedCornerShape(12.dp), - color = Color(0xFF78350F) - ) { - Text( - text = "Reintentar", - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelLarge, - color = Color.White - ) - } - } + Text(label, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center, style = MaterialTheme.typography.labelMedium, maxLines = 2) } } - -@Composable -private fun CurrencyBadge( - code: String, - size: androidx.compose.ui.unit.Dp, - accent: Color -) { - Surface( - modifier = Modifier.size(size), - shape = CircleShape, - color = accent.copy(alpha = 0.15f), - border = androidx.compose.foundation.BorderStroke(1.5.dp, accent.copy(alpha = 0.4f)) - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = FxCalculator.currencySymbol(code), - fontWeight = FontWeight.Bold, - fontSize = (size.value * 0.35f).sp, - color = accent - ) - } - } -} - -private fun copyToClipboard(context: Context, text: String) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("BCV monto", text)) -} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/home/LiteHomeScreen.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/home/LiteHomeScreen.kt new file mode 100644 index 0000000..ee52106 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/home/LiteHomeScreen.kt @@ -0,0 +1,139 @@ +package com.codeonyx.bcvaqui.ui.home + +import android.content.Intent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.codeonyx.bcvaqui.domain.FxCalculator +import com.codeonyx.bcvaqui.domain.VeNumber +import com.codeonyx.bcvaqui.ui.MainViewModel +import com.codeonyx.bcvaqui.ui.theme.BcvGold +import com.codeonyx.bcvaqui.ui.theme.BrandColors +import com.codeonyx.bcvaqui.ui.tools.copyText + +/** Modo lite: solo el dólar BCV en grande y un convertidor simple. */ +@Composable +fun LiteHomeScreen(viewModel: MainViewModel) { + val dashboard by viewModel.dashboard.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val context = LocalContext.current + val focus = LocalFocusManager.current + val rate = dashboard?.bcvRates?.get("USD")?.rateToVes ?: 0.0 + var input by remember { mutableStateOf("") } + var dollarsToBs by remember { mutableStateOf(true) } + val amount = VeNumber.parse(input) + val result = when { + rate <= 0 -> 0.0 + dollarsToBs -> FxCalculator.round2(amount * rate) + else -> FxCalculator.round2(amount / rate) + } + val resultText = if (dollarsToBs) "${FxCalculator.format2(result)} Bs." else "${FxCalculator.format2(result)} $" + val fromText = if (dollarsToBs) "${FxCalculator.format2(amount)} $" else "${FxCalculator.format2(amount)} Bs." + val shareText = "$fromText = $resultText\nDólar BCV: ${FxCalculator.format2(rate)} Bs.\n— Codivisas" + + // Cabe sin scroll en un teléfono normal; en pantallas muy pequeñas hace scroll como respaldo. + BoxWithConstraints(Modifier.fillMaxSize().background(Brush.verticalGradient(BrandColors.heroGradient))) { + val minH = maxHeight + Column( + Modifier.fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = minH) + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("DÓLAR BCV HOY", color = Color.White.copy(alpha = 0.9f), style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, letterSpacing = 2.sp, modifier = Modifier.weight(1f)) + TextButton(onClick = { viewModel.refresh(forceNetwork = true) }, enabled = !isLoading, + contentPadding = PaddingValues(horizontal = 10.dp)) { + Icon(Icons.Default.Refresh, null, tint = Color.White, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(4.dp)) + Text(if (isLoading) "Actualizando…" else "Actualizar", color = Color.White, fontSize = 15.sp) + } + } + Text(if (rate > 0) FxCalculator.format2(rate) else "—", color = BcvGold, fontSize = 60.sp, + lineHeight = 64.sp, fontWeight = FontWeight.ExtraBold, textAlign = TextAlign.Center) + Text("Bs. por dólar · ${FxCalculator.formatUpdatedAt(dashboard?.bcvUpdatedAt)}", color = Color.White.copy(alpha = 0.85f), + style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center) + } + Spacer(Modifier.height(10.dp)) + Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + BigToggle("$ → Bs.", dollarsToBs, Modifier.weight(1f)) { dollarsToBs = true } + BigToggle("Bs. → $", !dollarsToBs, Modifier.weight(1f)) { dollarsToBs = false } + } + OutlinedTextField( + value = input, + onValueChange = { input = VeNumber.sanitize(it) }, + label = { Text(if (dollarsToBs) "¿Cuántos dólares?" else "¿Cuántos bolívares?", fontSize = 16.sp) }, + textStyle = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + singleLine = true, + trailingIcon = if (input.isNotEmpty()) { { IconButton({ input = "" }) { Icon(Icons.Default.Close, "Borrar") } } } else null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focus.clearFocus() }), + modifier = Modifier.fillMaxWidth() + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Son ", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + AnimatedContent(resultText, transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "lite") { + Text(it, fontSize = 32.sp, lineHeight = 36.sp, fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.primary, maxLines = 1, + softWrap = false, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { copyText(context, FxCalculator.format2(result)) }, + modifier = Modifier.weight(1f).height(50.dp)) { + Icon(Icons.Default.ContentCopy, null); Spacer(Modifier.width(6.dp)); Text("Copiar", fontSize = 17.sp) + } + Button(onClick = { + val send = Intent(Intent.ACTION_SEND).apply { type = "text/plain"; putExtra(Intent.EXTRA_TEXT, shareText) } + runCatching { context.startActivity(Intent.createChooser(send, "Compartir")) } + }, modifier = Modifier.weight(1f).height(50.dp)) { + Icon(Icons.Default.Share, null); Spacer(Modifier.width(6.dp)); Text("Enviar", fontSize = 17.sp) + } + } + } + } + TextButton(onClick = { viewModel.setLiteMode(false) }, modifier = Modifier.padding(top = 4.dp)) { + Text("Volver al modo completo", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + } + } + } +} + +@Composable +private fun BigToggle(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { + if (selected) Button(onClick, modifier.height(46.dp)) { Text(label, fontSize = 18.sp, fontWeight = FontWeight.Bold) } + else OutlinedButton(onClick, modifier.height(46.dp)) { Text(label, fontSize = 18.sp) } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/home/MiniCalculatorSheet.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/home/MiniCalculatorSheet.kt index 2b5c3d8..3e59a06 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/home/MiniCalculatorSheet.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/home/MiniCalculatorSheet.kt @@ -1,9 +1,9 @@ package com.codeonyx.bcvaqui.ui.home -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.widget.Toast +import androidx.compose.ui.res.stringResource +import com.codeonyx.bcvaqui.R +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -11,23 +11,23 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width 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.Backspace import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.SwapHoriz -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material.icons.filled.SwapVert import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -35,7 +35,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -45,509 +45,291 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.codeonyx.bcvaqui.domain.FxCalculator -import com.codeonyx.bcvaqui.ui.theme.BcvBlue -import com.codeonyx.bcvaqui.ui.theme.BcvGold -import com.codeonyx.bcvaqui.ui.tools.parseToolAmount -import kotlin.math.abs - -private enum class CalcMode { CUENTAS, DOLARES } - -private enum class CalcOp(val symbol: String) { - Add("+"), - Subtract("−"), - Multiply("×"), - Divide("÷") -} - -private val usdPresets = listOf(1.0, 5.0, 10.0, 20.0, 50.0, 100.0, 200.0) -private val feePresets = listOf(3.0, 5.0, 10.0) - +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.codeonyx.bcvaqui.domain.CalcEngine +import com.codeonyx.bcvaqui.domain.CalcInput +import com.codeonyx.bcvaqui.ui.MainViewModel +import com.codeonyx.bcvaqui.ui.appViewModel +import com.codeonyx.bcvaqui.ui.tools.copyText + +/** Calculadora en hoja inferior (desde Inicio). */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun MiniCalculatorSheet( usdRate: Double, - onDismiss: () -> Unit + onDismiss: () -> Unit, + viewModel: MainViewModel = appViewModel() ) { - val context = LocalContext.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var mode by remember { mutableStateOf(CalcMode.DOLARES) } - var inputInUsd by remember { mutableStateOf(true) } - var display by remember { mutableStateOf("0") } - var accumulator by remember { mutableStateOf(null) } - var pendingOp by remember { mutableStateOf(null) } - var freshEntry by remember { mutableStateOf(true) } - - fun formatValue(value: Double, useGrouping: Boolean = true): String { - if (value.isNaN() || value.isInfinite()) return "Error" - val rounded = FxCalculator.round2(value) - if (!useGrouping) { - return if (abs(rounded - rounded.toLong()) < 0.001) { - rounded.toLong().toString() - } else { - rounded.toString() - } - } - return FxCalculator.format2(rounded) - } - - fun currentValue(): Double = parseToolAmount(display) - - fun resetCalc() { - display = "0" - accumulator = null - pendingOp = null - freshEntry = true - } - - fun applyPendingOperation(nextValue: Double): Double? { - val left = accumulator ?: return nextValue - val op = pendingOp ?: return nextValue - return when (op) { - CalcOp.Add -> left + nextValue - CalcOp.Subtract -> left - nextValue - CalcOp.Multiply -> left * nextValue - CalcOp.Divide -> if (nextValue == 0.0) Double.NaN else left / nextValue - } - } - - fun setError() { - display = "Error" - accumulator = null - pendingOp = null - freshEntry = true - } - - fun onDigit(digit: String) { - if (display == "Error") resetCalc() - display = when { - freshEntry -> if (digit == ".") "0." else digit - display == "0" && digit != "." -> digit - digit == "." && display.contains('.') -> display - else -> display + digit - } - freshEntry = false - } - - fun onOperator(op: CalcOp) { - if (display == "Error") return - val value = currentValue() - val result = applyPendingOperation(value) - if (result == null || result.isNaN() || result.isInfinite()) { - setError() - return - } - accumulator = result - pendingOp = op - display = formatValue(result, useGrouping = false) - freshEntry = true + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + CalculatorPanel( + viewModel = viewModel, + fallbackUsdRate = usdRate, + modifier = Modifier.navigationBarsPadding() + ) } +} - fun onEquals() { - if (display == "Error") return - val value = currentValue() - val result = applyPendingOperation(value) ?: return - if (result.isNaN() || result.isInfinite()) { - setError() - return - } - display = formatValue(result) - accumulator = null - pendingOp = null - freshEntry = true - } +private data class RateOption(val code: String, val label: String, val rate: Double) - fun onBackspace() { - if (freshEntry || display == "Error") return - display = when { - display.length <= 1 || (display.length == 2 && display.startsWith("-")) -> "0" - else -> display.dropLast(1) +/** Calculadora completa: teclado, resultado en vivo, conversión a/desde Bs. e historial. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CalculatorPanel( + viewModel: MainViewModel, + fallbackUsdRate: Double = 0.0, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val dashboard by viewModel.dashboard.collectAsStateWithLifecycle() + val prefs by viewModel.preferences.collectAsStateWithLifecycle() + + val rates = dashboard?.bcvRates.orEmpty() + val options = buildList { + val usd = rates["USD"]?.rateToVes ?: fallbackUsdRate.takeIf { it > 0 } + usd?.let { add(RateOption("USD", "$ BCV", it)) } + rates["EUR"]?.rateToVes?.let { add(RateOption("EUR", "€ BCV", it)) } + (dashboard?.binanceUsdtVes ?: prefs.lastKnownBinanceUsdtVes)?.let { + add(RateOption("USDT", "USDT", it)) } - if (display == "-") display = "0" - } - - fun setPreset(value: Double) { - display = formatValue(value, useGrouping = false) - freshEntry = true } - fun applyFeePercent(percent: Double, addToTotal: Boolean) { - if (display == "Error") return - val base = currentValue() - val next = if (addToTotal) { - base * (1.0 + percent / 100.0) - } else { - base * (1.0 - percent / 100.0) + var expression by rememberSaveable { mutableStateOf("") } + var rateCode by rememberSaveable { mutableStateOf("USD") } + var toBs by rememberSaveable { mutableStateOf(true) } + val input = CalcInput(expression) + val result = input.result + val option = options.firstOrNull { it.code == rateCode } ?: options.firstOrNull() + val converted = if (result != null && option != null && option.rate > 0) { + if (toBs) result * option.rate else result / option.rate + } else null + val fromUnit = if (toBs) option?.code ?: "" else "Bs." + val toUnit = if (toBs) "Bs." else option?.code ?: "" + + fun update(next: CalcInput) { expression = next.expression } + + fun commit() { + val r = result ?: return + if (expression.any { it in CalcEngine.OPERATORS || it == '%' }) { + viewModel.addCalculatorHistory("$expression = ${CalcEngine.format(r)}") } - display = formatValue(next) - accumulator = null - pendingOp = null - freshEntry = true - } - - fun primaryAmount(): Double { - if (display == "Error") return 0.0 - return currentValue() + update(input.withValue(r)) } - fun usdAmount(): Double { - val value = primaryAmount() - return if (inputInUsd) value else if (usdRate > 0) value / usdRate else 0.0 - } - - fun bsAmount(): Double { - val value = primaryAmount() - return if (inputInUsd) FxCalculator.round2(value * usdRate) else value - } - - fun copyText(text: String, label: String) { - copyToClipboard(context, text) - Toast.makeText(context, "$label copiado", Toast.LENGTH_SHORT).show() - } - - fun buildExpressionText(): String? { - val op = pendingOp ?: return null - val left = accumulator ?: return null - return if (freshEntry) { - "${formatValue(left)} ${op.symbol}" - } else { - "${formatValue(left)} ${op.symbol} $display" - } - } - - fun previewResult(): Double? { - if (display == "Error" || pendingOp == null || freshEntry) return null - val result = applyPendingOperation(currentValue()) ?: return null - return if (result.isNaN() || result.isInfinite()) null else result - } - - val expressionText = buildExpressionText() - val livePreview = previewResult() - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .padding(bottom = 28.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + // Pantalla + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth() ) { - Text( - text = "Calculadora rápida", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip( - selected = mode == CalcMode.DOLARES, - onClick = { mode = CalcMode.DOLARES }, - label = { Text("Dólares") }, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = BcvBlue.copy(alpha = 0.15f), - selectedLabelColor = BcvBlue - ) + Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.End) { + Text( + text = expression.ifEmpty { "0" }, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.End, + maxLines = 2, + overflow = TextOverflow.Clip, + modifier = Modifier.fillMaxWidth() ) - FilterChip( - selected = mode == CalcMode.CUENTAS, - onClick = { mode = CalcMode.CUENTAS }, - label = { Text("Cuentas") }, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = BcvBlue.copy(alpha = 0.15f), - selectedLabelColor = BcvBlue - ) + Spacer(modifier = Modifier.height(4.dp)) + val resultText = result?.let { CalcEngine.format(it) } ?: if (expression.isEmpty()) "0" else "—" + Text( + text = "= $resultText", + fontSize = 34.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClickLabel = stringResource(R.string.calc_copy_result), + onClick = { result?.let { copyText(context, CalcEngine.format(it, 2)) } }, + onLongClick = { result?.let { copyText(context, CalcEngine.format(it, 2)) } } + ) ) } + } + // Conversión + if (options.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + options.forEach { o -> + FilterChip( + selected = o.code == option?.code, + onClick = { rateCode = o.code }, + label = { Text("${o.label} ${CalcEngine.format(o.rate, 2)}") } + ) + } + } Surface( - modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.fillMaxWidth() ) { - Column(modifier = Modifier.padding(16.dp)) { - if (mode == CalcMode.DOLARES && usdRate > 0) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "1 USD = ${FxCalculator.format2(usdRate)} Bs.", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - TextButton(onClick = { inputInUsd = !inputInUsd }) { - Icon( - Icons.Default.SwapHoriz, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Text( - text = if (inputInUsd) "Ingreso USD" else "Ingreso Bs.", - modifier = Modifier.padding(start = 4.dp) - ) - } - } - } - - expressionText?.let { expr -> + Row( + modifier = Modifier.padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { Text( - text = expr, - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.End, - maxLines = 1 + "$fromUnit → $toUnit", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer ) - } - - livePreview?.let { preview -> Text( - text = "= ${formatValue(preview)}", - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.bodyLarge, - color = BcvBlue.copy(alpha = 0.75f), - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.End + text = converted?.let { "${CalcEngine.format(it, 2)} $toUnit" } ?: "—", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + IconButton(onClick = { toBs = !toBs }) { + Icon(Icons.Default.SwapVert, contentDescription = stringResource(R.string.calc_swap), + tint = MaterialTheme.colorScheme.onPrimaryContainer) + } + IconButton( + onClick = { converted?.let { copyText(context, CalcEngine.format(it, 2)) } }, + enabled = converted != null ) { - Column(modifier = Modifier.weight(1f)) { - val mainText = when { - display == "Error" -> "Error" - pendingOp != null && !freshEntry -> display - mode == CalcMode.DOLARES && inputInUsd -> - "${formatValue(usdAmount())} USD" - mode == CalcMode.DOLARES -> - "${formatValue(bsAmount())} Bs." - else -> display - } - Text( - text = mainText, - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.displaySmall.copy( - fontSize = 34.sp, - fontWeight = FontWeight.Bold - ), - textAlign = TextAlign.End, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - if (mode == CalcMode.DOLARES && display != "Error" && usdRate > 0) { - val conversionText = when { - livePreview != null && inputInUsd -> - "≈ ${formatValue(FxCalculator.round2(livePreview * usdRate))} Bs." - livePreview != null && !inputInUsd && usdRate > 0 -> - "≈ ${formatValue(livePreview / usdRate)} USD" - inputInUsd -> - "≈ ${formatValue(bsAmount())} Bs." - else -> - "≈ ${formatValue(usdAmount())} USD" + Icon(Icons.Default.ContentCopy, contentDescription = stringResource(R.string.calc_copy_conversion), + tint = MaterialTheme.colorScheme.onPrimaryContainer) + } + TextButton( + onClick = { + converted?.let { + if (result != null) { + viewModel.addCalculatorHistory( + "${CalcEngine.format(result, 2)} $fromUnit = ${CalcEngine.format(it, 2)} $toUnit" + ) } - Text( - text = conversionText, - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.titleMedium, - color = BcvGold, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.End - ) + update(CalcInput().withValue(Math.round(it * 100) / 100.0)) + toBs = !toBs } - } + }, + enabled = converted != null + ) { Text(stringResource(R.string.calc_use)) } + } + } + } - if (display != "Error") { - IconButton( + // Teclado + val keys = listOf( + listOf("AC", "⌫", "%", "÷"), + listOf("7", "8", "9", "×"), + listOf("4", "5", "6", "−"), + listOf("1", "2", "3", "+"), + listOf("00", "0", ",", "=") + ) + keys.forEach { row -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { key -> + val isOp = key in listOf("÷", "×", "−", "+", "%") + val isEq = key == "=" + val isFn = key == "AC" || key == "⌫" + val bg = when { + isEq -> MaterialTheme.colorScheme.primary + isOp -> MaterialTheme.colorScheme.secondaryContainer + isFn -> MaterialTheme.colorScheme.errorContainer + else -> MaterialTheme.colorScheme.surfaceContainerHighest + } + val fg = when { + isEq -> MaterialTheme.colorScheme.onPrimary + isOp -> MaterialTheme.colorScheme.onSecondaryContainer + isFn -> MaterialTheme.colorScheme.onErrorContainer + else -> MaterialTheme.colorScheme.onSurface + } + Surface( + shape = RoundedCornerShape(18.dp), + color = bg, + modifier = Modifier + .weight(1f) + .heightIn(min = 60.dp) + .combinedClickable( + onClickLabel = key, + onLongClick = if (key == "⌫") ({ update(input.clear()) }) else null, onClick = { - val text = when { - livePreview != null && pendingOp != null -> - "${expressionText} = ${formatValue(livePreview)}" - mode == CalcMode.DOLARES -> { - if (inputInUsd) { - "${formatValue(usdAmount())} USD = ${formatValue(bsAmount())} Bs." - } else { - "${formatValue(bsAmount())} Bs. = ${formatValue(usdAmount())} USD" - } - } - else -> formatValue(currentValue()) + when (key) { + "AC" -> update(input.clear()) + "⌫" -> update(input.backspace()) + "%" -> update(input.percent()) + "," -> update(input.comma()) + "=" -> commit() + "÷" -> update(input.operator(CalcEngine.DIVIDE)) + "×" -> update(input.operator(CalcEngine.TIMES)) + "−" -> update(input.operator(CalcEngine.MINUS)) + "+" -> update(input.operator(CalcEngine.PLUS)) + else -> update(input.digit(key)) } - copyText(text, "Resultado") } - ) { - Icon(Icons.Default.ContentCopy, contentDescription = "Copiar") + ) + ) { + Row( + modifier = Modifier.heightIn(min = 60.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (key == "⌫") { + Icon(Icons.AutoMirrored.Filled.Backspace, contentDescription = stringResource(R.string.calc_backspace), tint = fg) + } else { + Text(key, fontSize = 24.sp, fontWeight = FontWeight.SemiBold, color = fg) } } } } } + } - if (mode == CalcMode.DOLARES) { - Row( + // Historial + if (prefs.calculatorHistory.isNotEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.calc_history), style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + TextButton(onClick = { viewModel.clearCalculatorHistory() }) { Text(stringResource(R.string.calc_clear_history)) } + } + prefs.calculatorHistory.take(6).forEach { entry -> + val value = entry.substringAfterLast("= ").substringBefore(" ") + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainer, modifier = Modifier .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - usdPresets.forEach { preset -> - OutlinedButton( - onClick = { setPreset(preset) }, - shape = RoundedCornerShape(12.dp), - contentPadding = ButtonDefaults.ContentPadding - ) { - Text( - text = if (preset >= 1) "${preset.toInt()}" else formatValue(preset, false), - fontWeight = FontWeight.SemiBold - ) - } - } - } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + .combinedClickable( + onClickLabel = stringResource(R.string.calc_use_result), + onClick = { CalcEngine.evaluate(value)?.let { update(CalcInput().withValue(it)) } }, + onLongClick = { copyText(context, value) } + ) ) { - feePresets.forEach { pct -> - OutlinedButton( - onClick = { applyFeePercent(pct, addToTotal = false) }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp) - ) { - Text("−$pct%", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) - } - } - OutlinedButton( - onClick = { - if (display != "Error") { - display = formatValue(currentValue() / 2.0) - freshEntry = true - } - }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp) - ) { - Text("÷2", fontWeight = FontWeight.SemiBold) - } + Text( + entry, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } - - CalcKeypad( - onClear = { resetCalc() }, - onBackspace = { onBackspace() }, - onDigit = { onDigit(it) }, - onOperator = { onOperator(it) }, - onEquals = { onEquals() } - ) - } - } -} - -@Composable -private fun CalcKeypad( - onClear: () -> Unit, - onBackspace: () -> Unit, - onDigit: (String) -> Unit, - onOperator: (CalcOp) -> Unit, - onEquals: () -> Unit -) { - val rows = listOf( - listOf("C", "⌫", "÷", "×"), - listOf("7", "8", "9", "−"), - listOf("4", "5", "6", "+"), - listOf("1", "2", "3", "=") - ) - - rows.forEach { row -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - row.forEach { key -> - CalcKeyButton( - label = key, - modifier = Modifier.weight(1f), - emphasized = key == "=", - onClick = { - when (key) { - "C" -> onClear() - "⌫" -> onBackspace() - "+" -> onOperator(CalcOp.Add) - "−" -> onOperator(CalcOp.Subtract) - "×" -> onOperator(CalcOp.Multiply) - "÷" -> onOperator(CalcOp.Divide) - "=" -> onEquals() - "." -> onDigit(".") - else -> onDigit(key) - } - } - ) - } + Text(stringResource(R.string.calc_history_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - CalcKeyButton( - label = "0", - modifier = Modifier.weight(2f), - onClick = { onDigit("0") } - ) - CalcKeyButton( - label = ".", - modifier = Modifier.weight(1f), - onClick = { onDigit(".") } - ) - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.width(1.dp).height(8.dp)) } } -@Composable -private fun CalcKeyButton( - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - emphasized: Boolean = false -) { - val isOperator = label in listOf("+", "−", "×", "÷", "=") - if (emphasized || isOperator) { - Button( - onClick = onClick, - modifier = modifier.height(50.dp), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.buttonColors( - containerColor = if (emphasized) BcvBlue else MaterialTheme.colorScheme.primaryContainer, - contentColor = if (emphasized) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onPrimaryContainer - } - ) - ) { - Text(label, fontWeight = FontWeight.SemiBold, fontSize = 18.sp) - } - } else { - OutlinedButton( - onClick = onClick, - modifier = modifier.height(50.dp), - shape = RoundedCornerShape(14.dp) - ) { - Text(label, fontWeight = FontWeight.Medium, fontSize = 18.sp) - } - } -} - -private fun copyToClipboard(context: Context, text: String) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("Calculadora", text)) -} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/settings/SettingsScreen.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/settings/SettingsScreen.kt index 037e761..14a8086 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/settings/SettingsScreen.kt @@ -1,86 +1,57 @@ package com.codeonyx.bcvaqui.ui.settings +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.border import android.Manifest +import android.content.Intent +import android.net.Uri import android.os.Build +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable 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.Spacer -import androidx.compose.foundation.layout.fillMaxSize -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.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountBalanceWallet -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.Paid -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.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -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.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import android.widget.Toast +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.codeonyx.bcvaqui.BuildConfig +import com.codeonyx.bcvaqui.R +import com.codeonyx.bcvaqui.data.local.BackupManager +import com.codeonyx.bcvaqui.data.local.ThemeModes import com.codeonyx.bcvaqui.domain.FxCalculator import com.codeonyx.bcvaqui.domain.RateSources import com.codeonyx.bcvaqui.ui.MainViewModel import com.codeonyx.bcvaqui.ui.appViewModel -import com.codeonyx.bcvaqui.ui.theme.BcvBlue -import com.codeonyx.bcvaqui.ui.theme.currencyAccent +import com.codeonyx.bcvaqui.ui.theme.BcvGold +import com.codeonyx.bcvaqui.ui.tools.SegmentedChoice import com.codeonyx.bcvaqui.update.AppUpdateManager import kotlinx.coroutines.launch -private val notificationOptions = listOf( - 0 to "Desactivado", - 1 to "Cada 1 hora", - 2 to "Cada 2 horas", - 4 to "Cada 4 horas", - 6 to "Cada 6 horas", - 12 to "Cada 12 horas", - 24 to "Cada 24 horas" -) - -private val alertThresholdOptions = listOf( - 0.0 to "Desactivado", - 0.5 to "±0.5%", - 1.0 to "±1%", - 2.0 to "±2%", - 5.0 to "±5%" -) +private val notificationOptions = listOf(0 to "Desactivado", 1 to "Cada 1 hora", 2 to "Cada 2 horas", + 4 to "Cada 4 horas", 6 to "Cada 6 horas", 12 to "Cada 12 horas", 24 to "Cada 24 horas") +private val alertThresholdOptions = listOf(0.0 to "Cualquier cambio", 0.5 to "±0,5 %", 1.0 to "±1 %", 2.0 to "±2 %", 5.0 to "±5 %") +private val hours = (0..23).toList() +private fun h(x: Int) = "%02d:00".format(x) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -88,298 +59,321 @@ fun SettingsScreen( viewModel: MainViewModel = appViewModel(), onOpenPaymentAgenda: () -> Unit = {} ) { - val preferences by viewModel.preferences.collectAsState() - val dashboard by viewModel.dashboard.collectAsState() - val currencies = buildList { - addAll(dashboard?.bcvRates?.keys?.sorted().orEmpty()) - add(RateSources.BINANCE_USDT) - }.distinct() - - var currencyExpanded by remember { mutableStateOf(false) } - var intervalExpanded by remember { mutableStateOf(false) } - var alertExpanded by remember { mutableStateOf(false) } - var updateStatus by remember { mutableStateOf(null) } + val prefs by viewModel.preferences.collectAsStateWithLifecycle() + val dashboard by viewModel.dashboard.collectAsStateWithLifecycle() val context = LocalContext.current val scope = rememberCoroutineScope() + val currencies = buildList { addAll(dashboard?.bcvRates?.keys?.sorted().orEmpty()); add(RateSources.BINANCE_USDT) }.distinct() + var updateStatus by remember { mutableStateOf(null) } + var showAbout by remember { mutableStateOf(false) } + var confirmClear by remember { mutableStateOf(false) } + val toast: (String) -> Unit = { Toast.makeText(context, it, Toast.LENGTH_LONG).show() } - val permissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { } + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { } + fun askPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> + if (uri != null) scope.launch { + runCatching { + val data = BackupManager.export(context) + context.contentResolver.openOutputStream(uri)?.use { it.write(data.toByteArray()) } + }.onSuccess { toast("Respaldo guardado") }.onFailure { toast("No se pudo guardar el respaldo") } + } + } + val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) scope.launch { + runCatching { + val raw = context.contentResolver.openInputStream(uri)?.use { it.readBytes().decodeToString() } ?: error("vacío") + BackupManager.import(context, raw) + }.onSuccess { toast("Restaurado: $it") }.onFailure { toast("Archivo de respaldo no válido") } + } + } Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) ) { - Text( - text = "Personaliza tu experiencia", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - SettingsCard(title = "Moneda principal", icon = Icons.Default.Paid, iconTint = currencyAccent(preferences.selectedCurrency)) { - Text( - text = "Moneda que se muestra en el convertidor y en el widget.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(14.dp)) - ExposedDropdownMenuBox( - expanded = currencyExpanded, - onExpandedChange = { currencyExpanded = it } - ) { - OutlinedTextField( - value = "${FxCalculator.currencyLabel(preferences.selectedCurrency)} · ${preferences.selectedCurrency}", - onValueChange = {}, - readOnly = true, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(currencyExpanded) }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth(), - shape = MaterialTheme.shapes.small, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = BcvBlue, - focusedLabelColor = BcvBlue - ) - ) - ExposedDropdownMenu( - expanded = currencyExpanded, - onDismissRequest = { currencyExpanded = false } - ) { - currencies.forEach { code -> - DropdownMenuItem( - text = { Text("${FxCalculator.currencyLabel(code)} ($code)") }, - onClick = { - viewModel.setSelectedCurrency(code) - currencyExpanded = false - } - ) - } + if (prefs.liteMode) Surface(shape = MaterialTheme.shapes.large, color = BcvGold.copy(alpha = 0.18f)) { + Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Estás en Modo lite", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) + Text("Vista sencilla con el dólar BCV.", style = MaterialTheme.typography.bodyMedium) } + Button(onClick = { viewModel.setLiteMode(false) }) { Text("Modo completo") } } } - - SettingsCard( - title = "Datos de pago", - icon = Icons.Default.AccountBalanceWallet, - iconTint = BcvBlue - ) { - Text( - text = "Guarda pago móvil y cuentas para incluirlos al compartir conversiones.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + Section("Apariencia", Icons.Default.Palette) { + SwitchRow("Modo lite (más sencillo)", prefs.liteMode, true) { viewModel.setLiteMode(it) } + Hint("Solo el dólar BCV en grande y un convertidor fácil. Oculta herramientas, USDT y gráficos.") + Label("Tema") + SegmentedChoice( + options = listOf(ThemeModes.SYSTEM, ThemeModes.LIGHT, ThemeModes.DARK), + selected = prefs.themeMode, + label = { when (it) { ThemeModes.LIGHT -> "Claro"; ThemeModes.DARK -> "Oscuro"; else -> "Sistema" } }, + onSelect = viewModel::setThemeMode ) - Spacer(modifier = Modifier.height(8.dp)) - TextButton( - onClick = onOpenPaymentAgenda, - modifier = Modifier.fillMaxWidth() - ) { - Text("Abrir agenda de pagos", fontWeight = FontWeight.SemiBold) + Label("Paleta de colores") + PalettePicker( + selected = com.codeonyx.bcvaqui.domain.AppPalette.from(prefs.palette), + onSelect = viewModel::setPalette + ) + Hint("Aplica a la app, widgets e ícono.") + } + + Section("Moneda y tasas", Icons.Default.Paid) { + DropdownRow("Moneda principal", Icons.Default.CurrencyExchange, + "${FxCalculator.currencyLabel(prefs.selectedCurrency)} · ${prefs.selectedCurrency}", + currencies.map { it to "${FxCalculator.currencyLabel(it)} ($it)" }) { viewModel.setSelectedCurrency(it) } + Hint("Se usa en el convertidor y el widget. Los montos se muestran con 2 decimales, formato venezolano (1.234,56).") + } + + Section("Notificaciones", Icons.Default.Notifications) { + DropdownRow("Revisar tasas", Icons.Default.Schedule, + notificationOptions.firstOrNull { it.first == prefs.notificationIntervalHours }?.second ?: "Desactivado", + notificationOptions) { if (it > 0) askPermission(); viewModel.setNotificationInterval(it) } + val on = prefs.notificationIntervalHours > 0 + Label("Avisarme cuando cambie") + SwitchRow("Dólar BCV", prefs.notifyBcvUsd, on) { viewModel.setNotifySource("BCV_USD", it) } + SwitchRow("Euro BCV", prefs.notifyBcvEur, on) { viewModel.setNotifySource("BCV_EUR", it) } + SwitchRow("USDT Binance P2P", prefs.notifyUsdt, on) { viewModel.setNotifySource("USDT", it) } + DropdownRow("Cambio mínimo", Icons.Default.Tune, + alertThresholdOptions.firstOrNull { kotlin.math.abs(it.first - prefs.alertThresholdPercent) < 0.01 }?.second ?: "±1 %", + alertThresholdOptions) { viewModel.setAlertThresholdPercent(it) } + Hint("USDT avisa con cambios de 1 % o más. Cambiar de moneda en Inicio no genera avisos.") + SwitchRow("Horas de silencio", prefs.quietHoursEnabled, on) { + viewModel.setQuietHours(it, prefs.quietStartHour, prefs.quietEndHour) + } + if (prefs.quietHoursEnabled) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.weight(1f)) { DropdownRow("Desde", null, h(prefs.quietStartHour), hours.map { it to h(it) }) { + viewModel.setQuietHours(true, it, prefs.quietEndHour) } } + Box(Modifier.weight(1f)) { DropdownRow("Hasta", null, h(prefs.quietEndHour), hours.map { it to h(it) }) { + viewModel.setQuietHours(true, prefs.quietStartHour, it) } } } + SwitchRow("Recordatorios de deudas", prefs.debtReminders, true) { if (it) askPermission(); viewModel.setDebtReminders(it) } } - SettingsCard(title = "Notificaciones", icon = Icons.Default.Notifications, iconTint = BcvBlue) { - Text( - text = "Recibe avisos cuando cambie la tasa de tu moneda principal.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(14.dp)) - val intervalLabel = notificationOptions - .firstOrNull { it.first == preferences.notificationIntervalHours }?.second - ?: "Desactivado" - ExposedDropdownMenuBox( - expanded = intervalExpanded, - onExpandedChange = { intervalExpanded = it } - ) { - OutlinedTextField( - value = intervalLabel, - onValueChange = {}, - readOnly = true, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(intervalExpanded) }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth(), - shape = MaterialTheme.shapes.small, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = BcvBlue, - focusedLabelColor = BcvBlue - ) - ) - ExposedDropdownMenu( - expanded = intervalExpanded, - onDismissRequest = { intervalExpanded = false } - ) { - notificationOptions.forEach { (hours, label) -> - DropdownMenuItem( - text = { Text(label) }, - onClick = { - if (hours > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - viewModel.setNotificationInterval(hours) - intervalExpanded = false - } - ) - } - } + Section("Widgets", Icons.Default.Widgets) { + InfoRow("Actualización", "Cada 30 min") + DropdownRow("Alerta de color", Icons.Default.Contrast, + alertThresholdOptions.firstOrNull { kotlin.math.abs(it.first - prefs.alertThresholdPercent) < 0.01 }?.second ?: "±1 %", + alertThresholdOptions) { viewModel.setAlertThresholdPercent(it) } + Hint("Cómo agregar: mantén presionada la pantalla de inicio › Widgets › Codivisas. Tasa (redimensionable), Brecha y Convertidor.") + } + + Section("Métodos de pago", Icons.Default.AccountBalanceWallet) { + ActionRow(Icons.Default.Payments, "Agenda de pagos", "${prefs.paymentContacts.size} guardados", onOpenPaymentAgenda) + } + + Section("Datos", Icons.Default.Storage) { + ActionRow(Icons.Default.Upload, "Exportar respaldo", "Deudas, ahorros y métodos de pago (JSON)") { + exportLauncher.launch("codivisas-respaldo-${java.time.LocalDate.now()}.json") + } + ActionRow(Icons.Default.Download, "Importar respaldo", "Reemplaza los datos actuales") { + importLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) } + ActionRow(Icons.Default.DeleteSweep, "Borrar historial de calculadora", null) { confirmClear = true } } - SettingsCard( - title = "Alerta visual (widgets)", - icon = Icons.Default.Notifications, - iconTint = Color(0xFF059669) - ) { - Text( - text = "Si la tasa del día se mueve más que el umbral vs ayer, los widgets muestran borde verde o rojo.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(14.dp)) - val alertLabel = alertThresholdOptions - .firstOrNull { kotlin.math.abs(it.first - preferences.alertThresholdPercent) < 0.01 } - ?.second - ?: "±1%" - ExposedDropdownMenuBox( - expanded = alertExpanded, - onExpandedChange = { alertExpanded = it } - ) { - OutlinedTextField( - value = alertLabel, - onValueChange = {}, - readOnly = true, - label = { Text("Umbral") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(alertExpanded) }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth(), - shape = MaterialTheme.shapes.small, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = BcvBlue, - focusedLabelColor = BcvBlue - ) - ) - ExposedDropdownMenu( - expanded = alertExpanded, - onDismissRequest = { alertExpanded = false } - ) { - alertThresholdOptions.forEach { (pct, label) -> - DropdownMenuItem( - text = { Text(label) }, - onClick = { - viewModel.setAlertThresholdPercent(pct) - alertExpanded = false - } - ) - } + Section("Actualizaciones", Icons.Default.SystemUpdate) { + SwitchRow("Buscar automáticamente", prefs.autoUpdateCheck, true) { viewModel.setAutoUpdateCheck(it) } + ActionRow(Icons.Default.Refresh, "Buscar ahora", updateStatus ?: "Versión ${BuildConfig.VERSION_NAME}") { + scope.launch { + updateStatus = "Buscando…" + val m = AppUpdateManager.checkForUpdate(context, notifyIfAvailable = true) + updateStatus = if (m != null) "Actualización ${m.versionName} disponible" else "Ya tienes la última versión" } } } - SettingsCard(title = "Información", icon = Icons.Default.Info, iconTint = MaterialTheme.colorScheme.onSurfaceVariant) { - InfoRow("Última actualización", FxCalculator.formatUpdatedAt(dashboard?.bcvUpdatedAt)) - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.outlineVariant) - InfoRow("Fuente de datos", "bcv.today") - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.outlineVariant) - InfoRow("Versión", "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.outlineVariant) - InfoRow("Widget", "Actualización cada 30 min") - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.outlineVariant) - InfoRow("Hecho por", "Codeonyx") - Spacer(modifier = Modifier.height(12.dp)) - TextButton( - onClick = { - scope.launch { - updateStatus = "Buscando…" - val manifest = AppUpdateManager.checkForUpdate(context, notifyIfAvailable = true) - updateStatus = when { - manifest != null -> "Actualización ${manifest.versionName} disponible" - else -> "Ya tienes la última versión" - } - Toast.makeText(context, updateStatus, Toast.LENGTH_SHORT).show() - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Buscar actualizaciones", fontWeight = FontWeight.SemiBold) - } - updateStatus?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Section("Ayuda", Icons.AutoMirrored.Filled.HelpOutline) { + ActionRow(Icons.Default.Explore, "Ver guía de nuevo", "Recorrido por toda la app") { viewModel.replayTour() } + ActionRow(Icons.Default.Info, "Acerca de Codivisas", "Privacidad, fuentes y créditos") { showAbout = true } } + Spacer(Modifier.height(8.dp)) } + + if (showAbout) AboutSheet(onDismiss = { showAbout = false }, onGuide = { showAbout = false; viewModel.replayTour() }) + if (confirmClear) AlertDialog( + onDismissRequest = { confirmClear = false }, + title = { Text("Borrar historial") }, + text = { Text("Se eliminarán las operaciones guardadas de la calculadora.") }, + confirmButton = { TextButton({ viewModel.clearCalculatorHistory(); confirmClear = false; toast("Historial borrado") }) { Text("Borrar") } }, + dismissButton = { TextButton({ confirmClear = false }) { Text("Cancelar") } } + ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SettingsCard( - title: String, - icon: ImageVector, - iconTint: androidx.compose.ui.graphics.Color, - content: @Composable () -> Unit -) { - Card( - modifier = Modifier - .fillMaxWidth() - .shadow(2.dp, MaterialTheme.shapes.large), - shape = MaterialTheme.shapes.large, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) - ) { - Column(modifier = Modifier.padding(20.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Surface( - modifier = Modifier.size(40.dp), - shape = CircleShape, - color = iconTint.copy(alpha = 0.12f) - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = icon, - contentDescription = null, - tint = iconTint, - modifier = Modifier.size(22.dp) - ) +private fun AboutSheet(onDismiss: () -> Unit, onGuide: () -> Unit) { + val context = LocalContext.current + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp).padding(bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(14.dp)) { + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + AppIconBadge() + Spacer(Modifier.height(8.dp)) + Text("Codivisas", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text("Versión ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Section("Privacidad", Icons.Default.Lock) { + Check("Sin anuncios"); Check("Sin registro ni cuenta"); Check("Sin rastreo ni analíticas: tus datos se quedan en tu teléfono") + } + Section("Fuentes de datos", Icons.Default.Public) { + InfoRow("Tasas BCV", "bcv.today · dolarapi") + InfoRow("USDT", "Binance P2P") + } + Section("Aviso", Icons.Default.WarningAmber) { + Hint("Las tasas son referenciales y provienen de fuentes públicas. Verifica siempre la tasa vigente antes de pagar o cobrar.") + } + Section("Ayuda", Icons.AutoMirrored.Filled.HelpOutline) { + ActionRow(Icons.Default.Explore, "Ver guía", null, onGuide) + Hint("Widget: mantén presionada la pantalla de inicio › Widgets › Codivisas.") + } + Surface( + onClick = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://codeonyx.online")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }, + shape = MaterialTheme.shapes.large, color = BcvGold.copy(alpha = 0.14f) + ) { + Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Hecho por Codeonyx", fontWeight = FontWeight.Bold) + Text("codeonyx.online", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) } + Icon(Icons.AutoMirrored.Filled.OpenInNew, "Abrir sitio web", tint = MaterialTheme.colorScheme.primary) } - Spacer(modifier = Modifier.width(14.dp)) - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) } - Spacer(modifier = Modifier.height(16.dp)) - content() } } } +@Composable +private fun Section(title: String, icon: ImageVector, content: @Composable ColumnScope.() -> Unit) { + Column { + Row(Modifier.padding(start = 4.dp, bottom = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(icon, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(8.dp)) + Text(title.uppercase(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary) + } + Card(shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), content = content) + } + } +} + +@Composable private fun Label(t: String) = Text(t, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) +@Composable private fun Hint(t: String) = Text(t, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) +@Composable private fun Check(t: String) = Row { Text("✓ ", color = Color(0xFF10B981), fontWeight = FontWeight.Bold); Text(t, style = MaterialTheme.typography.bodyMedium) } + +@Composable +private fun SwitchRow(label: String, checked: Boolean, enabled: Boolean, onChange: (Boolean) -> Unit) { + Row(Modifier.fillMaxWidth().clickable(enabled = enabled) { onChange(!checked) }, verticalAlignment = Alignment.CenterVertically) { + Text(label, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant) + Switch(checked = checked, onCheckedChange = onChange, enabled = enabled) + } +} + +@Composable +private fun ActionRow(icon: ImageVector, title: String, subtitle: String?, onClick: () -> Unit) { + Row(Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).clickable(onClick = onClick).padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically) { + Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + subtitle?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + Icon(Icons.Default.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + @Composable private fun InfoRow(label: String, value: String) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DropdownRow(label: String, icon: ImageVector?, value: String, options: List>, onSelect: (T) -> Unit) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = value, onValueChange = {}, readOnly = true, singleLine = true, + label = { Text(label) }, + leadingIcon = icon?.let { { Icon(it, null) } }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), shape = MaterialTheme.shapes.small ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { (v, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { onSelect(v); expanded = false }) } + } + } +} + +/** + * Ícono de la app dibujado con el foreground vectorial sobre el fondo de marca. + * No cargar el mipmap ic_launcher con painterResource: es un adaptive-icon y Compose lanza + * IllegalArgumentException ("Only VectorDrawables and rasterized asset types are supported"). + */ +@Composable +private fun AppIconBadge() { + Box( + Modifier.size(76.dp).clip(CircleShape) + .background(com.codeonyx.bcvaqui.ui.theme.BcvBlue), + contentAlignment = Alignment.Center + ) { + Image( + painterResource(com.codeonyx.bcvaqui.ui.theme.PaletteManager.logoForeground(com.codeonyx.bcvaqui.ui.theme.ActivePalette.current)), + null, Modifier.fillMaxSize() ) } } + +@Composable +private fun PalettePicker( + selected: com.codeonyx.bcvaqui.domain.AppPalette, + onSelect: (com.codeonyx.bcvaqui.domain.AppPalette) -> Unit +) { + androidx.compose.foundation.layout.Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween + ) { + com.codeonyx.bcvaqui.domain.AppPalette.entries.forEach { p -> + val isSel = p == selected + Column( + Modifier.clip(MaterialTheme.shapes.small) + .clickable { onSelect(p) } + .padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + Modifier.size(56.dp).clip(CircleShape) + .background(androidx.compose.ui.graphics.Color(p.primary)) + .then( + if (isSel) Modifier.border(3.dp, MaterialTheme.colorScheme.secondary, CircleShape) + else Modifier + ), + contentAlignment = Alignment.Center + ) { + Image( + painterResource(com.codeonyx.bcvaqui.ui.theme.PaletteManager.logoForeground(p)), + contentDescription = "Paleta ${p.label}", + modifier = Modifier.fillMaxSize() + ) + } + Text( + p.label, + style = MaterialTheme.typography.labelSmall, + fontWeight = if (isSel) FontWeight.Bold else FontWeight.Normal + ) + } + } + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/share/ReceiptShareHelper.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/share/ReceiptShareHelper.kt index 04bb090..84dad97 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/share/ReceiptShareHelper.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/share/ReceiptShareHelper.kt @@ -28,7 +28,7 @@ data class ReceiptData( object ReceiptShareHelper { - fun share(context: Context, data: ReceiptData, paymentText: String? = null) { + fun share(context: Context, data: ReceiptData, paymentText: String? = null, fullText: String? = null) { runCatching { val bitmap = drawReceiptBitmap(context, data) val dir = File(context.cacheDir, "share").apply { mkdirs() } @@ -43,7 +43,7 @@ object ReceiptShareHelper { "${context.packageName}.fileprovider", file ) - val text = buildShareText(data, paymentText) + val text = fullText ?: buildShareText(data, paymentText) val intent = Intent(Intent.ACTION_SEND).apply { type = "image/png" putExtra(Intent.EXTRA_STREAM, uri) @@ -94,7 +94,7 @@ object ReceiptShareHelper { val logoCx = dp(50f) val logoCy = dp(52f) val logoR = dp(26f) - val discPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFF1E3A8A.toInt() } + val discPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = com.codeonyx.bcvaqui.ui.theme.ActivePalette.current.primary.toInt() } canvas.drawCircle(logoCx, logoCy, logoR, discPaint) val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { @@ -129,7 +129,7 @@ object ReceiptShareHelper { canvas.drawPath(head2, headPaint) val sparkPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = 0xFFFBBF24.toInt() + color = com.codeonyx.bcvaqui.ui.theme.ActivePalette.current.accent.toInt() style = Paint.Style.STROKE strokeWidth = dp(2f) strokeCap = Paint.Cap.ROUND @@ -144,7 +144,7 @@ object ReceiptShareHelper { ) } canvas.drawPath(spark, sparkPaint) - val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFFFBBF24.toInt() } + val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = com.codeonyx.bcvaqui.ui.theme.ActivePalette.current.accent.toInt() } canvas.drawCircle(logoCx + dp(12f), logoCy - dp(12f), dp(2.4f), dotPaint) // Title @@ -157,7 +157,7 @@ object ReceiptShareHelper { color = 0xFFA1A1AA.toInt() textSize = dp(13f) } - canvas.drawText("SpreadVE", dp(86f), dp(48f), titlePaint) + canvas.drawText("Codivisas", dp(86f), dp(48f), titlePaint) canvas.drawText("Comprobante de Cálculo", dp(86f), dp(68f), subtitlePaint) // White rate card diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/theme/Color.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/theme/Color.kt index 6f76ad7..c531b40 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/theme/Color.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/theme/Color.kt @@ -1,13 +1,25 @@ package com.codeonyx.bcvaqui.ui.theme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color +import com.codeonyx.bcvaqui.domain.AppPalette -val BcvBlue = Color(0xFF1E3A8A) -val BcvBlueLight = Color(0xFF3B82F6) -val BcvBlueDark = Color(0xFF172554) -val BcvIndigo = Color(0xFF6366F1) -val BcvGold = Color(0xFFFBBF24) -val BcvGoldDark = Color(0xFFD97706) + + +/** Paleta activa (estado de Compose: al cambiarla se recomponen todas las pantallas). */ +object ActivePalette { + var current: AppPalette by mutableStateOf(AppPalette.DEFAULT) +} + +// Los nombres históricos (BcvBlue/BcvGold…) ahora siguen la paleta elegida. +val BcvBlue: Color get() = Color(ActivePalette.current.primary) +val BcvBlueLight: Color get() = Color(ActivePalette.current.primaryLight) +val BcvBlueDark: Color get() = Color(ActivePalette.current.primaryDark) +val BcvIndigo: Color get() = Color(ActivePalette.current.tertiary) +val BcvGold: Color get() = Color(ActivePalette.current.accent) +val BcvGoldDark: Color get() = Color(ActivePalette.current.accentDark) val SurfaceLight = Color(0xFFF8FAFC) val CardElevated = Color(0xFFFFFFFF) val OnSurfaceMuted = Color(0xFF64748B) diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/theme/PaletteManager.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/theme/PaletteManager.kt new file mode 100644 index 0000000..ff9316f --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/theme/PaletteManager.kt @@ -0,0 +1,74 @@ +package com.codeonyx.bcvaqui.ui.theme + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import com.codeonyx.bcvaqui.R +import com.codeonyx.bcvaqui.domain.AppPalette + +/** Aplica la paleta fuera de Compose: ícono del launcher (activity-alias) y tema del splash. */ +object PaletteManager { + + fun aliasComponent(context: Context, p: AppPalette) = + ComponentName(context.packageName, "com.codeonyx.bcvaqui.${p.launcherAlias}") + + /** Habilita solo el alias de la paleta elegida (DONT_KILL_APP). Habilita primero el nuevo para no quedar sin ícono. */ + fun applyLauncherIcon(context: Context, palette: AppPalette) { + val pm = context.packageManager + runCatching { + pm.setComponentEnabledSetting( + aliasComponent(context, palette), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP + ) + AppPalette.entries.filter { it != palette }.forEach { + pm.setComponentEnabledSetting( + aliasComponent(context, it), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP + ) + } + } + } + + /** Alias habilitado actualmente según PackageManager (o null si no se puede leer). */ + fun currentLauncherPalette(context: Context): AppPalette? = runCatching { + val pm = context.packageManager + AppPalette.entries.firstOrNull { p -> + when (pm.getComponentEnabledSetting(aliasComponent(context, p))) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> p == AppPalette.DEFAULT + else -> false + } + } + }.getOrNull() + + fun splashTheme(p: AppPalette): Int = when (p) { + AppPalette.AZUL -> R.style.Theme_BcvAqui_Splash_Azul + AppPalette.ONIX -> R.style.Theme_BcvAqui_Splash_Onix + AppPalette.MORADO -> R.style.Theme_BcvAqui_Splash_Morado + AppPalette.ESMERALDA -> R.style.Theme_BcvAqui_Splash_Esmeralda + } + + fun logoForeground(p: AppPalette): Int = when (p) { + AppPalette.AZUL -> R.drawable.ic_launcher_foreground_azul + AppPalette.ONIX -> R.drawable.ic_launcher_foreground_onix + AppPalette.MORADO -> R.drawable.ic_launcher_foreground_morado + AppPalette.ESMERALDA -> R.drawable.ic_launcher_foreground_esmeralda + } + + /** Android 13+: el sistema recuerda el tema del splash para los próximos arranques. */ + fun applySplash(activity: Activity, palette: AppPalette) { + if (Build.VERSION.SDK_INT >= 33) { + runCatching { activity.splashScreen.setSplashScreenTheme(splashTheme(palette)) } + } + } + + /** Sincroniza ícono y splash si no coinciden con la paleta guardada (p. ej. tras importar un respaldo). */ + fun sync(activity: Activity, palette: AppPalette) { + if (currentLauncherPalette(activity) != palette) applyLauncherIcon(activity, palette) + applySplash(activity, palette) + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/theme/PaletteStore.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/theme/PaletteStore.kt new file mode 100644 index 0000000..4814c78 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/theme/PaletteStore.kt @@ -0,0 +1,22 @@ +package com.codeonyx.bcvaqui.ui.theme + +import android.content.Context +import com.codeonyx.bcvaqui.domain.AppPalette + +/** + * Copia síncrona de la paleta en SharedPreferences: se lee en onCreate antes de setContent, + * así la app arranca directamente con la paleta elegida (sin destello azul). + * DataStore sigue siendo la fuente para respaldos; ambos se escriben juntos. + */ +object PaletteStore { + private const val FILE = "codivisas_palette" + private const val KEY = "palette" + + fun read(context: Context): AppPalette = + AppPalette.from(context.getSharedPreferences(FILE, Context.MODE_PRIVATE).getString(KEY, null)) + + fun write(context: Context, palette: AppPalette) { + context.getSharedPreferences(FILE, Context.MODE_PRIVATE).edit().putString(KEY, palette.name).apply() + ActivePalette.current = palette + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/theme/Theme.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/theme/Theme.kt index 6fef742..4d09ed9 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/theme/Theme.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/theme/Theme.kt @@ -4,22 +4,28 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Shapes import androidx.compose.material3.Typography +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.codeonyx.bcvaqui.domain.AppPalette -private val LightColors = lightColorScheme( - primary = BcvBlue, +private fun lightColorsFor(p: AppPalette) = lightColorScheme( + primary = Color(p.primary), onPrimary = Color.White, - primaryContainer = Color(0xFFDBEAFE), - onPrimaryContainer = BcvBlueDark, - secondary = BcvGold, - onSecondary = BcvBlueDark, - tertiary = BcvIndigo, + primaryContainer = Color(p.container), + onPrimaryContainer = Color(p.primaryDark), + secondary = Color(p.accent), + onSecondary = Color(AppPalette.onColor(p.accent)), + tertiary = Color(p.tertiary), background = SurfaceLight, surface = CardElevated, surfaceVariant = Color(0xFFF1F5F9), @@ -30,6 +36,24 @@ private val LightColors = lightColorScheme( outlineVariant = Color(0xFFCBD5E1) ) +private fun darkColorsFor(p: AppPalette) = darkColorScheme( + primary = Color(p.darkPrimary), + onPrimary = Color(p.primaryDark), + primaryContainer = Color(p.primary), + onPrimaryContainer = Color(p.container), + secondary = Color(p.darkAccent), + onSecondary = Color(AppPalette.onColor(p.darkAccent)), + tertiary = Color(p.darkPrimary), + background = Color(p.darkBackground), + surface = Color(p.darkSurface), + surfaceVariant = Color(0xFF1F2937), + onBackground = Color(0xFFE5E7EB), + onSurface = Color(0xFFE5E7EB), + onSurfaceVariant = Color(0xFF9CA3AF), + outline = Color(0xFF374151), + outlineVariant = Color(0xFF4B5563) +) + private val AppTypography = Typography( displaySmall = TextStyle( fontSize = 40.sp, @@ -95,12 +119,42 @@ private val AppShapes = Shapes( extraLarge = RoundedCornerShape(28.dp) ) +/** true cuando el tema efectivo es oscuro (sistema o elección del usuario). */ +val LocalDarkTheme = staticCompositionLocalOf { false } + +/** Colores de marca que no son roles de Material 3 pero deben adaptarse al tema. */ +object BrandColors { + val heroGradient: List + @Composable get() = if (LocalDarkTheme.current) { + ActivePalette.current.let { listOf(Color(it.darkBackground), Color(it.darkTopBar), Color(it.primaryDark)) } + } else { + listOf(BcvBlueDark, BcvBlue, BcvIndigo) + } + + /** Barra superior: azul de marca en claro, superficie elevada en oscuro. */ + val topBar: Color + @Composable @ReadOnlyComposable get() = + if (LocalDarkTheme.current) Color(ActivePalette.current.darkTopBar) else BcvBlue + + val onTopBar: Color + @Composable @ReadOnlyComposable get() = Color.White + + val onTopBarMuted: Color + @Composable @ReadOnlyComposable get() = Color.White.copy(alpha = 0.78f) +} + @Composable -fun BcvAquiTheme(content: @Composable () -> Unit) { - MaterialTheme( - colorScheme = LightColors, - typography = AppTypography, - shapes = AppShapes, - content = content - ) +fun BcvAquiTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + palette: AppPalette = AppPalette.DEFAULT, + content: @Composable () -> Unit +) { + CompositionLocalProvider(LocalDarkTheme provides darkTheme) { + MaterialTheme( + colorScheme = if (darkTheme) darkColorsFor(palette) else lightColorsFor(palette), + typography = AppTypography, + shapes = AppShapes, + content = content + ) + } } diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/CalculatorTools.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/CalculatorTools.kt index ab63616..55f6326 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/CalculatorTools.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/CalculatorTools.kt @@ -1,5 +1,9 @@ package com.codeonyx.bcvaqui.ui.tools +import androidx.compose.material3.MaterialTheme + +import androidx.lifecycle.compose.collectAsStateWithLifecycle + import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -20,6 +24,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.codeonyx.bcvaqui.ui.appViewModel import com.codeonyx.bcvaqui.domain.FxCalculator +import com.codeonyx.bcvaqui.domain.VeNumber import com.codeonyx.bcvaqui.domain.RateSources import com.codeonyx.bcvaqui.ui.MainViewModel import com.codeonyx.bcvaqui.ui.share.ReceiptData @@ -89,77 +94,9 @@ fun ComisionScreen() { } } -@Composable -fun VariacionScreen(viewModel: MainViewModel = appViewModel()) { - val dashboard by viewModel.dashboard.collectAsState() - val current = dashboard?.bcvRates?.get("USD")?.rateToVes ?: 0.0 - - var fromRate by remember { mutableStateOf("") } - var toRate by remember(current) { - mutableStateOf(if (current > 0) FxCalculator.format2(current) else "") - } - - val a = parseToolAmount(fromRate) - val b = parseToolAmount(toRate) - val diff = FxCalculator.round2(b - a) - val pct = if (a > 0) FxCalculator.round2((b - a) / a * 100.0) else 0.0 - val canShowResult = a > 0 && b > 0 - - ToolScaffold { - ToolIntro("Mide cuánto subió o bajó una tasa entre dos momentos, por ejemplo ayer y hoy.") - ToolPanel { - Text("Escribe las dos tasas", fontWeight = FontWeight.Bold) - ToolNumberField( - fromRate, - { fromRate = it }, - "Antes", - "Bs.", - "La tasa inicial o más antigua." - ) - ToolNumberField( - toRate, - { toRate = it }, - "Ahora", - "Bs.", - "La tasa nueva que quieres comparar." - ) - if (current > 0) { - FilterChip( - selected = false, - onClick = { - fromRate = toRate - toRate = FxCalculator.format2(current) - }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Usar tasa BCV actual (${FxCalculator.format2(current)})") }, - colors = toolChipColors() - ) - } - } - if (!canShowResult) { - ToolEmptyResult( - title = "Escribe la tasa anterior", - hint = "Cuando completes ambas tasas verás la diferencia en bolívares y en porcentaje." - ) - } else { - ToolResultCard( - title = "Variación", - value = "${if (pct > 0) "+" else ""}${FxCalculator.format2(pct)}%", - detail = "${FxCalculator.format2(diff)} Bs. entre ambas tasas", - accent = when { - pct > 0.005 -> RateUp - pct < -0.005 -> RateDown - else -> BcvBlue - }, - changePercent = pct - ) - } - } -} - @Composable fun DividirScreen(viewModel: MainViewModel = appViewModel()) { - val dashboard by viewModel.dashboard.collectAsState() + val dashboard by viewModel.dashboard.collectAsStateWithLifecycle() val usdRate = dashboard?.bcvRates?.get("USD")?.rateToVes ?: 0.0 var total by remember { mutableStateOf("100") } @@ -227,117 +164,3 @@ fun DividirScreen(viewModel: MainViewModel = appViewModel()) { } } -@Composable -fun CompartirToolScreen(viewModel: MainViewModel = appViewModel()) { - val dashboard by viewModel.dashboard.collectAsState() - val preferences by viewModel.preferences.collectAsState() - val rates = dashboard?.bcvRates.orEmpty() - val currencies = buildList { - addAll(rates.keys.sorted().ifEmpty { listOf("USD") }) - add(RateSources.BINANCE_USDT) - }.distinct() - val context = LocalContext.current - - var amount by remember { mutableStateOf("1") } - var currency by remember { - mutableStateOf( - preferences.selectedCurrency.takeIf { it in currencies } ?: currencies.first() - ) - } - var convertToVes by remember { mutableStateOf(true) } - var selectedPaymentId by remember { mutableStateOf(null) } - - val rate = RateSources.effectiveRate( - currency = currency, - bcvRates = rates, - binanceUsdtVes = dashboard?.binanceUsdtVes ?: preferences.lastKnownBinanceUsdtVes - ) ?: 0.0 - val amt = parseToolAmount(amount) - val result = if (convertToVes) FxCalculator.round2(amt * rate) - else if (rate > 0) FxCalculator.round2(amt / rate) else 0.0 - val foreign = if (convertToVes) amt else result - val ves = if (convertToVes) result else amt - val contacts = preferences.paymentContacts - - ToolScaffold { - ToolIntro("Arma una conversión y compártela (opcional: datos de pago).") - ToolPanel { - CurrencyChips(currencies, currency) { currency = it } - ToolNumberField( - amount, - { amount = it }, - if (convertToVes) "Monto en $currency" else "Monto en Bs." - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilterChip( - selected = convertToVes, - onClick = { convertToVes = true }, - modifier = Modifier.weight(1f), - label = { Text("A bolívares") }, - colors = toolChipColors() - ) - FilterChip( - selected = !convertToVes, - onClick = { convertToVes = false }, - modifier = Modifier.weight(1f), - label = { Text("A $currency") }, - colors = toolChipColors() - ) - } - } - ToolResultCard( - title = "Resultado", - value = if (convertToVes) "${FxCalculator.formatAmount(result)} Bs." - else "${FxCalculator.formatAmount(result)} $currency", - accent = Color(0xFF0F766E) - ) - - if (contacts.isNotEmpty()) { - ToolPanel { - Text("Incluir datos de pago", fontWeight = FontWeight.SemiBold) - FilterChip( - selected = selectedPaymentId == null, - onClick = { selectedPaymentId = null }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Solo conversión") }, - colors = toolChipColors() - ) - contacts.forEach { c -> - FilterChip( - selected = selectedPaymentId == c.id, - onClick = { selectedPaymentId = c.id }, - modifier = Modifier.fillMaxWidth(), - label = { Text("${c.displayName} · ${c.typeLabel()}") }, - colors = toolChipColors() - ) - } - } - } - - Button( - onClick = { - val receipt = ReceiptData( - currencyCode = currency, - rateToVes = rate, - foreignAmount = foreign, - vesAmount = ves, - updatedAt = dashboard?.bcvUpdatedAt - .takeUnless { currency == RateSources.BINANCE_USDT } - ) - val payment = contacts.firstOrNull { it.id == selectedPaymentId } - ReceiptShareHelper.share( - context = context, - data = receipt, - paymentText = payment?.formatForCopy() - ) - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = BcvBlue) - ) { - Text("Compartir", fontWeight = FontWeight.SemiBold) - } - } -} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ComercianteScreen.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ComercianteScreen.kt new file mode 100644 index 0000000..2d7e5ae --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ComercianteScreen.kt @@ -0,0 +1,84 @@ +package com.codeonyx.bcvaqui.ui.tools + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.lifecycle.compose.collectAsStateWithLifecycle +import com.codeonyx.bcvaqui.domain.FxCalculator +import com.codeonyx.bcvaqui.domain.MarketMath +import com.codeonyx.bcvaqui.domain.VeNumber +import com.codeonyx.bcvaqui.ui.MainViewModel +import com.codeonyx.bcvaqui.ui.appViewModel + +/** Modo comerciante: precio en $ → Bs. con un % de ganancia o descuento guardado. */ +@Composable +fun ComercianteScreen(viewModel: MainViewModel = appViewModel()) { + val dashboard by viewModel.dashboard.collectAsStateWithLifecycle() + val prefs by viewModel.preferences.collectAsStateWithLifecycle() + val bcv = dashboard?.bcvRates?.get("USD")?.rateToVes ?: prefs.lastKnownUsdRate ?: 0.0 + val next = dashboard?.nextBcvUsd + + var marginInput by remember { mutableStateOf("") } + LaunchedEffect(prefs.merchantMarginPercent) { + if (marginInput.isEmpty() && prefs.merchantMarginPercent != 0.0) { + marginInput = FxCalculator.format2(prefs.merchantMarginPercent) + } + } + var amountInput by remember { mutableStateOf("1") } + var discount by remember(prefs.merchantMarginPercent) { mutableStateOf(prefs.merchantMarginPercent < 0) } + var useNext by remember { mutableStateOf(false) } + + val marginAbs = kotlin.math.abs(VeNumber.parse(marginInput)) + val margin = if (discount) -marginAbs else marginAbs + val rate = if (useNext && next != null) next.rate else bcv + val amount = VeNumber.parse(amountInput) + val base = FxCalculator.round2(amount * rate) + val withMargin = MarketMath.applyMargin(base, margin) + + ToolScaffold { + ToolIntro("Aplica tu % de ganancia o descuento a las conversiones. El porcentaje queda guardado para la próxima vez.") + ToolPanel { + SegmentedChoice(listOf(false, true), discount, { if (it) "Descuento −" else "Ganancia +" }, { + discount = it; viewModel.setMerchantMargin(if (it) -marginAbs else marginAbs) + }) + ToolNumberField( + value = marginInput, + onValueChange = { + marginInput = it + val v = kotlin.math.abs(VeNumber.parse(it)) + viewModel.setMerchantMargin(if (discount) -v else v) + }, + label = "Porcentaje", + suffix = "%" + ) + ToolNumberField(value = amountInput, onValueChange = { amountInput = it }, label = "Precio en dólares", suffix = "$") + if (next != null) { + SegmentedChoice(listOf(false, true), useNext, { if (it) "Próxima (${MarketMath.shortDate(next.date)})" else "Tasa de hoy" }, { useNext = it }) + } + } + ToolResultCard( + title = "Precio a cobrar", + value = "${FxCalculator.format2(withMargin)} Bs.", + detail = "Base ${FxCalculator.format2(base)} Bs. · ${if (margin >= 0) "+" else ""}${FxCalculator.format2(margin)}% · tasa ${FxCalculator.format2(rate)}" + ) + ToolResultCard( + title = "Tasa con margen", + value = "${FxCalculator.format2(MarketMath.marginRate(rate, margin))} Bs./$", + detail = "Útil para fijar precios en bolívares" + ) + Text("Referencial: verifica siempre la tasa vigente.", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Normal) + } +} + +private fun Int.dp() = androidx.compose.ui.unit.Dp(this.toFloat()) diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/HistorialScreen.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/HistorialScreen.kt index de533f4..1871f5c 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/HistorialScreen.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/HistorialScreen.kt @@ -1,5 +1,7 @@ package com.codeonyx.bcvaqui.ui.tools +import androidx.lifecycle.compose.collectAsStateWithLifecycle + import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -54,9 +56,9 @@ import com.codeonyx.bcvaqui.ui.theme.SurfaceLight @Composable fun HistorialScreen(viewModel: MainViewModel = appViewModel()) { - val history by viewModel.history.collectAsState() - val loading by viewModel.historyLoading.collectAsState() - val error by viewModel.historyError.collectAsState() + val history by viewModel.history.collectAsStateWithLifecycle() + val loading by viewModel.historyLoading.collectAsStateWithLifecycle() + val error by viewModel.historyError.collectAsStateWithLifecycle() var days by remember { mutableIntStateOf(30) } var currency by remember { mutableStateOf("USD") } @@ -93,16 +95,11 @@ fun HistorialScreen(viewModel: MainViewModel = appViewModel()) { .height(220.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator(color = BcvBlue) + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) } } error != null && history.isEmpty() -> { - ToolPanel { - Text(error ?: "", color = MaterialTheme.colorScheme.error) - TextButton(onClick = { viewModel.loadHistory(days) }) { - Text("Reintentar") - } - } + ToolErrorState(error ?: "Revisa tu conexión.", onRetry = { viewModel.loadHistory(days) }) } else -> { val points = history.mapNotNull { entry -> @@ -126,7 +123,7 @@ fun HistorialScreen(viewModel: MainViewModel = appViewModel()) { val periodColor = when { pct > 0.005 -> RateUp pct < -0.005 -> RateDown - else -> BcvBlue + else -> MaterialTheme.colorScheme.primary } ToolResultCard( @@ -142,7 +139,7 @@ fun HistorialScreen(viewModel: MainViewModel = appViewModel()) { .fillMaxWidth() .height(230.dp), shape = RoundedCornerShape(20.dp), - color = Color.White, + color = MaterialTheme.colorScheme.surface, shadowElevation = 2.dp ) { RateChart( @@ -193,7 +190,7 @@ private fun HistoryDayRow(date: String, rate: Double, dayChangePct: Double?) { modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) - .background(SurfaceLight) + .background(MaterialTheme.colorScheme.background) .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/MercadoScreen.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/MercadoScreen.kt new file mode 100644 index 0000000..eafcc2e --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/MercadoScreen.kt @@ -0,0 +1,180 @@ +package com.codeonyx.bcvaqui.ui.tools + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.codeonyx.bcvaqui.domain.DayPoint +import com.codeonyx.bcvaqui.domain.FxCalculator +import com.codeonyx.bcvaqui.domain.MarketMath +import com.codeonyx.bcvaqui.ui.MainViewModel +import com.codeonyx.bcvaqui.ui.appViewModel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MercadoScreen(viewModel: MainViewModel = appViewModel()) { + val dashboard by viewModel.dashboard.collectAsStateWithLifecycle() + val market by viewModel.market.collectAsStateWithLifecycle() + val loading by viewModel.marketLoading.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { viewModel.loadMarket() } + + val ranges = listOf(7, 30, 90) + var rangeIdx by remember { mutableIntStateOf(1) } + val days = ranges[rangeIdx] + + ToolScaffold { + ToolIntro("USDT compra/venta en Binance P2P, la próxima tasa BCV si ya fue publicada y gráficos de 7, 30 y 90 días.") + + val q = dashboard?.usdtQuote + ToolPanel { + Text("USDT · Binance P2P", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + if (q == null) { + Text( + dashboard?.binanceError?.let { "No disponible ahora: $it" } ?: "No disponible ahora. Desliza en Inicio para reintentar.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Stat("Compra", q.buy) + Stat("Venta", q.sell) + Stat("Promedio", q.average) + } + Text( + "Consultado: " + SimpleDateFormat("dd/MM/yyyy h:mm a", Locale("es", "VE")).format(Date(q.atEpochMs)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + dashboard?.nextBcvUsd?.let { next -> + ToolResultCard( + title = "Próxima tasa BCV (${MarketMath.shortDate(next.date)})", + value = "${FxCalculator.format2(next.rate)} Bs.", + detail = "Ya publicada por el BCV · aplica el próximo día hábil", + changePercent = dashboard?.bcvRates?.get("USD")?.rateToVes?.takeIf { it > 0 }?.let { + FxCalculator.round2((next.rate - it) / it * 100.0) + } + ) + } + + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + ranges.forEachIndexed { i, d -> + SegmentedButton( + selected = i == rangeIdx, + onClick = { rangeIdx = i }, + shape = SegmentedButtonDefaults.itemShape(i, ranges.size) + ) { Text("$d días") } + } + } + + if (loading && market == null) { + CircularProgressIndicator(Modifier.padding(16.dp)) + } + market?.let { m -> + ChartPanel("Dólar BCV (Bs.)", MarketMath.window(m.bcv, days), Color(0xFF2563EB), suffix = " Bs.") + ChartPanel("Paralelo / USDT (Bs.)", MarketMath.window(m.usdt, days), Color(0xFFF0B90B), suffix = " Bs.") + ChartPanel("Brecha (%)", MarketMath.window(m.gap, days), Color(0xFFDC2626), suffix = "%") + Text( + "Fuentes: bcv.today y ve.dolarapi.com. Tasas solo referenciales.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun Stat(label: String, value: Double?) { + Column { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value?.let { FxCalculator.format2(it) } ?: "—", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun ChartPanel(title: String, points: List, color: Color, suffix: String) { + ToolPanel { + val change = MarketMath.changePercent(points) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text( + points.lastOrNull()?.let { FxCalculator.format2(it.value) + suffix } ?: "—", + style = MaterialTheme.typography.titleSmall + ) + } + if (points.size < 2) { + Text("Sin datos suficientes para este período.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + LineChart(points.map { it.value }, color) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(MarketMath.shortDate(points.first().date), style = MaterialTheme.typography.labelSmall) + change?.let { + Text( + "${MarketMath.trendArrow(it)} ${if (it > 0) "+" else ""}${FxCalculator.format2(it)}%", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + Text(MarketMath.shortDate(points.last().date), style = MaterialTheme.typography.labelSmall) + } + } + } +} + +@Composable +private fun LineChart(values: List, color: Color) { + val grid = MaterialTheme.colorScheme.outlineVariant + Canvas(Modifier.fillMaxWidth().height(140.dp)) { + val min = values.min() + val max = values.max() + val span = (max - min).takeIf { it > 0 } ?: 1.0 + val stepX = size.width / (values.size - 1) + for (i in 0..3) { + val y = size.height * i / 3f + drawLine(grid, Offset(0f, y), Offset(size.width, y), strokeWidth = 1f) + } + val path = Path() + values.forEachIndexed { i, v -> + val x = i * stepX + val y = (size.height * (1 - (v - min) / span)).toFloat() + if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + drawPath(path, color, style = Stroke(width = 3.dp.toPx())) + val fill = Path().apply { + addPath(path) + lineTo(size.width, size.height) + lineTo(0f, size.height) + close() + } + drawPath(fill, color.copy(alpha = 0.12f)) + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/TaxAndGapTools.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/TaxAndGapTools.kt new file mode 100644 index 0000000..416e919 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/TaxAndGapTools.kt @@ -0,0 +1,158 @@ +package com.codeonyx.bcvaqui.ui.tools + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.codeonyx.bcvaqui.R +import com.codeonyx.bcvaqui.domain.FxCalculator +import com.codeonyx.bcvaqui.domain.PriceTools +import com.codeonyx.bcvaqui.domain.VeNumber +import com.codeonyx.bcvaqui.ui.MainViewModel +import com.codeonyx.bcvaqui.ui.appViewModel +import com.codeonyx.bcvaqui.ui.theme.RateDown +import com.codeonyx.bcvaqui.ui.theme.RateUp + +@Composable +fun IvaIgtfScreen(viewModel: MainViewModel = appViewModel()) { + val dashboard by viewModel.dashboard.collectAsStateWithLifecycle() + val usdRate = dashboard?.bcvRates?.get("USD")?.rateToVes ?: 0.0 + + var amount by remember { mutableStateOf("100") } + var includesIva by remember { mutableStateOf(false) } + var applyIgtf by remember { mutableStateOf(true) } + + val r = PriceTools.taxes( + amount = parseToolAmount(amount), + amountIncludesIva = includesIva, + applyIgtf = applyIgtf + ) + + ToolScaffold { + ToolIntro(stringResource(R.string.tool_iva_intro)) + ToolPanel { + ToolNumberField( + amount, { amount = it }, + stringResource(R.string.tool_iva_amount), "$", + stringResource(R.string.tool_iva_amount_helper) + ) + Text(stringResource(R.string.tool_iva_question), fontWeight = FontWeight.SemiBold) + ToolChoiceFlow(maxItemsInEachRow = 2) { + FilterChip( + selected = !includesIva, onClick = { includesIva = false }, + label = { Text(stringResource(R.string.tool_iva_add)) }, colors = toolChipColors() + ) + FilterChip( + selected = includesIva, onClick = { includesIva = true }, + label = { Text(stringResource(R.string.tool_iva_extract)) }, colors = toolChipColors() + ) + } + ToolChoiceFlow(maxItemsInEachRow = 1) { + FilterChip( + selected = applyIgtf, onClick = { applyIgtf = !applyIgtf }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.tool_iva_igtf)) }, colors = toolChipColors() + ) + } + } + if (r.total > 0.0) { + val detail = buildString { + append("Base: ${FxCalculator.format2(r.base)} · IVA 16 %: ${FxCalculator.format2(r.iva)}") + if (applyIgtf) append(" · IGTF 3 %: ${FxCalculator.format2(r.igtf)}") + if (usdRate > 0) append("\n≈ ${FxCalculator.format2(r.total * usdRate)} Bs. a tasa BCV") + } + ToolResultCard( + title = stringResource(R.string.tool_iva_total), + value = "$ ${FxCalculator.format2(r.total)}", + detail = detail + ) + } else { + ToolEmptyResult(stringResource(R.string.tool_empty_title), stringResource(R.string.tool_empty_amount)) + } + } +} + +private enum class GapCompare { USDT, EUR, MANUAL } + +@Composable +fun BrechaScreen(viewModel: MainViewModel = appViewModel()) { + val dashboard by viewModel.dashboard.collectAsStateWithLifecycle() + val preferences by viewModel.preferences.collectAsStateWithLifecycle() + val official = dashboard?.bcvRates?.get("USD")?.rateToVes ?: 0.0 + val usdt = dashboard?.binanceUsdtVes ?: preferences.lastKnownBinanceUsdtVes ?: 0.0 + val eur = dashboard?.bcvRates?.get("EUR")?.rateToVes ?: 0.0 + + var compare by rememberSaveable { mutableStateOf(GapCompare.USDT) } + var customLabel by rememberSaveable { mutableStateOf("") } + var officialInput by rememberSaveable(official) { mutableStateOf(VeNumber.toInput(official)) } + val prefill = when (compare) { + GapCompare.USDT -> usdt + GapCompare.EUR -> eur + GapCompare.MANUAL -> 0.0 + } + var marketInput by rememberSaveable(compare, prefill) { mutableStateOf(VeNumber.toInput(prefill)) } + + val o = parseToolAmount(officialInput) + val m = parseToolAmount(marketInput) + val gap = PriceTools.gapPercent(o, m) + val compareLabel = when (compare) { + GapCompare.USDT -> stringResource(R.string.gap_compare_usdt) + GapCompare.EUR -> stringResource(R.string.gap_compare_eur) + GapCompare.MANUAL -> customLabel.ifBlank { stringResource(R.string.gap_compare_manual) } + } + + ToolScaffold { + ToolIntro(stringResource(R.string.tool_gap_intro)) + ToolPanel { + Text(stringResource(R.string.gap_compare_with), style = MaterialTheme.typography.labelLarge) + val gapLabels = GapCompare.entries.associateWith { + stringResource(when (it) { + GapCompare.USDT -> R.string.gap_compare_usdt + GapCompare.EUR -> R.string.gap_compare_eur + GapCompare.MANUAL -> R.string.gap_compare_manual + }) + } + SegmentedChoice(GapCompare.entries, compare, { gapLabels.getValue(it) }, { compare = it }) + if (compare == GapCompare.MANUAL) { + OutlinedTextField( + value = customLabel, + onValueChange = { customLabel = it.take(30) }, + label = { Text(stringResource(R.string.gap_custom_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + ToolNumberField(officialInput, { officialInput = it }, stringResource(R.string.tool_gap_official), "Bs.") + ToolNumberField( + marketInput, { marketInput = it }, + stringResource(R.string.gap_compare_field, compareLabel), "Bs.", + helper = if (prefill <= 0.0 && compare != GapCompare.MANUAL) stringResource(R.string.gap_no_rate) else null + ) + } + if (gap != null) { + ToolResultCard( + title = stringResource(R.string.gap_result_vs, compareLabel), + value = "${FxCalculator.format2(gap)} %", + detail = stringResource( + R.string.tool_gap_detail, + FxCalculator.format2(m - o), + FxCalculator.format2(100.0 * o / m) + ), + accent = if (gap >= 0) RateDown else RateUp + ) + } else { + ToolEmptyResult(stringResource(R.string.tool_empty_title), stringResource(R.string.tool_gap_empty)) + } + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ToolCommon.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ToolCommon.kt index e0af5d5..ab7e737 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ToolCommon.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ToolCommon.kt @@ -1,5 +1,26 @@ package com.codeonyx.bcvaqui.ui.tools +import androidx.compose.material3.SegmentedButton +import androidx.compose.animation.togetherWith +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.TextButton +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.codeonyx.bcvaqui.R +import com.codeonyx.bcvaqui.domain.VeNumber import androidx.compose.animation.animateContentSize import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background @@ -27,6 +48,7 @@ import androidx.compose.material.icons.automirrored.filled.TrendingDown import androidx.compose.material.icons.automirrored.filled.TrendingUp import androidx.compose.material.icons.filled.Info import androidx.compose.material3.FilterChip +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -57,10 +79,11 @@ fun ToolScaffold( Column( modifier = Modifier .fillMaxSize() - .background(SurfaceLight) + .background(MaterialTheme.colorScheme.background) .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .imePadding() + .padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), content = content ) } @@ -70,8 +93,8 @@ fun ToolIntro(text: String) { Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(18.dp), - color = BcvBlue.copy(alpha = 0.08f), - border = BorderStroke(1.dp, BcvBlue.copy(alpha = 0.14f)) + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)) ) { Row( modifier = Modifier.padding(14.dp), @@ -79,12 +102,12 @@ fun ToolIntro(text: String) { ) { Surface( shape = RoundedCornerShape(10.dp), - color = BcvBlue.copy(alpha = 0.14f) + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) ) { Icon( imageVector = Icons.Default.Info, contentDescription = null, - tint = BcvBlue, + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(7.dp).size(18.dp) ) } @@ -94,7 +117,7 @@ fun ToolIntro(text: String) { text = "¿Qué hace esta herramienta?", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, - color = BcvBlue + color = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(2.dp)) Text( @@ -115,24 +138,27 @@ fun ToolPanel( Surface( modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), - color = Color.White, - shadowElevation = 2.dp + color = MaterialTheme.colorScheme.surface, + shadowElevation = 2.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) ) { Column( modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), content = content ) } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun ToolResultCard( title: String, value: String, detail: String? = null, - accent: Color = BcvBlue, - changePercent: Double? = null + accent: Color = MaterialTheme.colorScheme.primary, + changePercent: Double? = null, + shareText: String? = "$title: $value" + (detail?.let { "\n$it" } ?: "") + "\n— Codivisas" ) { Surface( modifier = Modifier @@ -140,9 +166,10 @@ fun ToolResultCard( .clip(RoundedCornerShape(20.dp)) .animateContentSize(), shape = RoundedCornerShape(20.dp), - color = Color.White, - shadowElevation = 2.dp, - tonalElevation = 0.dp + color = MaterialTheme.colorScheme.surface, + shadowElevation = 3.dp, + tonalElevation = 0.dp, + border = BorderStroke(1.dp, accent.copy(alpha = 0.25f)) ) { Column { Box( @@ -172,12 +199,29 @@ fun ToolResultCard( } } Spacer(modifier = Modifier.height(8.dp)) - Text( - text = value, - style = MaterialTheme.typography.headlineSmall, + val context = LocalContext.current + val plain = plainNumber(value) + androidx.compose.animation.AnimatedContent( + targetState = value, + transitionSpec = { + (androidx.compose.animation.fadeIn(androidx.compose.animation.core.tween(180)) + + androidx.compose.animation.slideInVertically { it / 3 }) togetherWith + androidx.compose.animation.fadeOut(androidx.compose.animation.core.tween(120)) + }, + label = "toolResult" + ) { shown -> Text( + text = shown, + style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, - color = accent - ) + color = accent, + maxLines = 2, + modifier = Modifier.combinedClickable( + onClickLabel = stringResource(R.string.copy_number), + onLongClickLabel = stringResource(R.string.copy_with_currency), + onClick = { copyText(context, plain) }, + onLongClick = { copyText(context, value) } + ) + ) } if (!detail.isNullOrBlank()) { Spacer(modifier = Modifier.height(4.dp)) Text( @@ -186,6 +230,10 @@ fun ToolResultCard( color = MaterialTheme.colorScheme.onSurfaceVariant ) } + if (shareText != null) { + Spacer(modifier = Modifier.height(12.dp)) + ResultActions(shareText = shareText, plainValue = plain, fullValue = value) + } } } } @@ -199,12 +247,18 @@ fun ToolEmptyResult( Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), - color = Color.White, + color = MaterialTheme.colorScheme.surface, shadowElevation = 2.dp ) { - Column(modifier = Modifier.padding(16.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 22.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally) { + Surface(shape = androidx.compose.foundation.shape.CircleShape, color = MaterialTheme.colorScheme.primary.copy(alpha = 0.10f)) { + Icon(Icons.Default.Info, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(12.dp).size(26.dp)) + } + Spacer(modifier = Modifier.height(10.dp)) Text( title, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface @@ -212,6 +266,7 @@ fun ToolEmptyResult( Spacer(modifier = Modifier.height(4.dp)) Text( hint, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -221,8 +276,8 @@ fun ToolEmptyResult( @Composable fun toolChipColors() = FilterChipDefaults.filterChipColors( - selectedContainerColor = BcvBlue.copy(alpha = 0.14f), - selectedLabelColor = BcvBlue + selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + selectedLabelColor = MaterialTheme.colorScheme.primary ) @Composable @@ -273,35 +328,104 @@ fun ToolNumberField( onValueChange: (String) -> Unit, label: String, suffix: String? = null, - helper: String? = null + helper: String? = null, + modifier: Modifier = Modifier ) { + val focus = androidx.compose.ui.platform.LocalFocusManager.current OutlinedTextField( value = value, - onValueChange = { raw -> - onValueChange( - raw.replace(',', '.').filter { it.isDigit() || it == '.' }.let { cleaned -> - val parts = cleaned.split('.') - if (parts.size <= 1) cleaned - else parts.first() + "." + parts.drop(1).joinToString("").take(4) - } - ) - }, + onValueChange = { raw -> onValueChange(VeNumber.sanitize(raw)) }, label = { Text(label) }, suffix = suffix?.let { { Text(it) } }, supportingText = helper?.let { { Text(it) } }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth(), + trailingIcon = if (value.isNotEmpty()) { + { + IconButton(onClick = { onValueChange("") }) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.action_clear_field, label) + ) + } + } + } else { + null + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = androidx.compose.ui.text.input.ImeAction.Done), + keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { focus.clearFocus() }), + textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + modifier = modifier.fillMaxWidth().heightIn(min = 60.dp), singleLine = true, shape = RoundedCornerShape(14.dp), colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = BcvBlue, - focusedLabelColor = BcvBlue, - unfocusedContainerColor = SurfaceLight, - focusedContainerColor = Color.White + focusedBorderColor = MaterialTheme.colorScheme.primary, + focusedLabelColor = MaterialTheme.colorScheme.primary, + cursorColor = MaterialTheme.colorScheme.primary, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + focusedContainerColor = MaterialTheme.colorScheme.surface ) ) } +/** Extrae el número de un texto de resultado: "$ 1.234,56" -> "1.234,56". */ +fun plainNumber(value: String): String = + Regex("-?[0-9][0-9.,]*").find(value)?.value?.trimEnd('.', ',') ?: value + +/** Copia al portapapeles y muestra "Copiado: …". */ +fun copyText(context: Context, text: String) { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("Codivisas", text)) + Toast.makeText(context, context.getString(R.string.toast_copied_value, text), Toast.LENGTH_SHORT).show() +} + +/** Acciones de un resultado: copiar número, copiar con moneda y compartir. */ +@Composable +fun ResultActions( + shareText: String, + plainValue: String? = null, + fullValue: String? = null, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val shareTitle = stringResource(R.string.action_share) + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { copyText(context, plainValue ?: shareText) }, + modifier = Modifier.weight(1f).heightIn(min = 48.dp) + ) { + Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(if (plainValue != null) R.string.copy_number else R.string.action_copy)) + } + OutlinedButton( + onClick = { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, shareText) + } + runCatching { context.startActivity(Intent.createChooser(send, shareTitle)) } + }, + modifier = Modifier.weight(1f).heightIn(min = 48.dp) + ) { + Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text(shareTitle) + } + } + if (fullValue != null && fullValue != plainValue) { + TextButton( + onClick = { copyText(context, fullValue) }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.copy_with_currency_value, fullValue)) + } + } + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun ToolChoiceFlow( @@ -337,7 +461,7 @@ fun CurrencyChips( FilterChip( selected = code == selected, onClick = { onSelect(code) }, - modifier = Modifier.widthIn(min = 96.dp), + modifier = Modifier.widthIn(min = 96.dp).heightIn(min = 44.dp), label = { Text( if (code == "VES") "Bs." @@ -351,5 +475,56 @@ fun CurrencyChips( } } -fun parseToolAmount(raw: String): Double = - raw.replace(',', '.').toDoubleOrNull() ?: 0.0 +fun parseToolAmount(raw: String): Double = VeNumber.parse(raw) + +/** Selector segmentado de una sola línea con pesos iguales (no se parte el texto). */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SegmentedChoice( + options: List, + selected: T, + label: (T) -> String, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier +) { + androidx.compose.material3.SingleChoiceSegmentedButtonRow(modifier.fillMaxWidth()) { + options.forEachIndexed { i, opt -> + SegmentedButton( + selected = opt == selected, + onClick = { onSelect(opt) }, + shape = androidx.compose.material3.SegmentedButtonDefaults.itemShape(i, options.size), + icon = {}, + modifier = Modifier.weight(1f) + ) { + Text( + label(opt), maxLines = 1, softWrap = false, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium + ) + } + } + } +} + + +/** Estado de error amable con botón de reintento. */ +@Composable +fun ToolErrorState(message: String, onRetry: () -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.55f) + ) { + Column(Modifier.fillMaxWidth().padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text("😕", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(6.dp)) + Text("No pudimos cargar los datos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center) + Spacer(Modifier.height(10.dp)) + androidx.compose.material3.FilledTonalButton(onClick = onRetry, modifier = Modifier.heightIn(min = 48.dp)) { + Text("Reintentar") + } + } + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ToolRoutes.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ToolRoutes.kt index 354795c..74f0498 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ToolRoutes.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ToolRoutes.kt @@ -6,9 +6,17 @@ import androidx.compose.material.icons.automirrored.filled.ShowChart import androidx.compose.material.icons.filled.AccountBalance import androidx.compose.material.icons.filled.Calculate import androidx.compose.material.icons.filled.Percent +import androidx.compose.material.icons.filled.Insights +import androidx.compose.material.icons.filled.Storefront +import androidx.compose.material.icons.filled.Savings +import androidx.compose.material.icons.filled.Handshake import androidx.compose.material.icons.filled.Public -import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.ReceiptLong +import androidx.compose.material.icons.filled.CompareArrows +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color +import com.codeonyx.bcvaqui.R import androidx.compose.ui.graphics.vector.ImageVector object ToolRoutes { @@ -16,91 +24,127 @@ object ToolRoutes { const val HUB = "hub" const val DONDE = "donde" const val COMISION = "comision" - const val VARIACION = "variacion" + const val CALCULADORA = "calculadora" const val HISTORIAL = "historial" const val DIVIDIR = "dividir" - const val COMPARTIR = "compartir" const val AGENDA = "agenda" + const val IVA_IGTF = "iva_igtf" + const val BRECHA = "brecha" + const val MERCADO = "mercado" + const val COMERCIANTE = "comerciante" + const val DEUDAS = "deudas" + const val AHORROS = "ahorros" } -enum class ToolCategory(val label: String) { - CONVERTIR("Convertir y calcular"), - MERCADO("Mercado y tasas"), - PLANIFICAR("Planificar y compartir") +enum class ToolCategory(@StringRes val label: Int) { + CONVERTIR(R.string.tool_cat_convertir), + MERCADO(R.string.tool_cat_mercado), + PLANIFICAR(R.string.tool_cat_planificar) } +@Immutable data class ToolItem( val route: String, - val title: String, - val subtitle: String, + @StringRes val title: Int, + @StringRes val subtitle: Int, val category: ToolCategory, val icon: ImageVector, val tint: Color ) +/** Herramientas destacadas (tarjetas grandes arriba del grid). */ +val featuredTools = listOf( + ToolItem(ToolRoutes.DEUDAS, R.string.tool_deudas_title, R.string.tool_deudas_desc, ToolCategory.PLANIFICAR, Icons.Default.Handshake, Color(0xFFFBBF24)), + ToolItem(ToolRoutes.AHORROS, R.string.tool_ahorros_title, R.string.tool_ahorros_desc, ToolCategory.PLANIFICAR, Icons.Default.Savings, Color(0xFFFBBF24)) +) + val toolCatalog = listOf( + ToolItem( + route = ToolRoutes.CALCULADORA, + title = R.string.tool_calculadora_title, + subtitle = R.string.tool_calculadora_desc, + category = ToolCategory.CONVERTIR, + icon = Icons.Default.Calculate, + tint = Color(0xFF4F46E5) + ), ToolItem( route = ToolRoutes.DONDE, - title = "Equivalencias", - subtitle = "Compara un monto en varias monedas", + title = R.string.tool_donde_title, + subtitle = R.string.tool_donde_desc, category = ToolCategory.CONVERTIR, icon = Icons.Default.Public, tint = Color(0xFF2563EB) ), ToolItem( route = ToolRoutes.COMISION, - title = "Comisión / remesa", - subtitle = "Calcula cuánto llega y cuánto pagas", + title = R.string.tool_comision_title, + subtitle = R.string.tool_comision_desc, category = ToolCategory.PLANIFICAR, icon = Icons.Default.Percent, tint = Color(0xFFD97706) ), ToolItem( - route = ToolRoutes.VARIACION, - title = "Variación de tasa", - subtitle = "Mide cuánto subió o bajó una tasa", + route = ToolRoutes.IVA_IGTF, + title = R.string.tool_iva_igtf_title, + subtitle = R.string.tool_iva_igtf_desc, + category = ToolCategory.CONVERTIR, + icon = Icons.Default.ReceiptLong, + tint = Color(0xFF059669) + ), + ToolItem( + route = ToolRoutes.BRECHA, + title = R.string.tool_brecha_title, + subtitle = R.string.tool_brecha_desc, category = ToolCategory.MERCADO, - icon = Icons.Default.Calculate, + icon = Icons.Default.CompareArrows, + tint = Color(0xFFF0B90B) + ), + ToolItem( + route = ToolRoutes.MERCADO, + title = R.string.tool_mercado_title, + subtitle = R.string.tool_mercado_desc, + category = ToolCategory.MERCADO, + icon = Icons.Default.Insights, + tint = Color(0xFF0D9488) + ), + ToolItem( + route = ToolRoutes.COMERCIANTE, + title = R.string.tool_comerciante_title, + subtitle = R.string.tool_comerciante_desc, + category = ToolCategory.PLANIFICAR, + icon = Icons.Default.Storefront, tint = Color(0xFF7C3AED) ), ToolItem( route = ToolRoutes.HISTORIAL, - title = "Historial BCV", - subtitle = "Consulta el gráfico y las tasas recientes", + title = R.string.tool_historial_title, + subtitle = R.string.tool_historial_desc, category = ToolCategory.MERCADO, icon = Icons.AutoMirrored.Filled.ShowChart, tint = Color(0xFFDC2626) ), ToolItem( route = ToolRoutes.DIVIDIR, - title = "Dividir gasto", - subtitle = "Reparte una cuenta entre varias personas", + title = R.string.tool_dividir_title, + subtitle = R.string.tool_dividir_desc, category = ToolCategory.CONVERTIR, icon = Icons.AutoMirrored.Filled.CallSplit, tint = Color(0xFFEA580C) ), - ToolItem( - route = ToolRoutes.COMPARTIR, - title = "Compartir conversión", - subtitle = "Genera una imagen con monto y datos de pago", - category = ToolCategory.PLANIFICAR, - icon = Icons.Default.Share, - tint = Color(0xFF0F766E) - ), ToolItem( route = ToolRoutes.AGENDA, - title = "Agenda de pagos", - subtitle = "Guarda pago móvil y cuentas para compartir", + title = R.string.tool_agenda_title, + subtitle = R.string.tool_agenda_desc, category = ToolCategory.PLANIFICAR, icon = Icons.Default.AccountBalance, - tint = Color(0xFF1E3A8A) + tint = com.codeonyx.bcvaqui.ui.theme.BcvBlue ) ) -fun toolTitle(route: String?): String = - toolCatalog.firstOrNull { it.route == route }?.title - ?: if (route == ToolRoutes.HUB || route == null) "Herramientas" else "Herramientas" +@StringRes +fun toolTitle(route: String?): Int = + toolCatalog.firstOrNull { it.route == route }?.title ?: R.string.tools_title -fun toolSubtitle(route: String?): String = - toolCatalog.firstOrNull { it.route == route }?.subtitle - ?: "Utilidades con tasa BCV" +@StringRes +fun toolSubtitle(route: String?): Int = + toolCatalog.firstOrNull { it.route == route }?.subtitle ?: R.string.tools_subtitle diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ToolsHubScreen.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ToolsHubScreen.kt index 327067b..32221a7 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ToolsHubScreen.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ToolsHubScreen.kt @@ -1,5 +1,17 @@ package com.codeonyx.bcvaqui.ui.tools +import androidx.compose.ui.res.stringResource +import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import com.codeonyx.bcvaqui.ui.tour.tourTarget +import com.codeonyx.bcvaqui.R +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.ui.graphics.lerp +import com.codeonyx.bcvaqui.ui.theme.LocalDarkTheme import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -14,8 +26,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -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 @@ -38,76 +48,59 @@ import com.codeonyx.bcvaqui.ui.theme.SurfaceLight @Composable fun ToolsHubScreen(onOpenTool: (String) -> Unit) { - Column( + val gridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState() + // Orden plano de items (igual que abajo) para que la guía haga scroll hasta cada tarjeta. + val flatKeys = remember { + buildList { + add("header_finanzas"); featuredTools.forEach { add(it.route) } + ToolCategory.entries.forEach { c -> + val t = toolCatalog.filter { it.category == c } + if (t.isNotEmpty()) { add("header_${c.name}"); t.forEach { add(it.route) } } + } + } + } + val focus = com.codeonyx.bcvaqui.ui.tour.LocalTourFocus.current + LaunchedEffect(focus) { + val route = focus?.removePrefix("tool_") ?: return@LaunchedEffect + val i = flatKeys.indexOf(route) + if (i >= 0) gridState.animateScrollToItem((i - 1).coerceAtLeast(0)) + } + LazyVerticalGrid( + state = gridState, + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier .fillMaxSize() - .background(SurfaceLight) + .background(MaterialTheme.colorScheme.background) ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - shape = RoundedCornerShape(18.dp), - color = Color.White, - shadowElevation = 1.dp - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background( - Brush.linearGradient( - listOf(Color(0xFF1E3A8A), Color(0xFF6366F1)) - ) - ), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.Build, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(22.dp) - ) - } - Spacer(modifier = Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Herramientas para tu día a día", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - Text( - text = "Calcula, compara y comparte sin complicaciones", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } + item(key = "header_finanzas", span = { GridItemSpan(maxLineSpan) }) { + Text( + text = stringResource(R.string.tool_cat_finanzas), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp, start = 2.dp) + ) } - - LazyColumn( - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxSize() - ) { - ToolCategory.entries.forEach { category -> - val tools = toolCatalog.filter { it.category == category } - item(key = "header_${category.name}") { - Text( - text = category.label, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 6.dp, start = 2.dp) - ) - } - items(tools, key = { it.route }) { tool -> - ToolCard(tool = tool, onClick = { onOpenTool(tool.route) }) - } + items(featuredTools, key = { it.route }, span = { GridItemSpan(maxLineSpan) }) { tool -> + Box(Modifier.tourTarget("tool_${tool.route}")) { FeaturedToolCard(tool = tool, onClick = { onOpenTool(tool.route) }) } + } + ToolCategory.entries.forEach { category -> + val tools = toolCatalog.filter { it.category == category } + if (tools.isEmpty()) return@forEach + item(key = "header_${category.name}", span = { GridItemSpan(maxLineSpan) }) { + Text( + text = stringResource(category.label), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp, start = 2.dp) + ) + } + items(tools, key = { it.route }) { tool -> + Box(Modifier.tourTarget("tool_${tool.route}")) { ToolCard(tool = tool, onClick = { onOpenTool(tool.route) }) } } } } @@ -115,82 +108,81 @@ fun ToolsHubScreen(onOpenTool: (String) -> Unit) { @Composable private fun ToolCard(tool: ToolItem, onClick: () -> Unit) { + val dark = LocalDarkTheme.current + val title = stringResource(tool.title) + val tint = if (dark) lerp(tool.tint, Color.White, 0.35f) else tool.tint Surface( modifier = Modifier .fillMaxWidth() - .height(104.dp) + .height(148.dp) .clip(RoundedCornerShape(20.dp)) - .clickable(onClick = onClick), + .clickable(onClickLabel = stringResource(R.string.tool_open_a11y, title), onClick = onClick), shape = RoundedCornerShape(20.dp), - color = Color.White, - shadowElevation = 2.dp + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)) ) { - Box(modifier = Modifier.fillMaxSize()) { - Row( + Column(modifier = Modifier.padding(14.dp)) { + Box( modifier = Modifier - .fillMaxSize() - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + .size(44.dp) + .clip(CircleShape) + .background(tint.copy(alpha = if (dark) 0.22f else 0.12f)), + contentAlignment = Alignment.Center ) { - Box( - modifier = Modifier - .size(52.dp) - .clip(RoundedCornerShape(16.dp)) - .background(tool.tint.copy(alpha = 0.14f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = tool.icon, - contentDescription = null, - tint = tool.tint, - modifier = Modifier.size(26.dp) - ) - } - Spacer(modifier = Modifier.width(14.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = tool.title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(modifier = Modifier.height(3.dp)) - Text( - text = tool.subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - Box( - modifier = Modifier - .size(32.dp) - .clip(CircleShape) - .background(tool.tint.copy(alpha = 0.1f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "Abrir ${tool.title}", - tint = tool.tint, - modifier = Modifier.size(20.dp) - ) - } + Icon(tool.icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) } - Box( - modifier = Modifier - .align(Alignment.BottomStart) - .fillMaxWidth() - .height(3.dp) - .background( - Brush.horizontalGradient( - listOf(tool.tint, tool.tint.copy(alpha = 0.15f)) - ) - ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(tool.subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +/** Tarjeta destacada navy/dorado a todo el ancho. */ +@Composable +private fun FeaturedToolCard(tool: ToolItem, onClick: () -> Unit) { + val title = stringResource(tool.title) + val navy = com.codeonyx.bcvaqui.ui.theme.BcvBlue + val navyDark = com.codeonyx.bcvaqui.ui.theme.BcvBlueDark + val gold = com.codeonyx.bcvaqui.ui.theme.BcvGold + Box( + modifier = Modifier + .fillMaxWidth() + .height(116.dp) + .clip(RoundedCornerShape(24.dp)) + .background(Brush.linearGradient(listOf(navyDark, navy, com.codeonyx.bcvaqui.ui.theme.BcvIndigo))) + .clickable(onClickLabel = stringResource(R.string.tool_open_a11y, title), onClick = onClick) + ) { + androidx.compose.foundation.Canvas(Modifier.matchParentSize()) { + drawCircle(gold.copy(alpha = 0.18f), radius = size.height * 0.9f, center = androidx.compose.ui.geometry.Offset(size.width, 0f)) + drawCircle(Color.White.copy(alpha = 0.06f), radius = size.height * 0.6f, center = androidx.compose.ui.geometry.Offset(size.width * 0.8f, size.height * 1.2f)) + } + Row(Modifier.fillMaxSize().padding(18.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(56.dp).clip(CircleShape).background(gold.copy(alpha = 0.22f)), + contentAlignment = Alignment.Center + ) { Icon(tool.icon, contentDescription = null, tint = gold, modifier = Modifier.size(30.dp)) } + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Black, color = Color.White) + Text(stringResource(tool.subtitle), style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.82f), + maxLines = 2, overflow = TextOverflow.Ellipsis) + } + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = gold) } } } diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/agenda/AgendaPagosScreen.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/agenda/AgendaPagosScreen.kt index f9afdb9..cbc90cb 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/agenda/AgendaPagosScreen.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/agenda/AgendaPagosScreen.kt @@ -1,5 +1,15 @@ package com.codeonyx.bcvaqui.ui.tools.agenda +import com.codeonyx.bcvaqui.ui.theme.BcvGold +import com.codeonyx.bcvaqui.data.model.ColombianBanks +import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material.icons.filled.Star +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.KeyboardType +import com.codeonyx.bcvaqui.R +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle + import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -69,7 +79,7 @@ import java.util.UUID @OptIn(ExperimentalMaterial3Api::class) @Composable fun AgendaPagosScreen(viewModel: MainViewModel = appViewModel()) { - val preferences by viewModel.preferences.collectAsState() + val preferences by viewModel.preferences.collectAsStateWithLifecycle() val contacts = preferences.paymentContacts val context = LocalContext.current @@ -78,7 +88,7 @@ fun AgendaPagosScreen(viewModel: MainViewModel = appViewModel()) { var deleteId by remember { mutableStateOf(null) } Scaffold( - containerColor = SurfaceLight, + containerColor = MaterialTheme.colorScheme.background, floatingActionButton = { FloatingActionButton( onClick = { @@ -107,7 +117,7 @@ fun AgendaPagosScreen(viewModel: MainViewModel = appViewModel()) { ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Guarda pagos móviles, cuentas VE o cuentas extranjeras para copiarlos rápido.", + text = stringResource(R.string.pay_empty_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -127,6 +137,7 @@ fun AgendaPagosScreen(viewModel: MainViewModel = appViewModel()) { copyText(context, contact.formatForCopy()) Toast.makeText(context, "Copiado", Toast.LENGTH_SHORT).show() }, + onSetDefault = { viewModel.setDefaultPaymentContact(contact.id) }, onEdit = { editing = contact showEditor = true @@ -172,12 +183,13 @@ fun AgendaPagosScreen(viewModel: MainViewModel = appViewModel()) { private fun PaymentCard( contact: PaymentContact, onCopy: () -> Unit, + onSetDefault: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit ) { Surface( shape = RoundedCornerShape(16.dp), - color = Color.White, + color = MaterialTheme.colorScheme.surface, shadowElevation = 2.dp, modifier = Modifier.fillMaxWidth() ) { @@ -192,7 +204,7 @@ private fun PaymentCard( Text( text = contact.typeLabel(), style = MaterialTheme.typography.labelMedium, - color = BcvBlue + color = MaterialTheme.colorScheme.primary ) Text( text = contact.subtitle(), @@ -200,8 +212,15 @@ private fun PaymentCard( color = MaterialTheme.colorScheme.onSurfaceVariant ) } + IconButton(onClick = onSetDefault) { + Icon( + if (contact.isDefault) Icons.Default.Star else Icons.Default.StarBorder, + contentDescription = stringResource(R.string.pay_default), + tint = if (contact.isDefault) BcvGold else MaterialTheme.colorScheme.onSurfaceVariant + ) + } IconButton(onClick = onCopy) { - Icon(Icons.Default.ContentCopy, contentDescription = "Copiar", tint = BcvBlue) + Icon(Icons.Default.ContentCopy, contentDescription = "Copiar", tint = MaterialTheme.colorScheme.primary) } IconButton(onClick = onEdit) { Icon(Icons.Default.Edit, contentDescription = "Editar") @@ -240,6 +259,10 @@ private fun PaymentEditorSheet( var extraInfo by remember { mutableStateOf(initial?.extraInfo.orEmpty()) } var notes by remember { mutableStateOf(initial?.notes.orEmpty()) } var bankMenu by remember { mutableStateOf(false) } + var email by remember { mutableStateOf(initial?.email.orEmpty()) } + var userHandle by remember { mutableStateOf(initial?.userHandle.orEmpty()) } + var accountType by remember { mutableStateOf(initial?.accountType.orEmpty()) } + var isDefault by remember { mutableStateOf(initial?.isDefault ?: false) } ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { Column( @@ -256,35 +279,32 @@ private fun PaymentEditorSheet( fontWeight = FontWeight.Bold ) - ToolChoiceFlow(maxItemsInEachRow = 3) { - FilterChip( - selected = type == PaymentType.PAGO_MOVIL, - onClick = { type = PaymentType.PAGO_MOVIL }, - modifier = Modifier.widthIn(min = 88.dp), - label = { Text("Pago móvil") }, - colors = toolChipColors() - ) - FilterChip( - selected = type == PaymentType.CUENTA_VE, - onClick = { type = PaymentType.CUENTA_VE }, - modifier = Modifier.widthIn(min = 88.dp), - label = { Text("Cuenta VE") }, - colors = toolChipColors() - ) - FilterChip( - selected = type == PaymentType.CUENTA_EXTRANJERA, - onClick = { type = PaymentType.CUENTA_EXTRANJERA }, - modifier = Modifier.widthIn(min = 88.dp), - label = { Text("Extranjera") }, - colors = toolChipColors() - ) - } + val typeOrder = listOf( + PaymentType.PAGO_MOVIL, PaymentType.CUENTA_VE, PaymentType.BINANCE, PaymentType.WALLY, + PaymentType.ZINLI, PaymentType.CUENTA_CO, PaymentType.ZELLE, PaymentType.PAYPAL, PaymentType.CUENTA_EXTRANJERA + ) + val typeLabels = typeOrder.map { stringResource(paymentTypeLabel(it)) } + OptionDropdown( + label = stringResource(R.string.pay_method_type), + selected = stringResource(paymentTypeLabel(type)), + options = typeLabels, + onSelect = { i -> + val newType = typeOrder[i] + if (newType != type) { + if (newType == PaymentType.CUENTA_CO || type == PaymentType.CUENTA_CO) { bankCode = ""; bankName = "" } + type = newType + } + } + ) - Field(displayName, { displayName = it }, "Nombre de quien es") + Field( + displayName, { displayName = it }, + stringResource(if (type == PaymentType.CUENTA_CO) R.string.pay_holder else R.string.pay_name) + ) when (type) { PaymentType.PAGO_MOVIL -> { - Field(phone, { phone = it }, "Teléfono") + Field(phone, { phone = it }, "Teléfono", KeyboardType.Phone) Field(documentId, { documentId = it }, "Cédula") BankDropdown( bankCode = bankCode, @@ -299,7 +319,7 @@ private fun PaymentEditorSheet( ) } PaymentType.CUENTA_VE -> { - Field(accountNumber, { accountNumber = it }, "Número de cuenta") + Field(accountNumber, { accountNumber = it }, "Número de cuenta", KeyboardType.Number) Field(documentId, { documentId = it }, "Cédula") BankDropdown( bankCode = bankCode, @@ -321,6 +341,49 @@ private fun PaymentEditorSheet( Field(documentId, { documentId = it }, "Documento (opcional)") Field(extraInfo, { extraInfo = it }, "SWIFT / routing / extras") } + PaymentType.BINANCE -> { + Field(accountNumber, { accountNumber = it }, stringResource(R.string.pay_binance_id), KeyboardType.Number) + Field(email, { email = it }, stringResource(R.string.pay_email), KeyboardType.Email) + Field(userHandle, { userHandle = it }, stringResource(R.string.pay_user)) + } + PaymentType.WALLY -> { + Field(phone, { phone = it }, stringResource(R.string.pay_phone), KeyboardType.Phone) + Field(email, { email = it }, stringResource(R.string.pay_email), KeyboardType.Email) + Field(userHandle, { userHandle = it }, stringResource(R.string.pay_user)) + } + PaymentType.ZINLI, PaymentType.ZELLE -> { + Field(email, { email = it }, stringResource(R.string.pay_email), KeyboardType.Email) + Field(phone, { phone = it }, stringResource(R.string.pay_phone), KeyboardType.Phone) + } + PaymentType.PAYPAL -> { + Field(email, { email = it }, stringResource(R.string.pay_email), KeyboardType.Email) + Field(userHandle, { userHandle = it }, stringResource(R.string.pay_paypal_me)) + } + PaymentType.CUENTA_CO -> { + OptionDropdown( + label = stringResource(R.string.pay_bank), + selected = bankName, + options = ColombianBanks.ALL, + onSelect = { bankName = ColombianBanks.ALL[it] } + ) + if (ColombianBanks.isWallet(bankName)) { + Field(phone, { phone = it }, stringResource(R.string.pay_phone), KeyboardType.Phone) + } else { + OptionDropdown( + label = stringResource(R.string.pay_account_type), + selected = accountType, + options = ColombianBanks.ACCOUNT_TYPES, + onSelect = { accountType = ColombianBanks.ACCOUNT_TYPES[it] } + ) + Field(accountNumber, { accountNumber = it }, stringResource(R.string.pay_account_number), KeyboardType.Number) + } + Field(documentId, { documentId = it }, stringResource(R.string.pay_cc_nit)) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.pay_default), modifier = Modifier.weight(1f)) + androidx.compose.material3.Switch(checked = isDefault, onCheckedChange = { isDefault = it }) } Field(notes, { notes = it }, "Nota privada (no se comparte)") @@ -336,12 +399,16 @@ private fun PaymentEditorSheet( phone = phone.trim(), accountNumber = accountNumber.trim(), documentId = documentId.trim(), - bankCode = if (type == PaymentType.CUENTA_EXTRANJERA) "" else bankCode, + bankCode = if (type == PaymentType.PAGO_MOVIL || type == PaymentType.CUENTA_VE) bankCode else "", bankName = bankName.trim(), country = country.trim(), currency = currency.trim(), extraInfo = extraInfo.trim(), - notes = notes.trim() + notes = notes.trim(), + email = email.trim(), + userHandle = userHandle.trim(), + accountType = accountType, + isDefault = isDefault ) ) }, @@ -365,7 +432,7 @@ private fun BankDropdown( ) { ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = onExpandedChange) { OutlinedTextField( - value = if (bankCode.isBlank()) "" else "$bankCode · $bankName", + value = if (bankCode.isBlank()) "" else "$bankCode - $bankName", onValueChange = {}, readOnly = true, label = { Text("Banco") }, @@ -375,14 +442,14 @@ private fun BankDropdown( .menuAnchor(), shape = RoundedCornerShape(12.dp), colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = BcvBlue, - focusedLabelColor = BcvBlue + focusedBorderColor = MaterialTheme.colorScheme.primary, + focusedLabelColor = MaterialTheme.colorScheme.primary ) ) ExposedDropdownMenu(expanded = expanded, onDismissRequest = { onExpandedChange(false) }) { VenezuelanBanks.ALL.forEach { bank -> DropdownMenuItem( - text = { Text(bank.label) }, + text = { Text("${bank.code} - ${bank.name}") }, onClick = { onSelect(bank.code, bank.name) } ) } @@ -391,8 +458,14 @@ private fun BankDropdown( } @Composable -private fun Field(value: String, onValueChange: (String) -> Unit, label: String) { +private fun Field( + value: String, + onValueChange: (String) -> Unit, + label: String, + keyboardType: KeyboardType = KeyboardType.Text +) { OutlinedTextField( + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), value = value, onValueChange = onValueChange, label = { Text(label) }, @@ -400,8 +473,8 @@ private fun Field(value: String, onValueChange: (String) -> Unit, label: String) singleLine = true, shape = RoundedCornerShape(12.dp), colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = BcvBlue, - focusedLabelColor = BcvBlue + focusedBorderColor = MaterialTheme.colorScheme.primary, + focusedLabelColor = MaterialTheme.colorScheme.primary ) ) } @@ -410,3 +483,55 @@ private fun copyText(context: Context, text: String) { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText("pago", text)) } + +@androidx.annotation.StringRes +private fun paymentTypeLabel(type: PaymentType): Int = when (type) { + PaymentType.PAGO_MOVIL -> R.string.pay_type_pago_movil + PaymentType.CUENTA_VE -> R.string.pay_type_cuenta_ve + PaymentType.CUENTA_EXTRANJERA -> R.string.pay_type_extranjera + PaymentType.BINANCE -> R.string.pay_type_binance + PaymentType.WALLY -> R.string.pay_type_wally + PaymentType.ZINLI -> R.string.pay_type_zinli + PaymentType.CUENTA_CO -> R.string.pay_type_co + PaymentType.ZELLE -> R.string.pay_type_zelle + PaymentType.PAYPAL -> R.string.pay_type_paypal +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun OptionDropdown( + label: String, + selected: String, + options: List, + onSelect: (Int) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = selected, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + focusedLabelColor = MaterialTheme.colorScheme.primary + ) + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEachIndexed { i, option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + onSelect(i) + expanded = false + } + ) + } + } + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/AhorrosScreen.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/AhorrosScreen.kt new file mode 100644 index 0000000..1f89af0 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/AhorrosScreen.kt @@ -0,0 +1,237 @@ +package com.codeonyx.bcvaqui.ui.tools.finance + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +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.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.codeonyx.bcvaqui.domain.* +import com.codeonyx.bcvaqui.ui.MainViewModel +import com.codeonyx.bcvaqui.ui.theme.BcvBlue +import com.codeonyx.bcvaqui.ui.theme.BcvGold +import com.codeonyx.bcvaqui.ui.theme.BcvIndigo +import com.codeonyx.bcvaqui.ui.theme.LocalDarkTheme +import com.codeonyx.bcvaqui.ui.tools.ToolNumberField +import com.codeonyx.bcvaqui.ui.tools.ToolScaffold +import java.time.LocalDate +import kotlin.math.cos +import kotlin.math.sin + +@Composable +fun AhorrosScreen(viewModel: MainViewModel, finance: FinanceViewModel = viewModel()) { + val dashboard by viewModel.dashboard.collectAsStateWithLifecycle() + val prefs by viewModel.preferences.collectAsStateWithLifecycle() + val rate = dashboard?.bcvRates?.get("USD")?.rateToVes ?: prefs.lastKnownUsdRate ?: 0.0 + val goals by finance.goals.collectAsStateWithLifecycle() + val today = remember { LocalDate.now() } + var editing by remember { mutableStateOf(null) } + var creating by remember { mutableStateOf(false) } + var celebrate by remember { mutableStateOf(null) } + + val savedUsd = goals.sumOf { FinancePlanner.toUsd(FinancePlanner.goalStatus(it, today).saved, it.currency, rate) } + val targetUsd = goals.sumOf { FinancePlanner.toUsd(it.target, it.currency, rate) } + + ToolScaffold { + FinanceHero( + title = "MIS AHORROS", + lines = listOf("Ahorrado" to money(savedUsd, Money.USD), "En Bs" to money(savedUsd * rate, Money.VES), "Metas" to "${goals.size}"), + footer = if (targetUsd > 0) "Llevas ${(savedUsd / targetUsd * 100).toInt()}% del total de tus metas (${money(targetUsd, Money.USD)})" else null + ) + if (goals.isEmpty()) { + EmptyIllustration(true, "Empieza tu primera meta", "Define cuánto quieres ahorrar y para cuándo. Te diremos cuánto apartar por semana, quincena o mes.") + } + goals.sortedBy { FinancePlanner.goalStatus(it, today).complete }.forEach { g -> + GoalCard(g, FinancePlanner.goalStatus(g, today), rate, finance, onEdit = { editing = g }, onCompleted = { celebrate = g.name }) + } + Button(onClick = { creating = true }, modifier = Modifier.fillMaxWidth().height(52.dp), shape = RoundedCornerShape(16.dp)) { + Icon(Icons.Default.Add, null); Spacer(Modifier.width(8.dp)); Text("Nueva meta de ahorro") + } + Text("Mantén presionada una meta para editarla o eliminarla.", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (creating || editing != null) { + GoalEditor(editing, rate, finance::newId, { finance.saveGoal(it) }, { creating = false; editing = null }) + } + celebrate?.let { name -> Celebration(name) { celebrate = null } } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun GoalCard(g: SavingsGoal, s: GoalStatus, rate: Double, finance: FinanceViewModel, onEdit: () -> Unit, onCompleted: () -> Unit) { + var menu by remember { mutableStateOf(false) } + var confirm by remember { mutableStateOf(false) } + var deposit by remember { mutableStateOf(null) } + var expanded by remember { mutableStateOf(false) } + val dark = LocalDarkTheme.current + val accent = if (s.complete) FinGood else if (dark) BcvIndigo else BcvBlue + Surface( + shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surface, shadowElevation = 2.dp, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(20.dp)) + .combinedClickable(onClick = { expanded = !expanded }, onLongClick = { menu = true }, onLongClickLabel = "Editar o eliminar") + .animateContentSize() + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + ProgressRing(s.progress, accent, 72.dp) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text("${g.emoji} ${g.name}", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("${money(s.saved, g.currency)} de ${money(g.target, g.currency)}", style = MaterialTheme.typography.bodyMedium) + Text("Meta: ${LocalDate.ofEpochDay(g.targetEpochDay).es()}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + if (s.complete) { + StatusPill("🎉 ¡Meta cumplida!", FinGood) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Aparta por ${g.frequency.plural}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(money(s.requiredPerPeriod, g.currency), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Black, color = accent) + } + if (s.behindBy > 0.5) StatusPill("Atrasado ${money(s.behindBy, g.currency)}", FinBad) else StatusPill("Vas al día ✓", FinGood) + } + val proj = s.projectedDate + Text( + if (proj == null) "Haz tu primer depósito para estimar cuándo llegarás." + else "A tu ritmo actual llegarías el ${proj.es()}" + if (proj.isAfter(LocalDate.ofEpochDay(g.targetEpochDay))) " (después de la meta)" else " 🚀", + style = MaterialTheme.typography.bodySmall + ) + } + if (g.currency == Money.VES && rate > 0) { + val usdNow = s.saved / rate + val erosion = FinancePlanner.erosionPercent(g.rateAtCreation, rate) + Text("Hoy equivale a ${money(usdNow, Money.USD)}" + (erosion?.takeIf { it > 0 }?.let { " · la meta perdió $it% de su valor en $ desde que la creaste" } ?: ""), + style = MaterialTheme.typography.bodySmall, color = if ((erosion ?: 0.0) > 0) FinBad else MaterialTheme.colorScheme.onSurfaceVariant) + } + if (expanded) { + Text("Historial", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + if (g.movements.isEmpty()) Text("Sin movimientos todavía.", style = MaterialTheme.typography.bodySmall) + g.movements.sortedByDescending { it.epochDay }.forEach { m -> + Row { Text(LocalDate.ofEpochDay(m.epochDay).es() + (if (m.note.isNotBlank()) " · ${m.note}" else ""), Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + Text((if (m.amount >= 0) "+" else "−") + money(kotlin.math.abs(m.amount), g.currency), style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, color = if (m.amount >= 0) FinGood else FinBad) } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton(onClick = { deposit = true }) { Text("Depositar") } + TextButton(onClick = { deposit = false }, enabled = s.saved > 0) { Text("Retirar") } + } + } + } + if (menu) { + AlertDialog(onDismissRequest = { menu = false }, title = { Text("${g.emoji} ${g.name}") }, + text = { Text("¿Qué deseas hacer con esta meta?") }, + confirmButton = { TextButton(onClick = { menu = false; onEdit() }) { Text("Editar") } }, + dismissButton = { TextButton(onClick = { menu = false; confirm = true }) { Text("Eliminar", color = FinBad) } }) + } + if (confirm) ConfirmDelete("la meta ${g.name}", { finance.deleteGoal(g.id) }, { confirm = false }) + deposit?.let { isDeposit -> + AmountDialog(if (isDeposit) "Depositar en ${g.name}" else "Retirar de ${g.name}", g.currency, + if (isDeposit) s.requiredPerPeriod.takeIf { it > 0 } else null, + { a, n -> + val signed = if (isDeposit) a else -minOf(a, s.saved) + finance.addMovement(g.id, signed, n) + if (isDeposit && !s.complete && s.saved + a >= g.target - 0.005) onCompleted() + }, { deposit = null }) + } +} + +private val emojis = listOf("🎯", "🏠", "🚗", "✈️", "🎓", "🎁") + +@Composable +private fun GoalEditor(initial: SavingsGoal?, rate: Double, newId: () -> String, onSave: (SavingsGoal) -> Unit, onDismiss: () -> Unit) { + val today = LocalDate.now() + var name by remember { mutableStateOf(initial?.name ?: "") } + var emoji by remember { mutableStateOf(initial?.emoji ?: "🎯") } + var target by remember { mutableStateOf(initial?.target?.let { VeNumber.toInput(it) } ?: "") } + var currency by remember { mutableStateOf(initial?.currency ?: Money.USD) } + var date by remember { mutableStateOf(initial?.targetEpochDay ?: today.plusMonths(6).toEpochDay()) } + var freq by remember { mutableStateOf(initial?.frequency ?: Frequency.MENSUAL) } + val amount = VeNumber.parse(target) + val valid = name.isNotBlank() && amount > 0 && date != null && date!! > today.toEpochDay() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (initial == null) "Nueva meta" else "Editar meta") }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + emojis.forEach { e -> + val sel = emoji == e + Box( + Modifier.size(38.dp).clip(androidx.compose.foundation.shape.CircleShape) + .background(if (sel) BcvGold.copy(alpha = 0.35f) else MaterialTheme.colorScheme.surfaceVariant) + .clickable { emoji = e }, + contentAlignment = Alignment.Center + ) { Text(e, style = MaterialTheme.typography.titleMedium) } + } + } + OutlinedTextField(name, { name = it }, label = { Text("Nombre de la meta") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + ToolNumberField(target, { target = it }, "Monto objetivo", suffix = if (currency == Money.USD) "$" else "Bs") + CurrencyToggle(currency) { currency = it } + DateField("Fecha meta", date, { date = it ?: date }) + Text("Quiero apartar dinero cada:", style = MaterialTheme.typography.labelLarge) + FrequencyChips(freq) { freq = it } + if (valid) { + val saved = initial?.movements?.sumOf { it.amount } ?: 0.0 + val periods = freq.periodsBetween(today, LocalDate.ofEpochDay(date!!)) + Text("Necesitas ${money(FxCalculator.round2((amount - saved).coerceAtLeast(0.0) / periods), currency)} por ${freq.plural} ($periods ${freq.plural}s)", + fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + } + } + }, + confirmButton = { + TextButton(enabled = valid, onClick = { + onSave((initial ?: SavingsGoal(newId(), "", target = 0.0, createdEpochDay = today.toEpochDay(), targetEpochDay = 0, + rateAtCreation = rate.takeIf { it > 0 })).copy( + name = name.trim(), emoji = emoji, target = amount, currency = currency, targetEpochDay = date!!, frequency = freq + )) + onDismiss() + }) { Text("Guardar") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancelar") } } + ) +} + +/** Celebración: confeti dibujado con Canvas. */ +@Composable +private fun Celebration(name: String, onDismiss: () -> Unit) { + val t = rememberInfiniteTransition(label = "confetti") + val phase by t.animateFloat(0f, 1f, infiniteRepeatable(tween(2200, easing = LinearEasing)), label = "p") + val colors = listOf(BcvGold, BcvBlue, BcvIndigo, FinGood, Color(0xFFF472B6)) + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Canvas(Modifier.size(140.dp, 90.dp)) { + repeat(28) { i -> + val a = i * 0.7f + val y = ((phase + i / 28f) % 1f) * size.height + val x = size.width / 2 + cos(a) * size.width * 0.45f * (0.4f + (i % 5) / 5f) + sin(phase * 6.28f + i) * 6f + drawCircle(colors[i % colors.size], radius = 3f + (i % 3) * 2f, center = Offset(x, y)) + } + } + }, + title = { Text("¡Meta cumplida! 🎉") }, + text = { Text("Completaste \"$name\". La constancia paga: ¿qué tal una nueva meta?") }, + confirmButton = { TextButton(onClick = onDismiss) { Text("¡Genial!") } } + ) +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/DeudasScreen.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/DeudasScreen.kt new file mode 100644 index 0000000..6f568f6 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/DeudasScreen.kt @@ -0,0 +1,242 @@ +package com.codeonyx.bcvaqui.ui.tools.finance + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +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.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.codeonyx.bcvaqui.domain.* +import com.codeonyx.bcvaqui.ui.MainViewModel +import com.codeonyx.bcvaqui.ui.theme.BcvGoldDark +import com.codeonyx.bcvaqui.ui.tools.ToolNumberField +import com.codeonyx.bcvaqui.ui.tools.ToolScaffold +import com.codeonyx.bcvaqui.ui.tools.toolChipColors +import java.time.LocalDate + +@Composable +fun DeudasScreen(viewModel: MainViewModel, finance: FinanceViewModel = viewModel()) { + val dashboard by viewModel.dashboard.collectAsStateWithLifecycle() + val prefs by viewModel.preferences.collectAsStateWithLifecycle() + val rate = dashboard?.bcvRates?.get("USD")?.rateToVes ?: prefs.lastKnownUsdRate ?: 0.0 + val debts by finance.debts.collectAsStateWithLifecycle() + val today = remember { LocalDate.now() } + var tab by rememberSaveable { mutableIntStateOf(0) } // 0 Debo, 1 Me deben, 2 Archivadas + var editing by remember { mutableStateOf(null) } + var creating by remember { mutableStateOf(false) } + + val active = debts.filter { !it.archived } + fun pendingUsd(iOwe: Boolean) = active.filter { it.iOwe == iOwe }.sumOf { + FinancePlanner.toUsd(FinancePlanner.debtStatus(it, today).remaining, it.currency, rate) + } + val debo = pendingUsd(true) + val meDeben = pendingUsd(false) + val net = meDeben - debo + val overdueCount = active.count { FinancePlanner.debtStatus(it, today).overdue } + + ToolScaffold { + FinanceHero( + title = "CONTROL DE DEUDAS", + lines = listOf("Debo" to money(debo, Money.USD), "Me deben" to money(meDeben, Money.USD), "Neto" to money(net, Money.USD)), + footer = buildString { + if (rate > 0) append("Neto en Bs: ${money(net * rate, Money.VES)} · BCV ${FxCalculator.format2(rate)}") + if (overdueCount > 0) append("\n⚠ $overdueCount pago(s) vencido(s)") + } + ) + com.codeonyx.bcvaqui.ui.tools.SegmentedChoice(listOf(0, 1, 2), tab, { listOf("Debo", "Me deben", "Archivadas")[it] }, { tab = it }) + val shown = when (tab) { + 0 -> active.filter { it.iOwe } + 1 -> active.filter { !it.iOwe } + else -> debts.filter { it.archived } + }.sortedWith(compareBy({ FinancePlanner.debtStatus(it, today).settled }, { FinancePlanner.debtStatus(it, today).nextDue ?: LocalDate.MAX })) + if (shown.isEmpty()) { + EmptyIllustration( + piggy = false, + title = if (tab == 2) "Nada archivado" else if (tab == 0) "No debes nada 🎉" else "Nadie te debe", + body = if (tab == 2) "Las deudas archivadas aparecerán aquí." else "Registra un préstamo, cuotas o una cuenta pendiente y lleva el control de cada abono." + ) + } + shown.forEach { d -> + DebtCard(d, FinancePlanner.debtStatus(d, today), rate, finance, onEdit = { editing = d }) + } + if (tab != 2) { + Button(onClick = { creating = true }, modifier = Modifier.fillMaxWidth().height(52.dp), shape = RoundedCornerShape(16.dp)) { + Icon(Icons.Default.Add, null); Spacer(Modifier.width(8.dp)) + Text(if (tab == 0) "Agregar algo que debo" else "Agregar algo que me deben") + } + } + Text("Mantén presionada una deuda para editarla o eliminarla.", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (creating || editing != null) { + DebtEditor( + initial = editing, iOweDefault = tab != 1, newId = finance::newId, + onSave = { finance.saveDebt(it) }, onDismiss = { creating = false; editing = null } + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun DebtCard(d: Debt, s: DebtStatus, rate: Double, finance: FinanceViewModel, onEdit: () -> Unit) { + var menu by remember { mutableStateOf(false) } + var confirm by remember { mutableStateOf(false) } + var pay by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(false) } + val accent = when { s.settled -> FinGood; s.overdue -> FinBad; d.iOwe -> BcvGoldDark; else -> MaterialTheme.colorScheme.primary } + Surface( + shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surface, shadowElevation = 2.dp, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(20.dp)) + .combinedClickable(onClick = { expanded = !expanded }, onLongClick = { menu = true }, onLongClickLabel = "Editar o eliminar") + .animateContentSize() + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(d.person, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(if (d.iOwe) "Le debo" else "Me debe", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + StatusPill( + when { + s.settled -> "Pagada" + s.overdue -> "Vencida" + s.daysToNext != null && s.daysToNext <= 3 -> if (s.daysToNext == 0L) "Vence hoy" else "En ${s.daysToNext} días" + else -> "Al día" + }, accent + ) + } + Row(verticalAlignment = Alignment.Bottom) { + Text(money(s.remaining, d.currency), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Black, color = accent) + Spacer(Modifier.width(8.dp)) + Text("de ${money(d.amount, d.currency)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (rate > 0) { + val other = if (d.currency == Money.USD) money(s.remaining * rate, Money.VES) else money(s.remaining / rate, Money.USD) + Text("≈ $other a tasa BCV", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + ProgressBar(s.progress, accent) + if (!s.settled) { + val next = s.nextDue?.let { "Próximo pago: ${money(s.nextAmount, d.currency)} · ${it.es()}" } ?: "Sin fecha de vencimiento" + Text(next, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = if (s.overdue) FinBad else MaterialTheme.colorScheme.onSurface) + } + if (s.installmentAmount != null) { + val done = ((s.paid + 0.01) / s.installmentAmount).toInt().coerceAtMost(d.installments) + Text("Cuotas: $done de ${d.installments} · ${money(s.installmentAmount, d.currency)} ${d.frequency.label.lowercase()}", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (expanded) { + if (d.notes.isNotBlank()) Text("📝 ${d.notes}", style = MaterialTheme.typography.bodySmall) + Text("Historial de abonos", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + if (d.payments.isEmpty()) Text("Sin abonos todavía.", style = MaterialTheme.typography.bodySmall) + d.payments.sortedByDescending { it.epochDay }.forEach { p -> + Row { Text(LocalDate.ofEpochDay(p.epochDay).es() + (if (p.note.isNotBlank()) " · ${p.note}" else ""), Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + Text(money(p.amount, d.currency), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold) } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (!s.settled && !d.archived) { + FilledTonalButton(onClick = { pay = true }) { Text(if (d.iOwe) "Abonar" else "Registrar cobro") } + TextButton(onClick = { finance.setDebtFlags(d.id, paid = true) }) { Text("Marcar pagada") } + } else { + TextButton(onClick = { finance.setDebtFlags(d.id, archived = !d.archived) }) { Text(if (d.archived) "Restaurar" else "Archivar") } + if (d.markedPaid && !d.archived) TextButton(onClick = { finance.setDebtFlags(d.id, paid = false) }) { Text("Reabrir") } + } + } + } + } + if (menu) { + AlertDialog(onDismissRequest = { menu = false }, title = { Text(d.person) }, + text = { Text("¿Qué deseas hacer con esta deuda?") }, + confirmButton = { TextButton(onClick = { menu = false; onEdit() }) { Text("Editar") } }, + dismissButton = { + Row { + TextButton(onClick = { menu = false; finance.setDebtFlags(d.id, archived = !d.archived) }) { Text(if (d.archived) "Restaurar" else "Archivar") } + TextButton(onClick = { menu = false; confirm = true }) { Text("Eliminar", color = FinBad) } + } + }) + } + if (confirm) ConfirmDelete("la deuda con ${d.person}", { finance.deleteDebt(d.id) }, { confirm = false }) + if (pay) AmountDialog(if (d.iOwe) "Abono a ${d.person}" else "Cobro de ${d.person}", d.currency, s.nextAmount, + { a, n -> finance.addPayment(d.id, a, n) }, { pay = false }) +} + +@Composable +private fun DebtEditor(initial: Debt?, iOweDefault: Boolean, newId: () -> String, onSave: (Debt) -> Unit, onDismiss: () -> Unit) { + val today = LocalDate.now().toEpochDay() + var iOwe by remember { mutableStateOf(initial?.iOwe ?: iOweDefault) } + var person by remember { mutableStateOf(initial?.person ?: "") } + var amount by remember { mutableStateOf(initial?.amount?.let { VeNumber.toInput(it) } ?: "") } + var currency by remember { mutableStateOf(initial?.currency ?: Money.USD) } + var start by remember { mutableStateOf(initial?.startEpochDay ?: today) } + var due by remember { mutableStateOf(initial?.dueEpochDay) } + var useCuotas by remember { mutableStateOf((initial?.installments ?: 0) > 0) } + var cuotas by remember { mutableStateOf(initial?.installments?.takeIf { it > 0 }?.toString() ?: "3") } + var freq by remember { mutableStateOf(initial?.frequency ?: Frequency.MENSUAL) } + var notes by remember { mutableStateOf(initial?.notes ?: "") } + var remind by remember { mutableStateOf((initial?.remindDaysBefore ?: 1) >= 0) } + val total = VeNumber.parse(amount) + val n = cuotas.toIntOrNull() ?: 0 + val valid = person.isNotBlank() && total > 0 && (!useCuotas || n in 1..120) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (initial == null) "Nueva deuda" else "Editar deuda") }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + com.codeonyx.bcvaqui.ui.tools.SegmentedChoice(listOf(true, false), iOwe, { if (it) "Debo" else "Me deben" }, { iOwe = it }) + OutlinedTextField(person, { person = it }, label = { Text("Persona") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + ToolNumberField(amount, { amount = it }, "Monto total", suffix = if (currency == Money.USD) "$" else "Bs") + CurrencyToggle(currency) { currency = it } + DateField("Inicio", start, { start = it ?: start }) + if (!useCuotas) DateField("Vence", due, { due = it }, clearable = true) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Pagar en cuotas", Modifier.weight(1f)); Switch(useCuotas, { useCuotas = it }) + } + if (useCuotas) { + OutlinedTextField(cuotas, { cuotas = it.filter(Char::isDigit).take(3) }, label = { Text("Número de cuotas") }, singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number), + modifier = Modifier.fillMaxWidth()) + FrequencyChips(freq) { freq = it } + if (total > 0 && n > 0) { + val last = FinancePlanner.schedule(LocalDate.ofEpochDay(start ?: today), n, freq).last() + Text("Cuota: ${money(FinancePlanner.installmentAmount(total, n), currency)} · última el ${last.es()}", + fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Recordarme un día antes", Modifier.weight(1f)); Switch(remind, { remind = it }) + } + OutlinedTextField(notes, { notes = it }, label = { Text("Notas") }, modifier = Modifier.fillMaxWidth()) + } + }, + confirmButton = { + TextButton(enabled = valid, onClick = { + val s = start ?: today + onSave( + (initial ?: Debt(newId(), iOwe, "", 0.0, startEpochDay = s)).copy( + iOwe = iOwe, person = person.trim(), amount = total, currency = currency, startEpochDay = s, + dueEpochDay = if (useCuotas) FinancePlanner.schedule(LocalDate.ofEpochDay(s), n, freq).last().toEpochDay() else due, + installments = if (useCuotas) n else 0, frequency = freq, notes = notes.trim(), + remindDaysBefore = if (remind) 1 else -1 + ) + ) + onDismiss() + }) { Text("Guardar") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancelar") } } + ) +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/FinanceCommon.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/FinanceCommon.kt new file mode 100644 index 0000000..08d8a9c --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/FinanceCommon.kt @@ -0,0 +1,195 @@ +package com.codeonyx.bcvaqui.ui.tools.finance + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.codeonyx.bcvaqui.domain.FxCalculator +import com.codeonyx.bcvaqui.domain.Frequency +import com.codeonyx.bcvaqui.domain.Money +import com.codeonyx.bcvaqui.ui.theme.BcvBlue +import com.codeonyx.bcvaqui.ui.theme.BcvBlueDark +import com.codeonyx.bcvaqui.ui.theme.BcvGold +import com.codeonyx.bcvaqui.ui.theme.BcvIndigo +import com.codeonyx.bcvaqui.ui.theme.LocalDarkTheme +import com.codeonyx.bcvaqui.ui.tools.toolChipColors +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +val FinGood = Color(0xFF16A34A) +val FinBad = Color(0xFFDC2626) + +private val dateFmt = DateTimeFormatter.ofPattern("d MMM yyyy", Locale("es", "VE")) +fun LocalDate.es(): String = format(dateFmt) +fun money(amount: Double, c: Money): String = + if (c == Money.USD) "$ ${FxCalculator.format2(amount)}" else "Bs ${FxCalculator.format2(amount)}" + +/** Encabezado navy/dorado con cifras resumen. */ +@Composable +fun FinanceHero(title: String, lines: List>, footer: String? = null) { + Box( + Modifier.fillMaxWidth() + .background(Brush.linearGradient(listOf(BcvBlueDark, BcvBlue, BcvIndigo)), RoundedCornerShape(24.dp)) + ) { + Canvas(Modifier.matchParentSize()) { + drawCircle(BcvGold.copy(alpha = 0.16f), radius = size.height * 0.75f, center = Offset(size.width * 0.95f, 0f)) + drawCircle(Color.White.copy(alpha = 0.06f), radius = size.height * 0.5f, center = Offset(size.width * 0.75f, size.height * 1.1f)) + } + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(title, color = BcvGold, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + lines.forEach { (label, value) -> + Column(Modifier.weight(1f)) { + Text(label, color = Color.White.copy(alpha = 0.75f), style = MaterialTheme.typography.labelSmall) + Text(value, color = Color.White, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1) + } + } + } + footer?.let { Text(it, color = Color.White.copy(alpha = 0.85f), style = MaterialTheme.typography.bodySmall) } + } + } +} + +@Composable +fun ProgressRing(progress: Float, color: Color, size: Dp = 64.dp, stroke: Dp = 7.dp, label: String? = null) { + val anim by animateFloatAsState(progress, tween(700), label = "ring") + val track = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + Box(Modifier.size(size), contentAlignment = Alignment.Center) { + Canvas(Modifier.fillMaxSize()) { + val s = Stroke(stroke.toPx(), cap = StrokeCap.Round) + drawArc(track, 0f, 360f, false, style = s) + drawArc(Brush.sweepGradient(listOf(color, BcvGold, color)), -90f, 360f * anim, false, style = s) + } + Text(label ?: "${(anim * 100).toInt()}%", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + } +} + +@Composable +fun ProgressBar(progress: Float, color: Color) { + val anim by animateFloatAsState(progress, tween(700), label = "bar") + val track = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + Canvas(Modifier.fillMaxWidth().height(10.dp)) { + val r = CornerRadius(size.height / 2) + drawRoundRect(track, cornerRadius = r) + drawRoundRect(Brush.horizontalGradient(listOf(color, BcvGold)), size = Size(size.width * anim, size.height), cornerRadius = r) + } +} + +/** Ilustración de estado vacío dibujada con formas: billetera o alcancía. */ +@Composable +fun EmptyIllustration(piggy: Boolean, title: String, body: String) { + val dark = LocalDarkTheme.current + val navy = if (dark) BcvIndigo else BcvBlue + Column(Modifier.fillMaxWidth().padding(vertical = 24.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Canvas(Modifier.size(150.dp)) { + val w = size.width + drawCircle(navy.copy(alpha = 0.10f), radius = w * 0.48f) + if (piggy) { + drawOval(navy, topLeft = Offset(w * 0.18f, w * 0.32f), size = Size(w * 0.62f, w * 0.44f)) + drawCircle(navy, radius = w * 0.07f, center = Offset(w * 0.80f, w * 0.52f)) + drawRect(navy, topLeft = Offset(w * 0.28f, w * 0.70f), size = Size(w * 0.08f, w * 0.12f)) + drawRect(navy, topLeft = Offset(w * 0.58f, w * 0.70f), size = Size(w * 0.08f, w * 0.12f)) + drawRoundRect(BcvGold, topLeft = Offset(w * 0.42f, w * 0.30f), size = Size(w * 0.14f, w * 0.03f), cornerRadius = CornerRadius(4f)) + drawCircle(Color.White, radius = w * 0.025f, center = Offset(w * 0.66f, w * 0.46f)) + drawCircle(BcvGold, radius = w * 0.09f, center = Offset(w * 0.49f, w * 0.15f)) + drawCircle(Color(0xFFD97706), radius = w * 0.09f, center = Offset(w * 0.49f, w * 0.15f), style = Stroke(w * 0.015f)) + } else { + drawRoundRect(BcvGold.copy(alpha = 0.9f), topLeft = Offset(w * 0.26f, w * 0.22f), size = Size(w * 0.42f, w * 0.26f), cornerRadius = CornerRadius(10f)) + drawRoundRect(navy, topLeft = Offset(w * 0.16f, w * 0.34f), size = Size(w * 0.68f, w * 0.42f), cornerRadius = CornerRadius(18f)) + drawRoundRect(BcvGold, topLeft = Offset(w * 0.62f, w * 0.48f), size = Size(w * 0.24f, w * 0.14f), cornerRadius = CornerRadius(10f)) + drawCircle(navy, radius = w * 0.025f, center = Offset(w * 0.70f, w * 0.55f)) + } + } + Spacer(Modifier.height(8.dp)) + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, modifier = Modifier.padding(horizontal = 24.dp)) + } +} + +@Composable +fun CurrencyToggle(value: Money, onChange: (Money) -> Unit) = + com.codeonyx.bcvaqui.ui.tools.SegmentedChoice(Money.entries, value, { if (it == Money.USD) "Dólares ($)" else "Bolívares (Bs)" }, onChange) + +@Composable +fun FrequencyChips(value: Frequency, onChange: (Frequency) -> Unit) = + com.codeonyx.bcvaqui.ui.tools.SegmentedChoice(Frequency.entries, value, { it.label }, onChange) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DateField(label: String, epochDay: Long?, onChange: (Long?) -> Unit, clearable: Boolean = false) { + var open by remember { mutableStateOf(false) } + OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Default.CalendarMonth, null, Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("$label: " + (epochDay?.let { LocalDate.ofEpochDay(it).es() } ?: "sin fecha")) + } + if (open) { + val state = rememberDatePickerState(initialSelectedDateMillis = (epochDay ?: LocalDate.now().toEpochDay()) * 86_400_000L) + DatePickerDialog( + onDismissRequest = { open = false }, + confirmButton = { TextButton(onClick = { onChange(state.selectedDateMillis?.let { it / 86_400_000L }); open = false }) { Text("Listo") } }, + dismissButton = { + TextButton(onClick = { if (clearable) onChange(null); open = false }) { Text(if (clearable) "Quitar" else "Cancelar") } + } + ) { DatePicker(state) } + } +} + +@Composable +fun ConfirmDelete(what: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("¿Eliminar $what?") }, + text = { Text("Se borrará junto con su historial. Esta acción no se puede deshacer.") }, + confirmButton = { TextButton(onClick = { onConfirm(); onDismiss() }) { Text("Eliminar", color = FinBad) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancelar") } } + ) +} + +/** Diálogo de abono / depósito / retiro. */ +@Composable +fun AmountDialog(title: String, currency: Money, suggested: Double?, onSave: (Double, String) -> Unit, onDismiss: () -> Unit) { + var amount by remember { mutableStateOf(suggested?.let { com.codeonyx.bcvaqui.domain.VeNumber.toInput(it) } ?: "") } + var note by remember { mutableStateOf("") } + val parsed = com.codeonyx.bcvaqui.domain.VeNumber.parse(amount) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + com.codeonyx.bcvaqui.ui.tools.ToolNumberField(amount, { amount = it }, "Monto", suffix = if (currency == Money.USD) "$" else "Bs") + OutlinedTextField(note, { note = it }, label = { Text("Nota (opcional)") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + }, + confirmButton = { TextButton(enabled = parsed > 0, onClick = { onSave(parsed, note.trim()); onDismiss() }) { Text("Guardar") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancelar") } } + ) +} + +@Composable +fun StatusPill(text: String, color: Color) { + Surface(color = color.copy(alpha = 0.15f), shape = RoundedCornerShape(50)) { + Text(text, color = color, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)) + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/FinanceViewModel.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/FinanceViewModel.kt new file mode 100644 index 0000000..8287593 --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/FinanceViewModel.kt @@ -0,0 +1,49 @@ +package com.codeonyx.bcvaqui.ui.tools.finance + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.codeonyx.bcvaqui.data.local.FinanceRepository +import com.codeonyx.bcvaqui.domain.Debt +import com.codeonyx.bcvaqui.domain.Movement +import com.codeonyx.bcvaqui.domain.SavingsGoal +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.util.UUID + +class FinanceViewModel(app: Application) : AndroidViewModel(app) { + private val repo = FinanceRepository(app) + + val debts: StateFlow> = repo.debts.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val goals: StateFlow> = repo.goals.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun newId(): String = UUID.randomUUID().toString() + + fun saveDebt(debt: Debt) = viewModelScope.launch { + repo.updateDebts { list -> if (list.any { it.id == debt.id }) list.map { if (it.id == debt.id) debt else it } else list + debt } + } + + fun deleteDebt(id: String) = viewModelScope.launch { repo.updateDebts { l -> l.filterNot { it.id == id } } } + + fun addPayment(id: String, amount: Double, note: String) = viewModelScope.launch { + repo.updateDebts { l -> l.map { if (it.id == id) it.copy(payments = it.payments + Movement(newId(), LocalDate.now().toEpochDay(), amount, note)) else it } } + } + + fun setDebtFlags(id: String, paid: Boolean? = null, archived: Boolean? = null) = viewModelScope.launch { + repo.updateDebts { l -> l.map { if (it.id == id) it.copy(markedPaid = paid ?: it.markedPaid, archived = archived ?: it.archived) else it } } + } + + fun saveGoal(goal: SavingsGoal) = viewModelScope.launch { + repo.updateGoals { list -> if (list.any { it.id == goal.id }) list.map { if (it.id == goal.id) goal else it } else list + goal } + } + + fun deleteGoal(id: String) = viewModelScope.launch { repo.updateGoals { l -> l.filterNot { it.id == id } } } + + /** [amount] positivo = depósito, negativo = retiro. */ + fun addMovement(id: String, amount: Double, note: String) = viewModelScope.launch { + repo.updateGoals { l -> l.map { if (it.id == id) it.copy(movements = it.movements + Movement(newId(), LocalDate.now().toEpochDay(), amount, note)) else it } } + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/ui/tour/Tour.kt b/app/src/main/java/com/codeonyx/bcvaqui/ui/tour/Tour.kt new file mode 100644 index 0000000..b10c91b --- /dev/null +++ b/app/src/main/java/com/codeonyx/bcvaqui/ui/tour/Tour.kt @@ -0,0 +1,168 @@ +package com.codeonyx.bcvaqui.ui.tour + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.codeonyx.bcvaqui.ui.theme.BcvGold + +object TourKeys { + const val RATE = "rate"; const val AMOUNT = "amount"; const val RESULT = "result" + const val COPY = "copy"; const val SHARE = "share"; const val STATUS = "status" + const val NAV_TOOLS = "nav_tools"; const val NAV_SETTINGS = "nav_settings" + const val TOOLS_CONTENT = "tools_content"; const val SETTINGS_CONTENT = "settings_content" +} + +/** route: "home" | "tools" | "settings" — la guía navega sola a la pantalla del paso. */ +data class TourStep(val key: String?, val route: String, val emoji: String, val title: String, val body: String) + +val tourSteps = listOf( + TourStep(null, "home", "👋", "Bienvenido a Codivisas", "Te mostramos la app en menos de un minuto. Puedes saltar la guía cuando quieras."), + TourStep(TourKeys.RATE, "home", "💱", "Elige la tasa", "Toca aquí para cambiar entre Dólar BCV, Euro BCV o USDT Binance P2P."), + TourStep(TourKeys.AMOUNT, "home", "⌨️", "Escribe el monto", "Ingresa la cantidad y usa el botón para invertir la conversión."), + TourStep(TourKeys.RESULT, "home", "✨", "Resultado al instante", "Tócalo para copiar el monto; mantén presionado para copiarlo con la moneda."), + TourStep(TourKeys.COPY, "home", "📋", "Copia rápida", "Copia la tasa del día, la conversión completa o solo el monto."), + TourStep(TourKeys.SHARE, "home", "📤", "Comparte", "Envía un comprobante con tus datos de pago por WhatsApp u otra app."), + TourStep(TourKeys.STATUS, "home", "🕒", "Estado de la tasa", "Aquí ves cuándo se actualizó y la brecha entre BCV y USDT."), + TourStep(TourKeys.NAV_TOOLS, "tools", "🧰", "Herramientas", "Aquí están todas las utilidades. Te mostramos cada una."), + TourStep("tool_deudas", "tools", "🤝", "Deudas", "Lleva lo que debes y lo que te deben, con abonos, fechas y recordatorios."), + TourStep("tool_ahorros", "tools", "🐷", "Ahorros", "Crea metas, planifica cuánto apartar y mira tu progreso."), + TourStep("tool_calculadora", "tools", "🧮", "Calculadora", "Calcula en $ y Bs. a la vez, con historial de operaciones."), + TourStep("tool_donde", "tools", "🌎", "Equivalencias", "Convierte bolívares a otras monedas del mundo (pesos, soles, reales…)."), + TourStep("tool_iva_igtf", "tools", "🧾", "IVA + IGTF", "Suma el IVA y el IGTF de un pago en divisas en un toque."), + TourStep("tool_dividir", "tools", "✂️", "Dividir gasto", "Reparte un gasto entre varias personas en $ o Bs."), + TourStep("tool_brecha", "tools", "📊", "Brecha cambiaria", "Compara la tasa BCV con USDT o el euro y mira la diferencia en %."), + TourStep("tool_mercado", "tools", "📈", "Mercado y gráficos", "Sigue la evolución de las tasas con gráficos."), + TourStep("tool_historial", "tools", "🗓️", "Historial BCV", "Consulta tasas BCV de días anteriores."), + TourStep("tool_comision", "tools", "💸", "Comisión / remesa", "Descubre cuánto llega realmente tras comisiones de envío o pago."), + TourStep("tool_comerciante", "tools", "🏪", "Modo comerciante", "Calcula precios con ganancia o descuento a la tasa de hoy."), + TourStep("tool_agenda", "tools", "🏦", "Agenda de pagos", "Guarda tus datos de pago móvil y cuentas para compartirlos."), + TourStep(TourKeys.SETTINGS_CONTENT, "settings", "⚙️", "Ajustes", "Tema, moneda, notificaciones por fuente, respaldo de datos y actualizaciones."), + TourStep(null, "home", "📱", "Widgets", "Mantén presionada la pantalla de inicio › Widgets › Codivisas para ver la tasa sin abrir la app."), + TourStep(null, "home", "🚀", "¡Listo!", "¿Prefieres algo más sencillo? Activa el Modo lite: solo la tasa del dólar y un convertidor grande. Puedes cambiarlo en Ajustes y ver esta guía de nuevo en Ajustes › Ayuda.") +) + +/** Clave del paso actual, para que las pantallas hagan scroll hasta el elemento. */ +val LocalTourFocus = compositionLocalOf { null } + +val LocalTourTargets = staticCompositionLocalOf?> { null } + +fun Modifier.tourTarget(key: String): Modifier = composed { + val map = LocalTourTargets.current + if (map == null) this else onGloballyPositioned { map[key] = it.boundsInRoot() } +} + +@Composable +fun TourOverlay( + targets: Map, + onNavigate: (String) -> Unit, + onFinish: () -> Unit, + onFocus: (String?) -> Unit = {}, + onLite: (() -> Unit)? = null +) { + var index by remember { mutableIntStateOf(0) } + val step = tourSteps[index] + LaunchedEffect(index) { onNavigate(step.route); onFocus(step.key) } + val rect = step.key?.let { targets[it] } + val density = LocalDensity.current + val pad = with(density) { 8.dp.toPx() } + val l by animateFloatAsState(rect?.left?.minus(pad) ?: 0f, tween(350), label = "l") + val t by animateFloatAsState(rect?.top?.minus(pad) ?: 0f, tween(350), label = "t") + val r by animateFloatAsState(rect?.right?.plus(pad) ?: 0f, tween(350), label = "r") + val b by animateFloatAsState(rect?.bottom?.plus(pad) ?: 0f, tween(350), label = "b") + val alpha by animateFloatAsState(if (rect != null) 1f else 0f, tween(250), label = "a") + + BoxWithConstraints( + Modifier.fillMaxSize() + .clickable(remember { MutableInteractionSource() }, null) { } + ) { + Canvas(Modifier.fillMaxSize().graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }) { + drawRect(Color(0xE6050B1A)) + if (alpha > 0f) { + val cr = CornerRadius(18.dp.toPx()) + drawRoundRect(Color.Black, Offset(l, t), Size(r - l, b - t), cr, alpha = alpha, blendMode = BlendMode.Clear) + drawRoundRect(BcvGold, Offset(l, t), Size(r - l, b - t), cr, alpha = alpha, style = Stroke(2.dp.toPx())) + } + } + val heightPx = with(density) { maxHeight.toPx() } + val cardOnTop = rect != null && rect.center.y > heightPx * 0.55f + Column( + Modifier.fillMaxSize().systemBarsPadding().padding(16.dp), + verticalArrangement = when { + rect == null -> Arrangement.Center + cardOnTop -> Arrangement.Top + else -> Arrangement.Bottom + } + ) { + if (cardOnTop) Spacer(Modifier.height(56.dp)) + Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surface, shadowElevation = 12.dp) { + Column(Modifier.padding(20.dp)) { + AnimatedContent(index, transitionSpec = { fadeIn(tween(220)) togetherWith fadeOut(tween(150)) }, label = "tour") { i -> + val s = tourSteps[i] + Column { + Text(s.emoji, style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(6.dp)) + Text(s.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(4.dp)) + Text(s.body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Spacer(Modifier.height(14.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { + tourSteps.indices.forEach { i -> + Box( + Modifier.height(6.dp).width(if (i == index) 18.dp else 6.dp) + .background(if (i <= index) BcvGold else MaterialTheme.colorScheme.outlineVariant, CircleShape) + ) + } + Spacer(Modifier.weight(1f)) + Text("${index + 1}/${tourSteps.size}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.height(10.dp)) + if (index == tourSteps.lastIndex && onLite != null) { + OutlinedButton(onClick = onLite, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { + Text("Usar Modo lite (más sencillo)") + } + Spacer(Modifier.height(4.dp)) + } + Row(verticalAlignment = Alignment.CenterVertically) { + if (index < tourSteps.lastIndex) TextButton(onClick = onFinish) { Text("Saltar") } + Spacer(Modifier.weight(1f)) + if (index > 0) TextButton(onClick = { index-- }) { Text("Atrás") } + Button(onClick = { if (index == tourSteps.lastIndex) onFinish() else index++ }) { + Text(if (index == tourSteps.lastIndex) "Empezar" else "Siguiente") + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/update/AppUpdateManager.kt b/app/src/main/java/com/codeonyx/bcvaqui/update/AppUpdateManager.kt index c5cdc30..da682cf 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/update/AppUpdateManager.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/update/AppUpdateManager.kt @@ -5,6 +5,10 @@ import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.Uri @@ -22,12 +26,17 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import java.io.File +import java.security.MessageDigest +import java.util.Locale object AppUpdateManager { private val json = Json { ignoreUnknownKeys = true } private const val PREFS_NAME = "app_update" private const val KEY_PENDING_DOWNLOAD_ID = "pending_download_id" private const val KEY_PENDING_APK_FILE = "pending_apk_file" + private const val KEY_PENDING_SHA256 = "pending_apk_sha256" + private const val TAG = "AppUpdateManager" + private const val ALLOWED_OWNER = "codeonyx-dev" private const val UPDATE_NOTIFICATION_ID = 2001 private const val INSTALL_READY_NOTIFICATION_ID = 2002 const val ACTION_INSTALL_UPDATE = "com.codeonyx.bcvaqui.update.ACTION_INSTALL_UPDATE" @@ -39,20 +48,17 @@ object AppUpdateManager { suspend fun checkForUpdate(context: Context, notifyIfAvailable: Boolean = true): UpdateManifest? { return withContext(Dispatchers.IO) { runCatching { - val connection = java.net.URL(ApiConfig.UPDATE_MANIFEST_URL).openConnection().apply { - connectTimeout = 15_000 - readTimeout = 15_000 - setRequestProperty("Accept", "application/json") - setRequestProperty("User-Agent", "SpreadVE/${BuildConfig.VERSION_NAME}") - } - val body = connection.getInputStream().bufferedReader().use { it.readText() } - val manifest = json.decodeFromString(body) - if (manifest.versionCode > BuildConfig.VERSION_CODE) { + val manifest = ApiConfig.UPDATE_MANIFEST_URLS.firstNotNullOfOrNull { url -> + runCatching { fetchManifest(url) } + .onFailure { Log.w(TAG, "Manifiesto no disponible en $url", it) } + .getOrNull() + } ?: return@runCatching null + if (manifest.versionCode > BuildConfig.VERSION_CODE && isAllowedApkUrl(manifest.apkUrl)) { if (notifyIfAvailable) { val onWifi = isOnWifi(context) showUpdateNotification(context, manifest, autoDownloading = onWifi) if (onWifi) { - startDownload(context, manifest.apkUrl, manifest.versionName, allowMetered = false) + startDownload(context, manifest.apkUrl, manifest.versionName, allowMetered = false, sha256 = manifest.sha256) } } manifest @@ -70,6 +76,7 @@ object AppUpdateManager { action = ACTION_INSTALL_UPDATE putExtra("apk_url", manifest.apkUrl) putExtra("version_name", manifest.versionName) + putExtra("sha256", manifest.sha256) } val downloadPending = PendingIntent.getBroadcast( context, @@ -93,7 +100,7 @@ object AppUpdateManager { val builder = NotificationCompat.Builder(context, BcvAquiApp.CHANNEL_APP_UPDATES) .setSmallIcon(R.drawable.ic_notification) - .setContentTitle("Actualización de SpreadVE") + .setContentTitle("Actualización de Codivisas") .setContentText(contentText) .setStyle(NotificationCompat.BigTextStyle().bigText(notes)) .setContentIntent(openPending) @@ -113,16 +120,22 @@ object AppUpdateManager { context: Context, apkUrl: String, versionName: String, - allowMetered: Boolean = true + allowMetered: Boolean = true, + sha256: String? = null ): Long { val appContext = context.applicationContext + if (!isAllowedApkUrl(apkUrl)) { + Log.w(TAG, "URL de APK no permitida, se ignora") + showVerificationFailedNotification(appContext) + return -1L + } val apkFile = apkFileFor(appContext, versionName) if (isDownloadInProgress(appContext)) { return prefs(appContext).getLong(KEY_PENDING_DOWNLOAD_ID, -1L) } val request = DownloadManager.Request(Uri.parse(apkUrl)).apply { - setTitle("SpreadVE $versionName") + setTitle("Codivisas $versionName") setDescription("Descargando actualización…") setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) setDestinationInExternalFilesDir(appContext, Environment.DIRECTORY_DOWNLOADS, apkFile.name) @@ -136,6 +149,7 @@ object AppUpdateManager { prefs(appContext).edit() .putLong(KEY_PENDING_DOWNLOAD_ID, downloadId) .putString(KEY_PENDING_APK_FILE, apkFile.absolutePath) + .putString(KEY_PENDING_SHA256, sha256?.trim()?.lowercase(Locale.ROOT)) .apply() return downloadId } @@ -205,7 +219,7 @@ object AppUpdateManager { val notification = NotificationCompat.Builder(context, BcvAquiApp.CHANNEL_APP_UPDATES) .setSmallIcon(R.drawable.ic_notification) .setContentTitle("No se pudo descargar la actualización") - .setContentText("Abre SpreadVE e intenta de nuevo desde Ajustes.") + .setContentText("Abre Codivisas e intenta de nuevo desde Ajustes.") .setContentIntent(openPending) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_DEFAULT) @@ -228,7 +242,7 @@ object AppUpdateManager { val notification = NotificationCompat.Builder(context, BcvAquiApp.CHANNEL_APP_UPDATES) .setSmallIcon(R.drawable.ic_notification) .setContentTitle("Actualización lista") - .setContentText("Toca para instalar SpreadVE") + .setContentText("Toca para instalar Codivisas") .setContentIntent(installPending) .addAction(0, "Instalar", installPending) .setAutoCancel(true) @@ -241,38 +255,159 @@ object AppUpdateManager { fun promptInstall(context: Context, downloadId: Long, savedApkPath: String? = null) { val appContext = context.applicationContext val manager = appContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - manager.getUriForDownloadedFile(downloadId)?.let { uri -> - launchInstallIntent(appContext, uri) - return - } - val apkPath = savedApkPath ?: prefs(appContext).getString(KEY_PENDING_APK_FILE, null) - if (apkPath != null) { - val file = File(apkPath) - if (file.exists()) { - promptInstallFromFile(appContext, file) - return + var file: File? = (savedApkPath ?: prefs(appContext).getString(KEY_PENDING_APK_FILE, null)) + ?.let(::File)?.takeIf { it.exists() } + if (file == null) { + manager.query(DownloadManager.Query().setFilterById(downloadId)).use { cursor -> + if (cursor.moveToFirst()) { + val localUri = cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)) + file = localUri?.let { Uri.parse(it).path?.let(::File) }?.takeIf { it.exists() } + } } } - - val query = DownloadManager.Query().setFilterById(downloadId) - manager.query(query).use { cursor -> - if (!cursor.moveToFirst()) return - val localUri = cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)) - val file = localUri?.let { Uri.parse(it).path?.let(::File) } - if (file != null && file.exists()) { - promptInstallFromFile(appContext, file) - } + val apk = file + if (apk == null) { + showVerificationFailedNotification(appContext) + return } + promptInstallFromFile(appContext, apk) } + /** Verifica integridad y firma antes de lanzar el instalador. */ fun promptInstallFromFile(context: Context, file: File) { + val appContext = context.applicationContext + val expectedSha = prefs(appContext).getString(KEY_PENDING_SHA256, null) + val error = verifyApk(appContext, file, expectedSha) + if (error != null) { + Log.w(TAG, "Verificación de APK fallida: $error") + runCatching { file.delete() } + prefs(appContext).edit().remove(KEY_PENDING_SHA256).apply() + NotificationManagerCompat.from(appContext).cancel(INSTALL_READY_NOTIFICATION_ID) + showVerificationFailedNotification(appContext) + return + } val uri = FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", + appContext, + "${appContext.packageName}.fileprovider", file ) - launchInstallIntent(context, uri) + launchInstallIntent(appContext, uri) + } + + /** Devuelve null si el APK es válido, o un motivo técnico (solo para log). */ + internal fun verifyApk(context: Context, file: File, expectedSha256: String?): String? { + if (!file.exists() || file.length() == 0L) return "archivo vacío" + if (!expectedSha256.isNullOrBlank()) { + val actual = sha256Hex(file) + if (!actual.equals(expectedSha256.trim(), ignoreCase = true)) return "sha256 no coincide" + } + val pm = context.packageManager + val archive = archivePackageInfo(pm, file.absolutePath) ?: return "APK ilegible" + if (archive.packageName != context.packageName) return "paquete distinto" + val installed = runCatching { installedPackageInfo(pm, context.packageName) }.getOrNull() + ?: return "no se pudo leer la firma instalada" + val newCerts = signerDigests(archive) + val currentCerts = signerDigests(installed) + if (newCerts.isEmpty() || currentCerts.isEmpty()) return "sin firma" + if (newCerts.intersect(currentCerts).isEmpty()) return "firma distinta" + return null + } + + fun sha256Hex(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read <= 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun fetchManifest(url: String): UpdateManifest { + val connection = java.net.URL(url).openConnection().apply { + connectTimeout = 15_000 + readTimeout = 15_000 + setRequestProperty("Accept", "application/json") + setRequestProperty("User-Agent", "Codivisas/${BuildConfig.VERSION_NAME}") + } + val body = connection.getInputStream().bufferedReader().use { it.readText() } + return json.decodeFromString(body) + } + + /** Solo se aceptan APKs de codeonyx.online/codivisas o de GitHub bajo la organización del proyecto. */ + fun isAllowedApkUrl(url: String): Boolean { + val uri = runCatching { java.net.URI(url) }.getOrNull() ?: return false + if (!uri.scheme.equals("https", ignoreCase = true)) return false + val host = uri.host?.lowercase(Locale.ROOT) ?: return false + val path = uri.path.orEmpty() + return when (host) { + "github.com" -> path.startsWith("/$ALLOWED_OWNER/") + "objects.githubusercontent.com" -> true + "codeonyx.online" -> path.startsWith("/codivisas/") + else -> false + } + } + + @Suppress("DEPRECATION") + private fun archivePackageInfo(pm: PackageManager, path: String): PackageInfo? { + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + PackageManager.GET_SIGNING_CERTIFICATES + } else { + PackageManager.GET_SIGNATURES + } + val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageArchiveInfo(path, PackageManager.PackageInfoFlags.of(flags.toLong())) + } else { + pm.getPackageArchiveInfo(path, flags) + } ?: return null + // Necesario en algunas versiones para que las firmas del archivo se resuelvan. + info.applicationInfo?.let { + it.sourceDir = path + it.publicSourceDir = path + } + return info + } + + @Suppress("DEPRECATION") + private fun installedPackageInfo(pm: PackageManager, packageName: String): PackageInfo { + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + PackageManager.GET_SIGNING_CERTIFICATES + } else { + PackageManager.GET_SIGNATURES + } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong())) + } else { + pm.getPackageInfo(packageName, flags) + } + } + + @Suppress("DEPRECATION") + private fun signerDigests(info: PackageInfo): Set { + val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val signing = info.signingInfo ?: return emptySet() + if (signing.hasMultipleSigners()) signing.apkContentsSigners else signing.signingCertificateHistory + } else { + info.signatures + } ?: return emptySet() + val md = MessageDigest.getInstance("SHA-256") + return signatures.map { sig -> md.digest(sig.toByteArray()).joinToString("") { "%02x".format(it) } }.toSet() + } + + private fun showVerificationFailedNotification(context: Context) { + val notification = NotificationCompat.Builder(context, BcvAquiApp.CHANNEL_APP_UPDATES) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.update_verify_failed_title)) + .setContentText(context.getString(R.string.update_verify_failed_body)) + .setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.update_verify_failed_body))) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + NotificationManagerCompat.from(context).notify(INSTALL_READY_NOTIFICATION_ID + 2, notification) } private fun launchInstallIntent(context: Context, uri: Uri) { @@ -287,7 +422,7 @@ object AppUpdateManager { private fun apkFileFor(context: Context, versionName: String): File { val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: context.filesDir - return File(dir, "SpreadVE-$versionName.apk") + return File(dir, "Codivisas-$versionName.apk") } private fun isDownloadInProgress(context: Context): Boolean { @@ -326,7 +461,8 @@ class UpdateDownloadReceiver : BroadcastReceiver() { AppUpdateManager.ACTION_INSTALL_UPDATE -> { val url = intent.getStringExtra("apk_url") ?: return val version = intent.getStringExtra("version_name") ?: "nueva" - AppUpdateManager.startDownload(context.applicationContext, url, version, allowMetered = true) + val sha = intent.getStringExtra("sha256") + AppUpdateManager.startDownload(context.applicationContext, url, version, allowMetered = true, sha256 = sha) } AppUpdateManager.ACTION_INSTALL_DOWNLOADED -> { val downloadId = intent.getLongExtra("download_id", -1L) diff --git a/app/src/main/java/com/codeonyx/bcvaqui/widget/ConverterWidgetProvider.kt b/app/src/main/java/com/codeonyx/bcvaqui/widget/ConverterWidgetProvider.kt index 7f6bab8..71253b1 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/widget/ConverterWidgetProvider.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/widget/ConverterWidgetProvider.kt @@ -18,6 +18,10 @@ class ConverterWidgetProvider : AppWidgetProvider() { WorkScheduler.onWidgetAdded(context) } + override fun onDisabled(context: Context) { + WorkScheduler.onWidgetRemoved(context) + } + override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, @@ -74,7 +78,7 @@ class ConverterWidgetProvider : AppWidgetProvider() { R.id.widget_converter_rate, rate?.let { "${RateSources.rateSourceLabel(snap.currency)} ${FxCalculator.format2(it)}" - } ?: "Sin tasa" + }?.let { r -> Widgets.offlineMarker(snap)?.let { "$r\n$it" } ?: r } ?: "Sin tasa" ) views.setOnClickPendingIntent( R.id.widget_converter_root, diff --git a/app/src/main/java/com/codeonyx/bcvaqui/widget/MiniRateWidgetProvider.kt b/app/src/main/java/com/codeonyx/bcvaqui/widget/MiniRateWidgetProvider.kt deleted file mode 100644 index d11f555..0000000 --- a/app/src/main/java/com/codeonyx/bcvaqui/widget/MiniRateWidgetProvider.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.codeonyx.bcvaqui.widget - -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.ComponentName -import android.content.Context -import android.widget.RemoteViews -import com.codeonyx.bcvaqui.R -import com.codeonyx.bcvaqui.domain.FxCalculator -import com.codeonyx.bcvaqui.worker.WorkScheduler - -class MiniRateWidgetProvider : AppWidgetProvider() { - override fun onEnabled(context: Context) { - WorkScheduler.onWidgetAdded(context) - } - - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - val pendingResult = goAsync() - Widgets.runAsync(pendingResult) { - val snapshot = Widgets.loadSnapshot(context) - appWidgetIds.forEach { updateWidget(context, appWidgetManager, it, snapshot) } - } - } - - companion object { - fun updateAllWidgets(context: Context, snapshot: WidgetSnapshot) { - val manager = AppWidgetManager.getInstance(context) - val ids = manager.getAppWidgetIds(ComponentName(context, MiniRateWidgetProvider::class.java)) - ids.forEach { updateWidget(context, manager, it, snapshot) } - } - - private fun updateWidget( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetId: Int, - snap: WidgetSnapshot - ) { - val views = RemoteViews(context.packageName, R.layout.widget_mini_rate) - Widgets.applyBrandAndAlert(views, R.id.widget_mini_root, snap) - views.setTextViewText(R.id.widget_mini_code, snap.currency) - views.setTextViewText( - R.id.widget_mini_rate, - snap.rate?.let { FxCalculator.format2(it) } ?: "—" - ) - views.setOnClickPendingIntent( - R.id.widget_mini_root, - Widgets.openAppPending(context, 2000 + appWidgetId) - ) - appWidgetManager.updateAppWidget(appWidgetId, views) - } - } -} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/widget/RateWidgetProvider.kt b/app/src/main/java/com/codeonyx/bcvaqui/widget/RateWidgetProvider.kt index dbcd23e..bf1c3d0 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/widget/RateWidgetProvider.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/widget/RateWidgetProvider.kt @@ -7,6 +7,9 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.widget.RemoteViews +import android.os.Build +import android.util.SizeF +import com.codeonyx.bcvaqui.domain.MarketMath import com.codeonyx.bcvaqui.R import com.codeonyx.bcvaqui.domain.FxCalculator import com.codeonyx.bcvaqui.worker.WorkScheduler @@ -17,6 +20,10 @@ class RateWidgetProvider : AppWidgetProvider() { WorkScheduler.onWidgetAdded(context) } + override fun onDisabled(context: Context) { + WorkScheduler.onWidgetRemoved(context) + } + override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, @@ -49,52 +56,87 @@ class RateWidgetProvider : AppWidgetProvider() { } } + private fun buildViews(context: Context, layout: Int, snap: WidgetSnapshot, appWidgetId: Int): RemoteViews { + val views = RemoteViews(context.packageName, layout) + Widgets.applyBrandAndAlert(views, R.id.widget_root, snap) + val small = layout == R.layout.widget_rate_small + views.setTextViewText( + R.id.widget_currency_label, + if (small) snap.currency else "${FxCalculator.currencyLabel(snap.currency)} · ${snap.currency}" + ) + views.setTextViewText(R.id.widget_rate_text, snap.rate?.let { FxCalculator.format2(it) } ?: "—") + val pct = snap.dayChangePct + val trend = "${MarketMath.trendArrow(pct)} " + (pct?.let { + (if (it > 0) "+" else "") + FxCalculator.format2(it) + "%" + } ?: "sin cambio") + if (small) "" else " hoy" + views.setTextViewText(R.id.widget_trend_text, trend) + if (!small) { + views.setTextViewText( + R.id.widget_rate_suffix, + if (snap.rate != null) "Bs. por 1 ${snap.currency}" else "Sin datos aún" + ) + views.setTextViewText( + R.id.widget_updated_text, + Widgets.offlineMarker(snap) ?: "Actualizado: ${FxCalculator.formatUpdatedAt(snap.prefs.lastKnownUpdatedAt)}" + ) + val refreshIntent = Intent(context, RateWidgetProvider::class.java).apply { action = ACTION_REFRESH } + views.setOnClickPendingIntent( + R.id.widget_refresh, + PendingIntent.getBroadcast( + context, appWidgetId + 1000, refreshIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + } + if (layout == R.layout.widget_rate_large) { + val usdt = snap.prefs.lastKnownBinanceUsdtVes + val usd = snap.prefs.lastKnownUsdRate + views.setTextViewText(R.id.widget_usdt_text, "USDT ${usdt?.let { FxCalculator.format2(it) } ?: "—"}") + val gap = if (usdt != null && usd != null && usd > 0) FxCalculator.round2((usdt - usd) / usd * 100) else null + views.setTextViewText(R.id.widget_gap_text, "Brecha ${gap?.let { FxCalculator.format2(it) + "%" } ?: "—"}") + } + views.setOnClickPendingIntent(R.id.widget_root, Widgets.openAppPending(context, appWidgetId)) + return views + } + private fun updateWidget( context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, snap: WidgetSnapshot ) { - val views = RemoteViews(context.packageName, R.layout.widget_rate) - Widgets.applyBrandAndAlert(views, R.id.widget_root, snap) - views.setTextViewText( - R.id.widget_currency_label, - "${FxCalculator.currencyLabel(snap.currency)} · ${snap.currency}" - ) - if (snap.rate != null) { - views.setTextViewText(R.id.widget_rate_text, FxCalculator.format2(snap.rate)) - views.setTextViewText(R.id.widget_rate_suffix, "Bs. por 1 ${snap.currency}") + val views = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + RemoteViews( + mapOf( + SizeF(60f, 60f) to buildViews(context, R.layout.widget_rate_small, snap, appWidgetId), + SizeF(200f, 110f) to buildViews(context, R.layout.widget_rate, snap, appWidgetId), + SizeF(250f, 200f) to buildViews(context, R.layout.widget_rate_large, snap, appWidgetId) + ) + ) } else { - views.setTextViewText(R.id.widget_rate_text, "—") - views.setTextViewText(R.id.widget_rate_suffix, "Sin datos aún") - } - val updated = FxCalculator.formatUpdatedAt(snap.prefs.lastKnownUpdatedAt) - val alertNote = when { - !snap.alertActive || snap.dayChangePct == null -> "" - else -> { - val sign = if (snap.dayChangePct > 0) "+" else "" - " · $sign${FxCalculator.format2(snap.dayChangePct)}%" + val opts = appWidgetManager.getAppWidgetOptions(appWidgetId) + val w = opts.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) + val h = opts.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) + val layout = when { + w in 1 until 200 -> R.layout.widget_rate_small + h >= 200 -> R.layout.widget_rate_large + else -> R.layout.widget_rate } + buildViews(context, layout, snap, appWidgetId) } - views.setTextViewText(R.id.widget_updated_text, "Actualizado: $updated$alertNote") - - views.setOnClickPendingIntent( - R.id.widget_root, - Widgets.openAppPending(context, appWidgetId) - ) - - val refreshIntent = Intent(context, RateWidgetProvider::class.java).apply { - action = ACTION_REFRESH - } - val refreshPending = PendingIntent.getBroadcast( - context, - appWidgetId + 1000, - refreshIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - views.setOnClickPendingIntent(R.id.widget_refresh, refreshPending) - appWidgetManager.updateAppWidget(appWidgetId, views) } } + + override fun onAppWidgetOptionsChanged( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + newOptions: android.os.Bundle + ) { + val pendingResult = goAsync() + Widgets.runAsync(pendingResult) { + updateWidget(context, appWidgetManager, appWidgetId, Widgets.loadSnapshot(context)) + } + } } diff --git a/app/src/main/java/com/codeonyx/bcvaqui/widget/ShareRateActivity.kt b/app/src/main/java/com/codeonyx/bcvaqui/widget/ShareRateActivity.kt deleted file mode 100644 index 18ceebe..0000000 --- a/app/src/main/java/com/codeonyx/bcvaqui/widget/ShareRateActivity.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.codeonyx.bcvaqui.widget - -import android.content.Intent -import android.os.Bundle -import android.widget.Toast -import androidx.activity.ComponentActivity -import androidx.lifecycle.lifecycleScope -import com.codeonyx.bcvaqui.domain.FxCalculator -import com.codeonyx.bcvaqui.domain.RateSources -import com.codeonyx.bcvaqui.ui.share.ReceiptData -import com.codeonyx.bcvaqui.ui.share.ReceiptShareHelper -import kotlinx.coroutines.launch - -/** - * Activity transparente lanzada desde el widget "Compartir tasa". - */ -class ShareRateActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycleScope.launch { - val snap = Widgets.loadSnapshot(this@ShareRateActivity) - val rate = snap.rate - if (rate == null) { - Toast.makeText(this@ShareRateActivity, "Sin tasa para compartir", Toast.LENGTH_SHORT).show() - finish() - return@launch - } - - val data = ReceiptData( - currencyCode = snap.currency, - rateToVes = rate, - foreignAmount = 1.0, - vesAmount = rate, - updatedAt = snap.prefs.lastKnownUpdatedAt - .takeUnless { snap.currency == RateSources.BINANCE_USDT } - ) - - val source = RateSources.rateSourceLabel(snap.currency) - runCatching { - ReceiptShareHelper.share(this@ShareRateActivity, data) - }.onFailure { - val send = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - "SpreadVE · ${snap.currency}: ${FxCalculator.format2(rate)} Bs. ($source)" - ) - } - startActivity(Intent.createChooser(send, "Compartir tasa")) - } - - finish() - } - } -} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/widget/ShareRateWidgetProvider.kt b/app/src/main/java/com/codeonyx/bcvaqui/widget/ShareRateWidgetProvider.kt deleted file mode 100644 index 0f42fdf..0000000 --- a/app/src/main/java/com/codeonyx/bcvaqui/widget/ShareRateWidgetProvider.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.codeonyx.bcvaqui.widget - -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.widget.RemoteViews -import com.codeonyx.bcvaqui.R -import com.codeonyx.bcvaqui.domain.FxCalculator -import com.codeonyx.bcvaqui.domain.RateSources -import com.codeonyx.bcvaqui.worker.WorkScheduler - -class ShareRateWidgetProvider : AppWidgetProvider() { - override fun onEnabled(context: Context) { - WorkScheduler.onWidgetAdded(context) - } - - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - val pendingResult = goAsync() - Widgets.runAsync(pendingResult) { - val snapshot = Widgets.loadSnapshot(context) - appWidgetIds.forEach { updateWidget(context, appWidgetManager, it, snapshot) } - } - } - - companion object { - fun updateAllWidgets(context: Context, snapshot: WidgetSnapshot) { - val manager = AppWidgetManager.getInstance(context) - val ids = manager.getAppWidgetIds(ComponentName(context, ShareRateWidgetProvider::class.java)) - ids.forEach { updateWidget(context, manager, it, snapshot) } - } - - private fun updateWidget( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetId: Int, - snap: WidgetSnapshot - ) { - val views = RemoteViews(context.packageName, R.layout.widget_share) - Widgets.applyBrandAndAlert(views, R.id.widget_share_root, snap) - views.setTextViewText( - R.id.widget_share_rate, - snap.rate?.let { FxCalculator.format2(it) } ?: "—" - ) - views.setTextViewText( - R.id.widget_share_label, - "${snap.currency} · ${RateSources.rateSourceLabel(snap.currency)}" - ) - - val shareIntent = Intent(context, ShareRateActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - val sharePending = PendingIntent.getActivity( - context, - 6000 + appWidgetId, - shareIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - views.setOnClickPendingIntent(R.id.widget_share_button, sharePending) - views.setOnClickPendingIntent( - R.id.widget_share_root, - Widgets.openAppPending(context, 6100 + appWidgetId) - ) - appWidgetManager.updateAppWidget(appWidgetId, views) - } - } -} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/widget/SpreadWidgetProvider.kt b/app/src/main/java/com/codeonyx/bcvaqui/widget/SpreadWidgetProvider.kt index 1098a31..b5564c0 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/widget/SpreadWidgetProvider.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/widget/SpreadWidgetProvider.kt @@ -14,6 +14,10 @@ class SpreadWidgetProvider : AppWidgetProvider() { WorkScheduler.onWidgetAdded(context) } + override fun onDisabled(context: Context) { + WorkScheduler.onWidgetRemoved(context) + } + override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, @@ -66,7 +70,7 @@ class SpreadWidgetProvider : AppWidgetProvider() { } ?: "—" ) - val footer = when { + val footer = Widgets.offlineMarker(snap) ?: when { p2p == null -> "P2P no disponible · abre la app para actualizar" spreadPct != null -> "P2P USDT/VES · Binance" else -> "USD · referencia de mercado" diff --git a/app/src/main/java/com/codeonyx/bcvaqui/widget/VariationWidgetProvider.kt b/app/src/main/java/com/codeonyx/bcvaqui/widget/VariationWidgetProvider.kt deleted file mode 100644 index 422bf07..0000000 --- a/app/src/main/java/com/codeonyx/bcvaqui/widget/VariationWidgetProvider.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.codeonyx.bcvaqui.widget - -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.ComponentName -import android.content.Context -import android.widget.RemoteViews -import com.codeonyx.bcvaqui.R -import com.codeonyx.bcvaqui.domain.FxCalculator -import com.codeonyx.bcvaqui.worker.WorkScheduler - -class VariationWidgetProvider : AppWidgetProvider() { - override fun onEnabled(context: Context) { - WorkScheduler.onWidgetAdded(context) - } - - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - val pendingResult = goAsync() - Widgets.runAsync(pendingResult) { - val snapshot = Widgets.loadSnapshot(context) - appWidgetIds.forEach { updateWidget(context, appWidgetManager, it, snapshot) } - } - } - - companion object { - fun updateAllWidgets(context: Context, snapshot: WidgetSnapshot) { - val manager = AppWidgetManager.getInstance(context) - val ids = manager.getAppWidgetIds(ComponentName(context, VariationWidgetProvider::class.java)) - ids.forEach { updateWidget(context, manager, it, snapshot) } - } - - private fun updateWidget( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetId: Int, - snap: WidgetSnapshot - ) { - val views = RemoteViews(context.packageName, R.layout.widget_variation) - Widgets.applyBrandAndAlert(views, R.id.widget_variation_root, snap) - views.setTextViewText( - R.id.widget_variation_currency, - "${FxCalculator.currencyLabel(snap.currency)} · ${snap.currency}" - ) - views.setTextViewText( - R.id.widget_variation_rate, - snap.rate?.let { FxCalculator.format2(it) } ?: "—" - ) - - val pct = snap.dayChangePct - val pctText = when { - pct == null -> "La comparación aparecerá mañana" - else -> { - val sign = if (pct > 0) "+" else "" - "$sign${FxCalculator.format2(pct)}% vs ayer" - } - } - views.setTextViewText(R.id.widget_variation_pct, pctText) - val color = when { - pct == null -> context.getColor(R.color.widget_text_muted) - pct >= 0 -> context.getColor(R.color.positive_green) - else -> context.getColor(R.color.negative_red) - } - views.setTextColor(R.id.widget_variation_pct, color) - - views.setOnClickPendingIntent( - R.id.widget_variation_root, - Widgets.openAppPending(context, 5000 + appWidgetId) - ) - appWidgetManager.updateAppWidget(appWidgetId, views) - } - } -} diff --git a/app/src/main/java/com/codeonyx/bcvaqui/widget/Widgets.kt b/app/src/main/java/com/codeonyx/bcvaqui/widget/Widgets.kt index 8b3d970..7f07292 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/widget/Widgets.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/widget/Widgets.kt @@ -4,6 +4,8 @@ import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.appwidget.AppWidgetManager +import android.content.ComponentName import android.widget.RemoteViews import com.codeonyx.bcvaqui.MainActivity import com.codeonyx.bcvaqui.R @@ -32,6 +34,7 @@ object Widgets { suspend fun loadSnapshot(context: Context): WidgetSnapshot { val repo = PreferencesRepository(context) val prefs = repo.preferences.first() + refreshNight(context) val cached = repo.decodeCachedRates(prefs.lastKnownRatesJson) val currency = prefs.selectedCurrency val rate = RateSources.effectiveRate( @@ -54,6 +57,13 @@ object Widgets { return WidgetSnapshot(prefs, currency, rate, dayChangePct, alertActive, alertUp) } + /** Marca de sin conexión para widgets, o null si la última actualización fue exitosa. */ + fun offlineMarker(snapshot: WidgetSnapshot): String? { + val snap = com.codeonyx.bcvaqui.domain.OfflineRates.decode(snapshot.prefs.ratesSnapshotJson) + if (!com.codeonyx.bcvaqui.domain.OfflineRates.isOffline(snapshot.prefs.lastRefreshFailed, snap)) return null + return com.codeonyx.bcvaqui.domain.OfflineRates.widgetMarker(snap?.loadedAtMs) + } + fun alertBackground(snapshot: WidgetSnapshot): Int = when { !snapshot.alertActive -> R.drawable.widget_background snapshot.alertUp -> R.drawable.widget_background_alert_up @@ -71,19 +81,65 @@ object Widgets { } fun applyBrandAndAlert(views: RemoteViews, rootId: Int, snapshot: WidgetSnapshot) { - views.setInt(rootId, "setBackgroundResource", alertBackground(snapshot)) + val palette = com.codeonyx.bcvaqui.domain.AppPalette.from(snapshot.prefs.palette) + val bg = if (snapshot.alertActive) alertBackground(snapshot) else paletteBackground(palette) + views.setInt(rootId, "setBackgroundResource", bg) + accentViewsFor(rootId).forEach { id -> views.setTextColor(id, paletteAccent(palette)) } + } + + /** Fondo del widget según la paleta (claro: blanco con tinte; oscuro: color de la paleta). */ + fun paletteBackground(p: com.codeonyx.bcvaqui.domain.AppPalette): Int = when (p) { + com.codeonyx.bcvaqui.domain.AppPalette.AZUL -> R.drawable.widget_background + com.codeonyx.bcvaqui.domain.AppPalette.ONIX -> R.drawable.widget_background_onix + com.codeonyx.bcvaqui.domain.AppPalette.MORADO -> R.drawable.widget_background_morado + com.codeonyx.bcvaqui.domain.AppPalette.ESMERALDA -> R.drawable.widget_background_esmeralda + } + + private var nightCache: Boolean = false + + /** Color de acento: color principal en claro, acento de la paleta en oscuro. */ + private fun paletteAccent(p: com.codeonyx.bcvaqui.domain.AppPalette): Int = + (if (nightCache) p.darkAccent + else if (com.codeonyx.bcvaqui.domain.AppPalette.contrast(p.accent, 0xFFFFFFFF) >= 4.5) p.accent + else p.primary).toInt() + + fun refreshNight(context: Context) { + nightCache = (context.resources.configuration.uiMode and + android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES + } + + /** Vista de acento (etiqueta de moneda) que existe en cada layout, para no tocar ids ausentes. */ + private fun accentViewsFor(rootId: Int): List = when (rootId) { + R.id.widget_root -> listOf(R.id.widget_currency_label) + R.id.widget_converter_root -> listOf(R.id.widget_converter_from) + R.id.widget_spread_root -> listOf(R.id.widget_spread_lbl_bcv, R.id.widget_spread_lbl_p2p, R.id.widget_spread_lbl_pct) + else -> emptyList() + } + + private val providers = listOf( + RateWidgetProvider::class.java, + SpreadWidgetProvider::class.java, + ConverterWidgetProvider::class.java, + ) + + /** true si el usuario tiene al menos un widget de Codivisas en la pantalla de inicio. */ + fun hasAnyWidget(context: Context): Boolean { + val manager = AppWidgetManager.getInstance(context) ?: return false + return providers.any { cls -> + runCatching { manager.getAppWidgetIds(ComponentName(context, cls)).isNotEmpty() } + .getOrDefault(false) + } } + /** Actualiza los widgets colocados; si no hay ninguno no lee nada. */ fun updateAll(context: Context) { + val appContext = context.applicationContext + if (!hasAnyWidget(appContext)) return runAsync { - val appContext = context.applicationContext val snapshot = loadSnapshot(appContext) RateWidgetProvider.updateAllWidgets(appContext, snapshot) - MiniRateWidgetProvider.updateAllWidgets(appContext, snapshot) SpreadWidgetProvider.updateAllWidgets(appContext, snapshot) ConverterWidgetProvider.updateAllWidgets(appContext, snapshot) - VariationWidgetProvider.updateAllWidgets(appContext, snapshot) - ShareRateWidgetProvider.updateAllWidgets(appContext, snapshot) } } diff --git a/app/src/main/java/com/codeonyx/bcvaqui/worker/RateCheckWorker.kt b/app/src/main/java/com/codeonyx/bcvaqui/worker/RateCheckWorker.kt index eab2baf..b372529 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/worker/RateCheckWorker.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/worker/RateCheckWorker.kt @@ -2,49 +2,77 @@ package com.codeonyx.bcvaqui.worker import android.content.Context import androidx.work.CoroutineWorker +import androidx.work.ListenableWorker.Result import androidx.work.WorkerParameters import com.codeonyx.bcvaqui.data.local.PreferencesRepository import com.codeonyx.bcvaqui.data.repository.FxRepository -import com.codeonyx.bcvaqui.domain.RateSources import com.codeonyx.bcvaqui.notification.NotificationHelper import com.codeonyx.bcvaqui.widget.Widgets import kotlinx.coroutines.flow.first +/** Trabajo periódico unificado: refresca tasas, avisa si cambió y actualiza widgets. */ class RateCheckWorker( appContext: Context, params: WorkerParameters ) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result = RateSync.run(applicationContext) +} - override suspend fun doWork(): Result { - val preferencesRepository = PreferencesRepository(applicationContext) +object RateSync { + /** Recordatorios de deudas: máx. una notificación al día, reutiliza este worker. */ + private suspend fun remindDebts(context: Context) = runCatching { + val repo = com.codeonyx.bcvaqui.data.local.FinanceRepository(context) + val today = java.time.LocalDate.now() + val due = com.codeonyx.bcvaqui.domain.FinancePlanner.debtsToRemind(repo.debts.first(), today) + if (due.isEmpty() || !repo.claimReminderDay(today.toEpochDay())) return@runCatching + val overdue = due.count { it.second.overdue } + val title = if (overdue > 0) "Tienes $overdue pago(s) vencido(s)" else "Pagos próximos" + val body = due.take(4).joinToString("\n") { (d, s) -> + val who = if (d.iOwe) "Pagar a ${d.person}" else "Cobrar a ${d.person}" + val cur = if (d.currency == com.codeonyx.bcvaqui.domain.Money.USD) "$" else "Bs" + val whenTxt = when { + s.daysToNext!! < 0 -> "vencido hace ${-s.daysToNext} d" + s.daysToNext == 0L -> "hoy" + else -> "en ${s.daysToNext} d" + } + "$who: $cur ${com.codeonyx.bcvaqui.domain.FxCalculator.format2(s.nextAmount)} ($whenTxt)" + } + NotificationHelper.showDebtReminder(context, title, body) + } + + suspend fun run(context: Context): Result { + if (PreferencesRepository(context).preferences.first().debtReminders) remindDebts(context) + val preferencesRepository = PreferencesRepository(context) val repository = FxRepository(preferencesRepository) val prefs = preferencesRepository.preferences.first() - val currency = prefs.selectedCurrency - val previousRate = prefs.lastKnownSelectedCurrencyRate return repository.refreshDashboard(forceNetwork = true).fold( onSuccess = { dashboard -> - val newRate = RateSources.effectiveRate( - currency = currency, - bcvRates = dashboard.bcvRates, - binanceUsdtVes = dashboard.binanceUsdtVes - ) - - if (newRate != null && previousRate != null && newRate != previousRate) { - NotificationHelper.showRateChangeNotification( - applicationContext, - currency, - previousRate, - newRate + if (dashboard.isFromCache) return@fold Result.retry() + if (prefs.notificationIntervalHours > 0) { + val current = mapOf( + com.codeonyx.bcvaqui.domain.NotifySource.BCV_USD to dashboard.bcvRates["USD"]?.rateToVes, + com.codeonyx.bcvaqui.domain.NotifySource.BCV_EUR to dashboard.bcvRates["EUR"]?.rateToVes, + com.codeonyx.bcvaqui.domain.NotifySource.USDT to dashboard.binanceUsdtVes ) + val enabled = buildSet { + if (prefs.notifyBcvUsd) add(com.codeonyx.bcvaqui.domain.NotifySource.BCV_USD) + if (prefs.notifyBcvEur) add(com.codeonyx.bcvaqui.domain.NotifySource.BCV_EUR) + if (prefs.notifyUsdt) add(com.codeonyx.bcvaqui.domain.NotifySource.USDT) + } + val quiet = com.codeonyx.bcvaqui.domain.RateNotifier.isQuietHour( + java.time.LocalTime.now().hour, prefs.quietHoursEnabled, prefs.quietStartHour, prefs.quietEndHour + ) + val decision = com.codeonyx.bcvaqui.domain.RateNotifier.decide( + current, preferencesRepository.notifyBaselines(), enabled, prefs.alertThresholdPercent, quiet + ) + preferencesRepository.setNotifyBaselines(decision.baselines) + NotificationHelper.showRateNotifications(context, decision.notifications) } - - Widgets.updateAll(applicationContext) + Widgets.updateAll(context) Result.success() }, - onFailure = { - Result.retry() - } + onFailure = { Result.retry() } ) } } diff --git a/app/src/main/java/com/codeonyx/bcvaqui/worker/UpdateCheckWorker.kt b/app/src/main/java/com/codeonyx/bcvaqui/worker/UpdateCheckWorker.kt index 0e755d1..83c057e 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/worker/UpdateCheckWorker.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/worker/UpdateCheckWorker.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.codeonyx.bcvaqui.update.AppUpdateManager +import kotlinx.coroutines.flow.first class UpdateCheckWorker( appContext: Context, @@ -11,7 +12,8 @@ class UpdateCheckWorker( ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { - AppUpdateManager.checkForUpdate(applicationContext, notifyIfAvailable = true) + val auto = com.codeonyx.bcvaqui.data.local.PreferencesRepository(applicationContext).preferences.first().autoUpdateCheck + if (auto) AppUpdateManager.checkForUpdate(applicationContext, notifyIfAvailable = true) return Result.success() } } diff --git a/app/src/main/java/com/codeonyx/bcvaqui/worker/WorkScheduler.kt b/app/src/main/java/com/codeonyx/bcvaqui/worker/WorkScheduler.kt index 97c9918..d5bb0f3 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/worker/WorkScheduler.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/worker/WorkScheduler.kt @@ -11,9 +11,9 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters -import com.codeonyx.bcvaqui.data.ApiConfig +import com.codeonyx.bcvaqui.domain.SyncPolicy +import java.time.ZonedDateTime import com.codeonyx.bcvaqui.data.local.PreferencesRepository -import com.codeonyx.bcvaqui.data.repository.FxRepository import com.codeonyx.bcvaqui.widget.Widgets import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -22,22 +22,15 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit -/** Refreshes cached rates and updates the home screen widget (no notifications). */ +/** + * Obsoleto: se mantiene sólo para que trabajos ya encolados por versiones anteriores + * no fallen al instanciarse. Delega en el sync unificado. + */ class WidgetRefreshWorker( appContext: Context, params: WorkerParameters ) : CoroutineWorker(appContext, params) { - - override suspend fun doWork(): Result { - val repository = FxRepository(PreferencesRepository(applicationContext)) - return repository.refreshDashboard(forceNetwork = true).fold( - onSuccess = { - Widgets.updateAll(applicationContext) - Result.success() - }, - onFailure = { Result.retry() } - ) - } + override suspend fun doWork(): Result = RateSync.run(applicationContext) } /** @@ -46,115 +39,105 @@ class WidgetRefreshWorker( * Adjust intervals in [ApiConfig.WIDGET_UPDATE_INTERVAL_MINUTES] and user settings. */ object WorkScheduler { - private const val RATE_CHECK_WORK = "rate_check_work" - private const val WIDGET_REFRESH_WORK = "widget_refresh_work" + // Nombres antiguos: se cancelan al reprogramar (había dos periódicos haciendo lo mismo). + private const val LEGACY_RATE_CHECK_WORK = "rate_check_work" + private const val LEGACY_WIDGET_REFRESH_WORK = "widget_refresh_work" + + private const val RATE_SYNC_WORK = "rate_sync_work" + private const val BCV_PUBLISH_WORK = "bcv_publish_check" private const val IMMEDIATE_REFRESH_WORK = "immediate_widget_refresh" private const val UPDATE_CHECK_WORK = "update_check_work" private val schedulerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val network = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + fun scheduleAllBackgroundWork(context: Context) { val appContext = context.applicationContext - scheduleWidgetRefresh(appContext) - schedulePeriodicRateCheck(appContext) + rescheduleRateSync(appContext) scheduleUpdateCheck(appContext) } + /** Refresco inmediato (botón del widget, arranque del equipo). No hace nada si no hay widgets. */ fun enqueueImmediateRefresh(context: Context) { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - val request = OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .build() - - WorkManager.getInstance(context.applicationContext).enqueueUniqueWork( - IMMEDIATE_REFRESH_WORK, - ExistingWorkPolicy.REPLACE, - request - ) + val appContext = context.applicationContext + if (!Widgets.hasAnyWidget(appContext)) return + enqueueImmediate(appContext) } - fun enqueueImmediateRateCheck(context: Context) { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() + fun enqueueImmediateRateCheck(context: Context) = enqueueImmediate(context.applicationContext) + private fun enqueueImmediate(context: Context) { val request = OneTimeWorkRequestBuilder() - .setConstraints(constraints) + .setConstraints(network) .build() - - WorkManager.getInstance(context.applicationContext).enqueueUniqueWork( - "immediate_rate_check", + WorkManager.getInstance(context).enqueueUniqueWork( + IMMEDIATE_REFRESH_WORK, ExistingWorkPolicy.REPLACE, request ) } fun onWidgetAdded(context: Context) { - scheduleWidgetRefresh(context.applicationContext) - enqueueImmediateRefresh(context.applicationContext) + rescheduleRateSync(context.applicationContext) + enqueueImmediate(context.applicationContext) } - fun schedulePeriodicRateCheck(context: Context) { - val appContext = context.applicationContext - schedulerScope.launch { - val intervalHours = PreferencesRepository(appContext) - .preferences.first().notificationIntervalHours - schedulePeriodicRateCheck(appContext, intervalHours) - } + /** Llamar cuando se quita el último widget de un tipo. */ + fun onWidgetRemoved(context: Context) { + rescheduleRateSync(context.applicationContext) } - private fun schedulePeriodicRateCheck(context: Context, intervalHours: Int) { - WorkManager.getInstance(context).cancelUniqueWork(RATE_CHECK_WORK) - if (intervalHours <= 0) return - - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - val request = PeriodicWorkRequestBuilder( - intervalHours.toLong(), - TimeUnit.HOURS - ) - .setConstraints(constraints) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES) - .build() + /** Compatibilidad con Ajustes: reprograma según notificaciones y widgets. */ + fun schedulePeriodicRateCheck(context: Context) = rescheduleRateSync(context.applicationContext) - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - RATE_CHECK_WORK, - ExistingPeriodicWorkPolicy.UPDATE, - request - ) + /** + * Un único trabajo periódico (tasas + notificación + widgets) y un chequeo diario + * a la hora de publicación del BCV. Si no hay widgets ni notificaciones, no se programa nada. + */ + fun rescheduleRateSync(context: Context) { + val appContext = context.applicationContext + schedulerScope.launch { + val hours = PreferencesRepository(appContext).preferences.first().notificationIntervalHours + val hasWidgets = Widgets.hasAnyWidget(appContext) + val wm = WorkManager.getInstance(appContext) + wm.cancelUniqueWork(LEGACY_RATE_CHECK_WORK) + wm.cancelUniqueWork(LEGACY_WIDGET_REFRESH_WORK) + + val interval = SyncPolicy.periodicIntervalMinutes(hasWidgets, hours) + if (interval == null) { + wm.cancelUniqueWork(RATE_SYNC_WORK) + wm.cancelUniqueWork(BCV_PUBLISH_WORK) + return@launch + } + val periodic = PeriodicWorkRequestBuilder(interval, TimeUnit.MINUTES) + .setConstraints(network) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES) + .build() + wm.enqueueUniquePeriodicWork(RATE_SYNC_WORK, ExistingPeriodicWorkPolicy.UPDATE, periodic) + scheduleNextPublishCheck(appContext) + } } - fun scheduleWidgetRefresh(context: Context) { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - val request = PeriodicWorkRequestBuilder( - ApiConfig.WIDGET_UPDATE_INTERVAL_MINUTES, - TimeUnit.MINUTES - ) - .setConstraints(constraints) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES) + /** Encadena un trabajo único para el próximo día hábil a las 17:30 (Caracas). */ + fun scheduleNextPublishCheck(context: Context) { + val delay = SyncPolicy.delayToNextBcvPublishMs(ZonedDateTime.now()) + val request = OneTimeWorkRequestBuilder() + .setConstraints(network) + .setInitialDelay(delay, TimeUnit.MILLISECONDS) + .setBackoffCriteria(BackoffPolicy.LINEAR, 20, TimeUnit.MINUTES) .build() - - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - WIDGET_REFRESH_WORK, - ExistingPeriodicWorkPolicy.UPDATE, + WorkManager.getInstance(context.applicationContext).enqueueUniqueWork( + BCV_PUBLISH_WORK, + ExistingWorkPolicy.REPLACE, request ) } fun scheduleUpdateCheck(context: Context) { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - val request = PeriodicWorkRequestBuilder(12, TimeUnit.HOURS) - .setConstraints(constraints) + .setConstraints(network) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.MINUTES) .build() @@ -165,3 +148,15 @@ object WorkScheduler { ) } } + +/** Chequeo a la hora de publicación del BCV; luego se reprograma para el siguiente día hábil. */ +class BcvPublishWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result { + val result = RateSync.run(applicationContext) + if (result !is Result.Retry) WorkScheduler.scheduleNextPublishCheck(applicationContext) + return result + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 5bbcf56..878228e 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,42 +1,8 @@ + - - - - - - - - - + android:width="108dp" android:height="108dp" + android:viewportWidth="108" android:viewportHeight="108"> + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground_azul.xml b/app/src/main/res/drawable/ic_launcher_foreground_azul.xml new file mode 100644 index 0000000..6c01fe9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground_azul.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground_esmeralda.xml b/app/src/main/res/drawable/ic_launcher_foreground_esmeralda.xml new file mode 100644 index 0000000..43f062c --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground_esmeralda.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground_morado.xml b/app/src/main/res/drawable/ic_launcher_foreground_morado.xml new file mode 100644 index 0000000..3fc6970 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground_morado.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground_onix.xml b/app/src/main/res/drawable/ic_launcher_foreground_onix.xml new file mode 100644 index 0000000..3d2a29b --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground_onix.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..13bac61 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_splash.xml b/app/src/main/res/drawable/ic_splash.xml index 54113f4..878228e 100644 --- a/app/src/main/res/drawable/ic_splash.xml +++ b/app/src/main/res/drawable/ic_splash.xml @@ -1,48 +1,8 @@ + - - - - - - - - - - - - + android:width="108dp" android:height="108dp" + android:viewportWidth="108" android:viewportHeight="108"> + + diff --git a/app/src/main/res/drawable/ic_widget_refresh.xml b/app/src/main/res/drawable/ic_widget_refresh.xml index 002564d..fb9390a 100644 --- a/app/src/main/res/drawable/ic_widget_refresh.xml +++ b/app/src/main/res/drawable/ic_widget_refresh.xml @@ -5,6 +5,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/widget_background_esmeralda.xml b/app/src/main/res/drawable/widget_background_esmeralda.xml new file mode 100644 index 0000000..ee7d87e --- /dev/null +++ b/app/src/main/res/drawable/widget_background_esmeralda.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_background_morado.xml b/app/src/main/res/drawable/widget_background_morado.xml new file mode 100644 index 0000000..6ec600f --- /dev/null +++ b/app/src/main/res/drawable/widget_background_morado.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_background_onix.xml b/app/src/main/res/drawable/widget_background_onix.xml new file mode 100644 index 0000000..a45b57f --- /dev/null +++ b/app/src/main/res/drawable/widget_background_onix.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_refresh_bg.xml b/app/src/main/res/drawable/widget_refresh_bg.xml index bc4f056..e990d01 100644 --- a/app/src/main/res/drawable/widget_refresh_bg.xml +++ b/app/src/main/res/drawable/widget_refresh_bg.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/layout/widget_converter.xml b/app/src/main/res/layout/widget_converter.xml index a1e8667..6d5c1cf 100644 --- a/app/src/main/res/layout/widget_converter.xml +++ b/app/src/main/res/layout/widget_converter.xml @@ -41,14 +41,18 @@ android:layout_height="wrap_content" android:layout_marginTop="10dp" android:text="100 USD" - android:textColor="@color/bcv_gold" + android:textColor="@color/widget_accent" android:textSize="14sp" android:textStyle="bold" /> - - - - - - - - diff --git a/app/src/main/res/layout/widget_rate.xml b/app/src/main/res/layout/widget_rate.xml index 7506495..c25ca8d 100644 --- a/app/src/main/res/layout/widget_rate.xml +++ b/app/src/main/res/layout/widget_rate.xml @@ -39,14 +39,18 @@ android:layout_height="wrap_content" android:layout_marginTop="10dp" android:text="Dólar · USD" - android:textColor="@color/bcv_gold" + android:textColor="@color/widget_accent" android:textSize="14sp" android:textStyle="bold" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_variation.xml b/app/src/main/res/layout/widget_rate_small.xml similarity index 59% rename from app/src/main/res/layout/widget_variation.xml rename to app/src/main/res/layout/widget_rate_small.xml index ab6d50b..bb2a7a7 100644 --- a/app/src/main/res/layout/widget_variation.xml +++ b/app/src/main/res/layout/widget_rate_small.xml @@ -1,48 +1,41 @@ + android:padding="10dp"> - - diff --git a/app/src/main/res/layout/widget_share.xml b/app/src/main/res/layout/widget_share.xml deleted file mode 100644 index e216e20..0000000 --- a/app/src/main/res/layout/widget_share.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/widget_spread.xml b/app/src/main/res/layout/widget_spread.xml index 9ce86e8..8ec33b7 100644 --- a/app/src/main/res/layout/widget_spread.xml +++ b/app/src/main/res/layout/widget_spread.xml @@ -31,10 +31,12 @@ android:orientation="vertical"> @@ -46,7 +48,7 @@ android:maxLines="1" android:text="—" android:textColor="@color/widget_text" - android:textSize="16sp" + android:textSize="18sp" android:textStyle="bold" /> @@ -58,10 +60,12 @@ android:orientation="vertical"> @@ -73,7 +77,7 @@ android:maxLines="1" android:text="—" android:textColor="@color/widget_text" - android:textSize="16sp" + android:textSize="18sp" android:textStyle="bold" /> @@ -85,10 +89,12 @@ android:orientation="vertical"> @@ -100,7 +106,7 @@ android:maxLines="1" android:text="—" android:textColor="@color/widget_text" - android:textSize="16sp" + android:textSize="18sp" android:textStyle="bold" /> diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 68f35aa..75df3f4 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,6 @@ - + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_azul.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_azul.xml new file mode 100644 index 0000000..989a852 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_azul.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_azul_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_azul_round.xml new file mode 100644 index 0000000..989a852 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_azul_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_esmeralda.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_esmeralda.xml new file mode 100644 index 0000000..7d92fbf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_esmeralda.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_esmeralda_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_esmeralda_round.xml new file mode 100644 index 0000000..7d92fbf --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_esmeralda_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_morado.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_morado.xml new file mode 100644 index 0000000..65a60d3 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_morado.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_morado_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_morado_round.xml new file mode 100644 index 0000000..65a60d3 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_morado_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_onix.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_onix.xml new file mode 100644 index 0000000..77760df --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_onix.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_onix_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_onix_round.xml new file mode 100644 index 0000000..77760df --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_onix_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 68f35aa..75df3f4 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,6 @@ - + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..e9d2385 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/app/src/main/res/mipmap-hdpi/ic_launcher.xml deleted file mode 100644 index 3a96436..0000000 --- a/app/src/main/res/mipmap-hdpi/ic_launcher.xml +++ /dev/null @@ -1,3 +0,0 @@ - - diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_azul.png b/app/src/main/res/mipmap-hdpi/ic_launcher_azul.png new file mode 100644 index 0000000..e9d2385 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_azul.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_azul_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_azul_round.png new file mode 100644 index 0000000..cf47e9f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_azul_round.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_esmeralda.png b/app/src/main/res/mipmap-hdpi/ic_launcher_esmeralda.png new file mode 100644 index 0000000..bdefc64 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_esmeralda.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_esmeralda_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_esmeralda_round.png new file mode 100644 index 0000000..96f571a Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_esmeralda_round.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_morado.png b/app/src/main/res/mipmap-hdpi/ic_launcher_morado.png new file mode 100644 index 0000000..d387185 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_morado.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_morado_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_morado_round.png new file mode 100644 index 0000000..9a096d3 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_morado_round.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_onix.png b/app/src/main/res/mipmap-hdpi/ic_launcher_onix.png new file mode 100644 index 0000000..aabd24a Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_onix.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_onix_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_onix_round.png new file mode 100644 index 0000000..44cb730 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_onix_round.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..cf47e9f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml deleted file mode 100644 index 3a96436..0000000 --- a/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml +++ /dev/null @@ -1,3 +0,0 @@ - - diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c2e91ad Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_azul.png b/app/src/main/res/mipmap-mdpi/ic_launcher_azul.png new file mode 100644 index 0000000..c2e91ad Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_azul.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_azul_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_azul_round.png new file mode 100644 index 0000000..0911993 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_azul_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_esmeralda.png b/app/src/main/res/mipmap-mdpi/ic_launcher_esmeralda.png new file mode 100644 index 0000000..e531df8 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_esmeralda.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_esmeralda_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_esmeralda_round.png new file mode 100644 index 0000000..a481e85 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_esmeralda_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_morado.png b/app/src/main/res/mipmap-mdpi/ic_launcher_morado.png new file mode 100644 index 0000000..45a72f5 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_morado.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_morado_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_morado_round.png new file mode 100644 index 0000000..cd3f764 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_morado_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_onix.png b/app/src/main/res/mipmap-mdpi/ic_launcher_onix.png new file mode 100644 index 0000000..fcf5bc1 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_onix.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_onix_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_onix_round.png new file mode 100644 index 0000000..7ac3675 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_onix_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..0911993 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..731811a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_azul.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_azul.png new file mode 100644 index 0000000..731811a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_azul.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_azul_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_azul_round.png new file mode 100644 index 0000000..a762e04 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_azul_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_esmeralda.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_esmeralda.png new file mode 100644 index 0000000..9b81b8a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_esmeralda.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_esmeralda_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_esmeralda_round.png new file mode 100644 index 0000000..a3ffa38 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_esmeralda_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_morado.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_morado.png new file mode 100644 index 0000000..8a858a9 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_morado.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_morado_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_morado_round.png new file mode 100644 index 0000000..45180ea Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_morado_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_onix.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_onix.png new file mode 100644 index 0000000..13db529 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_onix.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_onix_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_onix_round.png new file mode 100644 index 0000000..9048b7e Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_onix_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a762e04 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..fcf073c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_azul.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_azul.png new file mode 100644 index 0000000..fcf073c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_azul.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_azul_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_azul_round.png new file mode 100644 index 0000000..c62cee9 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_azul_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_esmeralda.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_esmeralda.png new file mode 100644 index 0000000..6be8aad Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_esmeralda.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_esmeralda_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_esmeralda_round.png new file mode 100644 index 0000000..59feeb8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_esmeralda_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_morado.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_morado.png new file mode 100644 index 0000000..d288b8b Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_morado.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_morado_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_morado_round.png new file mode 100644 index 0000000..43ef053 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_morado_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_onix.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_onix.png new file mode 100644 index 0000000..e4b4a88 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_onix.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_onix_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_onix_round.png new file mode 100644 index 0000000..60220c6 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_onix_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..c62cee9 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..ad23240 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_azul.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_azul.png new file mode 100644 index 0000000..ad23240 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_azul.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_azul_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_azul_round.png new file mode 100644 index 0000000..485921f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_azul_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_esmeralda.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_esmeralda.png new file mode 100644 index 0000000..08a63fa Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_esmeralda.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_esmeralda_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_esmeralda_round.png new file mode 100644 index 0000000..ad5c0ea Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_esmeralda_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_morado.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_morado.png new file mode 100644 index 0000000..5848cb9 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_morado.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_morado_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_morado_round.png new file mode 100644 index 0000000..b1ee8c8 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_morado_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_onix.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_onix.png new file mode 100644 index 0000000..36e3010 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_onix.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_onix_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_onix_round.png new file mode 100644 index 0000000..f55c555 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_onix_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..485921f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values-night/colors_theme.xml b/app/src/main/res/values-night/colors_theme.xml new file mode 100644 index 0000000..359c306 --- /dev/null +++ b/app/src/main/res/values-night/colors_theme.xml @@ -0,0 +1,4 @@ + + + #FF0B1120 + diff --git a/app/src/main/res/values-night/colors_widget.xml b/app/src/main/res/values-night/colors_widget.xml new file mode 100644 index 0000000..36899fe --- /dev/null +++ b/app/src/main/res/values-night/colors_widget.xml @@ -0,0 +1,15 @@ + + + #FF0F1B3D + #FF0B1120 + #FFFFFFFF + #B3FFFFFF + #FFFBBF24 + #33FFFFFF + #FF161618 + #FF0E0E10 + #FF1E0B3F + #FF0F0720 + #FF052E24 + #FF03140F + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..6be0198 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 34dae78..8f5f7df 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -3,12 +3,9 @@ #1E3A8A #172554 #FBBF24 - #FF1E3A8A - #FF172554 - #FFFFFFFF - #B3FFFFFF #FF2E7D32 #FFC62828 #FFF59E0B #FF78350F + #1E3A8A diff --git a/app/src/main/res/values/colors_palettes.xml b/app/src/main/res/values/colors_palettes.xml new file mode 100644 index 0000000..acbe86f --- /dev/null +++ b/app/src/main/res/values/colors_palettes.xml @@ -0,0 +1,8 @@ + + + + #1E3A8A + #1A1A1A + #4C1D95 + #065F46 + diff --git a/app/src/main/res/values/colors_theme.xml b/app/src/main/res/values/colors_theme.xml new file mode 100644 index 0000000..471454f --- /dev/null +++ b/app/src/main/res/values/colors_theme.xml @@ -0,0 +1,4 @@ + + + #FFF8FAFC + diff --git a/app/src/main/res/values/colors_widget.xml b/app/src/main/res/values/colors_widget.xml new file mode 100644 index 0000000..1414538 --- /dev/null +++ b/app/src/main/res/values/colors_widget.xml @@ -0,0 +1,16 @@ + + + + #FFFFFFFF + #FFEFF4FF + #FF0F172A + #B3172554 + #FF1E3A8A + #1A1E3A8A + #FFFFFFFF + #FFFEF2F2 + #FFFFFFFF + #FFF5F3FF + #FFFFFFFF + #FFECFDF5 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a20b050..23c8bae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,26 +1,134 @@ - SpreadVE - SpreadVE · Tasa + Codivisas + Codivisas · Tasa Tasa de tu moneda principal con botón para actualizar Actualizar tasa - SpreadVE · Mini - Tasa compacta de tu moneda principal (1×1) - SpreadVE · Comparador - SpreadVE · BCV · P2P · Spread - Compara tasa BCV, Binance P2P y el spread % - SpreadVE · Convertidor + Codivisas · Comparador + Brecha BCV · P2P + Compara tasa BCV, Binance P2P y la brecha % + Codivisas · Convertidor Convertidor rápido Monto Convierte un monto fijo a bolívares con la tasa seleccionada - SpreadVE · Variación - Variación del día - Tasa seleccionada y cambio porcentual vs ayer - SpreadVE · Compartir - Atajo para compartir la tasa seleccionada - Compartir tasa Cambios de tasa Actualizaciones de la app %1$s subió a %2$s Bs. (era %3$s) %1$s bajó a %2$s Bs. (era %3$s) + No se instaló la actualización + El archivo descargado no pasó la verificación de seguridad (firma o integridad). Por seguridad se descartó. Intenta de nuevo más tarde desde Ajustes. + Copiar + Compartir + Borrar %1$s + Borrar monto + Copiado al portapapeles + Actualizado %1$s + Brecha BCV/USDT %1$s %% + Sin resultado + Escribe un monto mayor que cero. + Calcula el precio final con IVA (16 %) y, si pagas en divisas, IGTF (3 %). + Monto + Puedes pegar montos como 1.234,56. + ¿El monto ya incluye IVA? + No, agregar IVA + Sí, desglosar IVA + Pago en divisas (aplicar IGTF 3 %) + Total a pagar + Compara la tasa oficial BCV con la referencia USDT de Binance P2P. + Tasa oficial (BCV) + Tasa USDT (Binance P2P) + Brecha cambiaria + Diferencia: %1$s Bs. por dólar · El oficial equivale al %2$s %% del USDT + Escribe ambas tasas para calcular la brecha. + Calculadora + Cuentas con % y conversión a Bs. + Equivalencias + Compara un monto en varias monedas + Comisión / remesa + Calcula cuánto llega y cuánto pagas + IVA + IGTF + Agrega o desglosa IVA 16 % e IGTF 3 % + Brecha cambiaria + Diferencia % entre BCV y USDT + Historial BCV + Consulta el gráfico y las tasas recientes + Dividir gasto + Reparte una cuenta entre varias personas + Agenda de pagos + Guarda pago móvil y cuentas para compartir + Convertir y calcular + Mercado y tasas + Pagos y planificación + Herramientas + Utilidades con tasa BCV + Abrir %1$s + Tema + Sistema + Claro + Oscuro + Copiar resultado + Invertir dirección + Copiar conversión + Usar + Historial + Borrar + Toca para reutilizar · mantén para copiar + Borrar último + Usar resultado + Copiar número + Copiar con moneda + Copiar con moneda (%1$s) + Copiado: %1$s + Compartir conversión + Elige los métodos de pago a incluir + Compartir solo conversión + Compartir con %1$d método(s) + Tasa COP por 1 USD + Opcional: muestra el monto en pesos + Predeterminado + Método predeterminado + Nombre de quien es + Titular + Binance Pay ID + Correo + Usuario + Teléfono + paypal.me (usuario o link) + Número de cuenta + Cédula / NIT + Guarda Pago Móvil, cuentas, Binance, Zelle, PayPal, Zinli, Wally o cuentas de Colombia para copiarlos y compartirlos rápido. + Pago móvil + Cuenta VE + Extranjera + Binance + Wally + Zinli + Colombia + Zelle + PayPal + Comparar BCV con + USDT (P2P) + Euro BCV + Manual + Nombre de la tasa (opcional) + Tasa %1$s + Sin tasa disponible: escríbela a mano + Brecha vs %1$s + Tasa del día + Conversión + Solo monto + Copiado + Método de pago + Banco + Tipo de cuenta + Mercado y gráficos + USDT compra/venta, próxima tasa BCV y gráficos 7/30/90 días + Deudas + Lo que debes y te deben, con cuotas, abonos y recordatorios + Ahorros + Metas con plan por semana, quincena o mes + Mis finanzas + Modo comerciante + Precios en Bs. con tu % de ganancia o descuento + Tasa con tendencia del día, compra y venta USDT diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index a3047cb..16035fe 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,14 +1,35 @@ + + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..874fb8e --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..0e8680b --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/mini_rate_widget_info.xml b/app/src/main/res/xml/mini_rate_widget_info.xml deleted file mode 100644 index 4a0c842..0000000 --- a/app/src/main/res/xml/mini_rate_widget_info.xml +++ /dev/null @@ -1,12 +0,0 @@ - - diff --git a/app/src/main/res/xml/rate_widget_info.xml b/app/src/main/res/xml/rate_widget_info.xml index c8577a0..fddb2eb 100644 --- a/app/src/main/res/xml/rate_widget_info.xml +++ b/app/src/main/res/xml/rate_widget_info.xml @@ -1,9 +1,13 @@ - diff --git a/app/src/main/res/xml/variation_widget_info.xml b/app/src/main/res/xml/variation_widget_info.xml deleted file mode 100644 index 230a6e7..0000000 --- a/app/src/main/res/xml/variation_widget_info.xml +++ /dev/null @@ -1,12 +0,0 @@ - - diff --git a/app/src/test/java/com/codeonyx/bcvaqui/domain/AppPaletteTest.kt b/app/src/test/java/com/codeonyx/bcvaqui/domain/AppPaletteTest.kt new file mode 100644 index 0000000..887b849 --- /dev/null +++ b/app/src/test/java/com/codeonyx/bcvaqui/domain/AppPaletteTest.kt @@ -0,0 +1,53 @@ +package com.codeonyx.bcvaqui.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppPaletteTest { + @Test fun requestedHexValues() { + assertEquals(0xFF1E3A8A, AppPalette.AZUL.primary); assertEquals(0xFFFBBF24, AppPalette.AZUL.accent) + assertEquals(0xFF1A1A1A, AppPalette.ONIX.primary); assertEquals(0xFFB91C1C, AppPalette.ONIX.accent) + assertEquals(0xFF4C1D95, AppPalette.MORADO.primary) + assertEquals(0xFF065F46, AppPalette.ESMERALDA.primary); assertEquals(0xFFFBBF24, AppPalette.ESMERALDA.accent) + } + + @Test fun defaultAndParsing() { + assertEquals(AppPalette.AZUL, AppPalette.from(null)) + assertEquals(AppPalette.AZUL, AppPalette.from("desconocida")) + assertEquals(AppPalette.MORADO, AppPalette.from("MORADO")) + } + + @Test fun uniqueAliases() { + assertEquals(4, AppPalette.entries.map { it.launcherAlias }.toSet().size) + assertEquals("LauncherAzul", AppPalette.DEFAULT.launcherAlias) + } + + @Test fun whiteOnPrimaryIsAA() { + AppPalette.entries.forEach { assertTrue(it.name, AppPalette.contrast(0xFFFFFFFF, it.primary) >= 4.5) } + } + + @Test fun moradoAccentIsAA() { + assertTrue(AppPalette.contrast(AppPalette.MORADO.accent, AppPalette.MORADO.primary) >= 4.5) + } + + @Test fun gemNames() { + assertEquals(listOf("Zafiro", "Ónix", "Amatista", "Esmeralda"), AppPalette.entries.map { it.label }) + } + + @Test fun onixRedsAreAA() { + val p = AppPalette.ONIX + assertTrue(AppPalette.contrast(0xFFFFFFFF, p.accent) >= 4.5) // texto blanco sobre rojo (claro) + assertTrue(AppPalette.contrast(p.darkAccent, p.darkBackground) >= 4.5) // rojo sobre fondo oscuro + assertTrue(AppPalette.contrast(p.darkAccent, p.darkSurface) >= 4.5) + assertTrue(AppPalette.contrast(p.darkPrimary, p.darkSurface) >= 4.5) + assertTrue(AppPalette.contrast(p.accent, 0xFFFFFFFF) >= 4.5) // rojo sobre fondo claro + } + + @Test fun onSecondaryAlwaysAA() { + AppPalette.entries.forEach { + assertTrue(it.name, AppPalette.contrast(AppPalette.onColor(it.accent), it.accent) >= 4.5) + assertTrue(it.name, AppPalette.contrast(AppPalette.onColor(it.darkAccent), it.darkAccent) >= 4.5) + } + } +} diff --git a/app/src/test/java/com/codeonyx/bcvaqui/domain/BcvFallbackTest.kt b/app/src/test/java/com/codeonyx/bcvaqui/domain/BcvFallbackTest.kt new file mode 100644 index 0000000..ad7a80c --- /dev/null +++ b/app/src/test/java/com/codeonyx/bcvaqui/domain/BcvFallbackTest.kt @@ -0,0 +1,52 @@ +package com.codeonyx.bcvaqui.domain + +import com.codeonyx.bcvaqui.data.local.HistoryDiskCache +import com.codeonyx.bcvaqui.data.model.BcvRateResponse +import com.codeonyx.bcvaqui.data.model.DolarApiQuote +import com.codeonyx.bcvaqui.data.repository.BcvFallback +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.io.File + +class BcvFallbackTest { + @Test fun mapsDolarApi() { + val r = BcvFallback.toBcvResponse( + DolarApiQuote("USD", "oficial", 887.302, "2026-10-27T00:00:00-04:00"), + DolarApiQuote("EUR", "oficial", 994.03, "2026-10-27T00:00:00-04:00") + )!! + assertEquals(887.302, r.USD!!, 1e-9) + assertEquals(994.03, r.EUR!!, 1e-9) + assertEquals("2026-10-27", r.effectiveDate) + } + + @Test fun missingUsdIsNull() { + assertNull(BcvFallback.toBcvResponse(DolarApiQuote(promedio = null), null)) + assertNull(BcvFallback.toBcvResponse(null, null)) + } + + @Test fun eurOptional() { + assertNull(BcvFallback.toBcvResponse(DolarApiQuote(promedio = 10.0), null)!!.EUR) + } +} + +class HistoryDiskCacheTest { + @Test fun roundTripAndLimit() = runBlocking { + val f = File.createTempFile("hist", ".json").apply { delete() } + val cache = HistoryDiskCache(f) + assertEquals(emptyList(), cache.read()) + val entries = (1..450).map { BcvRateResponse(USD = it.toDouble(), date = "d$it") } + cache.write(entries) + val read = cache.read() + assertEquals(HistoryDiskCache.MAX_ENTRIES, read.size) + assertEquals(450.0, read.last().USD!!, 1e-9) + f.delete() + } + + @Test fun corruptFileReadsEmpty() = runBlocking { + val f = File.createTempFile("hist", ".json").apply { writeText("{no json") } + assertEquals(0, HistoryDiskCache(f).read().size) + f.delete() + } +} diff --git a/app/src/test/java/com/codeonyx/bcvaqui/domain/CalcEngineTest.kt b/app/src/test/java/com/codeonyx/bcvaqui/domain/CalcEngineTest.kt new file mode 100644 index 0000000..87a0cb1 --- /dev/null +++ b/app/src/test/java/com/codeonyx/bcvaqui/domain/CalcEngineTest.kt @@ -0,0 +1,76 @@ +package com.codeonyx.bcvaqui.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CalcEngineTest { + private fun eval(e: String) = CalcEngine.evaluate(e)!! + + @Test fun precedence() { + assertEquals(14.0, eval("2+3×4"), 1e-9) + assertEquals(5.0, eval("20÷4"), 1e-9) + assertEquals(-1.0, eval("2−3"), 1e-9) + } + + @Test fun commaDecimals() { + assertEquals(3.75, eval("1,5+2,25"), 1e-9) + assertEquals(1234.5, eval("1.234,5"), 1e-9) + } + + @Test fun percentages() { + assertEquals(220.0, eval("200+10%"), 1e-9) + assertEquals(180.0, eval("200−10%"), 1e-9) + assertEquals(20.0, eval("200×10%"), 1e-9) + assertEquals(0.15, eval("15%"), 1e-9) + } + + @Test fun trailingOperatorIgnored() { + assertEquals(5.0, eval("5+"), 1e-9) + assertEquals(5.0, eval("5,"), 1e-9) + } + + @Test fun invalid() { + assertNull(CalcEngine.evaluate("5÷0")) + assertNull(CalcEngine.evaluate("")) + assertNull(CalcEngine.evaluate("×")) + } + + @Test fun negativeStart() { + assertEquals(-3.0, eval("−5+2"), 1e-9) + } + + @Test fun format() { + assertEquals("887,3", CalcEngine.format(887.3)) + assertEquals("1.234.567,8912", CalcEngine.format(1234567.89123)) + assertEquals("0", CalcEngine.format(0.0)) + } +} + +class CalcInputTest { + @Test fun typing() { + var s = CalcInput() + s = s.digit("1").digit("2").comma().digit("5").operator(CalcEngine.PLUS).digit("3") + assertEquals("12,5+3", s.expression) + assertEquals(15.5, s.result!!, 1e-9) + } + + @Test fun operatorReplaces() { + val s = CalcInput("5+").operator(CalcEngine.TIMES) + assertEquals("5×", s.expression) + } + + @Test fun singleCommaPerNumber() { + assertEquals("1,5", CalcInput("1,5").comma().expression) + assertEquals("0,", CalcInput().comma().expression) + } + + @Test fun leadingZero() { + assertEquals("7", CalcInput("0").digit("7").expression) + assertEquals("0", CalcInput("0").digit("0").expression) + } + + @Test fun withValue() { + assertEquals("1234,5", CalcInput().withValue(1234.5).expression) + } +} diff --git a/app/src/test/java/com/codeonyx/bcvaqui/domain/CopyTextsTest.kt b/app/src/test/java/com/codeonyx/bcvaqui/domain/CopyTextsTest.kt new file mode 100644 index 0000000..6e13be1 --- /dev/null +++ b/app/src/test/java/com/codeonyx/bcvaqui/domain/CopyTextsTest.kt @@ -0,0 +1,20 @@ +package com.codeonyx.bcvaqui.domain + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDate + +class CopyTextsTest { + @Test fun rateOfDay() { + assertEquals("Tasa BCV 27/10/2026: 888,12 Bs.", CopyTexts.rateOfDay("BCV", 888.12, "2026-10-27T00:00:00-04:00")) + assertEquals("Tasa Usdt 01/02/2026: 1.000,00 Bs.", CopyTexts.rateOfDay("Usdt", 1000.0, null, LocalDate.of(2026, 2, 1))) + assertEquals("Tasa Euro 01/02/2026: 5,00 Bs.", CopyTexts.rateOfDay("Euro", 5.0, "basura", LocalDate.of(2026, 2, 1))) + } + + @Test fun conversionBothWays() { + assertEquals("5,00 $ = 4.440,60 Bs.", CopyTexts.conversion(5.0, 4440.6, "$", true)) + assertEquals("4.440,60 Bs. = 5,00 $", CopyTexts.conversion(4440.6, 5.0, "$", false)) + } + + @Test fun amountOnly() = assertEquals("4.440,60", CopyTexts.amountOnly(4440.6)) +} diff --git a/app/src/test/java/com/codeonyx/bcvaqui/domain/DomainTest.kt b/app/src/test/java/com/codeonyx/bcvaqui/domain/DomainTest.kt new file mode 100644 index 0000000..80b70f1 --- /dev/null +++ b/app/src/test/java/com/codeonyx/bcvaqui/domain/DomainTest.kt @@ -0,0 +1,94 @@ +package com.codeonyx.bcvaqui.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FxCalculatorTest { + @Test fun format2_usesVenezuelanSeparators() { + assertEquals("1.234,57", FxCalculator.format2(1234.567)) + assertEquals("0,00", FxCalculator.format2(0.0)) + } + + @Test fun convert_handlesZeroRates() { + assertEquals(0.0, FxCalculator.convert(10.0, 0.0, 5.0), 0.0) + assertEquals(20.0, FxCalculator.convert(10.0, 10.0, 5.0), 0.0) + } + + @Test fun round2_halfUp() { + assertEquals(1.01, FxCalculator.round2(1.005), 0.0) + } +} + +class VeNumberTest { + @Test fun sanitize_typingDotBecomesComma() { + assertEquals("12,5", VeNumber.sanitize("12.5")) + assertEquals("1500,", VeNumber.sanitize("1500.")) + } + + @Test fun sanitize_pastedFormats() { + assertEquals("1234,56", VeNumber.sanitize("Bs. 1.234,56")) + assertEquals("1234,56", VeNumber.sanitize("1,234.56")) + assertEquals("1500", VeNumber.sanitize("1.500")) + assertEquals("1234567", VeNumber.sanitize("1.234.567")) + } + + @Test fun parse_values() { + assertEquals(1234.56, VeNumber.parse("1.234,56"), 1e-9) + assertEquals(0.5, VeNumber.parse(",5"), 1e-9) + assertEquals(0.0, VeNumber.parse("abc"), 0.0) + } + + @Test fun toInput_roundTrips() { + assertEquals("1234,5", VeNumber.toInput(1234.5)) + assertEquals(1234.5, VeNumber.parse(VeNumber.toInput(1234.5)), 1e-9) + } +} + +class PriceToolsTest { + @Test fun taxes_addIvaAndIgtf() { + val r = PriceTools.taxes(100.0, amountIncludesIva = false, applyIgtf = true) + assertEquals(16.0, r.iva, 0.0) + assertEquals(3.48, r.igtf, 0.0) + assertEquals(119.48, r.total, 0.0) + } + + @Test fun taxes_extractIva() { + val r = PriceTools.taxes(116.0, amountIncludesIva = true, applyIgtf = false) + assertEquals(100.0, r.base, 0.0) + assertEquals(116.0, r.total, 0.0) + } + + @Test fun gap() { + assertEquals(10.0, PriceTools.gapPercent(100.0, 110.0)!!, 0.0) + assertNull(PriceTools.gapPercent(null, 110.0)) + } +} + +class RateAlertPolicyTest { + @Test fun bcv_respectsThreshold() { + assertFalse(RateAlertPolicy.shouldNotify("USD", 100.0, 100.5, 1.0)) + assertTrue(RateAlertPolicy.shouldNotify("USD", 100.0, 101.5, 1.0)) + assertTrue(RateAlertPolicy.shouldNotify("USD", 100.0, 100.2, 0.0)) + } + + @Test fun bcv_ignoresRoundingNoise() { + assertFalse(RateAlertPolicy.shouldNotify("USD", 100.0, 100.001, 0.0)) + } + + @Test fun usdt_hasMinimumThreshold() { + assertFalse(RateAlertPolicy.shouldNotify("USDT", 100.0, 100.5, 0.0)) + assertTrue(RateAlertPolicy.shouldNotify("USDT", 100.0, 102.0, 0.0)) + } +} + +class DataAgeTest { + @Test fun describe() { + val now = 10_000_000_000L + assertEquals("hace 5 min", DataAge.describe(now - 5 * 60_000L, now)) + assertEquals("hace 3 h", DataAge.describe(now - 3 * 3_600_000L, now)) + assertNull(DataAge.describe(0L, now)) + } +} diff --git a/app/src/test/java/com/codeonyx/bcvaqui/domain/FinancePlannerTest.kt b/app/src/test/java/com/codeonyx/bcvaqui/domain/FinancePlannerTest.kt new file mode 100644 index 0000000..8e7f98e --- /dev/null +++ b/app/src/test/java/com/codeonyx/bcvaqui/domain/FinancePlannerTest.kt @@ -0,0 +1,67 @@ +package com.codeonyx.bcvaqui.domain + +import org.junit.Assert.* +import org.junit.Test +import java.time.LocalDate + +class FinancePlannerTest { + private val d0 = LocalDate.of(2026, 1, 1) + private fun mv(a: Double) = Movement("m$a", d0.toEpochDay(), a) + + @Test fun installments_andSchedule() { + assertEquals(33.33, FinancePlanner.installmentAmount(100.0, 3), 0.0) + assertEquals(listOf(LocalDate.of(2026, 2, 1), LocalDate.of(2026, 3, 1)), + FinancePlanner.schedule(d0, 2, Frequency.MENSUAL)) + assertEquals(LocalDate.of(2026, 1, 16), Frequency.QUINCENAL.advance(d0, 1)) + } + + @Test fun debt_nextInstallmentAfterPayment() { + val debt = Debt("1", true, "Ana", 300.0, startEpochDay = d0.toEpochDay(), installments = 3, + payments = listOf(mv(100.0))) + val s = FinancePlanner.debtStatus(debt, d0) + assertEquals(200.0, s.remaining, 0.0) + assertEquals(LocalDate.of(2026, 3, 1), s.nextDue) + assertEquals(100.0, s.nextAmount, 0.0) + assertFalse(s.overdue) + } + + @Test fun debt_overdue() { + val debt = Debt("1", false, "Luis", 50.0, startEpochDay = d0.toEpochDay(), dueEpochDay = d0.toEpochDay()) + val s = FinancePlanner.debtStatus(debt, d0.plusDays(3)) + assertTrue(s.overdue) + assertEquals(-3L, s.daysToNext) + assertEquals(1, FinancePlanner.debtsToRemind(listOf(debt), d0.plusDays(3)).size) + } + + @Test fun debt_settledWhenPaidOrMarked() { + val a = Debt("1", true, "X", 10.0, startEpochDay = 0, payments = listOf(mv(10.0))) + assertTrue(FinancePlanner.debtStatus(a, d0).settled) + val b = Debt("2", true, "X", 10.0, startEpochDay = 0, dueEpochDay = 0, markedPaid = true) + assertFalse(FinancePlanner.debtStatus(b, d0).overdue) + } + + @Test fun goal_requiredPerPeriodAndBehind() { + val goal = SavingsGoal("g", "Moto", target = 1200.0, createdEpochDay = d0.toEpochDay(), + targetEpochDay = d0.plusMonths(12).toEpochDay(), frequency = Frequency.MENSUAL) + val today = d0.plusMonths(6) + val s = FinancePlanner.goalStatus(goal.copy(movements = listOf(mv(300.0))), today) + assertEquals(6, s.periodsLeft) + assertEquals(150.0, s.requiredPerPeriod, 0.0) + assertTrue(s.behindBy > 290 && s.behindBy < 310) + } + + @Test fun goal_projectionAndComplete() { + val goal = SavingsGoal("g", "x", target = 100.0, createdEpochDay = d0.toEpochDay(), + targetEpochDay = d0.plusDays(100).toEpochDay(), movements = listOf(mv(50.0))) + assertEquals(d0.plusDays(20), FinancePlanner.projectedCompletion(goal, 50.0, d0.plusDays(10))) + assertNull(FinancePlanner.projectedCompletion(goal, 0.0, d0.plusDays(10))) + assertTrue(FinancePlanner.goalStatus(goal.copy(movements = listOf(mv(100.0))), d0).complete) + } + + @Test fun periods_andConversions() { + assertEquals(4, Frequency.SEMANAL.periodsBetween(d0, d0.plusDays(28))) + assertEquals(1, Frequency.MENSUAL.periodsBetween(d0, d0)) + assertEquals(10.0, FinancePlanner.toUsd(1000.0, Money.VES, 100.0), 0.0) + assertEquals(50.0, FinancePlanner.erosionPercent(100.0, 200.0)!!, 0.0) + } +} diff --git a/app/src/test/java/com/codeonyx/bcvaqui/domain/MarketMathTest.kt b/app/src/test/java/com/codeonyx/bcvaqui/domain/MarketMathTest.kt new file mode 100644 index 0000000..b83431a --- /dev/null +++ b/app/src/test/java/com/codeonyx/bcvaqui/domain/MarketMathTest.kt @@ -0,0 +1,63 @@ +package com.codeonyx.bcvaqui.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MarketMathTest { + private val hist = listOf( + DayPoint("2026-10-26", 880.0), DayPoint("2026-10-27", 887.3), DayPoint("2026-10-28", 888.12) + ) + + @Test fun nextRate_picksFirstFutureDate() { + assertEquals(NextRate("2026-10-28", 888.12), MarketMath.nextRate(hist, "2026-10-27")) + } + + @Test fun nextRate_nullWhenNotPublished() { + assertNull(MarketMath.nextRate(hist, "2026-10-28")) + } + + @Test fun window_excludesFutureAndLimits() { + val w = MarketMath.window(hist, 7, today = "2026-10-27") + assertEquals(listOf("2026-10-26", "2026-10-27"), w.map { it.date }) + assertEquals(1, MarketMath.window(hist, 1).size) + } + + @Test fun gapSeries_matchesDates() { + val gap = MarketMath.gapSeries( + listOf(DayPoint("a", 100.0), DayPoint("b", 200.0)), + listOf(DayPoint("b", 250.0), DayPoint("c", 300.0)) + ) + assertEquals(listOf(DayPoint("b", 25.0)), gap) + } + + @Test fun merge_primaryWins() { + val m = MarketMath.merge(listOf(DayPoint("b", 2.0)), listOf(DayPoint("a", 1.0), DayPoint("b", 9.0))) + assertEquals(listOf(DayPoint("a", 1.0), DayPoint("b", 2.0)), m) + } + + @Test fun changeAndTrend() { + assertEquals(10.0, MarketMath.changePercent(listOf(DayPoint("a", 100.0), DayPoint("b", 110.0)))!!, 0.0) + assertNull(MarketMath.changePercent(listOf(DayPoint("a", 1.0)))) + assertEquals(Trend.UP, MarketMath.trend(0.5)) + assertEquals(Trend.DOWN, MarketMath.trend(-0.5)) + assertEquals(Trend.FLAT, MarketMath.trend(null)) + assertEquals("▲", MarketMath.trendArrow(1.0)) + } + + @Test fun margin() { + assertEquals(110.0, MarketMath.applyMargin(100.0, 10.0), 0.0) + assertEquals(95.0, MarketMath.applyMargin(100.0, -5.0), 0.0) + assertEquals(887.3, MarketMath.marginRate(887.3, 0.0), 0.0) + } + + @Test fun usdtAverage() { + assertEquals(1001.0, UsdtQuote(1000.0, 1002.0, 0).average!!, 0.0) + assertEquals(1000.0, UsdtQuote(1000.0, null, 0).average!!, 0.0) + assertNull(UsdtQuote(null, null, 0).average) + } + + @Test fun shortDate() { + assertEquals("28/10/2026", MarketMath.shortDate("2026-10-28")) + } +} diff --git a/app/src/test/java/com/codeonyx/bcvaqui/domain/OfflineRatesTest.kt b/app/src/test/java/com/codeonyx/bcvaqui/domain/OfflineRatesTest.kt new file mode 100644 index 0000000..b713115 --- /dev/null +++ b/app/src/test/java/com/codeonyx/bcvaqui/domain/OfflineRatesTest.kt @@ -0,0 +1,86 @@ +package com.codeonyx.bcvaqui.domain + +import com.codeonyx.bcvaqui.data.model.CurrencyRate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class OfflineRatesTest { + // 2026-10-27 22:45 en Caracas (UTC-4) = 2026-10-28T02:45:00Z + private val loadedAt = 1793155500000L + + private val full = RatesSnapshot( + bcvRates = listOf(CurrencyRate("USD", 220.5, "2026-10-27"), CurrencyRate("EUR", 255.1)), + bcvUpdatedAt = "2026-10-27", + worldRatesUsd = mapOf("COP" to 4100.0, "EUR" to 0.92), + worldUpdatedAt = "w1", + usdtBuy = 300.0, + usdtSell = 305.0, + loadedAtMs = loadedAt + ) + + @Test fun encodeDecodeRoundTrip() { + assertEquals(full, OfflineRates.decode(OfflineRates.encode(full))) + } + + @Test fun decodeInvalidReturnsNull() { + assertNull(OfflineRates.decode(null)) + assertNull(OfflineRates.decode("")) + assertNull(OfflineRates.decode("{not json")) + } + + @Test fun decodeIgnoresUnknownKeys() { + val raw = """{"bcvRates":[],"loadedAtMs":5,"futureField":1}""" + assertEquals(5L, OfflineRates.decode(raw)?.loadedAtMs) + } + + @Test fun formatIsCaracasTime() { + assertEquals("27/10/2026 10:45 PM", OfflineRates.formatLoadedAt(loadedAt)) + assertEquals("—", OfflineRates.formatLoadedAt(0L)) + assertEquals("—", OfflineRates.formatLoadedAt(null)) + } + + @Test fun bannerText() { + assertEquals( + "Modo sin conexión · Última tasa cargada: 27/10/2026 10:45 PM", + OfflineRates.bannerText(loadedAt) + ) + assertEquals("⚠ Sin conexión · 27/10/2026 10:45 PM", OfflineRates.widgetMarker(loadedAt)) + } + + @Test fun mergeKeepsLastGoodSourceWhenOneFails() { + val fresh = RatesSnapshot( + bcvRates = listOf(CurrencyRate("USD", 221.0)), + bcvUpdatedAt = "2026-10-28", + worldRatesUsd = emptyMap(), + usdtBuy = null, + usdtSell = 310.0, + loadedAtMs = loadedAt + 1000 + ) + val m = OfflineRates.merge(full, fresh) + assertEquals(221.0, m.bcvRates.single().rateToVes, 0.0) + assertEquals(full.worldRatesUsd, m.worldRatesUsd) + assertEquals("w1", m.worldUpdatedAt) + assertEquals(300.0, m.usdtBuy!!, 0.0) + assertEquals(310.0, m.usdtSell!!, 0.0) + assertEquals(loadedAt + 1000, m.loadedAtMs) + } + + @Test fun mergeWithoutPreviousIsFresh() { + assertEquals(full, OfflineRates.merge(null, full)) + } + + @Test fun offlineState() { + assertTrue(OfflineRates.isOffline(true, full)) + assertFalse(OfflineRates.isOffline(false, full)) + assertFalse(OfflineRates.isOffline(true, null)) + assertFalse(OfflineRates.isOffline(true, full.copy(loadedAtMs = 0))) + } + + @Test fun ratesMapByCode() { + assertEquals(setOf("USD", "EUR"), OfflineRates.ratesMap(full).keys) + assertTrue(OfflineRates.ratesMap(null).isEmpty()) + } +} diff --git a/app/src/test/java/com/codeonyx/bcvaqui/domain/RateNotifierTest.kt b/app/src/test/java/com/codeonyx/bcvaqui/domain/RateNotifierTest.kt new file mode 100644 index 0000000..57f1b50 --- /dev/null +++ b/app/src/test/java/com/codeonyx/bcvaqui/domain/RateNotifierTest.kt @@ -0,0 +1,57 @@ +package com.codeonyx.bcvaqui.domain + +import org.junit.Assert.* +import org.junit.Test + +class RateNotifierTest { + private val all = NotifySource.entries.toSet() + + @Test fun firstRun_setsBaselineWithoutNotifying() { + val d = RateNotifier.decide(mapOf(NotifySource.BCV_USD to 100.0), emptyMap(), all, 0.0, false) + assertTrue(d.notifications.isEmpty()) + assertEquals(100.0, d.baselines["BCV_USD"]!!, 0.0) + } + + @Test fun eachSourceComparedAgainstItsOwnBaseline() { + // Simula el bug: antes se comparaba USDT contra la tasa BCV al cambiar de moneda. + val base = mapOf("BCV_USD" to 100.0, "USDT" to 150.0) + val d = RateNotifier.decide(mapOf(NotifySource.BCV_USD to 100.0, NotifySource.USDT to 150.0), base, all, 0.0, false) + assertTrue(d.notifications.isEmpty()) + } + + @Test fun realChange_notifiesOnce_noDuplicates() { + val base = mapOf("BCV_USD" to 100.0) + val d1 = RateNotifier.decide(mapOf(NotifySource.BCV_USD to 101.0), base, all, 0.0, false) + assertEquals(1, d1.notifications.size) + assertEquals("Dólar BCV subió 1,00 %", d1.notifications[0].title) + val d2 = RateNotifier.decide(mapOf(NotifySource.BCV_USD to 101.0), d1.baselines, all, 0.0, false) + assertTrue(d2.notifications.isEmpty()) + } + + @Test fun disabledSource_updatesBaselineSilently() { + val d = RateNotifier.decide(mapOf(NotifySource.BCV_EUR to 120.0), mapOf("BCV_EUR" to 110.0), setOf(NotifySource.BCV_USD), 0.0, false) + assertTrue(d.notifications.isEmpty()) + assertEquals(120.0, d.baselines["BCV_EUR"]!!, 0.0) + } + + @Test fun quietHours_holdBaselineUntilLater() { + val base = mapOf("BCV_USD" to 100.0) + val q = RateNotifier.decide(mapOf(NotifySource.BCV_USD to 102.0), base, all, 0.0, true) + assertTrue(q.notifications.isEmpty()) + assertEquals(100.0, q.baselines["BCV_USD"]!!, 0.0) + assertEquals(1, RateNotifier.decide(mapOf(NotifySource.BCV_USD to 102.0), q.baselines, all, 0.0, false).notifications.size) + } + + @Test fun usdtNeedsMinimumThreshold() { + val d = RateNotifier.decide(mapOf(NotifySource.USDT to 100.5), mapOf("USDT" to 100.0), all, 0.0, false) + assertTrue(d.notifications.isEmpty()) + } + + @Test fun quietHourWindow() { + assertTrue(RateNotifier.isQuietHour(23, true, 22, 7)) + assertTrue(RateNotifier.isQuietHour(3, true, 22, 7)) + assertFalse(RateNotifier.isQuietHour(12, true, 22, 7)) + assertFalse(RateNotifier.isQuietHour(23, false, 22, 7)) + assertTrue(RateNotifier.isQuietHour(14, true, 13, 15)) + } +} diff --git a/app/src/test/java/com/codeonyx/bcvaqui/domain/ShareFormatterTest.kt b/app/src/test/java/com/codeonyx/bcvaqui/domain/ShareFormatterTest.kt new file mode 100644 index 0000000..a743006 --- /dev/null +++ b/app/src/test/java/com/codeonyx/bcvaqui/domain/ShareFormatterTest.kt @@ -0,0 +1,115 @@ +package com.codeonyx.bcvaqui.domain + +import com.codeonyx.bcvaqui.data.model.PaymentContact +import com.codeonyx.bcvaqui.data.model.PaymentMigration +import com.codeonyx.bcvaqui.data.model.PaymentType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ShareFormatterTest { + private val pm = PaymentContact( + id = "1", type = PaymentType.PAGO_MOVIL, displayName = "Dalvin", + phone = "04140000000", documentId = "V12345678", bankCode = "0102", bankName = "Banco de Venezuela" + ) + private val conv = ShareConversion("Usdt", "USDT", 43.0, 41470.06) + + @Test fun exampleFormat() { + val expected = """ + -Tasa: Usdt + -Monto en Dolares: 43,00 + -Monto Bs: 41.470,06 + + -Pago Movil: + -Tlf: 04140000000 + -CI: V12345678 + -Banco: 0102 - Banco de Venezuela + """.trimIndent() + assertEquals(expected, ShareFormatter.build(conv, listOf(pm))) + } + + @Test fun onlyConversion() { + assertEquals("-Tasa: Usdt\n-Monto en Dolares: 43,00\n-Monto Bs: 41.470,06", ShareFormatter.build(conv, emptyList())) + } + + @Test fun rateLabels() { + assertEquals("BCV", ShareFormatter.rateLabelFor("USD")) + assertEquals("Euro", ShareFormatter.rateLabelFor("EUR")) + assertEquals("Usdt", ShareFormatter.rateLabelFor("USDT")) + assertEquals("Kontigo", ShareFormatter.rateLabelFor("USD", "Kontigo")) + } + + @Test fun zelleShowsUsdAmount() { + val zelle = PaymentContact(id = "z", type = PaymentType.ZELLE, displayName = "Ana Pérez", email = "ana@mail.com") + val text = ShareFormatter.build(ShareConversion("BCV", "USD", 43.0, 9000.0), listOf(zelle)) + assertTrue(text.endsWith("-Zelle:\n-Nombre: Ana Pérez\n-Correo: ana@mail.com\n-Monto a enviar: 43,00 USD")) + } + + @Test fun euroUsesUsdAmountForUsdMethods() { + val pp = PaymentContact(id = "p", type = PaymentType.PAYPAL, email = "a@b.com", userHandle = "ana") + val text = ShareFormatter.build(ShareConversion("Euro", "EUR", 10.0, 1000.0, usdAmount = 11.5), listOf(pp)) + assertTrue(text.contains("-Monto en Euros: 10,00")) + assertTrue(text.contains("-Link: paypal.me/ana")) + assertTrue(text.contains("-Monto a enviar: 11,50 USD")) + } + + @Test fun binanceUsesUsdt() { + val b = PaymentContact(id = "b", type = PaymentType.BINANCE, accountNumber = "123456") + assertTrue(ShareFormatter.build(conv, listOf(b)).contains("-Pay ID: 123456\n-Monto a enviar: 43,00 USDT")) + } + + @Test fun colombiaCop() { + val co = PaymentContact( + id = "c", type = PaymentType.CUENTA_CO, displayName = "Luis", bankName = "Bancolombia", + accountType = "Ahorros", accountNumber = "123", documentId = "CC 99" + ) + val text = ShareFormatter.build(ShareConversion("BCV", "USD", 10.0, 1000.0), listOf(co), copPerUsd = 4000.0) + assertTrue(text.contains("-Cuenta Colombia:\n-Banco: Bancolombia\n-Tipo: Ahorros\n-Número: 123\n-Titular: Luis\n-CC/NIT: CC 99\n-Monto COP: 40.000")) + assertFalse(ShareFormatter.build(ShareConversion("BCV", "USD", 10.0, 1000.0), listOf(co)).contains("COP")) + } + + @Test fun nequiWallet() { + val n = PaymentContact(id = "n", type = PaymentType.CUENTA_CO, bankName = "Nequi", phone = "3001234567") + assertTrue(ShareFormatter.methodBlock(n, null, null).startsWith("-Nequi:\n-Tlf: 3001234567")) + } + + @Test fun multipleMethodsSeparated() { + val z = PaymentContact(id = "z", type = PaymentType.ZELLE, email = "x@y.com") + val text = ShareFormatter.build(conv, listOf(pm, z)) + assertTrue(text.contains("-Banco: 0102 - Banco de Venezuela\n\n-Zelle:")) + } +} + +class PaymentMigrationTest { + @Test fun legacyJsonKeepsData() { + val legacy = """[{"id":"a","type":"PAGO_MOVIL","displayName":"Yo","phone":"0414","documentId":"V1","bankCode":"0102","bankName":"Banco de Venezuela"}, + {"id":"b","type":"CUENTA_EXTRANJERA","displayName":"Ext","accountNumber":"ES12","country":"España"}]""" + val list = PaymentMigration.decode(legacy) + assertEquals(2, list.size) + assertEquals("0414", list[0].phone) + assertEquals("Banco de Venezuela", list[0].bankName) + assertTrue(list[0].isDefault) + assertFalse(list[1].isDefault) + assertEquals("", list[1].email) + } + + @Test fun badEntrySkippedOthersKept() { + val raw = """[{"id":"a","type":"PAGO_MOVIL","phone":"1"},{"type":123},{"id":"c","type":"ZELLE","email":"z@z"}]""" + val list = PaymentMigration.decode(raw) + assertEquals(listOf("a", "c"), list.map { it.id }) + } + + @Test fun singleDefault() { + val l = listOf( + PaymentContact(id = "1", isDefault = true), PaymentContact(id = "2", isDefault = true) + ) + assertEquals(listOf(true, false), PaymentMigration.normalize(l).map { it.isDefault }) + assertEquals(listOf(false, true), PaymentMigration.setDefault(l, "2").map { it.isDefault }) + } + + @Test fun emptyOrGarbage() { + assertEquals(0, PaymentMigration.decode(null).size) + assertEquals(0, PaymentMigration.decode("{not json").size) + } +} diff --git a/app/src/test/java/com/codeonyx/bcvaqui/domain/SyncPolicyTest.kt b/app/src/test/java/com/codeonyx/bcvaqui/domain/SyncPolicyTest.kt new file mode 100644 index 0000000..70ed1e7 --- /dev/null +++ b/app/src/test/java/com/codeonyx/bcvaqui/domain/SyncPolicyTest.kt @@ -0,0 +1,43 @@ +package com.codeonyx.bcvaqui.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.ZonedDateTime + +class SyncPolicyTest { + private fun at(s: String) = ZonedDateTime.parse(s) + private val hour = 3_600_000L + + @Test fun noWorkWhenNothingNeedsIt() { + assertNull(SyncPolicy.periodicIntervalMinutes(hasWidgets = false, notificationHours = 0)) + } + + @Test fun widgetsUseHourlyInterval() { + assertEquals(60L, SyncPolicy.periodicIntervalMinutes(true, 0)) + assertEquals(60L, SyncPolicy.periodicIntervalMinutes(true, 4)) + } + + @Test fun notificationsOnlyUseUserInterval() { + assertEquals(240L, SyncPolicy.periodicIntervalMinutes(false, 4)) + } + + @Test fun publishLaterToday() { + // martes 10:30 Caracas -> 7 h + assertEquals(7 * hour, SyncPolicy.delayToNextBcvPublishMs(at("2026-10-27T10:30-04:00[America/Caracas]"))) + } + + @Test fun publishAfterTimeGoesToNextDay() { + assertEquals(24 * hour, SyncPolicy.delayToNextBcvPublishMs(at("2026-10-27T17:30-04:00[America/Caracas]"))) + } + + @Test fun skipsWeekend() { + // viernes 18:30 -> lunes 17:30 = 71 h + assertEquals(71 * hour, SyncPolicy.delayToNextBcvPublishMs(at("2026-10-30T18:30-04:00[America/Caracas]"))) + } + + @Test fun convertsFromUtc() { + // 14:30Z = 10:30 Caracas + assertEquals(7 * hour, SyncPolicy.delayToNextBcvPublishMs(at("2026-10-27T14:30Z"))) + } +} diff --git a/app/src/test/java/com/codeonyx/bcvaqui/ui/ResourceUsageGuardTest.kt b/app/src/test/java/com/codeonyx/bcvaqui/ui/ResourceUsageGuardTest.kt new file mode 100644 index 0000000..d5f037f --- /dev/null +++ b/app/src/test/java/com/codeonyx/bcvaqui/ui/ResourceUsageGuardTest.kt @@ -0,0 +1,17 @@ +package com.codeonyx.bcvaqui.ui + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** Evita regresiones del crash de "Acerca de": painterResource no soporta adaptive-icons (mipmap). */ +class ResourceUsageGuardTest { + @Test + fun noPainterResourceWithMipmap() { + val root = listOf(File("src/main/java"), File("app/src/main/java")).first { it.exists() } + val bad = root.walkTopDown().filter { it.extension == "kt" } + .filter { Regex("""painterResource\(\s*(id\s*=\s*)?R\.mipmap""").containsMatchIn(it.readText()) } + .map { it.name }.toList() + assertTrue("painterResource(R.mipmap…) crashea con adaptive icons: $bad", bad.isEmpty()) + } +} diff --git a/update.json b/update.json index 19f8969..44e2530 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,7 @@ { - "versionCode": 6, - "versionName": "1.3.0", - "apkUrl": "https://github.com/codeonyx-dev/spread-ve/releases/download/v1.3.0/SpreadVE.apk", - "releaseNotes": "Herramientas rediseñadas y más claras; USDT de Binance siempre disponible en el selector; tarjetas y chips de resultado corregidos." -} + "versionCode": 7, + "versionName": "1.3.1", + "apkUrl": "https://github.com/codeonyx-dev/spread-ve/releases/download/v1.3.1/Codivisas.apk", + "releaseNotes": "Nuevo nombre Codivisas y logo; cuatro paletas (Zafiro, Ónix, Amatista, Esmeralda); modo sin conexión con la última tasa; widgets depurados; actualizaciones desde codeonyx.online.", + "sha256": "878675c2903380e3b6816ef3746ad4c5b8ff7903b21a17091f5e86e2d397a2f0" +} \ No newline at end of file