From f413e7994d5d4866bb8eaa00c7fa59f918f8afa2 Mon Sep 17 00:00:00 2001 From: Raimond Caldera <110433086+codeonyx-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:06:52 -0400 Subject: [PATCH 01/14] =?UTF-8?q?Mejora:=20seguridad=20de=20firma,=20red?= =?UTF-8?q?=20y=20verificaci=C3=B3n=20de=20actualizaciones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/android.yml | 40 ++++ app/build.gradle.kts | 27 ++- .../com/codeonyx/bcvaqui/data/ApiConfig.kt | 2 + .../com/codeonyx/bcvaqui/data/api/FxApis.kt | 19 ++ .../bcvaqui/data/api/NetworkModule.kt | 11 +- .../bcvaqui/data/model/UpdateManifest.kt | 4 +- .../bcvaqui/update/AppUpdateManager.kt | 191 +++++++++++++++--- .../bcvaqui/worker/UpdateCheckWorker.kt | 4 +- app/src/main/res/xml/backup_rules.xml | 8 + .../main/res/xml/data_extraction_rules.xml | 14 ++ update.json | 3 +- 11 files changed, 280 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/android.yml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml 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/app/build.gradle.kts b/app/build.gradle.kts index 85701a3..2c0d228 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,8 @@ android { } 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/java/com/codeonyx/bcvaqui/data/ApiConfig.kt b/app/src/main/java/com/codeonyx/bcvaqui/data/ApiConfig.kt index 95ba1ae..c8ad643 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 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/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/update/AppUpdateManager.kt b/app/src/main/java/com/codeonyx/bcvaqui/update/AppUpdateManager.kt index c5cdc30..e225aea 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" @@ -43,16 +52,16 @@ object AppUpdateManager { connectTimeout = 15_000 readTimeout = 15_000 setRequestProperty("Accept", "application/json") - setRequestProperty("User-Agent", "SpreadVE/${BuildConfig.VERSION_NAME}") + setRequestProperty("User-Agent", "Codivisas/${BuildConfig.VERSION_NAME}") } val body = connection.getInputStream().bufferedReader().use { it.readText() } val manifest = json.decodeFromString(body) - if (manifest.versionCode > BuildConfig.VERSION_CODE) { + 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 +79,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 +103,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 +123,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 +152,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 +222,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 +245,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 +258,147 @@ 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) } + } + + /** Solo se aceptan APKs servidos por 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 + 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 +413,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 +452,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/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/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/update.json b/update.json index 19f8969..022a424 100644 --- a/update.json +++ b/update.json @@ -2,5 +2,6 @@ "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." + "releaseNotes": "Herramientas rediseñadas y más claras; USDT de Binance siempre disponible en el selector; tarjetas y chips de resultado corregidos.", + "sha256": "a70fa074b9975150a54c8bd400ad381cc21b3541f7bea18ee325b82f2f0104a2" } From 9802f7b3877798ad1924360959d3ba46670675da Mon Sep 17 00:00:00 2001 From: Raimond Caldera <110433086+codeonyx-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:06:52 -0400 Subject: [PATCH 02/14] Mejora: herramientas Deudas y Ahorros --- .../bcvaqui/data/local/FinanceRepository.kt | 49 ++++ .../codeonyx/bcvaqui/domain/FinancePlanner.kt | 185 +++++++++++++ .../bcvaqui/ui/tools/finance/AhorrosScreen.kt | 237 +++++++++++++++++ .../bcvaqui/ui/tools/finance/DeudasScreen.kt | 242 ++++++++++++++++++ .../bcvaqui/ui/tools/finance/FinanceCommon.kt | 195 ++++++++++++++ .../ui/tools/finance/FinanceViewModel.kt | 49 ++++ .../bcvaqui/domain/FinancePlannerTest.kt | 67 +++++ 7 files changed, 1024 insertions(+) create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/data/local/FinanceRepository.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/domain/FinancePlanner.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/AhorrosScreen.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/DeudasScreen.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/FinanceCommon.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/ui/tools/finance/FinanceViewModel.kt create mode 100644 app/src/test/java/com/codeonyx/bcvaqui/domain/FinancePlannerTest.kt 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/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/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/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) + } +} From 7b6b75aa0d85181071b23a7d67fc304f579e6984 Mon Sep 17 00:00:00 2001 From: Raimond Caldera <110433086+codeonyx-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:06:52 -0400 Subject: [PATCH 03/14] Mejora: modo lite --- .../bcvaqui/ui/home/LiteHomeScreen.kt | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/ui/home/LiteHomeScreen.kt 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) } +} From dfc9262a0cd2beee606983ec1074ee0c0b591ec9 Mon Sep 17 00:00:00 2001 From: Raimond Caldera <110433086+codeonyx-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:06:52 -0400 Subject: [PATCH 04/14] Mejora: recorrido guiado y Ajustes reorganizados --- .../bcvaqui/ui/settings/SettingsScreen.kt | 622 +++++++++--------- .../java/com/codeonyx/bcvaqui/ui/tour/Tour.kt | 168 +++++ 2 files changed, 476 insertions(+), 314 deletions(-) create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/ui/tour/Tour.kt 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/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") + } + } + } + } + } + } +} From 4604b3d117006d2b8b1eafa9525eb989ee00b5ed Mon Sep 17 00:00:00 2001 From: Raimond Caldera <110433086+codeonyx-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:06:53 -0400 Subject: [PATCH 05/14] =?UTF-8?q?Mejora:=20Mercado=20y=20gr=C3=A1ficos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bcvaqui/data/local/HistoryDiskCache.kt | 37 ++++ .../com/codeonyx/bcvaqui/domain/MarketMath.kt | 82 ++++++++ .../bcvaqui/ui/tools/HistorialScreen.kt | 23 +-- .../bcvaqui/ui/tools/MercadoScreen.kt | 180 ++++++++++++++++++ .../codeonyx/bcvaqui/domain/MarketMathTest.kt | 63 ++++++ 5 files changed, 372 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/data/local/HistoryDiskCache.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/domain/MarketMath.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/ui/tools/MercadoScreen.kt create mode 100644 app/src/test/java/com/codeonyx/bcvaqui/domain/MarketMathTest.kt 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/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/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/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")) + } +} From b8f4c8c6d865be0b9be8e557d0b2c31e1ce12238 Mon Sep 17 00:00:00 2001 From: Raimond Caldera <110433086+codeonyx-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:06:53 -0400 Subject: [PATCH 06/14] =?UTF-8?q?Mejora:=20modo=20sin=20conexi=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/local/PreferencesRepository.kt | 141 +++++++++++++++++- .../codeonyx/bcvaqui/data/model/FxModels.kt | 23 ++- .../bcvaqui/data/repository/BcvFallback.kt | 20 +++ .../bcvaqui/data/repository/FxRepository.kt | 132 ++++++++++++++-- .../com/codeonyx/bcvaqui/domain/DataAge.kt | 20 +++ .../codeonyx/bcvaqui/domain/OfflineRates.kt | 71 +++++++++ .../com/codeonyx/bcvaqui/domain/SyncPolicy.kt | 38 +++++ .../bcvaqui/domain/BcvFallbackTest.kt | 52 +++++++ .../bcvaqui/domain/OfflineRatesTest.kt | 86 +++++++++++ .../codeonyx/bcvaqui/domain/SyncPolicyTest.kt | 43 ++++++ 10 files changed, 604 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/data/repository/BcvFallback.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/domain/DataAge.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/domain/OfflineRates.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/domain/SyncPolicy.kt create mode 100644 app/src/test/java/com/codeonyx/bcvaqui/domain/BcvFallbackTest.kt create mode 100644 app/src/test/java/com/codeonyx/bcvaqui/domain/OfflineRatesTest.kt create mode 100644 app/src/test/java/com/codeonyx/bcvaqui/domain/SyncPolicyTest.kt 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/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/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/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/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/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/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/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"))) + } +} From 64e36adf3918bc6924226386b1b23d56db4b6996 Mon Sep 17 00:00:00 2001 From: Raimond Caldera <110433086+codeonyx-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:06:54 -0400 Subject: [PATCH 07/14] Arreglo: notificaciones al cambiar de moneda --- .../bcvaqui/domain/RateAlertPolicy.kt | 38 ++++ .../codeonyx/bcvaqui/domain/RateNotifier.kt | 65 +++++++ .../notification/NotificationHelper.kt | 45 ++++- .../bcvaqui/worker/RateCheckWorker.kt | 72 +++++--- .../codeonyx/bcvaqui/worker/WorkScheduler.kt | 171 +++++++++--------- .../bcvaqui/domain/RateNotifierTest.kt | 57 ++++++ 6 files changed, 337 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/domain/RateAlertPolicy.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/domain/RateNotifier.kt create mode 100644 app/src/test/java/com/codeonyx/bcvaqui/domain/RateNotifierTest.kt 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/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/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/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/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)) + } +} From 264cbfead9b1027cf964fb014d5544e594bb55f4 Mon Sep 17 00:00:00 2001 From: Raimond Caldera <110433086+codeonyx-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:06:55 -0400 Subject: [PATCH 08/14] Mejora: widgets depurados --- .../bcvaqui/widget/ConverterWidgetProvider.kt | 6 +- .../bcvaqui/widget/MiniRateWidgetProvider.kt | 56 --------- .../bcvaqui/widget/RateWidgetProvider.kt | 114 +++++++++++------ .../bcvaqui/widget/ShareRateActivity.kt | 55 --------- .../bcvaqui/widget/ShareRateWidgetProvider.kt | 73 ----------- .../bcvaqui/widget/SpreadWidgetProvider.kt | 6 +- .../bcvaqui/widget/VariationWidgetProvider.kt | 76 ------------ .../com/codeonyx/bcvaqui/widget/Widgets.kt | 66 +++++++++- .../main/res/drawable/ic_widget_refresh.xml | 2 +- .../drawable/widget_background_esmeralda.xml | 10 ++ .../res/drawable/widget_background_morado.xml | 10 ++ .../res/drawable/widget_background_onix.xml | 10 ++ .../main/res/drawable/widget_refresh_bg.xml | 2 +- app/src/main/res/layout/widget_converter.xml | 8 +- app/src/main/res/layout/widget_mini_rate.xml | 38 ------ app/src/main/res/layout/widget_rate.xml | 18 ++- app/src/main/res/layout/widget_rate_large.xml | 116 ++++++++++++++++++ ...et_variation.xml => widget_rate_small.xml} | 33 ++--- app/src/main/res/layout/widget_share.xml | 44 ------- app/src/main/res/layout/widget_spread.xml | 20 +-- .../main/res/values-night/colors_widget.xml | 15 +++ app/src/main/res/values/colors_widget.xml | 16 +++ .../main/res/xml/mini_rate_widget_info.xml | 12 -- app/src/main/res/xml/rate_widget_info.xml | 6 +- app/src/main/res/xml/share_widget_info.xml | 12 -- .../main/res/xml/variation_widget_info.xml | 12 -- 26 files changed, 381 insertions(+), 455 deletions(-) delete mode 100644 app/src/main/java/com/codeonyx/bcvaqui/widget/MiniRateWidgetProvider.kt delete mode 100644 app/src/main/java/com/codeonyx/bcvaqui/widget/ShareRateActivity.kt delete mode 100644 app/src/main/java/com/codeonyx/bcvaqui/widget/ShareRateWidgetProvider.kt delete mode 100644 app/src/main/java/com/codeonyx/bcvaqui/widget/VariationWidgetProvider.kt create mode 100644 app/src/main/res/drawable/widget_background_esmeralda.xml create mode 100644 app/src/main/res/drawable/widget_background_morado.xml create mode 100644 app/src/main/res/drawable/widget_background_onix.xml delete mode 100644 app/src/main/res/layout/widget_mini_rate.xml create mode 100644 app/src/main/res/layout/widget_rate_large.xml rename app/src/main/res/layout/{widget_variation.xml => widget_rate_small.xml} (59%) delete mode 100644 app/src/main/res/layout/widget_share.xml create mode 100644 app/src/main/res/values-night/colors_widget.xml create mode 100644 app/src/main/res/values/colors_widget.xml delete mode 100644 app/src/main/res/xml/mini_rate_widget_info.xml delete mode 100644 app/src/main/res/xml/share_widget_info.xml delete mode 100644 app/src/main/res/xml/variation_widget_info.xml 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/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/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/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/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 @@ - - From 535c4daeaea02b683aad246cf3980cdf944c1ad4 Mon Sep 17 00:00:00 2001 From: Raimond Caldera <110433086+codeonyx-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:06:55 -0400 Subject: [PATCH 09/14] Arreglo: destello azul al abrir --- .../java/com/codeonyx/bcvaqui/MainActivity.kt | 47 ++++++++++++++++++- .../codeonyx/bcvaqui/ui/theme/PaletteStore.kt | 22 +++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/ui/theme/PaletteStore.kt 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/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 + } +} From 9909e54b45500d3a728271753d2364b5458da5c4 Mon Sep 17 00:00:00 2001 From: Raimond Caldera <110433086+codeonyx-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:06:56 -0400 Subject: [PATCH 10/14] =?UTF-8?q?Mejora:=20paletas=20Zafiro,=20=C3=93nix,?= =?UTF-8?q?=20Amatista=20y=20Esmeralda=20con=20logo=20por=20paleta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bcvaqui/data/local/BackupManager.kt | 54 ++++++++++++ .../com/codeonyx/bcvaqui/domain/AppPalette.kt | 54 ++++++++++++ .../com/codeonyx/bcvaqui/ui/MainViewModel.kt | 58 +++++++++++++ .../com/codeonyx/bcvaqui/ui/theme/Color.kt | 24 ++++-- .../bcvaqui/ui/theme/PaletteManager.kt | 74 +++++++++++++++++ .../com/codeonyx/bcvaqui/ui/theme/Theme.kt | 82 +++++++++++++++---- .../main/res/values-night/colors_theme.xml | 4 + app/src/main/res/values-night/themes.xml | 36 ++++++++ app/src/main/res/values/colors_palettes.xml | 8 ++ app/src/main/res/values/colors_theme.xml | 4 + app/src/main/res/values/themes.xml | 25 +++++- .../codeonyx/bcvaqui/domain/AppPaletteTest.kt | 53 ++++++++++++ 12 files changed, 454 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/data/local/BackupManager.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/domain/AppPalette.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/ui/theme/PaletteManager.kt create mode 100644 app/src/main/res/values-night/colors_theme.xml create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values/colors_palettes.xml create mode 100644 app/src/main/res/values/colors_theme.xml create mode 100644 app/src/test/java/com/codeonyx/bcvaqui/domain/AppPaletteTest.kt 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/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/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/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/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/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/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_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/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/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) + } + } +} From 8d5e8dd33744d511d64e50bf5721d01e5e4757f0 Mon Sep 17 00:00:00 2001 From: Raimond Caldera <110433086+codeonyx-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:06:57 -0400 Subject: [PATCH 11/14] Mejora: nuevo nombre Codivisas y logo --- README.md | 46 +++--- app/src/main/AndroidManifest.xml | 98 +++++++------ .../com/codeonyx/bcvaqui/domain/CopyTexts.kt | 22 +++ .../codeonyx/bcvaqui/domain/ShareFormatter.kt | 133 +++++++++++++++++ .../bcvaqui/ui/share/ReceiptShareHelper.kt | 12 +- .../res/drawable/ic_launcher_foreground.xml | 44 +----- .../drawable/ic_launcher_foreground_azul.xml | 8 ++ .../ic_launcher_foreground_esmeralda.xml | 8 ++ .../ic_launcher_foreground_morado.xml | 8 ++ .../drawable/ic_launcher_foreground_onix.xml | 8 ++ .../res/drawable/ic_launcher_monochrome.xml | 8 ++ app/src/main/res/drawable/ic_splash.xml | 50 +------ .../res/mipmap-anydpi-v26/ic_launcher.xml | 3 +- .../mipmap-anydpi-v26/ic_launcher_azul.xml | 6 + .../ic_launcher_azul_round.xml | 6 + .../ic_launcher_esmeralda.xml | 6 + .../ic_launcher_esmeralda_round.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_morado.xml | 6 + .../ic_launcher_morado_round.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_onix.xml | 6 + .../ic_launcher_onix_round.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 3 +- app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3918 bytes app/src/main/res/mipmap-hdpi/ic_launcher.xml | 3 - .../main/res/mipmap-hdpi/ic_launcher_azul.png | Bin 0 -> 3918 bytes .../mipmap-hdpi/ic_launcher_azul_round.png | Bin 0 -> 4710 bytes .../res/mipmap-hdpi/ic_launcher_esmeralda.png | Bin 0 -> 3856 bytes .../ic_launcher_esmeralda_round.png | Bin 0 -> 4659 bytes .../res/mipmap-hdpi/ic_launcher_morado.png | Bin 0 -> 3875 bytes .../mipmap-hdpi/ic_launcher_morado_round.png | Bin 0 -> 4689 bytes .../main/res/mipmap-hdpi/ic_launcher_onix.png | Bin 0 -> 3833 bytes .../mipmap-hdpi/ic_launcher_onix_round.png | Bin 0 -> 4616 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 4710 bytes .../res/mipmap-hdpi/ic_launcher_round.xml | 3 - app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2529 bytes .../main/res/mipmap-mdpi/ic_launcher_azul.png | Bin 0 -> 2529 bytes .../mipmap-mdpi/ic_launcher_azul_round.png | Bin 0 -> 3021 bytes .../res/mipmap-mdpi/ic_launcher_esmeralda.png | Bin 0 -> 2501 bytes .../ic_launcher_esmeralda_round.png | Bin 0 -> 2983 bytes .../res/mipmap-mdpi/ic_launcher_morado.png | Bin 0 -> 2512 bytes .../mipmap-mdpi/ic_launcher_morado_round.png | Bin 0 -> 2998 bytes .../main/res/mipmap-mdpi/ic_launcher_onix.png | Bin 0 -> 2465 bytes .../mipmap-mdpi/ic_launcher_onix_round.png | Bin 0 -> 2932 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3021 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 5362 bytes .../res/mipmap-xhdpi/ic_launcher_azul.png | Bin 0 -> 5362 bytes .../mipmap-xhdpi/ic_launcher_azul_round.png | Bin 0 -> 6368 bytes .../mipmap-xhdpi/ic_launcher_esmeralda.png | Bin 0 -> 5280 bytes .../ic_launcher_esmeralda_round.png | Bin 0 -> 6294 bytes .../res/mipmap-xhdpi/ic_launcher_morado.png | Bin 0 -> 5342 bytes .../mipmap-xhdpi/ic_launcher_morado_round.png | Bin 0 -> 6391 bytes .../res/mipmap-xhdpi/ic_launcher_onix.png | Bin 0 -> 5233 bytes .../mipmap-xhdpi/ic_launcher_onix_round.png | Bin 0 -> 6181 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 6368 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 8197 bytes .../res/mipmap-xxhdpi/ic_launcher_azul.png | Bin 0 -> 8197 bytes .../mipmap-xxhdpi/ic_launcher_azul_round.png | Bin 0 -> 9820 bytes .../mipmap-xxhdpi/ic_launcher_esmeralda.png | Bin 0 -> 8098 bytes .../ic_launcher_esmeralda_round.png | Bin 0 -> 9699 bytes .../res/mipmap-xxhdpi/ic_launcher_morado.png | Bin 0 -> 8184 bytes .../ic_launcher_morado_round.png | Bin 0 -> 9791 bytes .../res/mipmap-xxhdpi/ic_launcher_onix.png | Bin 0 -> 8045 bytes .../mipmap-xxhdpi/ic_launcher_onix_round.png | Bin 0 -> 9530 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 9820 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 11057 bytes .../res/mipmap-xxxhdpi/ic_launcher_azul.png | Bin 0 -> 11057 bytes .../mipmap-xxxhdpi/ic_launcher_azul_round.png | Bin 0 -> 13271 bytes .../mipmap-xxxhdpi/ic_launcher_esmeralda.png | Bin 0 -> 10901 bytes .../ic_launcher_esmeralda_round.png | Bin 0 -> 13069 bytes .../res/mipmap-xxxhdpi/ic_launcher_morado.png | Bin 0 -> 10977 bytes .../ic_launcher_morado_round.png | Bin 0 -> 13259 bytes .../res/mipmap-xxxhdpi/ic_launcher_onix.png | Bin 0 -> 10788 bytes .../mipmap-xxxhdpi/ic_launcher_onix_round.png | Bin 0 -> 12905 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 13271 bytes app/src/main/res/values/colors.xml | 5 +- app/src/main/res/values/strings.xml | 136 ++++++++++++++++-- .../codeonyx/bcvaqui/domain/CopyTextsTest.kt | 20 +++ .../bcvaqui/domain/ShareFormatterTest.kt | 115 +++++++++++++++ 78 files changed, 599 insertions(+), 182 deletions(-) create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/domain/CopyTexts.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/domain/ShareFormatter.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground_azul.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground_esmeralda.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground_morado.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground_onix.xml create mode 100644 app/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_azul.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_azul_round.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_esmeralda.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_esmeralda_round.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_morado.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_morado_round.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_onix.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_onix_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_azul.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_azul_round.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_esmeralda.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_esmeralda_round.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_morado.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_morado_round.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_onix.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_onix_round.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_azul.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_azul_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_esmeralda.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_esmeralda_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_morado.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_morado_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_onix.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_onix_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_azul.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_azul_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_esmeralda.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_esmeralda_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_morado.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_morado_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_onix.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_onix_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_azul.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_azul_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_esmeralda.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_esmeralda_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_morado.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_morado_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_onix.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_onix_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_azul.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_azul_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_esmeralda.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_esmeralda_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_morado.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_morado_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_onix.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_onix_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/test/java/com/codeonyx/bcvaqui/domain/CopyTextsTest.kt create mode 100644 app/src/test/java/com/codeonyx/bcvaqui/domain/ShareFormatterTest.kt 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/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/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/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/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/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/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 0000000000000000000000000000000000000000..e9d23857def9e80d82efd7246ec8d3523739a7bb GIT binary patch literal 3918 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 - 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 0000000000000000000000000000000000000000..e9d23857def9e80d82efd7246ec8d3523739a7bb GIT binary patch literal 3918 zcmV-U53%rxP)& zcUV=&x`*G{R4D>dL_~@x7(fCd7C>Xyqo$c?&NW_R)R^-`&9OvH^Q4$-B8i%E<1vXQ z#x!H>Ip$b`f{NH^21G0rDI&dY>fY=AVJKT*??vai=luuwns3c~>-Wr>`R1Eh3Und~ zr{K-QBuVu+YB5F#`XS+nz*P6WJ&GzIPYC`%O=yjQ-p^TQR;TN_Nwi!f38$csW(!HZ zK|;89prbtnl_dBXq&4Xg>#|xhYF3~Dfgx@JegsBzntMHm5+S5F(jwM=4nlgcu!%rU z!5;*pR_q7ty2QPnf?&68rN!c?s26KAFE_$DXw!H}62}39E_JTwaKyIC;_1dBDeC^= zJoZCZB=IxQR54L3M2`sR@x(hZFhLpvk)&kkN$9O{V#w4K_Slwi9Oxt zRF9(&h05*Dxz`F6API#ez1!U$^)RfE^wt6hr{ItLQLC?Xw?{n?Wrd-#uR>C*pXhE+ zdMGBw)uuD`kqFinPj?pfwr03ETH)N^l0KFu_1CMaYbjS&b1$!q8!7o@-7D#O=l(=U z>L>Mupnq31W@l~6_|XFyGs1=7;m%lD^pbU7X;~Ff@mYjjNaob}WYRK=I@i%g2!$9T zoB}^A1<9;a8W1pgm=p77`7wFSAoTSVx^L2gB*BH4JM2CX%kl6VsMJkX1|3bYa0(3R zlvalO^yjU=Przr0eP>ip;;mfX`Q#Ll(RVtf{yI8bAX{rw-un9lW=^A9jF$c^K>7x3uBt`P@d_H=I!eKijUJM&JdzrzGtzF;&vH_gJj z(Tvnpcnq{*{}<18O56JFtw5m=Y+N&i|9Z@))%R9Z){vS}NMUg$YPCc!V|~m_jj*>h z!@}ISRdtn>S5aN7YPAo#XqP}jz`Ls^wMv}yyT$za`*n_j+VU7$Y1bgVe8Fg5c_CPq?akDD-uvt{;TKboTG^tT zb05~dIF_l83~o{u9iPQ>tM}p5JA)U;#%YvU^lDzPl1f9C@0o-o=^iNC+=IrAa%I(` zAf0xVRkdvVJe(g6#Zs$Mx2dh$X$35K{Sc3gbmil>X3*!)AoJYiG?u(}i1LbRq~3xl zUMc7+bgQ3~-A9%<(8fX9%AyyW-}nm(h4w~r^2>Q<*&e>#AKhkg8WVmog_#R?^GDJ> zemkAS^K14~UQrD|LY56*oqbqm9g-yY#}RqG9@^N0)-NBAO&{&MK53an{B_wLvT{nb z+6}SKXL0aV3=Py|7Mii?*hsDRnb{@GT(}z{X!5H5@9AO8_D{mbtfIyH_eZ;PB~5G@dh})1G%u|-MO}@f(uVh5i?W`tTPPJ!$bkkW-7p)R#?&A;Z>^+-zoYPwFU{S`FIj0D4 z$!@mX)w+m9K}9SIx=L(@9qaZz!mV7Z`trW!N|sNEVzz%0`g+X|{7Kn;xL0bz*unS3 zuT?2ne|U`SnX56f1rh>gjQ7xL6Lukm==c_Qap`Sb#jXXv;bvDPt4&|W9Nw4_$@9CW z)7!X;7stl&TwpxC3~HOTPtLXElTd$tiyw%sSp}z7@4?uhmaoqFa5KlM*?V*$TcC#q z+0jqOf^f$k?Wyf~Bd;|PCpFK4qw#L2)BY%~xuHz;PUW*x{xYAkDY69`{SL2%H)fA$1ygj<-MbUB;K{=pL0((D^6Ci8K0l_V_&ofwW@}J z-Ne`cWn~SYo%ZMJ2p_6y_2fKav&a_6%U%0cebgUW)YfVb2F;DDHGZqq3Iv)1?73V0 zS^DE7#(SjmPU0u33cebY5A@vhD~Zs%I!)Tbc{Z)dLL&`Sr|e#VdE2`-dY8Uhe@!-=pP zPB>eauztq*diw&UF<+kX<@*bsRMa%MKGdTxs}=_{A+TYF{oAu6bWRv@kS$OjOYKSa z-g%d598d^HKt`(n_@Q23gg|!^f;a5#NpV%ge482TBV0tEyye~$Ie{I&IliN zo%hyw9X$uzv1)M;lY#~{nO$ek@a|fHdKqg!(~zB8rnTJGT*<{02R0lX&Fj-6n=C4g z^i@prN@1E;3i;*6e0s{CA1-?G)b=OP6KYB-8(x(mZnmsk6v))E?oEV|B=~n&5*t4c zm-}d1ku8w1k#^In>gK23x`1w~XELuI7{|Af-ds%S$AZ8FrhDJU)Tn7L+1j*{cOH$v zr++SM_m8W;KG?;Y6^nwHKF+<#LRpgFwV3Y zESnI;oJIlNhfjs!@088^$42q$fw64(S0H~GdYf5(HwbXfM)5$edTQ7We!uR_i3C@c z%^$_;7lND3Ae@d&X3LHUu3m3G_-qfd1yWYjw3r4>O3ouE|Nb=G+qjA)W8+xROh6}u zgtK)Cc4pzpdv}!s~-^#UO%ZUI^BswEWf~DhQm^dVj2_AQ- zRSCX0>sue>nHtt=gaH6|i|^;1OnVu2sW5fO&Ph)Ua^vfeS;Sns%iCK+iM@80ph20e zn|cv1ho-5G%p!9>3H9etOhfn|;F8Vvi+JE$79pv|jTMV`<;F|>(>Q~E+$t^WuTevSjUGk(xrJ2`x zdRS7E>D}72f(b*KU!oj|8^oEL_j_%3hy$$|q$_KPjl0Twk4G_kcoKR-bJi-VFyhNI zzU+?l(ui_}fG4MVQC?Ban}^lJXW6i9e6-fgU$sh)UFW^oe4_i0X}2QXA+dA*u_mum zd6fa*L=0s|girlap$>14zPz(yBEx;|pRhS_EP*#S8?*Py5CUAX8S9=-uv-Qm_6X4J3&ghi&XbpA+8O*DH` zevy**J`3aE@dSH^-|wsGvdtN7n(h9_{}fTZ20t4 zryP@8u`rO8GPb?*3d-?+bX&XHYS;Oozs(!b;$h73qg^pGZC^I;L3*1Rv331)S>ga} z*%{I9Hrp(ay{#DwXSbXTcX71h)3;}~wR#PNfT?2#bMpIzI(K#JIC%UTKOBnFZK#7b zpYB+(FsQ}!hq9uE)z{qXa0z}K@MW~Qxs!1j=qS;U;BKj}>WwuW|}QH1-# ze7AMB&aI)&+O(JuK7hu)_!n|yn^O;K06ahf?fNfd3MF#4i^VH z@ECA>(mf{3{hA*Sw}0l4OIl_T^H=@CTU$eQZd$gLPWcoeXt*{ z#g08!*#5(%7I#xR3gHwOQUq9ZM&;v1xw3GMKfwVm=;=7mD2KZ_rF?fFmTwP4cgo7W z8A^mxV8~TqXlJ%!Z)?VsF@p&j?nHo(Bi2^(PQ{+W_M+))>bCCyV_uHYlgL@3D#C72mxwIBCkNny}UB+=9H3@oJYB`OL|I6 zoYI%n92BB!1z>wW;~({6LB z=a4T+JXiOB327`fY^y9^0giNwTRnqnN$_O)`L*>Ke+_}6qF$`I)8Azl@MV{~*8SuQ zAx%qK}Fm^u@Zwpb`V(tBFc;CAcZY#>tzaZQUhy~DGAYIDK;NFwX#s8tV*U|-@|Qxn zOA5wzr~XfXG_BM;TbS~@EORTGO^|QI`iUsyUM%0P?>DLhVn*KSRmaS!WY+fU7rA~q z3i&nvd*lE(OXRmc(d(Wyr;^D8`9`gKNeY<&*qYt7{~lNi$!ES%>t8grf=N@%Cvxr6 zLWl%F+3l`%f|rLQ{@$+m4RoQei+$VkMs+i_b!u*w*KjSTg5sNZdaB8Lp+$&aY=e#oDg(SEo(+nH3ax~7rTE5#(9$>!MUZ1RdK zE$PO+P)m5S;B?$!LqY}#@*cZxs6xpFy1C{E0gS}rL@fBkP^;Z%;)2M zmpGJk4UJY>qz|Z)5MFRP?z%n!y#x(;VvCKss^K&+syi)@4jIVre=~uQVP4&-ZWLFu z%J{?PW2B_zTdFG6+SxW*pPi)lU)ogFxRGv<Hy&uTTE@%GA1d2k@nQ)clAKz&0KxrMj6UDbeAE3vax;pkw4mxm+HPUe|g zHMNbXn=J}7jwrRQS^{jk_hEh?L1vjer`nw-E za6FT%KbGp;iG+Z`er}8n_2%ivhBGF_+u+E|xK_%X<$Ej{J$F%2Z(ZkKmKa}i_d$md zWT&=UjEUp`ESVQ+GC4oyR`AZ&<0PHUK^oberG%{93bJx5`1Ff(2K)8pwU@{7_#;ER z$fjK>X5PvKeESp;KTJZQ78;AIt`7@tl5^tr(ay6S)(;YwO%|jHh{nUeky%ijm%T$^LXiZ zUr<}GCU`(O`+mJ2mBP4flv&c3DO;b>T_`BgYE^>@Q&!Z&J=?st(SEk6X%>?MkaRYOX$wB)KbbfA=6EJAtlCR$y&8awVt4+2@p6`qN^W4+$`d(#5BkY~g`Q^+~MXV5x=ydyUbL^9rR ztrm<7>c@=9!3Je&>(#vY`hJt_lELnkOb`2!sUxm4CnCd?E&yH0i5gtQ=ZpSrkeu6f zHb(yet-2rcj@&p9T1Suq%`8mx)K|V}+}2sO<{+8b<))~^O4UB~?5vtm3N0FGy3ZEm zb&DmjX-pZJZB+Kp2P3&vXOj7#P%4_H1Mjs=v6e$UqZsVhm)M9w24zm2&*Q+cOoK8) zNG1)>WpZ#1F+&PFlpR{0z_A~EId~<2iv^y#b{YWu-K$tJ=>k)Nvk}7h+HgMCi@j-G zcR_v7A|?Ru31;n9`WEGu11`oYz5VKV7Ck%Kpv?4_KPBx-vEK3l&NXbDeT*^wB}O}W zsnCBl|cqYdnIely!`Dp$(4EC%vsfVH7rMx*cg%>`WLLb{k zULKduyr?VKSvBjF&n|Oe^U-j=xiSb3$9j&f+=Z=GGk-r7VlFuVF1|PNLsbf)ebAWU z2JpZTPx^a08I;+%>w>}Z=f{5Eg`C`SXAWE$j7BRMF`$g`K?QA7rmYu~`v zOZVc{r&g!lg4<4PIU3HFmxi>hUpK4m`QX$@9v_j*)??wuUr-06RuiIXr?$_awy`~25i_KSzde5lTdQW?IS|Fw8*cpdxg$Es zDXwy4+wm|G(t>E#v@h)TwpP?NG_mz~IR8ivp|QDpkzyf4pb8-k&zn{R2YMPTPyJ6Z z&CQ05K_^>vhu<2l0@2C%I+Zn$#h*=Nd{6Mm-w{b@X z^<%}N7$!uu@36o6(;S$P0VD?wl2Xz^C56j0gk4~ z+S+u9t#v@7TKCp_*PwfRcs8%^i{+mwBRHGWpZQT4Oc`+<2b-=LDsB!9{Nc%DLI#!* zw>P%!`A~m1mMx5CN^D>kM_EcZaw?0BJCnJZRcgvBnF1%nZ+#EcO&WuvsI#};_j z6H=Ldk3qM7W+LIfCH(1N1h4NK$LfDa@x;jM%m};27{3w}okrEnQP=qH2R{yH1h8ao z1S?;R(HTKFo|4VRoylDO!MO2hE>u7bCL}%8)i>!4gJx!zQ(Doo4fnBatPljA)#iVn1yLMfuv&vlAcz6tlulgaS zVDb2iJUT3o2|@WZYXpBg8QNy#IaoJ$FarQ?RJFvN9PF&`A2D|!s8UHuE*L+>NIIL# zyqTl8`27vyHy!2D_cw?hQp9V&I7{$=uAz;hN+&iS4d;uC?dJa&{}NuE(Bh5=Az3l$ z0`sCWNH6f9S*u{Qe+hlP3GEH$cQd09-^xA&D zOU`1b=Pf>*f2Pe_z87WH_H0iI<@0mHY0`9z>pU|mv&;7G=1|XsVY*$EuhNHb;>VUz zd%wv65Rj=73NlTZVs#BoTuQ&pJ3mWh*62)>qT{YrS#QJpCqnr+CAfo?D+Ek`Jeb;g zHE(^Xh{a^1f(QWrIq4xXMac+gphnN_h6UlRNH99 zhsh(^nHgBiTF!Is?@hB3y!gmHld#0)MZ$gAB7-<}l4 zu5-gEsx--d?NunWTvike^i2aQjR}YOly)GeNuyxb`3HD()3aBwla$np1fLV_^Q?>D{bRpw$y%)dg!SCEHg&MR1@e^PUM~)6Qf* z|LSs&95G43arVsC++8e=f?+>+f0GBh9vAh z@E_J}OVqj0Hfh_#75A=Prr?dmv29@(Hy3-}`Ryd;KQoFo+mkq&tT!jhBq+2Th1Qc* zpXjv%gcSL7BL{Kr%rI83n5eTX<60?iZ8&1kZcz#?F7}PsT5G7Pv!6`gY$GT)F}^_v`4?05EL#w*H~djo z-^7*7n-t%?gO!z%h%g^kubfC=fV)|JwXs$)Z9)*=d^nHy|MWCo9wvpZxSCbQ8|w^a z_v(d|V*TCB4(OC0)KIP1ZsRE90HbdbI^2u&t0m+WRgzbDyX9|DCAo#S+d>~og&;Vv zA4}#%@bDOa=FSM?;`cW=c{Z1e-xrfxSV`5L2I?A`u(ncSWu;`WuPcE8?nI38Wqf1+ z4tD0*H`)2Ocz*dF>KeLrRji`Hy7v8hA9OJ~{G!&bl!$k`qutHLp3va_gbnXcSnvQs zhxhLd15PjUiYl4C_;aJ-U%ikl%TI~heD9+!hDV-t{bnDv!yJJ$o@~v25yGm{6OMaV0v6Pt>}>0#d=W!^O^sjR;`D>~Lbn_@gwE&u9v6lveY}zDxXbUm7*FI^V}! zR0@?Cm3LxQjy?gSz4nh;AFI{MZ?WJaotK9rk3Bq;=+VB63Gv3w)wHV$8m;8VoN~^k z7xM3;KXCqX5!#+}UDOCUEkF6SBZh=byHFRk{$~QYmma!M=jLipV1PSb9*(%V*yHAE zj}V}hlJZ-1+$^u*Mrk#f+2z#M^~gx+3@wtM=cmN&F(zp8xNo_}D0k3%pP9H{&#9ULZV%~+>D`)E!&3$xp;r5_MP)iEWC2PK>_x9(4fR0*TaB?r2>p5&bVZJv#@Z7i@AGc4|(020{jR8<#c=w7NJ zSZtj5#{4-(NX0gDFW~hygy6E&@JjxfxNb(xTJ`iuZ(`$BK27#Zh2#xP2$l3Q%8|U2 zZ&SC8#CVI%>>j;9*)L?hgN=h+gkTjg&}fC8MJdR?T5Ie!W}SVt$G%3;D>qF2W8-aQ zlif@y6>~sNqzAv)&g2lw}m@~quTzk=Kc5rj%10;Q0FLJ~mBmmOSa&zG#BU-*}ZvX%Q07*qoM6N<$f}nv-HUIzs literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bdefc64697e82ad802b3bcbe99a33cb20f2fc546 GIT binary patch literal 3856 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 l145!&| literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9a096d30688ccbfc32bbb9188b1e214caa1ece95 GIT binary patch literal 4689 zcmV-X60YruP)^=`~Fo(<|93qIE|Fi6SE42BNIWjvz1$%gnidASm0+J2Pn7-(NHDdCqgr`sU8(Z)jWip4Y=E)Pe7xDBM1&)RDHR#eASg9>gr(a4LuS9 zHfr=LhexFpPXcoR)$M2pFi1Fv6rZ)IHD9I{Kcye)28NWN1m8s0Rt-Nv@+xrGQ0i8I z8U&m5YW45;Uw*D}sFV%~LDA8FwD+&-Scl*>5MD!}(|X8yiFmitPrWHC>$whFwb@Ki zT;S$=1adPl(NU1!^vb*rb zHft1E^I|nl<&m2b@w#9@|mAy3o>!784XVa`Sv4H zoLsW%pe6Mza=-BbA1x75Ci8Db4uAx$G#Ylt2PQsdNj=2`#f{wj9|-X!;AC;{{$(H> zfNk*siO(vop+snL;N~AnnST*hdSHt7@4*oh%}ciD@VOvnt3`$unbv}?0F21HrY__!(l zJiB2gV)PL3~e1NA1;S z*2N{!Sl4Q;iZc~{>Zra6uDY+3Yk<#sEFedy1gqb@mxas5Tim0z)=sV#HPF=1hS4Z- z)TnWGb-;VL3$9wroTje1oz8Zf78)nsRo%HJ3x0p=!(P94f^VX0s}se5w-Sv)!1Hg< z=7AN;6ZxvL>l{rkByHy=skWvvj087_SQA|$R#cTZe?Q~A1mLkn~XDOkq@ET=>*ZAk> zX9)=P#`HxY{bVm>RkC4a3T__m#6EIQuQDg+fk&ZU*Tz3S-aAk~ymd}I2fWw) zpyzj)6TH>Q&7iw1!QXWEyAVBRByWBihftX|I_>&Sw!WLe?!TSI&}mendQe2&JCc__ zn2)Xs@c$Ia7;bsOiJC8xex$Iq93%g#^pY$gZb)ZtRr$0bpiS3~-)OT6{t6!a}!Hnpm( zhU5Ek%<7O5_I{CON@TbmrzPEAZcZ`D3Z)%==8c(n_&A%CsVHgW7mxkJtn2j+@a6~i zO~y`bM?-BBso#8OQoj016YJxW5Q2VFBt0dINi)N7aT~mEdB-kLURYtW4TM|)Y=avk zC_Z4z2q4yUtuBm?@Fn))aFa6nmJZ%|X1Aia#CwnI8Z^!h#!|bd4aXG9(Bo6lLm4Vx13uc94?%dL`;m-;$r%JE##Vfix=5 zvHV7OGb1jVu~GdEcwZd)DudQ$MLR<(FfIW;QUl!B)H7QvAk>rS*dUWKr_;(wOBpox z1G?P!n8~9EzdM9c;{$q?J^SWk6y}vuaOyHwiYf>7c%FV*?tLhR@TgEl!D(4x1$i0y z<^_8?$z0$gbpqS%T1Cw)C3O}pA8)oZpTBp)WVxG{D-S+BkHFCb`vb3G9>fguAZGp~ zt`=31@>wc%71w&oU9~REijO8@##rnI@mSSY>S%7%5i&jy0E5BEo^40Xh8KMho4|HE zwLY*W3h*!|EdmG}AlD z&5doG$uPY(6CO3H*LPi0D_h_Fl6L*g{4jW20FS<~2(`VM14-#rmDTd_^9u(gr>VY~ z^j%rxW#nTp+>EoFobA!;I!I5+BJ)5logG%@Xi|o$k;ZT(s~RB_eNC31&#I);Ofcx+ zgZOEJXE5AJ71=Pw;=J3$hy8X?`3 zOfe_tfjvhz>MBh~%`WcFlonj&!1i?JK03W$QmMALBRndE@Td@UO|7J*oFx13Sw8!p zZxBL7TkFlK5+1Pi5u< zq5TqNDdEJvB0k@kNzu7lC9k9eZl>S*7Id^5aMTPg-Zxu!)w(bua!dVK>O z`VMl_FOZvl0e3G~V&+dKI(A|&qQ*@bO~L6R+&rE7k#p*BDcgRZN&cxS3tw9?n$$?q zgfLG~RasNNKvCD+&bE!2?EEwj5X9U!ndyt7(75y`vZA<}!`~jKB>yUptXf26X$@&R zZw7GJ>zmoXJsqu=8)G7ad!DB^s^ zQF7qReE#yA?`W)RWqizNWWLL-7O8jKv@ zd%7pcvv;yb>(l#3xPGl`WNMu4?<6r@5HzYm7K1Rmi*f338Sy`kAV0f`KfQUBg45Lm zj~&61=k8}%VE-VpvA&74MR|uH1Bpki2gFV}hP*q;bj0d7juKXD~4P+k3(NxeA3@y0-sxYS(v=c_5d}{W(3ULN`Ju`tsacvlus}%XS_5I-f1SJVH*|c>+fJ z6Ea~W!Q)2YH{#|_)3M#B$T@nR#`-2(-_LfaWUkt&%=c7lU`+#N)`Uik8s3W>gTY8% zMm|S(WYTh7$MTnB*!{P&)K|6O>FdmjSEn&Qq4!9OeK?%X4kMdhIYjaKa*EHFGj2*K z4?S~VkIc~>nMey(w)}&&-21~Z?D!NmZYJ#Xd0U@%%l zj=h5&uWeaK$OIqaS4OaHVX@bt3D5Sc)o|eXzUP}me zY8B7CF%y6ZG5$TFKsTg>{r`8Kt?wQi(9jwTH&c{bZI==}_4;%|CinoL_13WJo!P`c z6~WebGRQnoY>}@15C`B!mil1ehj$rKm14yi#JH6cdH&b42Q0gMww5iwJYq6#5h{Vk z#R+={HTsq|+Vsk%$&%+HdHVGkgO;B@TE-vVII3usVE`D6b_DDyUR~K^AmRcO_X@br zRIR>nad+h7qss?8Zz8{Pc7Cj;GfB5tM7+f zww_f&{?$@NkF2_T(8HKhVuNsYbr`VBTDZ77^3wbB6p;h)`FokxH6%q37d-?usa%qP zt4eiv4|n0grw0$f{e!jq>_dwNFl$L3%3J58q$;%iW@4z;}<0ihm@4D%vl`dv(k4Z_LAvICk|E;aD0<;m!ER$UdV)9B@&Zhg=%$%qR~ zd|ANntkJIZ)({psjPSet2#*?0Sft;O+aXh^C~4&93Exsz*kdu|_)p~1q>u-yDVv_FzXTH12Z&vNG?GUH0dNZZHANJeaPGv)<5ONKm z*grScwX*4zLp0RttnHlDkP?!&=JBg#J8U6G3MtkMbcJ)!Jbv-Wk2Xmu)>_hvtJaZ~ zKcCJbD^9#xgNl+yw!D6X>?38iDUy7!vwZcsfr`vsMMwvtEp2b|%m7xrGL7)4VU|`^ zgqG`VeEUfbUvJ4CbT`EUWNO@fXC<9oI$#KIawX{gz)hiQ?6M?iP00<0=-81w`1B+q zX9c3Nv;Bm5ZACM?K08CoU(O6w=H4Jw#;D|ywMByonC*3Zz=v}Xs@>Rdki zV)D!YCQS85>!a+dg3%x;y;#q=6BQgyF63-xg+-^&OhdM-1o1mc*X%PTrSL-CM-NHF zR}9gGI<2>cpl~m|hr8hJrNP5T1ETBbT|9_uguo5b{YSS24R` zvE^JGv}G7*C<({0x#; zfDuEf+bks2sMum~Fl^ji_~P}UQhQr&n9d2_>g3v~djug?fSk_|{$oQQ7zFG?ioZ6g zHDCYB|3%k8SQzw2e|tmcBM9aJ_X5g}*$<9uz+n*kjdt>@UB#sgpPdPg~d?RNU0 z>d8hUkt#`;6e0}4C?xJ6J-B&ZvKd4Jg6l|%gp>tRsPcu%nB!F9mvbloCh-3Osa)Iq Tq4m0y00000NkvXXu0mjfjou|} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..aabd24a0810744babe16c85d898310856fe187e2 GIT binary patch literal 3833 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..44cb7306896cc439cefea1e9141ff4546d328402 GIT binary patch literal 4616 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c2e91ad18b3d5a85143ee37b197f23d29c275c16 GIT binary patch literal 2529 zcmV<72_E)|P)R-5c@t(BUowe4Sb6@`$?$ZeRJcmJ3~aAsfzSlfPo&2ygfobx-+`&`a> zM!*QXCVemlQCKR$EWiU8ZcOig3<;49OCFQso&pd&r+yTGR+rEJ-7m&cnMj?mGe?aOdU zv~gQMPW$|4Na|x zqJXW9HU2)s2o7|?R%vZ?T)SGM&wTm-vjd1(5Mq*iePb&dx1Z+Uhl=?AX0uTp%-?+^KLRqnU0l_xeT>6w4v6@I8@|q(r&Mt zSUJdwqyLP-b;J;ZzW9G+@X6rolgn1>^$RRt`hZdLdy@;$jE=sJO znN3QOH~E#_Ya&sQhz`2U>KQqBIH-*Je=}(cZ5=(&agV*T9vX#mkd=Y_(`{M$lcNi4 ztTb#|n!;4S%5L)I5$9MQagMYS4_57pMAc@^w9)0Po0W~f^UY2@tt66k&X*xdHIe>h zd{f}diHq(%lJ7NuIZ^|_FfqdPp>`wBHtqM+Ebz!J&8~1x%2f)*@2~m*P z9cPpJGNL|S zNJ}qEr`G^{JsnY48+K=&DL1O$)T+SM?iK*c!V9>evZ3gj6WWWnsQtb8^M4;MS0dMS}pXDiGi-(vu#vdXe5gNFeCHXWP5Ckv12 zH?m(9|MB~Z%Qg0Voj;0QIsVkQC^&rHi-e3Ia!Q?%%S7fs8oh&;BY2l4!lnYR{t8eMnzILODKmX-8ZK@jHoc%3}9w@>}rVp}KWy8kf!6>Y>gm_)&Oqnz9 zREH6t8cNcOd+~N^WTyWlmn2|F8oe$$~9sa4r< z>TCs{eQ}DM{7O@UC?tbbmnv_35(M3cdi5UnPFn~7&fI%pjKrAz_dvtiJIca zJ6}j#sXa~f_{%sCxsy`gY#GE~(q|KYJL{GIF2=mhqi9nB|4Lhv0O zNUf@rksc1bv0^%rlYIe*c+i_Uzp^3r;3OUydzFog4+HQ}++tdLxRe4$4X3`bmFgPf z`B1+CXlPZiEi;hqr^izIy&Vw`j^vAv=d=IV1!hh4!^%qkbdsG{$?I_`)Hmvn*s2D7 zk>9S7^+?`z#1M9DcoJKsHCq#N*}UVl#ZND07xi+P$iXk4Wt8{OPFY=j3-51AV^8vT z-D(0`MF-JAmuOL2la%Lc)Sj)?7g z2Yfsov673FRW+dQ(Bk9iNN|7)69Zido9M~NA95P4PU33$O`2}C)6&|3Ab^|m5IkM& z5yhS#|8nvxd1m#V&Q!%rn70S6l=SdbFQtgrVh_>Q zuIXL*E?D#&*4C~e=GCuAN-eh7kufx?+ITJY5Ubwa-zV~*UqV_e3va{2|Ot=mgKnan(qe-dVk1LVyd=QT|K~^~TrB5xLCxT(qk- zvALtpzi^kE2`#PQ8l2y>|BQ5hli^j4t7caRJYoxu2FHd zvfgMd-7iR#%R$%;(u{$yr_t)DsBWa9x^W=P^$v-HqE3?S00t&Gzqin6WwM=OW!5XL z62iJ4n@t~(P<3)mi3m`Uxh_$XgwK9#Mi!t@DM)Lf%kf#^?)9<&n}0k`W}{Gs`11=H zuc^SupL-tRA1o6P;sB?6POWEXfCQx=t-VbiO#D0PJuTi|t5z=-5at0t16=6CW!Fzg r2;2~Klu2+%E0gW4I=QCgZl(VMVLO?rzpr$?00000NkvXXu0mjfA;0L& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c2e91ad18b3d5a85143ee37b197f23d29c275c16 GIT binary patch literal 2529 zcmV<72_E)|P)R-5c@t(BUowe4Sb6@`$?$ZeRJcmJ3~aAsfzSlfPo&2ygfobx-+`&`a> zM!*QXCVemlQCKR$EWiU8ZcOig3<;49OCFQso&pd&r+yTGR+rEJ-7m&cnMj?mGe?aOdU zv~gQMPW$|4Na|x zqJXW9HU2)s2o7|?R%vZ?T)SGM&wTm-vjd1(5Mq*iePb&dx1Z+Uhl=?AX0uTp%-?+^KLRqnU0l_xeT>6w4v6@I8@|q(r&Mt zSUJdwqyLP-b;J;ZzW9G+@X6rolgn1>^$RRt`hZdLdy@;$jE=sJO znN3QOH~E#_Ya&sQhz`2U>KQqBIH-*Je=}(cZ5=(&agV*T9vX#mkd=Y_(`{M$lcNi4 ztTb#|n!;4S%5L)I5$9MQagMYS4_57pMAc@^w9)0Po0W~f^UY2@tt66k&X*xdHIe>h zd{f}diHq(%lJ7NuIZ^|_FfqdPp>`wBHtqM+Ebz!J&8~1x%2f)*@2~m*P z9cPpJGNL|S zNJ}qEr`G^{JsnY48+K=&DL1O$)T+SM?iK*c!V9>evZ3gj6WWWnsQtb8^M4;MS0dMS}pXDiGi-(vu#vdXe5gNFeCHXWP5Ckv12 zH?m(9|MB~Z%Qg0Voj;0QIsVkQC^&rHi-e3Ia!Q?%%S7fs8oh&;BY2l4!lnYR{t8eMnzILODKmX-8ZK@jHoc%3}9w@>}rVp}KWy8kf!6>Y>gm_)&Oqnz9 zREH6t8cNcOd+~N^WTyWlmn2|F8oe$$~9sa4r< z>TCs{eQ}DM{7O@UC?tbbmnv_35(M3cdi5UnPFn~7&fI%pjKrAz_dvtiJIca zJ6}j#sXa~f_{%sCxsy`gY#GE~(q|KYJL{GIF2=mhqi9nB|4Lhv0O zNUf@rksc1bv0^%rlYIe*c+i_Uzp^3r;3OUydzFog4+HQ}++tdLxRe4$4X3`bmFgPf z`B1+CXlPZiEi;hqr^izIy&Vw`j^vAv=d=IV1!hh4!^%qkbdsG{$?I_`)Hmvn*s2D7 zk>9S7^+?`z#1M9DcoJKsHCq#N*}UVl#ZND07xi+P$iXk4Wt8{OPFY=j3-51AV^8vT z-D(0`MF-JAmuOL2la%Lc)Sj)?7g z2Yfsov673FRW+dQ(Bk9iNN|7)69Zido9M~NA95P4PU33$O`2}C)6&|3Ab^|m5IkM& z5yhS#|8nvxd1m#V&Q!%rn70S6l=SdbFQtgrVh_>Q zuIXL*E?D#&*4C~e=GCuAN-eh7kufx?+ITJY5Ubwa-zV~*UqV_e3va{2|Ot=mgKnan(qe-dVk1LVyd=QT|K~^~TrB5xLCxT(qk- zvALtpzi^kE2`#PQ8l2y>|BQ5hli^j4t7caRJYoxu2FHd zvfgMd-7iR#%R$%;(u{$yr_t)DsBWa9x^W=P^$v-HqE3?S00t&Gzqin6WwM=OW!5XL z62iJ4n@t~(P<3)mi3m`Uxh_$XgwK9#Mi!t@DM)Lf%kf#^?)9<&n}0k`W}{Gs`11=H zuc^SupL-tRA1o6P;sB?6POWEXfCQx=t-VbiO#D0PJuTi|t5z=-5at0t16=6CW!Fzg r2;2~Klu2+%E0gW4I=QCgZl(VMVLO?rzpr$?00000NkvXXu0mjfA;0L& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..091199361e204ac2613235bd28e411c619001881 GIT binary patch literal 3021 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 P00000NkvXXu0mjf^+@un literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e531df82fc96b58ee1afa849183b3da3198f7a9c GIT binary patch literal 2501 zcmV;$2|D(PP)sOU&$WvwdZ&Zma30xp|bW?Uq?< z9(m>MYE+h@6&@vuD0m=33JAk}4a1zj{bK^5nSmK$J3fES@A-Y7=lMMIJ)Y5`2CSNL$$=j2F(Q}Acp!EV5B49* z{n2swd->WOCrJ`lHH93`Jj2dY$2ggDu{~}ZjYbQ~q(lop&j(IGA(QjM$npGT>_h^6 z{9N{Z?s^Wd?pVjsi|H=wHiKDECM8O)Y7GdBWc_pV=-n;2JL6(poKS@-H%eVAi_Vnck}swZdXocW<7WAM5N>b!rcci5bY=mzN+4wtId1 z$rNU9d7nycU0Y@VP|D>jn>2%2i4U{m)KO-vdm9hYz_~~A>8di@jMrNP2J8>!T8+Yv zHKaBJ@KCyOe%U%g{A?@gH}-GlAOGEoB(*f*>t<$BSRGoUj4e58yLRKlqd1*=8Ou+) zNxf=WGqluZ+=>hzmZW!fL{DmS3C$Td*)I7l#}Bh~{}#JCy}V4k6JL(Wg2Mp5zjg@# zr6}=qWDT#zRN0Klt5xvU1^aoW5v>N`;`BWElrq)wbs{u@i8-j<@%|^cLz&09;h8D6; za*6FDiWwQyvRs(cuZlVSs`$FlgQR2rR2xM`cduvhJr(rzy=B8J3GB#KV7#sPKtg{%+wo^VXRNM4> z#&=!$vcLm?$9mTB-jFh;91UP$Y*h>LpXYn9z0W z!HzsnHeB(fLND@JekcB&<;`)83b{Qw{)8!n`3C}EGMkAG?#Z{8&$mASdz%-a zthTzvbF{CXIsK|xeZiYaDcyK2wxa3ZkMuFJ=v7a&QnAkB|sMec-q8u4+n&ZNO2psAPf&E*W1AKcFI z?96r%Z$zuL(DNf6;NaO*Ms>|+VO#})?p88dlfc?b-uyGun@YX)^w~ACn6V-C4jrw| z^kzYt>z!L0XuTFXm9dw7!%B(v)dSYbaKjZZR-X4pqqC}x?l*vOv4fa*U>(`j1xyO7 zBgo^{{wZq^S#`mO_p-b@nmo`(CSEb1jA&myCJSuK_2kWTUwZc#h(>poh=A_A_4xCQ zjg1E&v2TBdF8(tM(rS3<4lP@UYXFGa6-vF?apM;g8ctbVHH8&r9Unl2LB^VkUaY{D=k#|>h#P`ZZdFgs&g8|7?^0f8E#huyt?~x5&?@;5KYxH}!IvjI=k_5)~BMBrC0{V*bvL*?j!#7CnKd!otL!b<~+;?9A*@GOR=@nXOmfwN1Bs)!KqsbwaT5>9(uBcOr=n=>WLSea?%_R z_8-dGr5_R>9%c9azYgtnnmjn!)Xy6AP*ZJmv$BjF%}sJki^#Zs4TVh3=9z!vi;VBt zmzGM6&h{wvQ+qKkb`UR&98dqAy&d{FdNG{^yVg5=Zx2rGf!^JMQOM=wmK32k8t5Gu z!oaXT#79Jt5EVm2KzG1;t#Y-vfU0^e_4)<`0Yd!(=;=dEB+g#GEiU2H#@!pse;(HZovs=9VFr!(kzWc4C;et*nWM|M%Etz+hf z)lBtV_s@lbg9Y5~5;x{m~=_N8IVM(*~n~jo%*P%>!G}RNKD@a}pLonUq)r zbh}*(1HvMi6raGzyW)rp3_&il|E;9KXdo>wn|*1i>^ybM;SHXPD3(ibL1;$+r*p4x zI`;}-O?JVZyV9deH~iGrJW#FEQdm(+j^?J_Ub>yQAx98)AxWb;!kWo!=Eu@vek?8S z2y;$A`cgD%YqkKc^>M!qm;}kPRb2{*xm%W$OO`+Fz0^$Oo!) P00000NkvXXu0mjfQ%c-c literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a481e854861d17204d636d2c3d76923a0a1b53d4 GIT binary patch literal 2983 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..45a72f5605782bf3f4f78495ca8d8178a293adb7 GIT binary patch literal 2512 zcmV;>2`~1EP)NklU=OCa-5QxnBg3f$7Ts z637EcNKXx@I^MnZUj|?h?cVt%kiP-zl2sY$0K&cgA4G_fc#{9WXMQv^J4xCwnNc!6 z#4nFLfS(5YhZGW0(nNao@OEZu;3VuFu3wXZK0Oy|e`KInKaru31lei@5I7zua)pe! z%L18~6h!o5Ka^h1=7ULavHl8W1x*xY*HTs5HlDE8yv`DmRT)NZ+7GOOLLp=2qjzxM zAEUU<$HnH*^_87$f9nVpMa?$b-T+ZZR%J-GT7^YTX2a%0e1nza2@b$_J1hBcV<85; zaV+MN%vOt}2P61fR@#J+2Uz~lOy2xF6@|iPDgY>KQC|C60%=c1SzI@Y5>=&b)K_+J z^@QRnuCQZKaxBhnPQ&a(gGhCGgGIli z7O{hqf^B&Z;_d4?Z0+;SYIeP=M!Ly^qq74u=Z2!w=s8tfZPxGf7$0iN+mVbWgeXkz7r$z8!-WAS9`P1eMd_#887D#lM4a_g!?f&!PFPZ z5q?AD^Or|6e|a?Zl}%)Sd;raWj^I1|S#obYll{B~^(3Rj8FeiVP7Z`b22fRUj`}lA zBa$CAfyJqz!(?3@{T%x0>@fL~RdWZ)Yx=b`R$s)$%@MzlsQ@TF-9Qjn^K1%%;l9_2 zOHh5Hp2OK{I@`KXdbu+tz>7nfMWd1*F#&}_#vKX1!{p_K%@_>Bcg?WbfrG!l-F}F= z(-#1k5gW`s4=-i!M+aDx5;uf=-N{A@vemRVccM_ph+61J`Qc_h+_($9*1E>eVgkOw z${`))HJm(RzIV~~YvDFivBxIP=4y98mzp}MDmh1E^+l%mdl9pA761)rnkmda&c*ZX z$Q3e{rOzP!Pjm25x%0~XdpUjdqE+NaOu$lC+16-&Ln+!<#=Va(Ll7V^+?U5*TTN$M z7iW&wQBhdMK%bVnit`*lSV2==D{_U5yH`&qeSIub156v6XfQHuhUdg2V6J!px_=xP z^55IflljY|Ikvxo-P`uDC?yUbm8m$rrg*S8HI8||iem4_-?RIJ?+|1HOVh$wvpyEz zASGa`gQDDX?D)qqTI#P@O@71#MA3YAsO>ks)+TwnvnVx=neibAf^Bc`a4n3#Aln1QFpT4Y`ifi(A_!o zN_VvM@~=0G=gC8TI&x zU&DyK(27T9addwJexWKBr^ewr$uxQCwR%ePPjhsCIhuY`6Sw;5dkBdPuo&pr_vQSs z=jeoxz5yfmLZ$g_q&=|$Z$D4K)ES;AtfF{d8GSvbwt3E-{=_E+lDVab%WYjm#E0Uh zygp(M^lB*He**QvlM_uI7-c6;{7nMhex4XbBd3qna5$%gpqU=@_4IT5v`IX^aRG66 z2LceY#Gef*S^Ti)DAQx6k@ke?u;cw#cc9lw!PId#lS3PNFLD&orHtz^8M&($ss0Nt1S05Isq5y^Y|y7I=36r5b_ z*!xihS=-8NK6jQAi6fAevL)#Sm-nK*TA6Y&dD5*F=4 z_?#(3&iCc^X{Kh(C`z<6T%oU9i)O%pAi!i_H>UWzBgjTR{5zv=<@HCtMbmFn5nERS zE$6NPVDG44)9y6F=S(rHCzA`PLOjf_jfDKKYuNHqK03|FNQGap61jylPMubywxXRQx#uXzuCsW8XCvAa5}Xpo zkw8s(J2mB|*PdJ$?<8l)XAo))0uis&D8WX zr_M}gLNp&e^-MXIm(syomMI4>fLw%95kV9IA;6G3n|*oD9~+b%zs&}8=KI(Ft#`d^ zeb@fIm-VjqC%6k(9F{p=YZebn8UM+O#dYpeFNLB!8f9X#4}vh2R~Fi{-7umCqW7(_vTvq}{t| zqE*XIkW=oA-!LI4v0CJ^Lv`yfIaAd*Q%E&r{LHGAh5t120Fro(RurZfHl{mM9WsE_ z;LMd$Xg=j1H)I?`;)xKuQ-U`=J(QZmA(R@DxkgIfAE{p-|1kuGjl;!NdrQ{cY2sSN z_jGrdMrsZKDaEq~Yu9F|wW$V>YRLGR6q=oYR;_&?w2Foq3&WV198SVq1Kxh>3zL*^ zz3L{#IW?Tfs-UcBL|cGGAeQd0S-a0^0BMHINUNX-@EXdZqG(w1-!!5T0{5{x zLSuZ1NeINvL#NvBY)%coUwIIVc^Gd2I!qcR`bhPfdH`jB`xl2IpSCK_A^G-}9(HFI zbNus5+-&Yr)p2!Kcwlh^%Qs9XC~Vvk_;vSfUtE+l|7mKU7+1%FuI96vh zy#V|M`rbggext8RBd*~fw@G|Bl>hu-F+wwV(aPR?i7l_5prdtIJ3op}!!KT$P1>qB za{gYyyU%}(R?)HM_2rE5bRVQ|FpgM8$FS@J{j03IfdW;u|l0Q9BAs#yBWRQ&aWH{xTTmGjBF1*(1S8Sl-)We;Mt zSO*2rkMiR00MKeR#Ltao_Ts67bXuBPIeqx717_BL12RQ&=$0Uz)m(?lo-k&t3m+A& z#MND~lQ-3M@bvt@(QCBW$%TN>7z0yg$1o`|5`f$<&T^^b3iVeTf1oRfO^RerY9ii# z>RZmP_YP8Zp~gV~NR46&Qd}&Shon&7k!L1P7(^amXGY;5@;X-?kFH5(Laf0~K5J0| zvlb;#b-sqIZC}yT-Aly%A6P z2VYZp?iv7-rbqC|6Z6@(^(*G2Cfbp&{HB_mtO6Qqn^6=E@edj(Ia$k=S3kvMbe{N0 zicI>Ys=jc64O3ztn|*|XCK4EEx;|FVyBdDoqi??aRV(pxPlb(B>$RJWRz2ndVO^NUxO(%jI(g)^0uo+zWc(@16MRn8nOrKYkTt)k&aODD2y!*qf|Z5x}# zY-PeE-;o61t>ECWqBeN}TEFkMd-gZ@GHX!+`A16mZ1Z8}q$Uy=Y@3{Z<9(Q$mdMN> z$FpzSmwfjA7YL1DetI;^H%vz#?hV-H!Kp)++5S#GbyYWqOCG>eoh4wgsOKfdE;9h* ze7%^HmdNC!NQ4kXPYGk+*01>R%{>GL`*GjQXkwEh@$ejji<^#=Cm$yBx7+#mjSHB) zIO3MQvpF?<^maa#-!!WFa|Q)l(dnKaHncX||Ij2 zoo!v5K3t5VXwgRmQFpnX?`oRK*v?s)rp;X1~a@06UZWyhZjC@ZWV z74bf_D*{c6L=FnzT15-JMhg~`l|7q_Ir!J}OnD%dHE+zr)y)L}W4Dp~FH0#n_AOrC zV_Es~qafkf=ebmuU&klF3pWo}!1hKv+d4UyTgR@9A}$o%5%GT96e+=(M}QxzwJu(9 zA9+WvV2BE4ZdxK^$JvrqlhH&`_Bry7l+e>F&%_#1oyTV{CnL>akDmb z-5ovT9yv?F(QocHc?6 zs}GR)qc8xb&I{rB)IA*kJdcS}Cy>568GubMZ^zVYc1R+8vL9{V_t0G5F^UMRxr$Is z$D~;&QP~W-I*k+_`<9~YbF?(JGId@EzxybKQwJ-VnH-LyvrXdjXX?rPk8IjodH`s> z(FuUrWEqmYzkV#QZ%@VDL&v_YrR>>UJd)Cj6k7BD34oN~%rijZP}*8WL&lM11V{PY zlC?H>^Wj^$9NTlz?o8m~rXzNGBu1l&vVuxgdu|>&{%cP<;gfGCbS+I?eEfDkM|YJC zI%B9H??BDESqgxZ;$tD?kO3q=7Hdbo*J$C_sk>>a8~o~fdrXv^EOThf)5ne1K3rl) z9&AOiU%mYhS1;b6qIBqeLBPk*SCw#))*V2nBV{)a9gqFm{tCdwRm(45dvHi6YM8Ys zoQ+>CXX4aA)%L${D|Jd9=v1t7C-fca_tveyks7>dt3*Dhrk%Db?pAJeqoI@ftF6>m zw{qozEsK0{<3b9KRdf1K61;L9(``lw2+(^n zKEzA)Og#`63hH@n#Q}P{oJ?XtyH!JU|Mire)g>XDUJ_FN(NSL)H-$GpNh4<3_@SMQ z5ZQY#k?~SCy*-XstbRy%;b6_WKlMM`mBSkRwSPw9=oC$FkD1q39U>>I;*JRSqodVG z#!K1!X~WTBkq3$!0+ja#3PxSrjhn6Ki5F)uX%ugx*J$DB&N4pzOCc>y>V+^BbZ9JM zde-%|c4ZFLfth01n2r#;8Af?MIzE88X;H+_3C0lNjn+Ypv)5>$qO_6Ihc1(oRq3#T z=KvOoc=TY++N>WoIGkBaLN=`yQf?WE7pD>)eLc;FhX>K++x*=cSiaF$Q^y`VG`mo zA$USU(kN_6ARokblXUsyNbOVKkA}yn0~m<(z|8?B7wba;F%x8r6ih%$e{R>DIuN2s sNUngmD3N)(Ud{2n-#s(BTFL-t5<+sARm=X%IJPQ#>mk4C%5tSxkLvxXLPTi5Dr`_mz%xM!k&FEd8IgJ&D z_|#xIPTB?LfOO<#9vOj0Je*NM51_y*y9*1l@*uLy?*8r%5TV_LUFEXgf9>z{`#iiq z`+fR+zPkc;;OFNTC6mdXkt8V@2n2lW>HVJ}0Xjht8YM|OE{fv27cX8k{7^|4{SXoo zGQ(st?E=yPw;ze9v$zaw(rUGbMhiz0@b&ebPha`uZj_6u@sqAmmm&h@!}&k3P!MrAvA6 z!3UW&YnEL-Ns?%4YNE8Xl%k>{YHDh3CG2(FBdAm=D?d07oPj8ctXj2-b?esQ>+9=s zu!9K0EGN#?3W zT3Q-~g@uzs9$>|a73AgRA&TNn*}G)5b?a8vu3hW!y~Sdorly9vx;n00xq?h4!_(7~ z$jC@yV`EV&m3HlnMx$%2N5Sa?)~s3Mki5ZQV9%aC6ciND(a~X7N1;%Vl9Iy4jT;FG z2^o=>l$5Y}-Fnh|0`YV(3dJ2-)Yq=sedp8~c;t~sIDGgpGMVj+D<~*n%a$#4cXv%f5lBu>wjmF&d-rY|@>AVJ-iltpLwAmC z-ZFwC$Ow+$OjkSW>Z|Fsm|1+MFE7rS%UsXtBYGA|qOkoE)8$i0@}A8Hdd)=SfeCh@ zC!To1MyA*6$qfaD0Uvf~ zdA<2t8vA<*Rw%i5+6?}7{;LVekDGufiXP{C-|#}7#i{s3H1_vW*xt%NbZQJ{ z6Q??LKE90L*4HQBhG;R8%;ffWr~6w|D`%y1K?ZM=9@M z|J-<52ClN|>(8m{yK$i1HFX-BLZc{Ou!KA1Q&?SBNp+8bWy_Xv=FAzgv$F{f4n~qB zii?X`v}h5B4juYo$Z_Ft1S}SdU3q_hKL9}rB`<_T@}y4yGC?4DrXOqTt4XdoL$v2~ zmiq>h>KnkUDIR!=BH59##Q*0Md*6JMl$4Yaf2F0R?A*DNFTecKCE^n%(An8J=2@fB zux;BmuC;ft{jPgip$bJV47Cw@(*QZ@2Ha&b?wjsSb@yfJ`?`6pz~-2M5`=Zy!hAew!8E{$$<1glVE2fFAQ8 z2QHoGt=0>u+!g#Q?ja;#`-M8r8afG4D48*=J19xC8?RAOQ9({l4pmiEw?zCp#yyGC z)6@C*qmNkRrXVvk3g0On0Q6hT9MUwg|6(KE=Aqr^@GpKzlJ{(ffeu`1X93_wzf8!o10Oq)zsJ5BZ?yX_wT2qq=b_vPtx1lYh&-_EDX6^PD@J*CX^tgocI! zL*++PQxiQsJ@ohYBM1Tk0RaRB1=+n27>%l`D$>%@MoJYY;rto^@bK{9*s){8#l^W2 zWI_}a6p)#j$-uyXtER5q%nb|-@a(hCQdCsrY9PDl?(QZtGn0&rjGG}33{4>m}>RUz03BaU=>s5!1Q%OurBs@Ia z;T@;ZXr#8bmXjw>QdCsr@CMICvSG{jGQ5eRh)Sg*Ffb4=FE0RkdwbF8bTlJvM(OqX>A<7F1Yj%>55)AUv;jAOTHpe3((QI1ZE9+| zcCWt0U=1x6OT3gaAD9PB8kBP(PD?3w2_ZgpI-NHL)yF^$EEbCuSSF?X84x`fry6J% zLhKMitamsZ&Vd+496)wqsXH7UoT{s1k$@CMTlD{2!fJ`Z6rO9M^KAd8|0az@S`9g>y{(3{E zKg0?l_E;>IH~UixIfP6m(?1I#J{+jm#Kc6Blaq;xit@|a+S<5u=@M;iZ3FGD4?kB` zb&FPmW&kFW=_f*nVqi$Po)tym>8GD&_Uzd_@x&7(BqV6+NhztVt);B2jMCClDl01o z5`Y)@sj8~`!v~gu@CJg!f{CxK9+jn2c1I(K@kD{U?6h(S}J@yopYyBPJ$>2@@uem6e6jXwWXO3>ccN>Hrs#M z?e_YB0Hl-!fVKx<;lhO>$-7)Gwrtr#Nl6J!O--6Q27`gTygZgJT}pCtvR{7W$Ptz= zT1C7*La*1eV#Nv;E?mg|{rh?At+y~pIQHla;`LEM`rXpU^ok?Y-3b7CawnV9 z>AWET%x3e?fz4VD4F&^cWo1|_mY}u`8#eI%`|l&A43H03^vq2fO}p2_=hwc|3?wTn zi>j(Bd_ErlFPm(ve{f=u-e&tneqHyiRu})Os_NFhS2|DA3$S?cVomaU_wHrm#*IPb z0aBx5cyHXpta~ssRJWWxdlsM1hh7L4SjMtEB|S*T-q}u}T{|6;QoafxY&M(8?RGoS z*b`!7V>x&390r3SK<;!p$<56TntTGtjZ5T3(-`I?S^?N%ui?uZ^;F$*1?cL8U{0cy zWyuq;8MLQf{+~|p$L59*uK?-scub1h?VhGZ9$?n2SwZ9hHgDb>L_T_m!bcfX`0)dY z0rDlb@hq{8=V)^Si_V{>)$3vU14;a1>_a>hGu*G|m6Cl8-!oi4l$r6P_@dF-A9(c8PN$r@gq4r z9(O0*kidU8sjRR2mP;M2*bGJIUy6D_P8Kan@iS5T0W9zg!^jcfe6?pyxS)`Fra2r zTgC!V*4#+mnLjfB&nKzrZ0DtyUZT9boT8#4rcRw2DS4!n;}j|72(8AEBS&f)+3j{s z`R1z^`5S$$OgM&}&?YKPM9J*f5cE@E--MoRKD7^5(3oKo_6r0Tk zfXCw@Jw2ThCr(5Z05C#Fh{KB3LM)KmIsU@&K_RGbpdu zt10j3=m21h!N{`Y2|S-@MJEKaMw)o@{Ap%Y9wj4YI4>lPW=@iogrUQTQ4}^!$YRp} z9pX11en?(kp1;4cvNG1LUCTG$d@~T@ekkFAj_%1;%FD~y zuwetIPoEwX@m{nlfJ-ZYnwlEAy1H&97vg=mDa>Z`A>e7P z)>mJBmH+wjOQsDmP>_;N($HZ5ba*{{?5t(mcb91K^sGKR|Nb#%#*Yf=>4Wbt@!pm5 z_k*+_LWn;o^wcUf12|h!%I@@R(v3Z*%Wmmow|bQ=_8JnNLe;>{6ZhrG!4LclH?ko9TToh)r`DNf!t=7Gnk27_|NIcTV-fQ2p(SCu{ zDU#s>kN#Wgh-(|*(89>kSnwOV{%jF_3!OZrIDFCFE|DM|+76gEdj0~Eao2jp_ zA253%#EXi@d(Gowd~6Zs*A-pJey$-5fY@fZ4NWqu2M0?^CBv zv3&V*T&|vk&Cz)qfG(e}U-A}NxJc)#rW_3NxzvxYr;_5`HWF^a;RL@R9`5BnP(n*AA#M)vL7M@B}5U)I>z z$lA4QDK0M7Ow)gWld7stRRE-vyMTTJc;%H>0?2oDb@A-8&*F3j7n9pO9(Jo&L-rLH z7sn@`d=fw&dWvG}*RSWog$qEJ9b1uzP-2b)^n(LI-NHx7RwGPag4GoIigaMN!zcZ5v099O2->gS57`2HD5O#W8c{OyW48#av3 zKmVNU?Cfw(285E55()|m=7L&=e5jYphxU;j9*Is*#($dm< z;p<0BOA7@B1uR*zBqH*_*-@iL72Yivba6K)Cx>_5d51}p27bTS)zw8&Q4yOqZKAQU zF;cTOV3Mk;7w;B^Dz85;gxEs_<@Le(nDNlO6-lGi- e4H55Rq4584SV{+-pw8C-0000_DaB!(g#(4zE3W?7u|;PM14q5y zL#OJW0QnR!vXk9p16!>Y%RfuAR#kMSgAO|oIC@QxPA8TDzXCivGpG|JwJ374&8Gir zNyd`Wj+nOFfe|C#)t4KpoMA72#rgaZk0;T|B*bvFrCes6B?WNm^;Nb9SDv~m@8rX z|8UB*g>F*F9l>MP{JJeAms2PtHgN$$zSGfBd-d?5Z=f$mqpnp}Tie9h!U}5ZnmXED z3laJ3hlsoh{?F4F^ff zDQc&Ms8OVdDa=@w=ODfHX2_724AtIVS?)gAVS@u$`^SgrAL7?R+Rpf7=W+hL?pw@e zYdf^%(O!){qjPf?m|G+cAF-*s=Ms11XHSh{$Hv)rggn3#j}GONbu&>_w+kX5qOTdM z7CA^-E`nm;3RJM&GV6oHqgfr$RSh>i@v z%fp~)cPOindCNY>Vzsr$i5j6(2NtC+z5<}Oh!tU34z9`1pE<%M`O2z#Hf+u0U*DYO z+VxsZ9gi-09-cUmg>z%+8|2$6-=BVgRj+Sn*2r^s8O@ruU!UyHrOIy2cd{$ire(lV zXnq6sz)UU1MQn3lTz}qQGYv&?UbJ@ZInAHm`j#qFgSO|lL9f@b>bddEpE-gr_nu+t zYrFB#)v<3$5pgF44B=6Bh&uh!>*CfC8 z{WRYGFdZo!<=!5ROdWcjx+XoJ9~tP-W;R=S`R)BA@5|%(nJd_A60_Mz;y0sMJ?)Uw z7@H48Ig$quc&R#d7Ow<-DSPzWWsGK1kQJ>DRN0m!_m)u?Z&! zT5$B#WdQU#8?$20@SBN8oOFt>cV}Ztw5GijJPm9Rq2tyDNUOONjXj~Omw}w*=kVyF zcaUHHp_&P^-lwt2;vg47GA8UY(}(9Vb!Y(qn^K0c=X76Al=ODcRfJ?pQ~`_c&nDQ{ zq)Bq#mWMfXE=aQtkUFb5K*eI2h|u1VC*K?HL>^%MmJBEIo_Y%#pWMT^0VNLdXU8Ao z+409nIUCHPq)C_>3``hU%<{=uMEGBC)w4>4J(-^GK7V5V+juM=JDTb=kU=>Zpi}(p@ge|k5;3rsIsWE zQd7S1z%bsPwcq|B4=Cl|1HPc3%!e;egp-sVL1mqhy=OxCAT5gQLVr}9!i+v$0Nt|(z zv1^`S{a>c>(1e?N!=bD~65mbbL|*4gL@H1NIE9uKm30pHmw%|{mA@UN!BobdA34J8 zQK!-C>`s=OJlSwC8l&FIh|sI#7Wwn)l~~s9iy`^>&*|H8BbyIJu_gU( z{C#Tp^0_aN!0O$xWE2Dt+M^n8PkVf0wknjHJUM*y5`X{bFxe+cZXdD}5I0ny00_Ii zsiD!rE9(!iCpDjYLyxlSg&gkcW`8SnO)4K}MDkwhP^#*U0Q~mBOgs(t&Fz(+raBF^ zB|Q>TgMm%kGVcI!JEXLoQ$gl|ctA@bH*+5!%uXS~-(K}=vg+8LHHeKV!zee|W4{Ua z^do-k0ABq_a-lqvDMJhJ?|w6+zFyabcT$G&VcOkyjCc!#LcS`cI4lGUwG{sFa0U_n z6Wyqkk7R4+5DKq(Gk$bGK6-No zyAI?tdE5Z>dVA3#>v##jOV~qYm3@gVy!)AI~McZ;w`4MP(gtY)s*^-QPRp1YSlnQ=-mO*JL31 zxYOrl*YPm$*{0bH4!;?tmRHo0@LnoAcAa#}F`U@L;Y;Z&Vp|^1R(B4}o*LyqzOl)| zBfr?nueS&B zW5RfPdNffH{w{f?@gsV00K+5u z5Ia1Ce$6qB)h4-Ee4Xl=M(XO#2myiq-3jUAqnT6RitH05JiYj{)=;Glm=rSuTaKq3 zdJTzNvl7H=SC(CjDxa=@oZ*q$ufMa{x#u)1SARo8qs6VRKrSmvU-@Rsy+b*ySEJ8c zAdk8-ZfLYHf5jIh@5^(g(jiot8d$md8y3C1t6k)QBRv~^*54}Vba6LsL;lyo&nzN$SA5# zA)t3Rg8O*m>+J!6siu)DWhO3`RBGnZ?SK@5XNuBR{6nj;&7~nYE@7^K&D>H${XfHs z6weoDEO+@J-JVhg$F6x)2=Ot{&7JNYMzs|3DXnR9E82C;8Z=ZX#WqmaA^bSCa?FX}+P+^woEwmsyb6TcdSj$0dm#k^d? zGi@D)bO@4KA>_NlED}5V1IUm1*+bRqW@8giO2Md3^d%)^Dw1th)K5z?mRxsl;*3r^ za4W*%5_>lZIZ+_*5r_yW=?C<};ddL#h2Ww@=1Ih1gA@k}(wCS0*s=c$=C@_|k}mXW P00000NkvXXu0mjf^+@un literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..731811a02871ff8e7e5e5e0e7682732e124a2bb7 GIT binary patch literal 5362 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 Q+5i9m07*qoM6N<$f=w-yrvLx| literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..731811a02871ff8e7e5e5e0e7682732e124a2bb7 GIT binary patch literal 5362 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 Q+5i9m07*qoM6N<$f=w-yrvLx| literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a762e046825330c59e9d28390cb4de23d304116c GIT binary patch literal 6368 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 i#7E7t`7R4v;C}&A;XJ_yrB*Wl0000 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..45180eab47458b705bfa2f44d6f89b9478161f46 GIT binary patch literal 6391 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 FV1m(j*t7ru literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..13db529b68b88c48267c3e047f8118e6a748cce9 GIT binary patch literal 5233 zcmW+)byO667Y12ELQq%flvqjW5KtPVTe`a>7M3mnDN(u>q+O6)x&&eA5D;mQmIapX z6~1}DIcH}6m~-yj-;L)!&yCg6P$DCyA;!VMAyZM7*8$#^|J_6cz;i9rPYeeKET$qa zqX)~~hx^08`i)(o(@S$ZuCLQEuCC0kHXlCVvHlTJw9Dip!e#hMYDuvoN1&xBV~;~k zF(Ln&5tknJw~K>w?Q0m3pe;rCmwRUi z^@jyJH%N@nM}SoEBO@YMigk8mwZRTJ9p5Uy(lXt<1B0xJ-jvKc>?qLFFtM`sT2#+e zKLlAl_%lgO*7mSJ9N)R&T1_cppN2_H?2=!GLK^4ZUWt*YEEmxa7tSWdzy z#islELe2yRgSol6I5_xyF3*Jr!pE25jazduD>H0jX{@TkRJ)Nk`p7UdGv5+Ku2Nz` z-qDXDof`s7PLk3^UjyC(HvI5uQ{UJrpOD{wZE0zlpRLgs_X=eZo;LnT!e> zF`|LWJxY7CDTVX@z}=gIf79}lqfqd?ANEBH5`stPRVTajS;ElJFiM`{Jz;L8iYTh6 zw)V)A`o-d0cBUK!C?Ya)XR3@m`1+KYh2>t0>4UJgwwDo1S|%o{`ub0Vg@rdZH$AV8 z?dp)o3Fiil-^(lvpKfyL%BV>{+ImCriu& z`qpQ(j7$Oo$$Xuc5t*5pywwW@9$`#G+~)0>3h_^wiTpxrk&TV%mzS_A^Nx8tsKg!# zB<+ogh6X!7{{+S^zEIi5=9#;@B5tXMwythZBCY72vAvR5FV3Ub_o=C1cFnv+gjZaA zyo##odOu_6#*ZkHjg1X0{b`ABXf*nbvNF-c#6$uWf5gqf_x!J4Ar-rv5QruE7XtkB zsqY&H7f)r%diN!XypU_BoT_Q(hP{QRB(pZ(&v|(tA;#6_wzjOgrD|4_#VR0>51q}> z=&0R%z0<~~QAbVX)a()t5b1BGEtBY7QUbjEpyT_rBCda~&vtz$P)kej7Vm9=(A%K) z_I9-)TL%Y0C|Xa5hHNUL$B5$TeIb-{u&HU{MzC$u@6zd=God*3l$LRl6Mx~|+-Zq< zC|Jn(7bKm}!u<@Im6Zjg^KdPmZ@%)VU=Y97X^P$2#^!3~`>mbNEpTGBY@z#!ig>Rh zWi*>(C|4QG-}}Q>pLz3Nx6zX$>NjDP<_;+zeFekv^BHqQJsJb-Z+6P^k9!|$zPmnk z3Pf1TYimCompmcO5pi?gnbgRWRI#>ZVPj+C@ye&%I#E{%pWbw#LCwZ%@N{@)div-G3E!8Q-O8$AT~4{xtvPh2JOvf!#iour z8v*SWiB}fMiR?`Zc#@nC0agEOW-_|fu2!+@s#-=$K}o5!(izN?zEWpL!kIQ??VrW; zg8#+)`?$=#*XMoe)>t2g_6yvh`otg;^WL)TT{<4Fy~D(JPgqCv&aGrFUt`_=_R0TA z8}W8@dl6tFVHZ}G7cgW!mz?}aM^~38opG`!=8j&nP(R<}0TA&=_hglch*pE3_`}FC z@7D4WWerXuHDc0D)CY*{qLg~-uWV;{`pCR9=Iq=9ibej|&HV=9_fCV(y5e|vc&x3j z1D-az<)A)RQF*+QzMiHEn5E#kNn6b5=vlw~7v(cK`A`-2G*twGfhk~0V$TW8y}vP4*lPT^j!KVb_h9W1J;#pOe!#$fTXz~E@roVPvMTp0tE zZ8Gm;DBBLGr&b9KxZ;BJKK1l14m;8$WK_G3j@w7GL?mQ$`ciHf`!J?kBXJcIY3yqE zx+4jB(&={>7B-H0N@(oOls(SEmn^<*$w40gmJ%=2UyXKJ3oqy!e!l-`nOdlDsc$Bh zK$>f0dq0=7Y9=S?{>;vqnq**tmzzylOuJj{AWld~2mosFwqyi&0M5fvc3g|_QKg$K3eS$-s z>>Os&0+d_tPi-`mUwNV3Ynl!vfIl>$6-`07&|vyij5=Mj)OIF~*#F>yc7u-X66 zS{@uLW2KBAj+?jK?sLT*$~VMO`Rx;R98unbk4z&)HbqK-H?^V?A7fty$Cr0jZ8n#c zaRBaaTy5udGiI6cdaMA8r!Y@zKnUy z7snDq&bU?Cw7fQ1qWW|tN2+EJhhA#BY0B3q7AOED^S{f38eE>+v_{g5o1=?WPwZB| z5HSs!BAk&^^{SvQ^_2sBTjuUHJATwLj1OPnF&73veTt2(kEI29hlhv0w08rapRsaq zoJ?uHG78c>?`6kZWEc=p0_IYMVYR!_Eze_6DWOsggr$55R-pl&)Bn^HojZIvV0!51 zS)#Tui^i_%kj1D9pPuY>_g^IN@3c~3_Qz*X&d64Yw$&V<2lvT%pc3>8O&%Om zGblT6!0`G(X|)nLa)w&|^LYHR%Ge-N6NVsIvi%ah>}lGmjY`e$Eb8vHkK<|(4*EK_ zx|#E_ZefSnZQgs**JTnuc{mDmU_t_EN`DfA)KJ_Lu7M$Eul0|oJA=m|a%vji+_$x2 zqeZN97-+u!o0*bJkljTpJNA9ka%GF1bpN;mC5W7oEXLA@ReWYV%V?$Z3 z`&piONE8$lyq=ouoG}lqMD6@u(H*PnjEp^avgl6D!TARE56w2F*N`{e`H*^4 zH|~Q$e;f_%y*s^B$CS6#pR$m+N00j z!_nB7=KlTr#igbHaYPy#8lrbl8=zJ0p3u2(5I4iUr#IU@_bET7KcR-xu9#T;y2Qb) za>p_+3Q!K*A*!|g1TjI_OV45V0hL)S!TYEvZF6%CC{#F4hZ-1)Cqa&(k_(=9=0GG( z#Hj(?5!wB1M)7oTIGAvu@DWZK=bJ2F3~V@i^d($_XC#^EUJ9O^f6KUK#Ny)U(B|(Y zCbkQKK$7Yl(+;MT!AyVx6F#8((%znXzatHcSkX$gH=WwL4AhoZ<-M~#kx+NAMB;+} zO#OYUc$KZmT<-MsKScI*@tVjolA)lb9WK|e_z)K-TcX47u`CH7Q=}S2CdHSH!7eSu zoAV!L)&U%q8bXI|{`?4kqud(ZFYR2lb2#gPTriIT<&^C=4P@;(IfF51Iy717432`l zd<{UC8G=tI4Eh_4c>q@8y4YN6NA&zLDEjo!PD9y(9Y4A;nKA2DovW$mJFQ6d$Gj3D zb8@}PnWL=+3#wrBi-S#zg5@s`J<((%Iil4Hio6*}*Exa7$;kv7#%!$GRH?en#(+uR zbS%qu`S4Xs>7=sx(qLc4@aEofM-G77FYU(bMunr78>>o<%j{y%G$vIJ+68TCtTv0y zo~-Qbg)_S?K0A+AS6AClCspmu@CYKq0diaGyKkIg{;RRN+yMNNz9@J-QS?K6{4-wO zk48xQTKiF&3hP-oYKzyg-h+=F1Q|rMbz>q-IiZ!vlmT0T2lNuZ9!Wh`s9l05Q&Ur8 zkCH-NMX?#+`TVqh?oo0cf1&aWNHKNfL%{uK;^K3EF9%aNjDVT7J!bTM9cAnqf~TRE zT7NT>ORVCp)u<$S0p94C>wx{qt7jC>}iMbvCUN_fXHY9EiMQE zjQIRuWqi3K(5T6s-nqfe<&LMDIp-7*Na7(_vD@4IHl|MOu`0krr|T5H{+Dg*@jR)^ zD`>9gcR*zT>fygSkJ(`I8ZdNN{Smb<5&}R>{8Ju*7LVbH-rnBBrtkmAEG#VIH>9Mb zj9Pu3cXxNUU!yC0XZkS+Vp&<)Oko#;HUY1e+)17Fx0OZy8=sXfFV~ZJ+_Cnj0a6wr zS0!>jb#1um+(r=+ig7Tar16w!5gv@Y(oK{QpS+4S5(0q9tOrhA3? zs?VO$*7)q`#M9PSRXvE6j{pV&K()9{n?GT(L7mrVt_th1(of2)YMC#>@kyS}rZ#?| zPJ4qQ;cNf%bHvz}7N3k(WW@1a(bUes_&5g#N7*KysAvX&w_f}64e6C{%+1Y%?*3g| zUSI2IYj@{K1qC#bQn1M)*yQ}PIuBy~;|9v%*F&0kxy1_asOPhlBL zB|H!a^upygw-AlGs!fF+Cz5Qo!jMlffvWqfb`k7vL5!L^w7jBX9T<*S?8$g)Mux7o z_5^zW)9|LeqN2dNKd%W$sVsHx+~baqy#TAOBF7Gzt*kcb@$n>bF%Q_-*`tBs|M>A^ zIT);KJt?liT+nWQV_x9LJwyY)@EeN>eb$^Z{eQ)r^k`f@l zzTYez&?T!IR!n=H{?$ImEDhiOyWz>3g+5G+kS!1+^9k;o6lrd0;kWK3T3cKDkeF!q z^NUP#TaE>Kk^x{D+apv2?CDW>Ed3(e3Dte`ULr5ptqv=Ac_V<$m{N=J|=V3mlYqM#vPC2JY+KWT$itN;K2 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9048b7e1f5378a2b8f54b43a595f591c7017e524 GIT binary patch literal 6181 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a762e046825330c59e9d28390cb4de23d304116c GIT binary patch literal 6368 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fcf073c0691a7a7356b3c82be902e106900f1972 GIT binary patch literal 8197 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c62cee9bc814880319e0e75d790424d942d80f23 GIT binary patch literal 9820 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 wG!ZdchHw=@h%g$Af9dkP&ai&Hq=h{HA7_YkQk8 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d288b8bf9f3e815d430291945439cb74752c48b2 GIT binary patch literal 8184 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 eHnCcP{r>~ zcVLuN)`y?_&ZLoq0O_3wBw#2Zbb=5NkrF^qY^ZBnT><;*?&`Ygx2t};y1D|ft$wSE z6;u=mMG%lc0wmPX0x5)4QXz%3nYrH|kU&V8_D&`s&tEX}zW1JlAGe%)&bfkL8Poh0 zdn*pw{)#a4Hb~K1KySbsi6;u4NIU@z;0U;0c&!F#0Q3mTFi?h|3@DLsQW5ebh-_Vh zK7C!$6D9Wex$K3ow*j_fR@lFMrOt7T6pTg6A;4h3&z5}(D3p+fV84(eS=%6zR-S&W z*p_|TldDQVv%EfWH#pZ$7BE!`F%jrzZ<{V~T*4Lwv0l_V{k*F5p(=YDq!w4afTsB^ z_SQOT=1K$egkZb`2YcJpF|q-~HYvmkMW^|Gb-|;h_O{3xu1En*>$})VTd$cb;eKEe zptZ9}mZAgkv&8VJ!O5_4UB(mj_BO~|t|$S;1%1#@3jJdO?gKpSZI8;7g8Z){#rv!B z7G~Jn7*n|-1T-V$y4EW4ioz#Aw7qS)k`Rz4rSkBayvKIi*$(?KFLJqKZB^;t zkn$O>{+*Sr&Qm^dcns`)0!XQN`$#Eg^_jPvPoi+kZ>$})VQ>Xa{f+sqw&tD@#%8#5q zd>-$tDXmikG%M_1z6PDc55VZo=ZPJ$UJOTXteI0nH40ZyG{b3-qv6AJ;HS zPX+PQ%;2T7ZPkHI1QZt%KT`_15^%9a``0iFCke~rLgH_;MdPXqXlBroTakPZIH}$C zH7rFqBKc}YQ2bogTUN8NnZfZ>B&_0^c)BREK`P4Jgq#HlYPG1EfZ~Fd#3Gb+fU{a{ zs*Ogg5D?};XoM%B5xwvUa>vWx4NqTJJiOYTZR^f8Qg^PA+L}gcDjTS(sN>|ZGICE; zaPn9gMW-r}2HD9!iB;4}#LWr03li06Ocepm2#p^g4D0}Us?nZWI5=t;I68pHk^V%E z3Lt!l501|2hELbm>d4KgAbsy?(vtHzw5x!#WwomIp+2%)(JNzD6)Z@%wk4|tG&A5s zFR9Tb1AVP+OAVY|9T;FEz&aR!Cq#+HGV|$B8-jL0XHJKFWci9Y{Y(?8R zW>xl6r>$wkDgn)lT<%y~S@H{Az{5OnQ~;B2?@!G1KAkR__K*h2!Q^~4esh!^37O~` z4fZyvC6Xi;5ATWA9D7(Lpc%pO9|(BVnl`LKqg9BR(T6#YMA3JUx4q0TiL#;^R{!T9 zYnL6Ow%YD-V3#1{*W@l*Wa--$3n(u5y$3-4pQZ0xgHQyqxAf!gXGY=|(%l{=Sj^e7 zT2?IG%i3j!Xl&5i+Z+IdkoT=VwP=||?^^QGg++5Jb_hR>o)E$VFOH#i|0_E!EI;cE z@4fH~2b1#cZ3;k*q8Gy~SyY=Xpkc#4bnq|M?EprY`>r*3`nmGkSH=-Dy-#O$Yg43z zP2U{l!)_{|^3aO-28qSG$T-xvJE_BD1Z(owBoh**{Tc!9A8O&Wm| zMe`4AEFMQE4bLr|a!ovdK`$vQsMb~KH&BuQtX>FEjZCZh%pu7g$+|ffF z3S(yI`;(->@UyCBUD~-Kc>3)dnK(DxR$WkZs*<0+I>OK2rc-`e-DyFiRTvTz#KgJb z#LVb}gQMl%jfOfMZ~Sfpshhh@;SB(U6tNxEC+bi@al!G4z&O>dy1>IP$1wY$AvS3{ zecx%mem9jpTTgXLz*i5iZp`@2KxREOm~L*4X1!Zi)5u>RTHAcA;o|0uZ;&U>-C72A zY8C9bEjbO%o15q@_Xa1VXdS3ZW0>}0g zHGlTNi?`t$?4`PGdcA?g&wfgMote=a203<3{-WfzzUp8XCZ9F07t;y5-iw8A#H!k6 zL%p8Q-`dTS)0W#!JOEV{b$s-4BG1nHmQ%;eJAC$RX)Q0@@nZ||0GuhQw25sEwGHSR zEjV)%;+gioYEwXQK_B!JFw^{=jN|6v#0wuy#mPmz(9)dra-N#DoNpGVcA229jIeuJv*r8a{C@U#q-i; z&$jtq)tMSLezRRwTkFXN3XghGQl}P4AE_W1gkEK0z#IT7Ws_wX!y}66G zpV~y-xvmu9*P8WT9^uvd6R4`F$|vBx>l=sq^`2$RzUUmP!~4`7QP;f?udFYC?(Q+_^3ah}yTDtIXk z+F8JgOFwKSpbOUrsLp-QkH$Mtox{-&Usg{%02AhnX5gsS_p+QVtK$6^zfi3oa~+or z^^Ig7%V+S|2#foyEU#vJg2~Yt9U`QBfXf8bVhWoX@Szvy@19T<-{9^{ogb-6TPx!C zlJJ>&;sMZV+g|QDI-BRuQIo!dLwWcww_7D1fQ{d5r?Fn0DPF+jIe|-C#1gj{fuu%% z+vT6B!Te`OqIIx-O}^`=Y?l3FrwSIeL?~_BW{qdrQbPL&5_`)i{CoRY-PVbNIh;6f z(t4vVAi+T=m;-#=^jS->NO_yur(Pl9y@DGe1|6He*`Z2Xmm-CjM_U4#<@JfXLDb%$_M!XT&x}AQ*0=fg zQ=6!&s8hkB)+m~`M;al{i^wA9n~8XMx-({4G(%$gBZPXt`^42LoGr73LsG`h^Nx30 zQL?ZaE)kHyxpp!LRVRHfe>X-?3A4JLq_vr^o*s3T%G4_C&;4P(&Q{%ADny4*_g%1GU8tBSwEvDSr&z-qX33RA>1 zR356Q+&KVEJJW-y=yV1?`@1^p9tS4}hE0fM=!8f-Jl#!t$IGuLUVc4^o)C#NNU{#+ za$v_%P8>XG(IKsqixXp}Mlo#SARHXkzb8*h$%ZczF<9bpHn|U`a=Czn6qBTi3esp5 zZn|@T)$OeR{BXM-TXX0;Fodc1#^UB-c`l?-1bqgD&}UExr3K||{b3gw2TeP7(>iDw zK4~DMrVPc!)$;O5jrENr&|HRB)m}&4)SEwlGTo{+ z8XNRH9P_!=aXjPVh7&VmI4UkNnmL%1&c?43X>4eFoup_K291dzX2vkO_q5y(R#{$6 z(%L;7NzLG)mu_!!1U*+-%fDa!n%Wwh-<&D+je6g8MNgDy0rZUyF>3#E$HiG+?M00s zu$URc7#BC(YFpZ4@Yo0pdOhpEXc_q;1cVO_W5TRa^z!Rrar@P0&yl=-FZ;J0X+8)| zTDO;J_e^N@?9bmNcZPTXgyNtb1FX`X|C<=C=3Z6N2%;xg7ouv=OICik&#E>;BLZwH zo+eJ6Xqmha8WBM3oY7YKa?VxNvh%0??A>~})h_T*ayp}@MA6g7qxrL(^a76TK5lV; z=AuZ$SegVRrHoQ@0E&q6rF$>yVue$;>#F1n~2B$($**O%PfKoEOkEzs24_`;HI`h#D7c)%$v#!Rn~O>mrR$p?bZ6<%<(2 z$ZlEXO-d*&C}-QMa`q=4<<fImzy{XwR&2@<}8XI)%-Fk?fKkcWs z+B_fAs!ulI7wSdUp;Kn}s{(;@0+)Jfg@a=KUi%UpowZyy(7Jfuqp3w!d!2iD8Ta#K zAI@zhp3AAMsOF1*uHv5G&&DU{;?K3^+vMhVg@EbzPQ=B{^l4kK)3Yz}C_C5hrTXkS zqtE*Uxl>tDM_tXuX|1O69GPi$5f1>35Uy2(=&hy$vF{-7cF&efM3rThD#JzXkVRZXDa2Me3G|p5Ex#fm}Dxv{6^DH;|T;PSUzPRF-!fR0ikc z$M1S-IHRY8v145pe}8O)N&R#NLJ_^S!XSD}p`L=&s2rBQ&#JqBaZNX4t zZwYTcOB^?&59`JTc9p7T7?5FFgODL=?!W@B^DzF95l$Si{+g@G3giE~wGLY5KQoJ; zzfIz3N`}#Z^YWrIE&M$it%lhT-q>bdRufW6()VVueRV3OMaJW{gZuU5uBS#YZbqLr z525dQZWPbY`QB(doq-Udw^m5ui;5$VUx;-ADlM$0qQv-`z!=B&WiiZz9g&NhGjaD% zV)E@VWTxeimYhybdcH|p;u~g-z&oh@5%=V=e71bIlhe5+M%xPP(}TO79KrZmeLIX^ z7%@nil#C9ixAqQGvkvF7eRT>2CykG!0bw58`Q&gW%nmmx5fR{yCx&$i0fF$+ z0;-lf?cU3}8=g+*s^`yG``@kH_s6+rhf8>Px-)iKG-IblQ&w2P(LI@@B_D4UU-Qck zyC}{tWBR?ZI5=v_&n)DZA5%DW+-O-H{6e~O=aa*UofB?$#DY(td#44ub%c=8Q;Wny z%@{Ik69!B2jjvry<#cW-8^22A=K13-4%YJY?|I=cb{Lsyr`WzGm7-G@r=+7P8B`Qk z;^?Stb)?Z2K0)r>`NVK0-WFl@Znd#N&+7j>$nqt7>@FSv1U>s88D)+RS|$f2%=*n@eB20`I5CpQ;>W4x-xHJ zG&kRUz1hOi>kMrEE}gI6O{F-mOJ3UIq(x-`8T8hTXASk{3CPLCiBVIAFk;f6<^xcT zM&ZsUrt{qg8#sA14>%tdvF6jwY+bpF=-5aGkBz|1!}6+q0b#z}x?n0_y|cQ_=syon z7jAhZidhc~GW&`tX^`w#m&NCA?jk?yjKwx~0#2&OR5sLG7l3|g4tYmt9W+G84rI*K z=r$w#9Go1uebIDwZ9KsCHL2)z=Y?5)wuWs#?j~_n3L)YCM2-w6B03Bw7xSY8gZuh3 ze9|CxZ#>ZaS@&Mf+_oT^>Guvadp#%(lI;nZeDzLBm(2BV3oX!qikIUynmK})i%!IfLQ2%H@<~$N**3Tp*q;5XR=Wp$9vsySqbElxoX>SA)0{RUJ zWBe^6@$xh8S^zKqUJRQ!kc3Y+x6`Md^nKZ+@5{!;)rtPY`Y?EGf0F{jx^;IUBs_qu z!{_}nd6}k<98$vWjoEzpPRf-goF?kEQc|zxHr~?f9?zE5^4b6HWNRDMNkhT|h`nVL z0oR!yb&``_z%MIzn&i|2sI94I-?k&{+jfKj!}~D(zKKS&C;URZn*~%^Zaj)WO4z;W zBwxLgLgpdkUg|DPy%xb4!1_Ge3lwCZVer^Mlb<)}CF{R9%(6FkQGK@Mj>bF4lZm&C zHuKsE!fB#Hi=-5RYDIg@*)<$ZE#!lj5?c-5?!DX?KWiidNA)w?0a#j8LE@_2 z9N(AS>}=C$HQe&ZO}O`PW$1)RN{cGkv&FOm4Vi~?nRHvn&$QTi3elptC7c&d%FieH z^6jfeI8Bsl5tOOfVqWGMqtBF|uHn-+lG(cQcngoJlZz8$rVVBI#DN{2aJNNec{SVD zq>#4rc&jt2o9>8dv7VVcFNSl~btV;X>gjE~np$P~MITK!H^=7mp(f#c^-c;IX;+nS znkdssC{{C^LgV!`O3`wIr?I z%f4+#nz!ngGGKTgqHegp#b-hh%zAJ#A$|SXwmPNVP*R^DFQ(2PZ`6juoQorrO&{YF~ljGbNKkx?JY7bgn$7f`Z8hmNIX2v ztD9I?+d#^u1ML3!V7u81J-oVe^Zd5sl!bty6CxQL)1QpAQxu*ormV0WMWf*A(T%YF z!So#xYIF)cTV6%MC7GbQ20hQr{FaP^*2gq;b)=LhwJ3&cq`Fy!`?lpa3#cIbEFZtR zy;YEWXhZ-LZykeQhF@gFLB4r#*&7-TF5fX(c=` z>a!NR6W8wD7(Z(SgU9wat6q>aNRFhOAaQl7S-DTS8HLnV*O`?mVGd`@s`&nc4K}TU z(bZ`b&glYRW^jCg1b`R@JoCZ*$C#pJHZT^MpcwHa{`q^bfE0*V7}oexyI|zqqa0J6=(ImZWuiNm{oTM`s88d;8!M=t=a1$QG3@ zF6GO&SD6&!{HsGD37SSAy=FTaokXPyo8eJm?f!#YN@-CA+gGKKzAvlUBkk($NWURI z?BAZ(<~z>a9GJH#np=Jwg`-o)5q?8rf>`p?9ju7o%h&%(Y2o#5sB0wWSUx$&@(Jqe z->$v+nYOL=1?L4H8qtgFBc)v<*4a2x=(NdjK|reu9xaUvj?VzBug41vj_Kdw_h(8g zNnD-Ek=-X+_)q|zd3Q1+C->&P7k**g=ZBj=7m8rg+;HxFel(tbrbiNH9WG_(hHQ)e zVWb3u!S-vN2md^lSq}~-eeY>L{_Bob*FSYNB=YE*f=5fCMU6zcMGB#cfV|s@*QqM6 zX2-g{q$M3|a}gRfE{Ktndjs(B%i}n4pqPxb5+X}eKP{KH2wMFiKS51fqX=b z4ziEtwHSrYlvc8H!#>iI(%ap=C>p^-e~D@SyQ7naKYnmC8EGZNOz&e>|JxHX`S&Z^ zO)igj`6$?gzR|YPm0tdCJo(n77CS>B&ga07x~Vs7K22l!l0B`$TH*73h+bgb7r&5oBo_})cS;K@$Uc&5)ZsRDeq^h{48FnL@eMZpjI6AvhL2uO zBx#*_w;s1QLbY``s}@2LJpIm%?cVzA;HY8FBT-DgeE{FTzn33BIY^_087nGN?_4Xk zHvOeJaOFbfanmBLj35n?V|y~$_0xVb({hZ8$F;i?cRxMC;{J4v23ExH<w;r2zt%z7sv%!3y` zn#!S_1$^+*HgeL{xyQ_0B!=ae{=LO-Y-YfRUQ(kg#0EF^v`0Xg2l1QkGCQNStf+=h z{ALPcQ$hm4Lc+_xT>Mc^sxt1P}hX<7|hv7(OYK*$)q~fkn2#(OJurZ%yLX z$A+rfu3l&0K*l~FaNNWfL0v7y+Oj~wzekrmVWddWZB)m5572-s2i*+ zCEOW=UGK%e66SH^ypBEdmhj~}DV^)F7UDnaGM;GVkZOD7D#TL2W=;o3XD#n6&*rva1 z4^J1y&+13q{euVyv%b7>M@WO@ttU6J?Z?)&18m!|+`J}l(T(kW-J!!!2wspt&sBuA7S7bAlf4{n;^Th)g1h01Vy-}}3Tu}Tb z1nnx&9E5=1{cSwqQN9%CR&qM8isHN~PUlu}I=7OdoU>+?hjDgwAaaC114joic3K~! zqd@M73Nq44IC-p$lKd*FD(a}LsH6H!eY0OlC<0ga^SNa1J)Q9n^}xTk2Ym;5(|52p zUjEjt-7MkjcT@S|pSwG~Utryu+=U%pJ84uv(}R|dQiMo0Cwv@hXNzVwgw z<+@0(P8$L2g&oCX5Q-eJI%iRb+dY#+5f}8{e-ZLtb?h>YZf=f*NBIyD?MK8=AHt)2 zEk1C)Qd#!qE|xFZW0$@qe3Fp6@L^-mnRsX_{seLkVAJtCYFrTGXZIsws4u~NdzzJi zcr~GGG!Xx%t!!F;%svJPs&qp8k|~dxH2$y2dn{iFUbD_Br3KYIJNr9TZtJ-c$w({VnYa~p zzRpP;DDu%RHu-6Dnw}mQAEhXyVB>fpCztaFkT=Y<{_5~60V!eC{~h4d*OSpT+BM@< zCxsZDaB6|c3ooi_8z#v*Wo3QowBdoo{-6+o4#h-s?K+y*&3bBzz_dEz@nSJ=@Q}r_H^s+ z{XgcuWYJ1>R#1FjgJ2&v-j!X-;IV-`@ZwmDLqNI``?u%u(O(j~QZ@P-MK6Y}Dp-(i z?u!<0b>c$e??dvLrSDsU5HNCbZ|-_}M5_{dR{|#v7W2hFce7`U{cc)r3n9h))p-lP zwCG)HI-U_6A1`2mHEmde5HRwlFz$YO1lL8Xdf}o?IGS3>ckl0G_okCwFZW&Y?wY)X zPg(l9RRWq9x!ke#Y{?b`=EtV0fDkZnbRaj*>rc$I>u`28y?~XisjtYcy~$ zDUTg%v)HjFgPN)?9r&N^(4dT7dHOM{oNlVPduR4u(qHP8BtZT6O$*Ry6~c%3Fyw|H z28|9RY=CFGDa6)b&`WYNDo9Jt=kTrq_9f;~Q`xoh*xN>yD|%(DC5z_EQSH27dhpWm z3SvDf-u$B`J-xdT8qtf;2v7Wbd(hLT8$G;S@aW}2FJD(&+}fW0sw(OzFFr?UVKrw; z&QVfOO<7?z*~iMrIaW^Hxz0=MQ;BnmFicr>>Ty+4I#fMyniaD2CW92KQK3MCJ#g*r zgrX4$0eYRhLPisFkPS%YtjSxnR;?CQKe3z<6hBu8zM&J-qpx8SI;6aPP2QrFYBs8R zG*&|1!tW8x1&m*ub&XC!HVA3BQ*H5p4WkTa1jbJk3ajWuZ}m0Wb_Oo?YmxjYaamfV!8J{kqTz#OsAV+8BF?2f9GEX}rKQJ8vSESF~juBKz$*VQFCGT$K z6}yk#T|z)j+#LF$zoWkK6)D6+K*v|{{Q8hc@wq`OpI>`&VVAj$rArB@iRr;hhASwq z0aNU4OIJX^dKCGhu|fy-pi2s=KR54iI3dOAW< zECv5Xi1#lPIlUsdq6E~$yvXH_=c-C)3n?EEFj+#^Nyjh?*&w9YC>1_+ara)e;_&TV zItsrnu1Eo0%FRI^_j06et`uS(FoCWgPFoLRtB`U<15PV1y}0B`{wZ+XrANSH~>ia8t(HkMdq##sE zd_Z^!DLoN*a(<`hK$BNoP=!Q?q!B?GP$r>F2#TeYCl#rjl+utbHOjGsQwzFG#e~k_ Z{{cVOKbh*Jpbr25002ovPDHLkV1lahmWBWT literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e4b4a88b739a1926ad4787669f1199273da90a5b GIT binary patch literal 8045 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..08a63faf8a83df44b60c4e3531fb097dc20bfd8a GIT binary patch literal 10901 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 b?;Bt{$x?KHIF=A*Hw7rls>;+#nST8b?Xtdk literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ad5c0eaac2bfebac014c66101e89d9ee8bfe29ef GIT binary patch literal 13069 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 Cqhfjh literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5848cb9598ec657cd6b25359da5d2bfec1a46387 GIT binary patch literal 10977 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..485921fdff41b68113040752bfad90998a567f6f GIT binary patch literal 13271 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 literal 0 HcmV?d00001 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/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/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/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) + } +} From 913d1b21988046c9c2c216524226cdba5882f9a7 Mon Sep 17 00:00:00 2001 From: Raimond Caldera <110433086+codeonyx-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:06:57 -0400 Subject: [PATCH 12/14] =?UTF-8?q?Mejora:=20herramientas,=20inicio,=20naveg?= =?UTF-8?q?aci=C3=B3n=20y=20arreglo=20de=20cierre=20en=20Acerca=20de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/codeonyx/bcvaqui/BcvAquiApp.kt | 1 - .../bcvaqui/data/model/PaymentModels.kt | 71 +- .../com/codeonyx/bcvaqui/domain/CalcEngine.kt | 150 ++++ .../com/codeonyx/bcvaqui/domain/PriceTools.kt | 47 ++ .../com/codeonyx/bcvaqui/domain/VeNumber.kt | 67 ++ .../com/codeonyx/bcvaqui/ui/Navigation.kt | 95 ++- .../codeonyx/bcvaqui/ui/donde/DondeScreen.kt | 53 +- .../bcvaqui/ui/home/HomeComponents.kt | 422 +++++++++++ .../codeonyx/bcvaqui/ui/home/HomeScreen.kt | 421 ++++------- .../bcvaqui/ui/home/MiniCalculatorSheet.kt | 714 ++++++------------ .../bcvaqui/ui/tools/CalculatorTools.kt | 189 +---- .../bcvaqui/ui/tools/ComercianteScreen.kt | 84 +++ .../bcvaqui/ui/tools/TaxAndGapTools.kt | 158 ++++ .../codeonyx/bcvaqui/ui/tools/ToolCommon.kt | 263 +++++-- .../codeonyx/bcvaqui/ui/tools/ToolRoutes.kt | 120 ++- .../bcvaqui/ui/tools/ToolsHubScreen.kt | 260 ++++--- .../ui/tools/agenda/AgendaPagosScreen.kt | 207 ++++- .../codeonyx/bcvaqui/domain/CalcEngineTest.kt | 76 ++ .../com/codeonyx/bcvaqui/domain/DomainTest.kt | 94 +++ .../bcvaqui/ui/ResourceUsageGuardTest.kt | 17 + 20 files changed, 2271 insertions(+), 1238 deletions(-) create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/domain/CalcEngine.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/domain/PriceTools.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/domain/VeNumber.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/ui/home/HomeComponents.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/ui/tools/ComercianteScreen.kt create mode 100644 app/src/main/java/com/codeonyx/bcvaqui/ui/tools/TaxAndGapTools.kt create mode 100644 app/src/test/java/com/codeonyx/bcvaqui/domain/CalcEngineTest.kt create mode 100644 app/src/test/java/com/codeonyx/bcvaqui/domain/DomainTest.kt create mode 100644 app/src/test/java/com/codeonyx/bcvaqui/ui/ResourceUsageGuardTest.kt 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/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/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/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/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/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/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/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/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/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/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/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()) + } +} From 33167300773eaa04d93ba1d76d3b57d915859e7b Mon Sep 17 00:00:00 2001 From: Raimond Caldera <110433086+codeonyx-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:13:20 -0400 Subject: [PATCH 13/14] =?UTF-8?q?Mejora:=20actualizaciones=20desde=20codeo?= =?UTF-8?q?nyx.online=20y=20versi=C3=B3n=201.3.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +-- .../com/codeonyx/bcvaqui/data/ApiConfig.kt | 7 +++-- .../bcvaqui/update/AppUpdateManager.kt | 27 ++++++++++++------- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2c0d228..b924596 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.codeonyx.bcvaqui" minSdk = 26 targetSdk = 35 - versionCode = 6 - versionName = "1.3.0" + versionCode = 7 + versionName = "1.3.1" } signingConfigs { 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 c8ad643..9631723 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/data/ApiConfig.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/data/ApiConfig.kt @@ -14,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/update/AppUpdateManager.kt b/app/src/main/java/com/codeonyx/bcvaqui/update/AppUpdateManager.kt index e225aea..da682cf 100644 --- a/app/src/main/java/com/codeonyx/bcvaqui/update/AppUpdateManager.kt +++ b/app/src/main/java/com/codeonyx/bcvaqui/update/AppUpdateManager.kt @@ -48,14 +48,11 @@ 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", "Codivisas/${BuildConfig.VERSION_NAME}") - } - val body = connection.getInputStream().bufferedReader().use { it.readText() } - val manifest = json.decodeFromString(body) + 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) @@ -330,7 +327,18 @@ object AppUpdateManager { return digest.digest().joinToString("") { "%02x".format(it) } } - /** Solo se aceptan APKs servidos por GitHub bajo la organización del proyecto. */ + 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 @@ -339,6 +347,7 @@ object AppUpdateManager { return when (host) { "github.com" -> path.startsWith("/$ALLOWED_OWNER/") "objects.githubusercontent.com" -> true + "codeonyx.online" -> path.startsWith("/codivisas/") else -> false } } From a6703653af625e81797aeb729bbe676b8f0afc21 Mon Sep 17 00:00:00 2001 From: Raimond Caldera <110433086+codeonyx-dev@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:15:30 -0400 Subject: [PATCH 14/14] =?UTF-8?q?Mejora:=20manifiesto=20de=20actualizaci?= =?UTF-8?q?=C3=B3n=201.3.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/update.json b/update.json index 022a424..44e2530 100644 --- a/update.json +++ b/update.json @@ -1,7 +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.", - "sha256": "a70fa074b9975150a54c8bd400ad381cc21b3541f7bea18ee325b82f2f0104a2" -} + "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