From 7fc25b33faa0b0e6a2ab86ca86cc8cce32412c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 10 Apr 2026 00:25:45 +0200 Subject: [PATCH 01/26] Android: multi-connection envelopes per exchange + Play Store warning fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows multiple credential sets ("envelopes") per exchange so users can run e.g. two Coinmate sub-accounts as "Hlavní" and "Spoření". Connection is now a first-class entity with its own credentials, balances, withdrawal thresholds and notifications. ## Schema (Room v18 -> v19) - New ExchangeConnectionEntity (id, exchange, name, createdAt, displayOrder) with unique index on (exchange, name) so the auto-created default envelope is unique per exchange and named envelopes can't collide. - DcaPlanEntity, TransactionEntity, WithdrawalEntity, NotificationEntity: new connectionId column (NOT NULL on plans, nullable elsewhere so history survives connection deletion). - WithdrawalThresholdEntity: PK changed from (crypto, exchange) to (crypto, connectionId). No DB-level FK; ExchangeConnectionRepository.delete cascades manually. - ExchangeBalanceEntity: composite PK (connectionId, currency) so two envelopes on the same exchange keep separate balance caches. - MIGRATION_18_19: auto-creates one default empty-named connection per Exchange enum referenced anywhere in user data, backfills connectionId on all child rows, recreates withdrawal_thresholds and exchange_balances with new PKs, post-migration sanity check on dca_plans.connectionId = 0. ## CredentialsStore (v2 -> v3 keying) - Keys are now credentials_v3_{prod|sandbox}_{connectionId}; env prefix is required because prod and sandbox each have their own Room DB with independent autoincrement IDs. - ensureMigrated(prodDb, sandboxDb) re-keys old credentials_{env}_{EXCHANGE} entries by looking up the corresponding connection in each DB. Idempotent via credentials_migration_v3_done flag. Called via runBlocking from AccBotApplication.onCreate so the migration completes before any background worker tries to load credentials by connectionId. - @Deprecated suspend shims keep legacy Exchange-based callers compiling during the gradual refactor. ## Repository / use case layer - New ExchangeConnectionRepository with observe/get/create/rename/delete + manual cascade (plans, thresholds, balances, credentials). delete() now blocks with IllegalStateException when plans still reference the connection and deletePlans=false. - ValidateAndSaveCredentialsUseCase is connection-aware: creates the connection up front, validates API keys, rolls back the connection on any failure (network, IO, business). Returns connectionId in Success. - CreateDcaPlanUseCase takes optional connectionId and throws on missing connection instead of silently auto-creating an empty one. ## DcaWorker / NotificationService - DcaWorker uses plan.connectionId for credentials lookup, threshold check, balance check and TransactionEntity inserts. - NotificationService.show* are now suspend (was runBlocking + N+1 DAO query). Notification titles render "Coinmate - Spoření" when the connection has a non-empty name. - NotificationEntity persists connectionId for the in-app notification history. ## Backup format v1 -> v2 - BackupPayload now carries a connections list, plus connectionId fields on plans/transactions/withdrawals/notifications/credentials/thresholds. - BackupDataRestorer handles both v1 (legacy: auto-create one default envelope per exchange) and v2 (remap backup-local IDs to fresh local IDs via connectionIdMap, dedupe by (exchange, name)). - Credentials are pre-validated before the DB transaction so a malformed backup aborts before any DB changes are made; race-safe insert pattern for default connections. ## UI - ExchangeManagementScreen: lists individual connections (one tile per envelope), grouped by exchange. The "Available" section no longer filters out exchanges with credentials so users can add a 2nd connection. Settings card subtitle counts connections, not unique exchanges. - AddExchangeScreen: optional "Connection name" input, becomes required when there's already 1+ connections on the same exchange. Validate button is disabled until the existing-connections lookup completes (race guard). - AddPlanScreen: connection picker. When the selected exchange has 0 connections -> credentials form (current behavior). 1 connection -> auto-selected, no picker needed. 2+ connections -> radio picker with envelope names plus a "Create new connection" option. - DashboardScreen plan card shows "Coinmate - Spoření" label when the connection has a name (batch lookup in DashboardViewModel). ## Play Store warning fixes (bundled) - enableEdgeToEdge() in MainActivity, removed deprecated statusBarColor / navigationBarColor from themes.xml + Theme.kt. Targets Android 15 edge-to-edge requirements. - DcaWorker.runFromAlarm: removed setExpedited() so the alarm-triggered WorkManager chain doesn't reach into a foreground service that would be flagged by Play Console as "FGS launched from BOOT_COMPLETED" on Android 15+ (alarm wakes the device, regular work runs immediately anyway). ## Critical migration bug fixed mid-rollout The first install on a real device crashed because the v18->v19 migration created withdrawal_thresholds with FOREIGN KEY ... ON DELETE CASCADE while the entity declared no foreign keys. Room schema validation rolled back the migration. Removed the FOREIGN KEY from the migration SQL; cascade is now handled explicitly by ExchangeConnectionRepository.delete via WithdrawalThresholdDao.deleteByConnection. Build: ./gradlew :app:assembleDebug succeeds; androidTest sources also compile after fixing the test fixtures (CredentialsStore now needs the ExchangeConnectionDao and credentials operations are suspend). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../accbot/dca/recording/EmulatorSetupTest.kt | 30 +- .../dca/screenshots/ScreenshotCaptureTest.kt | 46 ++- .../dca/screenshots/ScreenshotSetupTest.kt | 64 ++-- .../java/com/accbot/dca/AccBotApplication.kt | 31 ++ .../main/java/com/accbot/dca/MainActivity.kt | 27 +- .../dca/data/local/BackupDataCollector.kt | 66 +++-- .../dca/data/local/BackupDataRestorer.kt | 182 ++++++++++-- .../accbot/dca/data/local/CredentialsStore.kt | 276 +++++++++++++----- .../java/com/accbot/dca/data/local/Daos.kt | 75 ++++- .../com/accbot/dca/data/local/DcaDatabase.kt | 156 +++++++++- .../com/accbot/dca/data/local/Entities.kt | 88 +++++- .../accbot/dca/data/local/EntityMappers.kt | 14 +- .../ExchangeConnectionRepository.kt | 121 ++++++++ .../main/java/com/accbot/dca/di/AppModule.kt | 8 +- .../accbot/dca/domain/model/BackupModels.kt | 62 +++- .../com/accbot/dca/domain/model/Models.kt | 13 +- .../domain/usecase/CreateDcaPlanUseCase.kt | 26 +- .../ResolvePendingTransactionsUseCase.kt | 10 +- .../ValidateAndSaveCredentialsUseCase.kt | 69 ++++- .../credentials/CredentialFormDelegate.kt | 152 +++++++++- .../dca/presentation/navigation/Screen.kt | 20 +- .../dca/presentation/screens/AddPlanScreen.kt | 152 ++++++++-- .../presentation/screens/AddPlanViewModel.kt | 49 +++- .../presentation/screens/DashboardScreen.kt | 25 +- .../screens/DashboardViewModel.kt | 45 ++- .../presentation/screens/SettingsScreen.kt | 2 +- .../presentation/screens/SettingsViewModel.kt | 72 ++++- .../screens/exchanges/AddExchangeScreen.kt | 49 +++- .../screens/exchanges/AddExchangeViewModel.kt | 45 ++- .../exchanges/ExchangeDetailViewModel.kt | 47 +-- .../exchanges/ExchangeManagementScreen.kt | 39 ++- .../exchanges/ExchangeManagementViewModel.kt | 48 +-- .../screens/onboarding/OnboardingViewModel.kt | 29 +- .../screens/plans/PlanDetailsViewModel.kt | 4 +- .../accbot/dca/presentation/ui/theme/Theme.kt | 7 +- .../accbot/dca/service/NotificationService.kt | 98 +++++-- .../java/com/accbot/dca/worker/DcaWorker.kt | 48 ++- .../app/src/main/res/values-cs/strings.xml | 10 +- .../app/src/main/res/values/strings.xml | 10 +- .../app/src/main/res/values/themes.xml | 7 +- 40 files changed, 1882 insertions(+), 440 deletions(-) create mode 100644 accbot-android/app/src/main/java/com/accbot/dca/data/repository/ExchangeConnectionRepository.kt diff --git a/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/recording/EmulatorSetupTest.kt b/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/recording/EmulatorSetupTest.kt index 6052fa0..d5eb290 100644 --- a/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/recording/EmulatorSetupTest.kt +++ b/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/recording/EmulatorSetupTest.kt @@ -3,6 +3,7 @@ package com.accbot.dca.recording import androidx.test.platform.app.InstrumentationRegistry import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.DcaDatabase +import com.accbot.dca.data.local.ExchangeConnectionEntity import com.accbot.dca.data.local.OnboardingPreferences import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.domain.model.Exchange @@ -46,22 +47,31 @@ class EmulatorSetupTest { userPrefs.setSandboxMode(true) userPrefs.setBiometricLockEnabled(false) - // 4. Save Binance sandbox credentials - val credentialsStore = CredentialsStore(context) + // 4. Save Binance sandbox credentials. CredentialsStore now needs the connection + // DAO from the sandbox database (separate file from prod). + val sandboxDb = DcaDatabase.getInstance(context, isSandbox = true) + val credentialsStore = CredentialsStore(context, sandboxDb.exchangeConnectionDao()) val credentials = ExchangeCredentials( exchange = Exchange.BINANCE, apiKey = "EHF3PoIyxgXkJa1iUy7OsGPqtu7eSi6dis9O9QOBZL9SUXp16ThTyPHcIGc5ZidW", apiSecret = "pg6Xj5bBJUer1OnFsy6kanNK9YW6A5Xk6hsjp5AEMxEgum0Yqf7vbkpDg0MbZNHo" ) - val saved = credentialsStore.saveCredentials(credentials, isSandbox = true) - assert(saved) { "Failed to save Binance sandbox credentials" } - // DCA plan is NOT created here — it will be created via UI in ForegroundServiceDemoTest + kotlinx.coroutines.runBlocking { + // Create a default Binance connection then save credentials under it. + val binanceConnectionId = sandboxDb.exchangeConnectionDao().insert( + ExchangeConnectionEntity(exchange = Exchange.BINANCE, name = "") + ) + val saved = credentialsStore.saveCredentials(binanceConnectionId, credentials, isSandbox = true) + assert(saved) { "Failed to save Binance sandbox credentials" } - // Verify setup - val hasCredentials = credentialsStore.hasCredentials(Exchange.BINANCE, isSandbox = true) - assert(hasCredentials) { "Binance sandbox credentials not found after save" } - assert(userPrefs.isSandboxMode()) { "Sandbox mode not enabled" } - assert(onboarding.isOnboardingCompleted()) { "Onboarding not marked as completed" } + // DCA plan is NOT created here — it will be created via UI in ForegroundServiceDemoTest + + // Verify setup + val hasCredentials = credentialsStore.hasCredentials(binanceConnectionId, isSandbox = true) + assert(hasCredentials) { "Binance sandbox credentials not found after save" } + assert(userPrefs.isSandboxMode()) { "Sandbox mode not enabled" } + assert(onboarding.isOnboardingCompleted()) { "Onboarding not marked as completed" } + } } } diff --git a/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotCaptureTest.kt b/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotCaptureTest.kt index 6efab6a..7f3a82d 100644 --- a/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotCaptureTest.kt +++ b/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotCaptureTest.kt @@ -23,6 +23,7 @@ import com.accbot.dca.data.local.DailyPriceEntity import com.accbot.dca.data.local.DcaDatabase import com.accbot.dca.data.local.DcaPlanEntity import com.accbot.dca.data.local.ExchangeBalanceEntity +import com.accbot.dca.data.local.ExchangeConnectionEntity import com.accbot.dca.data.local.NotificationEntity import com.accbot.dca.data.local.NotificationType import com.accbot.dca.data.local.OnboardingPreferences @@ -295,17 +296,8 @@ class ScreenshotCaptureTest { prefs.setMarketPulseEnabled(true) prefs.setMarketPulseExpanded(true) - val creds = CredentialsStore(context) - creds.saveCredentials( - ExchangeCredentials(Exchange.COINMATE, "demo_key", "demo_secret", clientId = "12345"), - isSandbox = false - ) - creds.saveCredentials( - ExchangeCredentials(Exchange.BINANCE, "demo_key", "demo_secret"), - isSandbox = false - ) - val db = DcaDatabase.getInstance(context, isSandbox = false) + val creds = CredentialsStore(context, db.exchangeConnectionDao()) db.dcaPlanDao().deleteAllPlans() db.transactionDao().deleteAllTransactions() @@ -313,9 +305,30 @@ class ScreenshotCaptureTest { db.exchangeBalanceDao().deleteAllBalances() db.notificationDao().deleteAllNotifications() + // Insert default connections first (one per exchange used by the screenshots) + val coinmateConnectionId = db.exchangeConnectionDao().insert( + ExchangeConnectionEntity(exchange = Exchange.COINMATE, name = "") + ) + val binanceConnectionId = db.exchangeConnectionDao().insert( + ExchangeConnectionEntity(exchange = Exchange.BINANCE, name = "") + ) + + // Credentials (dummy — app won't call APIs during screenshots). + creds.saveCredentials( + connectionId = coinmateConnectionId, + credentials = ExchangeCredentials(Exchange.COINMATE, "demo_key", "demo_secret", clientId = "12345"), + isSandbox = false + ) + creds.saveCredentials( + connectionId = binanceConnectionId, + credentials = ExchangeCredentials(Exchange.BINANCE, "demo_key", "demo_secret"), + isSandbox = false + ) + val btcPlanId = db.dcaPlanDao().insertPlan( DcaPlanEntity( - exchange = Exchange.COINMATE, crypto = "BTC", fiat = "EUR", + exchange = Exchange.COINMATE, connectionId = coinmateConnectionId, + crypto = "BTC", fiat = "EUR", amount = BigDecimal("50"), frequency = DcaFrequency.DAILY, strategy = DcaStrategy.Classic, isEnabled = true, withdrawalEnabled = true, @@ -327,7 +340,8 @@ class ScreenshotCaptureTest { ) val ethPlanId = db.dcaPlanDao().insertPlan( DcaPlanEntity( - exchange = Exchange.BINANCE, crypto = "ETH", fiat = "EUR", + exchange = Exchange.BINANCE, connectionId = binanceConnectionId, + crypto = "ETH", fiat = "EUR", amount = BigDecimal("30"), frequency = DcaFrequency.WEEKLY, strategy = DcaStrategy.FearAndGreed(), isEnabled = true, createdAt = now.minus(Duration.ofDays(120)), @@ -401,10 +415,10 @@ class ScreenshotCaptureTest { db.exchangeBalanceDao().insertBalances( listOf( - ExchangeBalanceEntity("COINMATE_BTC", Exchange.COINMATE, "BTC", totalBtcAccumulated, now), - ExchangeBalanceEntity("COINMATE_EUR", Exchange.COINMATE, "EUR", BigDecimal("142.50"), now), - ExchangeBalanceEntity("BINANCE_ETH", Exchange.BINANCE, "ETH", totalEthAccumulated, now), - ExchangeBalanceEntity("BINANCE_EUR", Exchange.BINANCE, "EUR", BigDecimal("85.00"), now), + ExchangeBalanceEntity(coinmateConnectionId, "BTC", Exchange.COINMATE, totalBtcAccumulated, now), + ExchangeBalanceEntity(coinmateConnectionId, "EUR", Exchange.COINMATE, BigDecimal("142.50"), now), + ExchangeBalanceEntity(binanceConnectionId, "ETH", Exchange.BINANCE, totalEthAccumulated, now), + ExchangeBalanceEntity(binanceConnectionId, "EUR", Exchange.BINANCE, BigDecimal("85.00"), now), ) ) diff --git a/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotSetupTest.kt b/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotSetupTest.kt index c1b4eca..5995b81 100644 --- a/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotSetupTest.kt +++ b/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotSetupTest.kt @@ -8,6 +8,7 @@ import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.data.local.DailyPriceEntity import com.accbot.dca.data.local.DcaPlanEntity import com.accbot.dca.data.local.ExchangeBalanceEntity +import com.accbot.dca.data.local.ExchangeConnectionEntity import com.accbot.dca.data.local.NotificationEntity import com.accbot.dca.data.local.TransactionEntity import com.accbot.dca.domain.model.DcaFrequency @@ -57,32 +58,44 @@ class ScreenshotSetupTest { prefs.setMarketPulseEnabled(true) prefs.setMarketPulseExpanded(true) - // 2. Credentials (dummy — app won't call APIs during screenshots) - val creds = CredentialsStore(context) - creds.saveCredentials( - ExchangeCredentials(Exchange.COINMATE, "demo_key", "demo_secret", clientId = "12345"), - isSandbox = false - ) - creds.saveCredentials( - ExchangeCredentials(Exchange.BINANCE, "demo_key", "demo_secret"), - isSandbox = false - ) - - // 3. Room DB — prod database + // 2. Room DB — prod database (constructed first because CredentialsStore needs the DAO) val db = DcaDatabase.getInstance(context, isSandbox = false) + val creds = CredentialsStore(context, db.exchangeConnectionDao()) runBlocking { - // Clean slate + // Clean slate (also clears any prior connections so unique index doesn't trip) db.dcaPlanDao().deleteAllPlans() db.transactionDao().deleteAllTransactions() db.dailyPriceDao().deleteAllPrices() db.exchangeBalanceDao().deleteAllBalances() db.notificationDao().deleteAllNotifications() + // Insert default connections first (one per exchange used by the screenshots) + val coinmateConnectionId = db.exchangeConnectionDao().insert( + ExchangeConnectionEntity(exchange = Exchange.COINMATE, name = "") + ) + val binanceConnectionId = db.exchangeConnectionDao().insert( + ExchangeConnectionEntity(exchange = Exchange.BINANCE, name = "") + ) + + // Credentials (dummy — app won't call APIs during screenshots). + // Use the connection-keyed API directly to avoid the legacy shim's auto-create. + creds.saveCredentials( + connectionId = coinmateConnectionId, + credentials = ExchangeCredentials(Exchange.COINMATE, "demo_key", "demo_secret", clientId = "12345"), + isSandbox = false + ) + creds.saveCredentials( + connectionId = binanceConnectionId, + credentials = ExchangeCredentials(Exchange.BINANCE, "demo_key", "demo_secret"), + isSandbox = false + ) + // Insert plans val btcPlanId = db.dcaPlanDao().insertPlan( DcaPlanEntity( - exchange = Exchange.COINMATE, crypto = "BTC", fiat = "EUR", + exchange = Exchange.COINMATE, connectionId = coinmateConnectionId, + crypto = "BTC", fiat = "EUR", amount = BigDecimal("50"), frequency = DcaFrequency.DAILY, strategy = DcaStrategy.Classic, isEnabled = true, withdrawalEnabled = true, @@ -94,7 +107,8 @@ class ScreenshotSetupTest { ) val ethPlanId = db.dcaPlanDao().insertPlan( DcaPlanEntity( - exchange = Exchange.BINANCE, crypto = "ETH", fiat = "EUR", + exchange = Exchange.BINANCE, connectionId = binanceConnectionId, + crypto = "ETH", fiat = "EUR", amount = BigDecimal("30"), frequency = DcaFrequency.WEEKLY, strategy = DcaStrategy.FearAndGreed(), isEnabled = true, createdAt = now.minus(Duration.ofDays(120)), @@ -172,10 +186,10 @@ class ScreenshotSetupTest { db.exchangeBalanceDao().insertBalances( listOf( - ExchangeBalanceEntity("COINMATE_BTC", Exchange.COINMATE, "BTC", totalBtcAccumulated, now), - ExchangeBalanceEntity("COINMATE_EUR", Exchange.COINMATE, "EUR", BigDecimal("142.50"), now), - ExchangeBalanceEntity("BINANCE_ETH", Exchange.BINANCE, "ETH", totalEthAccumulated, now), - ExchangeBalanceEntity("BINANCE_EUR", Exchange.BINANCE, "EUR", BigDecimal("85.00"), now), + ExchangeBalanceEntity(coinmateConnectionId, "BTC", Exchange.COINMATE, totalBtcAccumulated, now), + ExchangeBalanceEntity(coinmateConnectionId, "EUR", Exchange.COINMATE, BigDecimal("142.50"), now), + ExchangeBalanceEntity(binanceConnectionId, "ETH", Exchange.BINANCE, totalEthAccumulated, now), + ExchangeBalanceEntity(binanceConnectionId, "EUR", Exchange.BINANCE, BigDecimal("85.00"), now), ) ) @@ -220,12 +234,12 @@ class ScreenshotSetupTest { isRead = true, createdAt = now.minus(Duration.ofDays(5)) ) ) - } - // Verify - assert(OnboardingPreferences(context).isOnboardingCompleted()) { "Onboarding not completed" } - assert(!UserPreferences(context).isSandboxMode()) { "Sandbox mode should be off" } - assert(CredentialsStore(context).hasCredentials(Exchange.COINMATE, isSandbox = false)) { "Coinmate credentials missing" } - assert(CredentialsStore(context).hasCredentials(Exchange.BINANCE, isSandbox = false)) { "Binance credentials missing" } + // Verify (inside runBlocking so we can use connectionIds + suspend hasCredentials) + assert(OnboardingPreferences(context).isOnboardingCompleted()) { "Onboarding not completed" } + assert(!UserPreferences(context).isSandboxMode()) { "Sandbox mode should be off" } + assert(creds.hasCredentials(coinmateConnectionId, isSandbox = false)) { "Coinmate credentials missing" } + assert(creds.hasCredentials(binanceConnectionId, isSandbox = false)) { "Binance credentials missing" } + } } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt b/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt index 4ba5c6a..1b56dc1 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt @@ -6,8 +6,13 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaDatabase import com.accbot.dca.data.local.UserPreferences import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import javax.inject.Inject /** @@ -23,6 +28,9 @@ class AccBotApplication : Application(), Configuration.Provider { @Inject lateinit var userPreferences: UserPreferences + @Inject + lateinit var credentialsStore: CredentialsStore + override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(workerFactory) @@ -38,6 +46,29 @@ class AccBotApplication : Application(), Configuration.Provider { if (tag.isNotEmpty()) { AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(tag)) } + + // CredentialsStore v2→v3 migration: re-key credentials from + // `credentials_${env}_${EXCHANGE}` to `credentials_v3_${env}_${connectionId}`. + // Needs Room DB access (to look up the connection per exchange) so it can't run + // inside the encryptedPrefs lazy init. Idempotent — safe to call every launch. + // + // BLOCKING: must complete before any background worker (DcaWorker) tries to load + // credentials by connectionId. The migration is fast (single-digit milliseconds for + // ~14 keys) and runs once per upgrade — acceptable startup cost. The previous + // background-launch version had a race window where the alarm-triggered DcaWorker + // could fire between Room migration and CredentialsStore migration completion, + // failing to find credentials under the new key. + runBlocking { + try { + withContext(Dispatchers.IO) { + val prodDb = DcaDatabase.getInstance(this@AccBotApplication, isSandbox = false) + val sandboxDb = DcaDatabase.getInstance(this@AccBotApplication, isSandbox = true) + credentialsStore.ensureMigrated(prodDb, sandboxDb) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to run CredentialsStore migration", e) + } + } } companion object { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt b/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt index b2eeef6..412b145 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt @@ -10,6 +10,7 @@ import android.os.PowerManager import android.provider.Settings import androidx.appcompat.app.AppCompatActivity import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import android.content.res.Configuration import androidx.compose.foundation.layout.Row @@ -94,6 +95,10 @@ class MainActivity : AppCompatActivity() { } override fun onCreate(savedInstanceState: Bundle?) { + // Edge-to-edge is required for apps targeting Android 15+ (SDK 35+). + // Must be called before super.onCreate() so the system bar styles are applied + // before the splash screen transitions away. + enableEdgeToEdge() super.onCreate(savedInstanceState) if (!isInstrumentedTest() && onboardingPreferences.isOnboardingCompleted()) { @@ -407,9 +412,9 @@ fun AccBotApp( AddPlanScreen( onNavigateBack = { navController.popBackStack() }, onPlanCreated = { navController.popBackStack() }, - onNavigateToExchangeDetail = { exchangeName -> + onNavigateToExchangeManagement = { navController.popBackStack() - navController.navigate(Screen.ExchangeDetail.createRoute(exchangeName, autoImport = true)) + navController.navigate(Screen.ExchangeManagement.route) } ) } @@ -457,8 +462,8 @@ fun AccBotApp( onNavigateToAddExchange = { exchangeName -> navController.navigate(Screen.AddExchange.createRoute(exchangeName)) }, - onNavigateToExchangeDetail = { exchangeName -> - navController.navigate(Screen.ExchangeDetail.createRoute(exchangeName)) + onNavigateToExchangeDetail = { connectionId -> + navController.navigate(Screen.ExchangeDetail.createRoute(connectionId)) } ) } @@ -466,7 +471,7 @@ fun AccBotApp( composable( route = Screen.ExchangeDetail.route, arguments = listOf( - navArgument(Screen.EXCHANGE_ARG) { type = NavType.StringType }, + navArgument(Screen.CONNECTION_ID_ARG) { type = NavType.LongType }, navArgument("autoImport") { type = NavType.BoolType; defaultValue = false } ) ) { @@ -486,9 +491,9 @@ fun AccBotApp( AddExchangeScreen( onNavigateBack = { navController.popBackStack() }, onExchangeAdded = { navController.popBackStack() }, - onNavigateToExchangeDetail = { exchangeName -> + onNavigateToExchangeManagement = { navController.popBackStack() - navController.navigate(Screen.ExchangeDetail.createRoute(exchangeName, autoImport = true)) + navController.navigate(Screen.ExchangeManagement.route) } ) } @@ -548,12 +553,8 @@ private fun MainTabPage( navController.navigate(Screen.PlanDetails.createRoute(planId)) }, onNavigateToPortfolio = { _, _ -> onSwitchToTab(1) }, - onNavigateToExchangeDetail = { exchangeName -> - if (exchangeName.isNotEmpty()) { - navController.navigate(Screen.ExchangeDetail.createRoute(exchangeName)) - } else { - navController.navigate(Screen.ExchangeManagement.route) - } + onNavigateToExchangeManagement = { + navController.navigate(Screen.ExchangeManagement.route) } ) 1 -> PortfolioScreen( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataCollector.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataCollector.kt index 4b659c5..76fedfc 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataCollector.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataCollector.kt @@ -14,6 +14,7 @@ class BackupDataCollector @Inject constructor( private val notificationDao: NotificationDao, private val withdrawalDao: WithdrawalDao, private val withdrawalThresholdDao: WithdrawalThresholdDao, + private val exchangeConnectionDao: ExchangeConnectionDao, private val credentialsStore: CredentialsStore, private val userPreferences: UserPreferences ) { @@ -31,12 +32,35 @@ class BackupDataCollector @Inject constructor( lowBalanceThresholdDays = userPreferences.getLowBalanceThresholdDays() ) - val thresholds = withdrawalThresholdDao.getAllThresholdsOnce().map { it.toBackup() } + // v2: snapshot all connections so the restorer can recreate the envelope structure. + val connections = exchangeConnectionDao.getAll().map { it.toBackup() } + + // Thresholds carry both exchange enum (v1 compat) and connectionId (v2). + val thresholds = withdrawalThresholdDao.getAllThresholdsOnce().mapNotNull { entity -> + val connection = exchangeConnectionDao.getById(entity.connectionId) ?: return@mapNotNull null + BackupWithdrawalThreshold( + crypto = entity.crypto, + exchange = connection.exchange.name, + connectionId = entity.connectionId, + thresholdAmount = entity.thresholdAmount.toPlainString() + ) + } + // Credentials: iterate all connections (not just exchanges) so multiple envelopes + // on the same exchange roundtrip cleanly. Each backup credential row carries the + // source connectionId. val credentials = if (options.includeCredentials) { val isSandbox = userPreferences.isSandboxMode() - credentialsStore.getConfiguredExchanges(isSandbox).mapNotNull { exchange -> - credentialsStore.getCredentials(exchange, isSandbox)?.toBackup() + connections.mapNotNull { conn -> + val source = credentialsStore.getCredentials(conn.id, isSandbox) ?: return@mapNotNull null + BackupCredentials( + exchange = source.exchange.name, + apiKey = source.apiKey, + apiSecret = source.apiSecret, + passphrase = source.passphrase, + clientId = source.clientId, + connectionId = conn.id + ) } } else { emptyList() @@ -67,7 +91,8 @@ class BackupDataCollector @Inject constructor( credentials = credentials, transactions = transactions, notifications = notifications, - withdrawals = withdrawals + withdrawals = withdrawals, + connections = connections ) } @@ -75,10 +100,12 @@ class BackupDataCollector @Inject constructor( suspend fun getDataCounts(): BackupDataCounts { val isSandbox = userPreferences.isSandboxMode() + @Suppress("DEPRECATION") + val credentialCount = credentialsStore.getConfiguredExchanges(isSandbox).size return BackupDataCounts( planCount = dcaPlanDao.getPlanCount(), thresholdCount = withdrawalThresholdDao.getAllThresholdsOnce().size, - credentialCount = credentialsStore.getConfiguredExchanges(isSandbox).size, + credentialCount = credentialCount, transactionCount = transactionDao.getTransactionCount(), notificationCount = notificationDao.getNotificationCount(), withdrawalCount = withdrawalDao.getWithdrawalCount() @@ -102,7 +129,8 @@ class BackupDataCollector @Inject constructor( createdAt = createdAt.toEpochMilli(), lastExecutedAt = lastExecutedAt?.toEpochMilli(), nextExecutionAt = nextExecutionAt?.toEpochMilli(), - targetAmount = targetAmount?.toPlainString() + targetAmount = targetAmount?.toPlainString(), + connectionId = connectionId ) private fun TransactionEntity.toBackup() = BackupTransaction( @@ -120,7 +148,8 @@ class BackupDataCollector @Inject constructor( exchangeOrderId = exchangeOrderId, errorMessage = errorMessage, warningMessage = warningMessage, - executedAt = executedAt.toEpochMilli() + executedAt = executedAt.toEpochMilli(), + connectionId = connectionId ) private fun NotificationEntity.toBackup() = BackupNotification( @@ -134,7 +163,8 @@ class BackupDataCollector @Inject constructor( isRead = isRead, isArchived = isArchived, templateArgs = templateArgs, - createdAt = createdAt.toEpochMilli() + createdAt = createdAt.toEpochMilli(), + connectionId = connectionId ) private fun WithdrawalEntity.toBackup() = BackupWithdrawal( @@ -148,20 +178,18 @@ class BackupDataCollector @Inject constructor( fee = fee.toPlainString(), status = status.name, errorMessage = errorMessage, - createdAt = createdAt.toEpochMilli() + createdAt = createdAt.toEpochMilli(), + connectionId = connectionId ) - private fun WithdrawalThresholdEntity.toBackup() = BackupWithdrawalThreshold( - crypto = crypto, + private fun ExchangeConnectionEntity.toBackup() = BackupExchangeConnection( + id = id, exchange = exchange.name, - thresholdAmount = thresholdAmount.toPlainString() + name = name, + createdAt = createdAt.toEpochMilli(), + displayOrder = displayOrder ) - private fun ExchangeCredentials.toBackup() = BackupCredentials( - exchange = exchange.name, - apiKey = apiKey, - apiSecret = apiSecret, - passphrase = passphrase, - clientId = clientId - ) + // Note: WithdrawalThresholdEntity → BackupWithdrawalThreshold conversion is inlined in + // collect() above because it requires looking up the parent connection's exchange. } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt index f99422d..7e13521 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt @@ -20,16 +20,69 @@ class BackupDataRestorer @Inject constructor( private val notificationDao: NotificationDao, private val withdrawalDao: WithdrawalDao, private val withdrawalThresholdDao: WithdrawalThresholdDao, + private val exchangeConnectionDao: ExchangeConnectionDao, private val credentialsStore: CredentialsStore, private val userPreferences: UserPreferences ) { + + /** + * Get-or-create the default empty-named connection for an exchange. Used for legacy v1 + * backups (no connection metadata) and as a fallback when v2 backup connectionId can't + * be remapped. + * + * Race-safe: re-checks after insert in case a parallel restore (or the v2 connections + * loop) raced and inserted a row with the same `(exchange, "")` key. The unique index + * on `(exchange, name)` would otherwise raise a constraint violation. + */ + private suspend fun resolveOrCreateDefaultConnection(exchange: Exchange): Long { + exchangeConnectionDao.getDefaultByExchange(exchange)?.let { return it.id } + return try { + exchangeConnectionDao.insert( + ExchangeConnectionEntity(exchange = exchange, name = "") + ) + } catch (e: android.database.sqlite.SQLiteConstraintException) { + // Concurrent insert won the race — re-fetch and use the existing row. + exchangeConnectionDao.getDefaultByExchange(exchange)?.id + ?: throw IllegalStateException("Failed to resolve default connection for $exchange", e) + } + } suspend fun restore(payload: BackupPayload, restoreMode: RestoreMode = RestoreMode.Merge): BackupResult { return try { + // PRE-VALIDATE credentials before touching the DB. If any credential has an + // unknown exchange enum, abort with an error WITHOUT modifying any DB state. + // This guards against the half-restored scenario where plans are committed but + // their credentials silently fail to save (leading to "no credentials" loops in + // DcaWorker for every restored plan). + val parsedCredentials = mutableListOf() + for (cred in payload.credentials) { + val exchange = try { + Exchange.valueOf(cred.exchange) + } catch (e: Exception) { + return BackupResult.Error("Backup contains credentials for unknown exchange '${cred.exchange}'") + } + parsedCredentials += ParsedCredential( + exchange = exchange, + backupConnectionId = cred.connectionId, + credentials = ExchangeCredentials( + exchange = exchange, + apiKey = cred.apiKey, + apiSecret = cred.apiSecret, + passphrase = cred.passphrase, + clientId = cred.clientId + ) + ) + } + // DB operations inside a single transaction val planIdMap = mutableMapOf() + // v2: map backup-local connection ids → newly assigned local ids. + val connectionIdMap = mutableMapOf() database.withTransaction { - // Replace mode: wipe all existing DB data first + // Replace mode: wipe all existing DB data first. + // NOTE: deleting plans last (after transactions) preserves any existing FK + // assumptions. Connections are NOT wiped — we preserve them and let merge + // dedupe by (exchange, name). if (restoreMode == RestoreMode.Replace) { transactionDao.deleteAllTransactions() withdrawalDao.deleteAllWithdrawals() @@ -38,13 +91,52 @@ class BackupDataRestorer @Inject constructor( dcaPlanDao.deleteAllPlans() } + // 0. Connections (v2): create or dedupe by (exchange, name). + // The unique index on (exchange, name) means duplicate inserts raise + // SQLiteConstraintException — we catch and re-fetch. + for (conn in payload.connections) { + val exchange = try { Exchange.valueOf(conn.exchange) } catch (_: Exception) { continue } + val existing = exchangeConnectionDao.getByExchange(exchange) + .firstOrNull { it.name == conn.name } + val targetId = existing?.id ?: try { + exchangeConnectionDao.insert( + ExchangeConnectionEntity( + exchange = exchange, + name = conn.name, + createdAt = if (conn.createdAt > 0) Instant.ofEpochMilli(conn.createdAt) else Instant.now(), + displayOrder = conn.displayOrder + ) + ) + } catch (_: android.database.sqlite.SQLiteConstraintException) { + exchangeConnectionDao.getByExchange(exchange) + .firstOrNull { it.name == conn.name }?.id + ?: continue + } + connectionIdMap[conn.id] = targetId + } + + // Helper: resolve a backup connectionId (v2) or fall back to default + // connection per exchange (v1 legacy). + suspend fun resolveConnectionForRestore(backupConnectionId: Long?, exchange: Exchange): Long { + if (backupConnectionId != null) { + connectionIdMap[backupConnectionId]?.let { return it } + } + return resolveOrCreateDefaultConnection(exchange) + } + // 1. Plans: merge with dedup or insert after wipe if (restoreMode == RestoreMode.Merge) { val existingPlans = dcaPlanDao.getAllPlansOnce() for (plan in payload.plans) { - val entity = plan.toEntity() + val planExchange = Exchange.valueOf(plan.exchange) + val connectionId = resolveConnectionForRestore(plan.connectionId, planExchange) + val entity = plan.toEntity(connectionId) + // Match must include connectionId so that two plans with identical + // crypto/fiat/amount on different envelopes ("Hlavní" vs "Spoření") + // don't collapse to one during merge restore. val match = existingPlans.find { existing -> - existing.exchange.name == plan.exchange && + existing.connectionId == connectionId && + existing.exchange.name == plan.exchange && existing.crypto == plan.crypto && existing.fiat == plan.fiat && existing.amount.compareTo(entity.amount) == 0 && @@ -70,7 +162,9 @@ class BackupDataRestorer @Inject constructor( } } else { for (plan in payload.plans) { - val entity = plan.toEntity() + val planExchange = Exchange.valueOf(plan.exchange) + val connectionId = resolveConnectionForRestore(plan.connectionId, planExchange) + val entity = plan.toEntity(connectionId) val newId = dcaPlanDao.insertPlan(entity) planIdMap[plan.id] = newId } @@ -83,24 +177,35 @@ class BackupDataRestorer @Inject constructor( val existing = transactionDao.getByExchangeOrderId(tx.exchangeOrderId) if (existing != null) continue // Already imported, skip } - transactionDao.insertTransaction(tx.toEntity(remappedPlanId)) + val txExchange = Exchange.valueOf(tx.exchange) + val connectionId = resolveConnectionForRestore(tx.connectionId, txExchange) + transactionDao.insertTransaction(tx.toEntity(remappedPlanId, connectionId)) } // 3. Insert withdrawals with remapped planId for (w in payload.withdrawals) { val remappedPlanId = planIdMap[w.planId] ?: w.planId - withdrawalDao.insertWithdrawal(w.toEntity(remappedPlanId)) + val wExchange = Exchange.valueOf(w.exchange) + val connectionId = resolveConnectionForRestore(w.connectionId, wExchange) + withdrawalDao.insertWithdrawal(w.toEntity(remappedPlanId, connectionId)) } // 4. Insert notifications with remapped planId for (n in payload.notifications) { val remappedPlanId = n.planId?.let { planIdMap[it] ?: it } - notificationDao.insert(n.toEntity(remappedPlanId)) + val connectionId = n.exchange?.let { name -> + try { + resolveConnectionForRestore(n.connectionId, Exchange.valueOf(name)) + } catch (_: Exception) { null } + } + notificationDao.insert(n.toEntity(remappedPlanId, connectionId)) } // 5. Upsert withdrawal thresholds for (t in payload.withdrawalThresholds) { - withdrawalThresholdDao.upsert(t.toEntity()) + val tExchange = Exchange.valueOf(t.exchange) + val connectionId = resolveConnectionForRestore(t.connectionId, tExchange) + withdrawalThresholdDao.upsert(t.toEntity(connectionId)) } } @@ -118,38 +223,51 @@ class BackupDataRestorer @Inject constructor( userPreferences.setLowBalanceThresholdDays(settings.lowBalanceThresholdDays) } - // Outside transaction: restore credentials + // Outside transaction: restore credentials. + // Pre-validation above guarantees all entries parse cleanly. Remap each + // backup-local connectionId to the freshly inserted local one and save. + // Failures here are logged but don't roll back the DB — at this point the + // restore is "best effort committed" and partial credentials is recoverable + // (user can re-enter API keys via AddExchange). val isSandbox = userPreferences.isSandboxMode() if (restoreMode == RestoreMode.Replace) { credentialsStore.clearAllCredentials(isSandbox) } - for (cred in payload.credentials) { + var failedCredentials = 0 + for (cred in parsedCredentials) { try { - val exchange = Exchange.valueOf(cred.exchange) - credentialsStore.saveCredentials( - ExchangeCredentials( - exchange = exchange, - apiKey = cred.apiKey, - apiSecret = cred.apiSecret, - passphrase = cred.passphrase, - clientId = cred.clientId - ), - isSandbox = isSandbox - ) - } catch (_: Exception) { - // Skip unknown exchanges + val targetConnectionId = cred.backupConnectionId + ?.let { connectionIdMap[it] } + ?: resolveOrCreateDefaultConnection(cred.exchange) + credentialsStore.saveCredentials(targetConnectionId, cred.credentials, isSandbox) + } catch (e: Exception) { + failedCredentials++ } } - BackupResult.Success() + if (failedCredentials > 0) { + BackupResult.Success("Restored, but $failedCredentials credential set(s) could not be saved") + } else { + BackupResult.Success() + } } catch (e: Exception) { BackupResult.Error(e.message ?: "Unknown error during restore") } } + /** + * Internal pre-parsed credential record. Built BEFORE the DB transaction so any + * malformed backup credential aborts the restore upfront, before plans are committed. + */ + private data class ParsedCredential( + val exchange: Exchange, + val backupConnectionId: Long?, + val credentials: ExchangeCredentials + ) + // Backup → Entity mapping (all with id=0 for Room auto-generate) - private fun BackupPlan.toEntity(): DcaPlanEntity { + private fun BackupPlan.toEntity(connectionId: Long): DcaPlanEntity { val now = Instant.now() val freq = DcaFrequency.valueOf(frequency) val restoredNext = nextExecutionAt?.let { Instant.ofEpochMilli(it) } @@ -166,6 +284,7 @@ class BackupDataRestorer @Inject constructor( return DcaPlanEntity( id = 0, exchange = Exchange.valueOf(exchange), + connectionId = connectionId, crypto = crypto, fiat = fiat, amount = BigDecimal(amount), @@ -182,10 +301,11 @@ class BackupDataRestorer @Inject constructor( ) } - private fun BackupTransaction.toEntity(remappedPlanId: Long) = TransactionEntity( + private fun BackupTransaction.toEntity(remappedPlanId: Long, connectionId: Long?) = TransactionEntity( id = 0, planId = remappedPlanId, exchange = Exchange.valueOf(exchange), + connectionId = connectionId, crypto = crypto, fiat = fiat, fiatAmount = BigDecimal(fiatAmount), @@ -200,10 +320,11 @@ class BackupDataRestorer @Inject constructor( executedAt = Instant.ofEpochMilli(executedAt) ) - private fun BackupWithdrawal.toEntity(remappedPlanId: Long) = WithdrawalEntity( + private fun BackupWithdrawal.toEntity(remappedPlanId: Long, connectionId: Long?) = WithdrawalEntity( id = 0, planId = remappedPlanId, exchange = Exchange.valueOf(exchange), + connectionId = connectionId, crypto = crypto, amount = BigDecimal(amount), address = address, @@ -214,7 +335,7 @@ class BackupDataRestorer @Inject constructor( createdAt = Instant.ofEpochMilli(createdAt) ) - private fun BackupNotification.toEntity(remappedPlanId: Long?) = NotificationEntity( + private fun BackupNotification.toEntity(remappedPlanId: Long?, connectionId: Long?) = NotificationEntity( id = 0, type = NotificationType.valueOf(type), title = title, @@ -222,15 +343,16 @@ class BackupDataRestorer @Inject constructor( planId = remappedPlanId, crypto = crypto, exchange = exchange?.let { try { Exchange.valueOf(it) } catch (_: Exception) { null } }, + connectionId = connectionId, isRead = isRead, isArchived = isArchived, templateArgs = templateArgs, createdAt = Instant.ofEpochMilli(createdAt) ) - private fun BackupWithdrawalThreshold.toEntity() = WithdrawalThresholdEntity( + private fun BackupWithdrawalThreshold.toEntity(connectionId: Long) = WithdrawalThresholdEntity( crypto = crypto, - exchange = Exchange.valueOf(exchange), + connectionId = connectionId, thresholdAmount = BigDecimal(thresholdAmount) ) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/CredentialsStore.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/CredentialsStore.kt index bff7211..1d8a42f 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/CredentialsStore.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/CredentialsStore.kt @@ -2,12 +2,14 @@ package com.accbot.dca.data.local import android.content.Context import android.content.SharedPreferences +import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.ExchangeCredentials import com.google.gson.Gson import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.Instant import javax.inject.Inject import javax.inject.Singleton @@ -16,8 +18,22 @@ import javax.inject.Singleton * Uses AES-256-GCM encryption via Android Keystore. * All credentials stay on device - never transmitted to any server. * - * Supports separate credentials for production and sandbox environments. - * Each environment has its own API keys stored with different prefixes. + * **Connection-keyed (v3+):** each credential set is keyed by a `connectionId` + * (from [ExchangeConnectionEntity]) plus an environment prefix: + * `credentials_v3_prod_${connectionId}` or `credentials_v3_sandbox_${connectionId}`. + * + * The env prefix is required because production and sandbox each have their own Room + * database file with independent autoincrement IDs (so connection #1 in prod and + * connection #1 in sandbox would otherwise collide here). + * + * Migration history: + * - v1 (legacy): `credentials_${EXCHANGE}` (no env separation, prod-only) + * - v2: `credentials_prod_${EXCHANGE}` / `credentials_sandbox_${EXCHANGE}` + * - v3 (this version): `credentials_v3_${env}_${connectionId}` + * + * The v1→v2 migration runs synchronously in [encryptedPrefs] lazy init. The v2→v3 + * migration ([ensureMigrated]) needs Room DB access and is therefore called + * explicitly from `AccBotApplication.onCreate` once both databases are ready. * * Security notes: * - Uses commit() instead of apply() for immediate persistence @@ -26,7 +42,14 @@ import javax.inject.Singleton */ @Singleton class CredentialsStore @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + /** + * DAO for the *current* environment's database (selected at app start by sandbox flag). + * Used by the legacy [Exchange]-keyed API shims to resolve a default connection per + * exchange. Phases 6–7 will refactor remaining callers off the legacy API and this + * field can then be removed. + */ + private val currentEnvConnectionDao: ExchangeConnectionDao ) { private val gson = Gson() @@ -44,22 +67,22 @@ class CredentialsStore @Inject constructor( EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ).also { prefs -> - migrateOldCredentials(prefs) + migrateLegacyToV2(prefs) } } /** - * Migrate credentials from old format (credentials_EXCHANGE) to new format (credentials_prod_EXCHANGE). - * This ensures existing users' production credentials are preserved. + * v1 → v2 migration. Renames `credentials_${EXCHANGE}` to `credentials_prod_${EXCHANGE}`. + * Runs synchronously on first prefs access; idempotent via flag. */ - private fun migrateOldCredentials(prefs: SharedPreferences) { - val migrationDone = prefs.getBoolean(KEY_MIGRATION_DONE, false) + private fun migrateLegacyToV2(prefs: SharedPreferences) { + val migrationDone = prefs.getBoolean(KEY_MIGRATION_V2_DONE, false) if (migrationDone) return val editor = prefs.edit() Exchange.entries.forEach { exchange -> val oldKey = "${KEY_PREFIX_LEGACY}${exchange.name}" - val newKey = "${KEY_PREFIX_PROD}${exchange.name}" + val newKey = "${KEY_PREFIX_PROD_V2}${exchange.name}" val oldValue = prefs.getString(oldKey, null) if (oldValue != null && !prefs.contains(newKey)) { @@ -67,31 +90,104 @@ class CredentialsStore @Inject constructor( editor.remove(oldKey) } } - editor.putBoolean(KEY_MIGRATION_DONE, true) + editor.putBoolean(KEY_MIGRATION_V2_DONE, true) editor.commit() } /** - * Save exchange credentials. - * Uses commit() for immediate persistence of security-critical data. - * @param credentials The credentials to save - * @param isSandbox Whether these are sandbox credentials (default: false for production) + * v2 → v3 migration. Re-keys `credentials_${env}_${EXCHANGE}` to + * `credentials_v3_${env}_${connectionId}` by looking up the corresponding + * [ExchangeConnectionEntity] in the prod/sandbox database. + * + * Must be called from `AccBotApplication.onCreate` before any caller uses the + * connection-aware API. Idempotent via [KEY_MIGRATION_V3_DONE] flag. + * + * If the v18→v19 Room migration didn't auto-create a connection for an exchange + * (e.g. user has saved API keys but no plan yet for that exchange), this method + * inserts an empty-named connection on the fly so credentials don't end up + * orphaned. + * + * @param prodDb Production database instance (used to look up / create prod connections) + * @param sandboxDb Sandbox database instance + */ + suspend fun ensureMigrated(prodDb: DcaDatabase, sandboxDb: DcaDatabase) { + if (encryptedPrefs.getBoolean(KEY_MIGRATION_V3_DONE, false)) return + + Log.d(TAG, "Running CredentialsStore v2→v3 migration") + try { + migrateV2ToV3ForEnv(prodDb, isSandbox = false) + migrateV2ToV3ForEnv(sandboxDb, isSandbox = true) + encryptedPrefs.edit().putBoolean(KEY_MIGRATION_V3_DONE, true).commit() + Log.d(TAG, "CredentialsStore v2→v3 migration complete") + } catch (e: Exception) { + // Don't set the flag — next launch will retry. Log so we notice. + Log.e(TAG, "CredentialsStore v2→v3 migration failed; will retry next launch", e) + } + } + + private suspend fun migrateV2ToV3ForEnv(db: DcaDatabase, isSandbox: Boolean) { + val v2Prefix = if (isSandbox) KEY_PREFIX_SANDBOX_V2 else KEY_PREFIX_PROD_V2 + val v3Prefix = if (isSandbox) KEY_PREFIX_SANDBOX_V3 else KEY_PREFIX_PROD_V3 + val connectionDao = db.exchangeConnectionDao() + + for (exchange in Exchange.entries) { + val oldKey = "$v2Prefix${exchange.name}" + val oldValue = encryptedPrefs.getString(oldKey, null) ?: continue + + // Find or create a connection for this exchange in the target DB. The Room + // migration v18→v19 already auto-created connections for exchanges referenced + // by data tables; this branch fires only when user has credentials but no + // plans/transactions yet for that exchange. + val connectionId = connectionDao.getDefaultByExchange(exchange)?.id ?: try { + connectionDao.insert( + ExchangeConnectionEntity( + exchange = exchange, + name = "", + createdAt = Instant.now() + ) + ) + } catch (e: android.database.sqlite.SQLiteConstraintException) { + // Concurrent insert raced (unlikely under runBlocking init, but safe); + // re-fetch and use the existing row. + connectionDao.getDefaultByExchange(exchange)?.id ?: run { + Log.e(TAG, "Failed to resolve connection for ${exchange.name} after constraint", e) + continue + } + } + + val newKey = "$v3Prefix$connectionId" + // Don't overwrite an existing v3 key (shouldn't happen but defensive) + if (encryptedPrefs.contains(newKey)) { + encryptedPrefs.edit().remove(oldKey).commit() + continue + } + encryptedPrefs.edit() + .putString(newKey, oldValue) + .remove(oldKey) + .commit() + Log.d(TAG, "Migrated credentials for ${exchange.name} (sandbox=$isSandbox) → connectionId=$connectionId") + } + } + + // ─────────────────────────────────────────────────────────────────────── + // Connection-keyed API (v3) + // ─────────────────────────────────────────────────────────────────────── + + /** + * Save exchange credentials for a specific connection. * @return true if save was successful */ - fun saveCredentials(credentials: ExchangeCredentials, isSandbox: Boolean = false): Boolean { - val key = getCredentialsKey(credentials.exchange, isSandbox) + fun saveCredentials(connectionId: Long, credentials: ExchangeCredentials, isSandbox: Boolean): Boolean { + val key = v3Key(connectionId, isSandbox) val json = gson.toJson(credentials) return encryptedPrefs.edit().putString(key, json).commit() } /** - * Get credentials for an exchange. - * @param exchange The exchange to get credentials for - * @param isSandbox Whether to get sandbox credentials (default: false for production) - * @return credentials or null if not found or corrupted + * Get credentials for a specific connection. Returns null if not found or corrupted. */ - fun getCredentials(exchange: Exchange, isSandbox: Boolean = false): ExchangeCredentials? { - val key = getCredentialsKey(exchange, isSandbox) + fun getCredentials(connectionId: Long, isSandbox: Boolean): ExchangeCredentials? { + val key = v3Key(connectionId, isSandbox) val json = encryptedPrefs.getString(key, null) ?: return null return try { gson.fromJson(json, ExchangeCredentials::class.java) @@ -100,72 +196,122 @@ class CredentialsStore @Inject constructor( } } - /** - * Check if credentials exist for an exchange. - * @param exchange The exchange to check - * @param isSandbox Whether to check for sandbox credentials (default: false for production) - */ - fun hasCredentials(exchange: Exchange, isSandbox: Boolean = false): Boolean { - return encryptedPrefs.contains(getCredentialsKey(exchange, isSandbox)) + fun hasCredentials(connectionId: Long, isSandbox: Boolean): Boolean { + return encryptedPrefs.contains(v3Key(connectionId, isSandbox)) } - /** - * Delete credentials for an exchange. - * Uses commit() for immediate persistence. - * @param exchange The exchange to delete credentials for - * @param isSandbox Whether to delete sandbox credentials (default: false for production) - * @return true if deletion was successful - */ - fun deleteCredentials(exchange: Exchange, isSandbox: Boolean = false): Boolean { - return encryptedPrefs.edit().remove(getCredentialsKey(exchange, isSandbox)).commit() - } - - /** - * Get list of exchanges with stored credentials. - * @param isSandbox Whether to get exchanges with sandbox credentials (default: false for production) - */ - fun getConfiguredExchanges(isSandbox: Boolean = false): List { - return Exchange.entries.filter { hasCredentials(it, isSandbox) } + fun deleteCredentials(connectionId: Long, isSandbox: Boolean): Boolean { + return encryptedPrefs.edit().remove(v3Key(connectionId, isSandbox)).commit() } /** * Delete all stored credentials for a specific environment. - * Uses commit() for immediate persistence. - * @param isSandbox Whether to clear sandbox credentials (default: false for production) + * Iterates the prefs map and removes any v3 key with the matching env prefix. * @return true if clear was successful */ - fun clearAllCredentials(isSandbox: Boolean = false): Boolean { + fun clearAllCredentials(isSandbox: Boolean): Boolean { + val prefix = if (isSandbox) KEY_PREFIX_SANDBOX_V3 else KEY_PREFIX_PROD_V3 val editor = encryptedPrefs.edit() - Exchange.entries.forEach { exchange -> - editor.remove(getCredentialsKey(exchange, isSandbox)) - } + encryptedPrefs.all.keys + .filter { it.startsWith(prefix) } + .forEach { editor.remove(it) } return editor.commit() } /** - * Delete all stored credentials for both environments. - * Uses commit() for immediate persistence. - * @return true if clear was successful + * Delete all stored credentials for both environments. Also clears any leftover + * v1/v2 keys for cleanliness. */ fun clearAllCredentialsBothEnvironments(): Boolean { val editor = encryptedPrefs.edit() - Exchange.entries.forEach { exchange -> - editor.remove(getCredentialsKey(exchange, false)) - editor.remove(getCredentialsKey(exchange, true)) - } + encryptedPrefs.all.keys + .filter { key -> + key.startsWith(KEY_PREFIX_PROD_V3) || + key.startsWith(KEY_PREFIX_SANDBOX_V3) || + key.startsWith(KEY_PREFIX_PROD_V2) || + key.startsWith(KEY_PREFIX_SANDBOX_V2) || + key.startsWith(KEY_PREFIX_LEGACY) + } + .forEach { editor.remove(it) } return editor.commit() } - private fun getCredentialsKey(exchange: Exchange, isSandbox: Boolean): String { - val prefix = if (isSandbox) KEY_PREFIX_SANDBOX else KEY_PREFIX_PROD - return "$prefix${exchange.name}" + private fun v3Key(connectionId: Long, isSandbox: Boolean): String { + val prefix = if (isSandbox) KEY_PREFIX_SANDBOX_V3 else KEY_PREFIX_PROD_V3 + return "$prefix$connectionId" + } + + // ─────────────────────────────────────────────────────────────────────── + // Legacy [Exchange]-keyed API shims (to be removed after Phases 6–7). + // + // These resolve the *default* (first) connection of the given exchange and + // delegate to the v3 connection-based API. They are suspend because resolving + // the connection requires a Room query. + // + // The injected [currentEnvConnectionDao] points at the database matching the + // app's current sandbox flag (set at app start). All legacy callers happen to + // use the same isSandbox value as the current env, so this is fine. + // ─────────────────────────────────────────────────────────────────────── + + @Deprecated("Use getCredentials(connectionId, isSandbox)") + suspend fun getCredentials(exchange: Exchange, isSandbox: Boolean = false): ExchangeCredentials? { + val connectionId = currentEnvConnectionDao.getDefaultByExchange(exchange)?.id ?: return null + return getCredentials(connectionId, isSandbox) + } + + @Deprecated("Use saveCredentials(connectionId, credentials, isSandbox) — explicitly create a connection first") + suspend fun saveCredentials(credentials: ExchangeCredentials, isSandbox: Boolean = false): Boolean { + // Resolve or create a default connection for this exchange so legacy + // "save credentials by exchange" callers (Phase 7 candidates) keep working. + val connectionId = currentEnvConnectionDao.getDefaultByExchange(credentials.exchange)?.id ?: try { + currentEnvConnectionDao.insert( + ExchangeConnectionEntity( + exchange = credentials.exchange, + name = "", + createdAt = Instant.now() + ) + ) + } catch (_: android.database.sqlite.SQLiteConstraintException) { + // Race lost — another caller just created the default. Re-fetch. + currentEnvConnectionDao.getDefaultByExchange(credentials.exchange)?.id + ?: return false + } + return saveCredentials(connectionId, credentials, isSandbox) + } + + @Deprecated("Use hasCredentials(connectionId, isSandbox)") + suspend fun hasCredentials(exchange: Exchange, isSandbox: Boolean = false): Boolean { + val connectionId = currentEnvConnectionDao.getDefaultByExchange(exchange)?.id ?: return false + return hasCredentials(connectionId, isSandbox) + } + + @Deprecated("Use deleteCredentials(connectionId, isSandbox) and delete the connection itself if needed") + suspend fun deleteCredentials(exchange: Exchange, isSandbox: Boolean = false): Boolean { + val connectionId = currentEnvConnectionDao.getDefaultByExchange(exchange)?.id ?: return false + return deleteCredentials(connectionId, isSandbox) + } + + /** + * Legacy: list distinct exchanges that have at least one connection with stored credentials + * in the given environment. Phase 7 will replace this with a connection-list API. + */ + @Deprecated("Use ExchangeConnectionDao.getAll() and filter by hasCredentials(connectionId, isSandbox)") + suspend fun getConfiguredExchanges(isSandbox: Boolean = false): List { + return currentEnvConnectionDao.getAll() + .filter { hasCredentials(it.id, isSandbox) } + .map { it.exchange } + .distinct() } companion object { + private const val TAG = "CredentialsStore" private const val PREFS_NAME = "accbot_credentials" private const val KEY_PREFIX_LEGACY = "credentials_" - private const val KEY_PREFIX_PROD = "credentials_prod_" - private const val KEY_PREFIX_SANDBOX = "credentials_sandbox_" - private const val KEY_MIGRATION_DONE = "credentials_migration_v2_done" + private const val KEY_PREFIX_PROD_V2 = "credentials_prod_" + private const val KEY_PREFIX_SANDBOX_V2 = "credentials_sandbox_" + private const val KEY_PREFIX_PROD_V3 = "credentials_v3_prod_" + private const val KEY_PREFIX_SANDBOX_V3 = "credentials_v3_sandbox_" + private const val KEY_MIGRATION_V2_DONE = "credentials_migration_v2_done" + private const val KEY_MIGRATION_V3_DONE = "credentials_migration_v3_done" } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt index 3c0c870..bb0354c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt @@ -6,6 +6,36 @@ import kotlinx.coroutines.flow.Flow import java.math.BigDecimal import java.time.Instant +@Dao +interface ExchangeConnectionDao { + @Query("SELECT * FROM exchange_connections ORDER BY exchange, displayOrder, createdAt") + fun getAllFlow(): Flow> + + @Query("SELECT * FROM exchange_connections ORDER BY exchange, displayOrder, createdAt") + suspend fun getAll(): List + + @Query("SELECT * FROM exchange_connections WHERE exchange = :exchange ORDER BY displayOrder, createdAt") + suspend fun getByExchange(exchange: Exchange): List + + @Query("SELECT * FROM exchange_connections WHERE exchange = :exchange ORDER BY displayOrder, createdAt LIMIT 1") + suspend fun getDefaultByExchange(exchange: Exchange): ExchangeConnectionEntity? + + @Query("SELECT * FROM exchange_connections WHERE id = :id") + suspend fun getById(id: Long): ExchangeConnectionEntity? + + @Query("SELECT COUNT(*) FROM exchange_connections WHERE exchange = :exchange") + suspend fun countByExchange(exchange: Exchange): Int + + @Insert + suspend fun insert(connection: ExchangeConnectionEntity): Long + + @Update + suspend fun update(connection: ExchangeConnectionEntity) + + @Query("DELETE FROM exchange_connections WHERE id = :id") + suspend fun deleteById(id: Long) +} + @Dao interface DcaPlanDao { @Query("SELECT * FROM dca_plans ORDER BY createdAt DESC") @@ -23,6 +53,15 @@ interface DcaPlanDao { @Query("SELECT * FROM dca_plans WHERE exchange = :exchange") fun getPlansByExchange(exchange: Exchange): Flow> + @Query("SELECT * FROM dca_plans WHERE connectionId = :connectionId") + fun getPlansByConnection(connectionId: Long): Flow> + + @Query("SELECT COUNT(*) FROM dca_plans WHERE connectionId = :connectionId") + suspend fun countPlansByConnection(connectionId: Long): Int + + @Query("DELETE FROM dca_plans WHERE connectionId = :connectionId") + suspend fun deletePlansByConnection(connectionId: Long) + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertPlan(plan: DcaPlanEntity): Long @@ -280,6 +319,9 @@ interface TransactionDao { @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE exchange = :exchange AND crypto = :crypto AND status = 'COMPLETED'") suspend fun getTotalCryptoByExchangeAndCrypto(exchange: String, crypto: String): String + @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE connectionId = :connectionId AND crypto = :crypto AND status = 'COMPLETED'") + suspend fun getTotalCryptoByConnectionAndCrypto(connectionId: Long, crypto: String): String + @Query("SELECT * FROM transactions WHERE exchangeOrderId = :orderId LIMIT 1") suspend fun getByExchangeOrderId(orderId: String): TransactionEntity? @@ -346,8 +388,11 @@ interface ExchangeBalanceDao { @Query("SELECT * FROM exchange_balances WHERE exchange = :exchange") fun getBalancesByExchange(exchange: Exchange): Flow> - @Query("SELECT * FROM exchange_balances WHERE id = :id") - suspend fun getBalance(id: String): ExchangeBalanceEntity? + @Query("SELECT * FROM exchange_balances WHERE connectionId = :connectionId") + fun getBalancesByConnection(connectionId: Long): Flow> + + @Query("SELECT * FROM exchange_balances WHERE connectionId = :connectionId AND currency = :currency") + suspend fun getBalance(connectionId: Long, currency: String): ExchangeBalanceEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertBalance(balance: ExchangeBalanceEntity) @@ -358,6 +403,9 @@ interface ExchangeBalanceDao { @Query("DELETE FROM exchange_balances WHERE exchange = :exchange") suspend fun deleteBalancesByExchange(exchange: Exchange) + @Query("DELETE FROM exchange_balances WHERE connectionId = :connectionId") + suspend fun deleteBalancesByConnection(connectionId: Long) + @Query("DELETE FROM exchange_balances") suspend fun deleteAllBalances() } @@ -442,17 +490,28 @@ interface WithdrawalThresholdDao { @Query("SELECT * FROM withdrawal_thresholds") fun getAll(): Flow> - @Query("SELECT * FROM withdrawal_thresholds WHERE crypto = :crypto AND exchange = :exchange") - suspend fun get(crypto: String, exchange: Exchange): WithdrawalThresholdEntity? + @Query("SELECT * FROM withdrawal_thresholds WHERE crypto = :crypto AND connectionId = :connectionId") + suspend fun get(crypto: String, connectionId: Long): WithdrawalThresholdEntity? + + @Query("SELECT * FROM withdrawal_thresholds WHERE connectionId = :connectionId") + fun getByConnection(connectionId: Long): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(entity: WithdrawalThresholdEntity) - @Query("DELETE FROM withdrawal_thresholds WHERE crypto = :crypto AND exchange = :exchange") - suspend fun delete(crypto: String, exchange: Exchange) + @Query("DELETE FROM withdrawal_thresholds WHERE crypto = :crypto AND connectionId = :connectionId") + suspend fun delete(crypto: String, connectionId: Long) + + /** + * Delete all thresholds for a connection. Used as manual cascade when an + * [ExchangeConnectionEntity] is deleted, since FK enforcement (`PRAGMA foreign_keys`) + * is currently disabled in Room and the schema-level `ON DELETE CASCADE` is a no-op. + */ + @Query("DELETE FROM withdrawal_thresholds WHERE connectionId = :connectionId") + suspend fun deleteByConnection(connectionId: Long) - @Query("SELECT thresholdAmount FROM withdrawal_thresholds WHERE exchange = :exchange AND crypto = :crypto") - suspend fun getThresholdAmount(exchange: Exchange, crypto: String): BigDecimal? + @Query("SELECT thresholdAmount FROM withdrawal_thresholds WHERE connectionId = :connectionId AND crypto = :crypto") + suspend fun getThresholdAmount(connectionId: Long, crypto: String): BigDecimal? @Query("DELETE FROM withdrawal_thresholds") suspend fun deleteAll() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt index 895cd2b..cc576dc 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt @@ -17,9 +17,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase MonthlySummaryEntity::class, DailyPriceEntity::class, NotificationEntity::class, - WithdrawalThresholdEntity::class + WithdrawalThresholdEntity::class, + ExchangeConnectionEntity::class ], - version = 18, + version = 19, exportSchema = true ) @TypeConverters(Converters::class) @@ -32,6 +33,7 @@ abstract class DcaDatabase : RoomDatabase() { abstract fun dailyPriceDao(): DailyPriceDao abstract fun notificationDao(): NotificationDao abstract fun withdrawalThresholdDao(): WithdrawalThresholdDao + abstract fun exchangeConnectionDao(): ExchangeConnectionDao companion object { private const val LEGACY_DATABASE_NAME = "accbot_dca.db" @@ -215,6 +217,154 @@ abstract class DcaDatabase : RoomDatabase() { } } + // Migration from version 18 to 19: Multi-credentials per exchange. + // Adds exchange_connections table, plus connectionId column to dca_plans, transactions, + // withdrawals, notifications. Recreates withdrawal_thresholds (PK changes to + // (crypto, connectionId)) and exchange_balances (PK changes to (connectionId, currency)). + // Auto-creates one empty-named connection per Exchange enum used by existing data so + // every legacy row gets a valid connectionId. + private val MIGRATION_18_19 = object : Migration(18, 19) { + override fun migrate(database: SupportSQLiteDatabase) { + // 1) Create exchange_connections table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS exchange_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + exchange TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + createdAt INTEGER NOT NULL, + displayOrder INTEGER NOT NULL DEFAULT 0 + ) + """.trimIndent() + ) + database.execSQL("CREATE INDEX IF NOT EXISTS index_exchange_connections_exchange ON exchange_connections(exchange)") + // Non-partial unique index on (exchange, name). The empty default name "" + // counts as a distinct value so each exchange can have AT MOST ONE empty-named + // connection (the auto-created default), AT MOST ONE per non-empty name. + // Crucially this lets `INSERT OR IGNORE` from multiple seed sources below + // converge on a single default row per exchange instead of duplicating. + database.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS index_exchange_connections_exchange_name " + + "ON exchange_connections(exchange, name)" + ) + + // 2) Auto-create one default connection per exchange used by existing data. + // The non-partial unique index on (exchange, name) ensures each exchange gets + // exactly ONE empty-named connection regardless of which seed source(s) reference it. + val nowMillis = System.currentTimeMillis() + val seedSources = listOf( + "SELECT DISTINCT exchange FROM dca_plans", + "SELECT DISTINCT exchange FROM transactions", + "SELECT DISTINCT exchange FROM withdrawals", + "SELECT DISTINCT exchange FROM withdrawal_thresholds", + "SELECT DISTINCT exchange FROM exchange_balances", + "SELECT DISTINCT exchange FROM notifications WHERE exchange IS NOT NULL" + ) + for (src in seedSources) { + database.execSQL( + "INSERT OR IGNORE INTO exchange_connections (exchange, name, createdAt, displayOrder) " + + "SELECT exchange, '', $nowMillis, 0 FROM ($src)" + ) + } + + // 3) dca_plans — add connectionId column and backfill from default connection + database.execSQL("ALTER TABLE dca_plans ADD COLUMN connectionId INTEGER NOT NULL DEFAULT 0") + database.execSQL( + "UPDATE dca_plans SET connectionId = " + + "(SELECT id FROM exchange_connections WHERE exchange = dca_plans.exchange LIMIT 1)" + ) + database.execSQL("CREATE INDEX IF NOT EXISTS index_dca_plans_connectionId ON dca_plans(connectionId)") + + // Sanity assertion: any plan with connectionId = 0 means an exchange enum + // existed in dca_plans but no row was created in exchange_connections — bug. + database.query("SELECT COUNT(*) FROM dca_plans WHERE connectionId = 0").use { c -> + if (c.moveToFirst() && c.getInt(0) > 0) { + throw IllegalStateException( + "Migration 18->19 left ${c.getInt(0)} dca_plans rows with connectionId=0; " + + "exchange_connections seeding failed for some Exchange enum" + ) + } + } + + // 4) transactions — nullable connectionId, no FK + database.execSQL("ALTER TABLE transactions ADD COLUMN connectionId INTEGER DEFAULT NULL") + database.execSQL( + "UPDATE transactions SET connectionId = " + + "(SELECT id FROM exchange_connections WHERE exchange = transactions.exchange LIMIT 1)" + ) + database.execSQL("CREATE INDEX IF NOT EXISTS index_transactions_connectionId ON transactions(connectionId)") + + // 5) withdrawals — nullable connectionId, no FK + database.execSQL("ALTER TABLE withdrawals ADD COLUMN connectionId INTEGER DEFAULT NULL") + database.execSQL( + "UPDATE withdrawals SET connectionId = " + + "(SELECT id FROM exchange_connections WHERE exchange = withdrawals.exchange LIMIT 1)" + ) + + // 6) withdrawal_thresholds — recreate with new PK (crypto, connectionId). + // No FOREIGN KEY constraint here: the entity declaration in Entities.kt + // doesn't declare one (Room schema validation requires the migrated table + // to match the entity exactly), and FK enforcement (`PRAGMA foreign_keys`) + // is disabled anyway. Cascade-on-connection-delete is handled explicitly + // by [ExchangeConnectionRepository.delete] via WithdrawalThresholdDao. + database.execSQL( + """ + CREATE TABLE withdrawal_thresholds_new ( + crypto TEXT NOT NULL, + connectionId INTEGER NOT NULL, + thresholdAmount TEXT NOT NULL, + PRIMARY KEY (crypto, connectionId) + ) + """.trimIndent() + ) + database.execSQL( + """ + INSERT INTO withdrawal_thresholds_new (crypto, connectionId, thresholdAmount) + SELECT wt.crypto, ec.id, wt.thresholdAmount + FROM withdrawal_thresholds wt + JOIN exchange_connections ec ON ec.exchange = wt.exchange + """.trimIndent() + ) + database.execSQL("DROP TABLE withdrawal_thresholds") + database.execSQL("ALTER TABLE withdrawal_thresholds_new RENAME TO withdrawal_thresholds") + database.execSQL("CREATE INDEX IF NOT EXISTS index_withdrawal_thresholds_connectionId ON withdrawal_thresholds(connectionId)") + + // 7) exchange_balances — recreate with new composite PK (connectionId, currency) + database.execSQL( + """ + CREATE TABLE exchange_balances_new ( + connectionId INTEGER NOT NULL, + currency TEXT NOT NULL, + exchange TEXT NOT NULL, + balance TEXT NOT NULL, + lastUpdated INTEGER NOT NULL, + PRIMARY KEY (connectionId, currency) + ) + """.trimIndent() + ) + database.execSQL( + """ + INSERT INTO exchange_balances_new (connectionId, currency, exchange, balance, lastUpdated) + SELECT ec.id, eb.currency, eb.exchange, eb.balance, eb.lastUpdated + FROM exchange_balances eb + JOIN exchange_connections ec ON ec.exchange = eb.exchange + """.trimIndent() + ) + database.execSQL("DROP TABLE exchange_balances") + database.execSQL("ALTER TABLE exchange_balances_new RENAME TO exchange_balances") + database.execSQL("CREATE INDEX IF NOT EXISTS index_exchange_balances_connectionId ON exchange_balances(connectionId)") + database.execSQL("CREATE INDEX IF NOT EXISTS index_exchange_balances_exchange ON exchange_balances(exchange)") + + // 8) notifications — nullable connectionId, no FK + database.execSQL("ALTER TABLE notifications ADD COLUMN connectionId INTEGER DEFAULT NULL") + database.execSQL( + "UPDATE notifications SET connectionId = " + + "(SELECT id FROM exchange_connections WHERE exchange = notifications.exchange LIMIT 1) " + + "WHERE exchange IS NOT NULL" + ) + } + } + // Migration from version 9 to 10: Add notifications and withdrawal_thresholds tables private val MIGRATION_9_10 = object : Migration(9, 10) { override fun migrate(database: SupportSQLiteDatabase) { @@ -317,7 +467,7 @@ abstract class DcaDatabase : RoomDatabase() { DcaDatabase::class.java, databaseName ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19) // Only allow destructive migration on app downgrade, never on failed upgrade // This protects user's transaction history from accidental deletion .fallbackToDestructiveMigrationOnDowngrade() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt index b2f0947..162c606 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt @@ -101,6 +101,43 @@ class Converters { } } +/** + * Exchange connection entity — represents one set of API credentials for one exchange. + * Multiple connections can target the same exchange enum (e.g. two Coinmate sub-accounts + * as "Hlavní" and "Spoření" envelopes). The actual API key/secret is stored separately + * in [CredentialsStore], keyed by this entity's [id]. + * + * Note: there is no `isSandbox` column because production and sandbox are stored in + * separate Room database files (see [DcaDatabase]); a connection is implicitly tied to + * the environment of the database it lives in. + * + * The unique index on `(exchange, name)` enforces: + * - At most ONE connection with the empty default name "" per exchange (the auto-created + * "Default" envelope after migration or first credentials save). + * - At most ONE connection with any given non-empty name per exchange (no duplicate + * "Spoření" connections on Coinmate). + * + * UI rule (enforced in [CredentialFormDelegate]): when adding a 2nd connection on the same + * exchange, a non-empty name is required so both envelopes are distinguishable. + */ +@Entity( + tableName = "exchange_connections", + indices = [ + Index(value = ["exchange"]), + Index(value = ["exchange", "name"], unique = true) + ] +) +@TypeConverters(Converters::class) +data class ExchangeConnectionEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val exchange: Exchange, + /** Empty string means "no custom name" — UI displays the exchange display name only. */ + val name: String = "", + val createdAt: Instant = Instant.now(), + val displayOrder: Int = 0 +) + /** * DCA Plan entity - stored in Room database */ @@ -109,6 +146,7 @@ class Converters { indices = [ Index(value = ["isEnabled"]), Index(value = ["exchange"]), + Index(value = ["connectionId"]), Index(value = ["nextExecutionAt"]), Index(value = ["isEnabled", "nextExecutionAt"]) ] @@ -118,6 +156,11 @@ data class DcaPlanEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val exchange: Exchange, + /** + * FK to [ExchangeConnectionEntity.id]. Set by migration for legacy plans, required + * for new plans. Resolved via [ExchangeConnectionDao] at plan creation time. + */ + val connectionId: Long = 0, val crypto: String, val fiat: String, val amount: BigDecimal, @@ -145,6 +188,7 @@ data class DcaPlanEntity( indices = [ Index(value = ["planId"]), Index(value = ["exchange"]), + Index(value = ["connectionId"]), Index(value = ["crypto"]), Index(value = ["status"]), Index(value = ["executedAt"]), @@ -159,6 +203,13 @@ data class TransactionEntity( val id: Long = 0, val planId: Long, val exchange: Exchange, + /** + * FK-like reference to [ExchangeConnectionEntity.id]. Nullable so historical + * transactions survive deletion of their parent connection (no FK constraint). + * UI falls back to [exchange] display name when this is null or the connection + * was deleted. + */ + val connectionId: Long? = null, val crypto: String, val fiat: String, val fiatAmount: BigDecimal, @@ -190,6 +241,8 @@ data class WithdrawalEntity( val id: Long = 0, val planId: Long, val exchange: Exchange, + /** Nullable reference to [ExchangeConnectionEntity.id], no FK constraint. */ + val connectionId: Long? = null, val crypto: String, val amount: BigDecimal, val address: String, @@ -201,21 +254,27 @@ data class WithdrawalEntity( ) /** - * Exchange balance cache entity - * Stores cached balances from exchanges for quick display + * Exchange balance cache entity. + * Stores cached balances from exchanges for quick display. + * + * PK is composite `(connectionId, currency)` so that two connections targeting the same + * exchange (e.g. two Coinmate sub-accounts) keep separate balance caches. The [exchange] + * field is retained as a redundant fallback for display when the parent connection is + * deleted. */ @Entity( tableName = "exchange_balances", + primaryKeys = ["connectionId", "currency"], indices = [ + Index(value = ["connectionId"]), Index(value = ["exchange"]) ] ) @TypeConverters(Converters::class) data class ExchangeBalanceEntity( - @PrimaryKey - val id: String, // "${exchange}_${currency}" - val exchange: Exchange, + val connectionId: Long, val currency: String, + val exchange: Exchange, val balance: BigDecimal, val lastUpdated: Instant = Instant.now() ) @@ -276,6 +335,8 @@ data class NotificationEntity( val planId: Long? = null, val crypto: String? = null, val exchange: Exchange? = null, + /** Optional reference to [ExchangeConnectionEntity.id], no FK constraint. */ + val connectionId: Long? = null, val isRead: Boolean = false, val isArchived: Boolean = false, val systemNotificationId: Int? = null, @@ -284,15 +345,26 @@ data class NotificationEntity( ) /** - * Withdrawal threshold configuration per crypto+exchange pair + * Withdrawal threshold configuration per crypto+connection pair. + * + * After v18→v19 migration the PK changed from `(crypto, exchange)` to + * `(crypto, connectionId)` so each connection (envelope) has its own withdrawal target. + * + * No DB-level FOREIGN KEY: cascade-on-connection-delete is handled explicitly by + * [ExchangeConnectionRepository.delete] via [WithdrawalThresholdDao.deleteByConnection], + * because FK enforcement (`PRAGMA foreign_keys`) is currently disabled in the Room + * database builder. */ @Entity( tableName = "withdrawal_thresholds", - primaryKeys = ["crypto", "exchange"] + primaryKeys = ["crypto", "connectionId"], + indices = [ + Index(value = ["connectionId"]) + ] ) @TypeConverters(Converters::class) data class WithdrawalThresholdEntity( val crypto: String, - val exchange: Exchange, + val connectionId: Long, val thresholdAmount: BigDecimal ) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt index e373f84..58a659b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt @@ -8,6 +8,7 @@ import com.accbot.dca.domain.model.WithdrawalThreshold fun DcaPlanEntity.toDomain() = DcaPlan( id = id, exchange = exchange, + connectionId = connectionId, crypto = crypto, fiat = fiat, amount = amount, @@ -27,6 +28,7 @@ fun TransactionEntity.toDomain() = Transaction( id = id, planId = planId, exchange = exchange, + connectionId = connectionId, crypto = crypto, fiat = fiat, fiatAmount = fiatAmount, @@ -49,13 +51,21 @@ fun NotificationEntity.toDomain() = AppNotification( planId = planId, crypto = crypto, exchange = exchange, + connectionId = connectionId, isRead = isRead, isArchived = isArchived, createdAt = createdAt ) -fun WithdrawalThresholdEntity.toDomain() = WithdrawalThreshold( +/** + * Convert a [WithdrawalThresholdEntity] to its domain model. Requires the parent + * [ExchangeConnectionEntity] for the denormalized `exchange` and `connectionName` + * fields that the UI needs without a JOIN at the consumer. + */ +fun WithdrawalThresholdEntity.toDomain(connection: ExchangeConnectionEntity) = WithdrawalThreshold( crypto = crypto, - exchange = exchange, + connectionId = connectionId, + exchange = connection.exchange, + connectionName = connection.name, thresholdAmount = thresholdAmount ) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/repository/ExchangeConnectionRepository.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/repository/ExchangeConnectionRepository.kt new file mode 100644 index 0000000..12ca649 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/repository/ExchangeConnectionRepository.kt @@ -0,0 +1,121 @@ +package com.accbot.dca.data.repository + +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaPlanDao +import com.accbot.dca.data.local.ExchangeBalanceDao +import com.accbot.dca.data.local.ExchangeConnectionDao +import com.accbot.dca.data.local.ExchangeConnectionEntity +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.data.local.WithdrawalThresholdDao +import com.accbot.dca.domain.model.Exchange +import kotlinx.coroutines.flow.Flow +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repository for exchange connections (envelopes). Wraps [ExchangeConnectionDao] with + * convenience operations and side effects (cascade-clean credentials/balances on delete). + * + * A "connection" is one set of API credentials targeting a specific [Exchange]. Multiple + * connections can exist for the same exchange (e.g. two Coinmate sub-accounts named + * "Hlavní" and "Spoření"). Each connection has its own credentials (in [CredentialsStore]), + * its own balance cache, and its own withdrawal thresholds. + * + * Production and sandbox connections live in separate Room databases — this repository + * operates against whichever DB is currently active for the running app. + */ +@Singleton +class ExchangeConnectionRepository @Inject constructor( + private val connectionDao: ExchangeConnectionDao, + private val dcaPlanDao: DcaPlanDao, + private val exchangeBalanceDao: ExchangeBalanceDao, + private val withdrawalThresholdDao: WithdrawalThresholdDao, + private val credentialsStore: CredentialsStore, + private val userPreferences: UserPreferences +) { + fun observeAll(): Flow> = connectionDao.getAllFlow() + + suspend fun getAll(): List = connectionDao.getAll() + + suspend fun getById(id: Long): ExchangeConnectionEntity? = connectionDao.getById(id) + + suspend fun getByExchange(exchange: Exchange): List = + connectionDao.getByExchange(exchange) + + suspend fun getDefaultByExchange(exchange: Exchange): ExchangeConnectionEntity? = + connectionDao.getDefaultByExchange(exchange) + + suspend fun countByExchange(exchange: Exchange): Int = + connectionDao.countByExchange(exchange) + + /** + * Create a new connection. The DB-level partial unique index on `(exchange, name)` + * (where name != '') prevents duplicate non-empty names per exchange; an empty name + * is allowed only when no other connection on the same exchange already has empty + * name. Caller is expected to validate the name uniqueness before calling for a + * better UX (Phase 7 [AddExchangeViewModel] does this). + */ + suspend fun create(exchange: Exchange, name: String): Long { + return connectionDao.insert( + ExchangeConnectionEntity( + exchange = exchange, + name = name, + createdAt = Instant.now() + ) + ) + } + + suspend fun rename(connectionId: Long, newName: String) { + val existing = connectionDao.getById(connectionId) ?: return + connectionDao.update(existing.copy(name = newName)) + } + + /** + * Delete a connection and manually cascade to all dependent rows. Cleanup order: + * 1. (optional) DCA plans referencing this connection + * 2. Withdrawal thresholds (manual — `PRAGMA foreign_keys` is disabled in Room + * so the schema-level `ON DELETE CASCADE` is a no-op) + * 3. Balance cache rows + * 4. Encrypted credentials in [CredentialsStore] + * 5. The connection row itself + * + * Transaction history is *not* deleted — its `connectionId` becomes orphaned + * (nullable, no FK), and the UI falls back to the [Exchange] enum for the label. + * + * @param deletePlans if true, also deletes any DCA plans tied to this connection. + * If false and plans still reference this connection, throws to prevent orphaning + * plans (which would loop in DcaWorker with "no credentials" errors). + * @throws IllegalStateException when [deletePlans] is false but active plans exist. + */ + suspend fun delete(connectionId: Long, deletePlans: Boolean) { + val planCount = dcaPlanDao.countPlansByConnection(connectionId) + if (planCount > 0 && !deletePlans) { + throw IllegalStateException( + "Cannot delete connection $connectionId: $planCount active plan(s) reference it. " + + "Pass deletePlans=true to remove them, or delete the plans first." + ) + } + val isSandbox = userPreferences.isSandboxMode() + if (deletePlans && planCount > 0) { + dcaPlanDao.deletePlansByConnection(connectionId) + } + // Manual cascade — FK enforcement is currently disabled. + withdrawalThresholdDao.deleteByConnection(connectionId) + exchangeBalanceDao.deleteBalancesByConnection(connectionId) + credentialsStore.deleteCredentials(connectionId, isSandbox) + connectionDao.deleteById(connectionId) + } + + /** + * Compute a "display label" for a connection — exchange name plus optional custom name. + * E.g. "Coinmate" (no name) or "Coinmate — Spoření". + */ + fun displayLabel(connection: ExchangeConnectionEntity): String { + return if (connection.name.isNotBlank()) { + "${connection.exchange.displayName} — ${connection.name}" + } else { + connection.exchange.displayName + } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt b/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt index bf962fe..9abe83f 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt @@ -5,6 +5,7 @@ import androidx.work.WorkManager import com.accbot.dca.data.local.DcaDatabase import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.ExchangeConnectionDao import com.accbot.dca.data.local.OnboardingPreferences import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.data.local.DailyPriceDao @@ -90,10 +91,13 @@ object AppModule { @Provides @Singleton - fun provideCredentialsStore(@ApplicationContext context: Context): CredentialsStore { - return CredentialsStore(context) + fun provideExchangeConnectionDao(database: DcaDatabase): ExchangeConnectionDao { + return database.exchangeConnectionDao() } + // CredentialsStore now uses @Inject constructor (needs ExchangeConnectionDao for legacy + // Exchange-keyed shims). Hilt provides it automatically — no manual @Provides needed. + @Provides @Singleton fun provideOnboardingPreferences(@ApplicationContext context: Context): OnboardingPreferences { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt index 07e2351..db4fccc 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt @@ -4,6 +4,14 @@ import java.time.Instant /** * Backup envelope – the top-level structure of a backup file (always plaintext JSON). + * + * Versions: + * - v1: legacy single-connection-per-exchange model. Plans/transactions/thresholds/ + * credentials are keyed by [Exchange] enum string. + * - v2: connection-aware model. Adds [BackupPayload.connections] and `connectionId` + * fields on plans/transactions/withdrawals/notifications/credentials/thresholds so + * multiple connections per exchange can roundtrip cleanly. v1 backups are still + * restored by auto-creating one default connection per exchange. */ data class BackupEnvelope( val format: String = FORMAT_IDENTIFIER, @@ -19,12 +27,16 @@ data class BackupEnvelope( ) { companion object { const val FORMAT_IDENTIFIER = "accbot-backup" - const val CURRENT_VERSION = 1 + const val CURRENT_VERSION = 2 } } /** * Backup payload – the actual data after decryption/decompression. + * + * In v2, [connections] carries the multi-connection metadata. Other entries reference + * a connection by its backup-local id (the source DB's autoincrement value at export + * time); during restore, BackupDataRestorer remaps these to fresh local IDs. */ data class BackupPayload( val plans: List = emptyList(), @@ -33,7 +45,21 @@ data class BackupPayload( val credentials: List = emptyList(), val transactions: List = emptyList(), val notifications: List = emptyList(), - val withdrawals: List = emptyList() + val withdrawals: List = emptyList(), + /** v2+: list of [ExchangeConnectionEntity]-equivalent rows. Empty for legacy v1 backups. */ + val connections: List = emptyList() +) + +/** + * v2+: serializable exchange connection (envelope) for backup. + */ +data class BackupExchangeConnection( + /** Source DB's autoincrement id at export time. Used as the join key for plans/etc. */ + val id: Long, + val exchange: String, + val name: String = "", + val createdAt: Long = 0, + val displayOrder: Int = 0 ) /** @@ -54,7 +80,9 @@ data class BackupPlan( val createdAt: Long = 0, // Instant epoch millis val lastExecutedAt: Long? = null, val nextExecutionAt: Long? = null, - val targetAmount: String? = null // BigDecimal.toPlainString() + val targetAmount: String? = null, // BigDecimal.toPlainString() + /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ + val connectionId: Long? = null ) /** @@ -73,13 +101,19 @@ data class BackupSettings( /** * Serializable exchange credentials for backup. + * + * In v2, [connectionId] identifies which envelope these credentials belong to (matches + * a row in [BackupPayload.connections]). v1 backups omit it and the restorer falls back + * to the default connection per exchange. */ data class BackupCredentials( val exchange: String, val apiKey: String, val apiSecret: String, val passphrase: String? = null, - val clientId: String? = null + val clientId: String? = null, + /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ + val connectionId: Long? = null ) /** @@ -100,7 +134,9 @@ data class BackupTransaction( val exchangeOrderId: String? = null, val errorMessage: String? = null, val warningMessage: String? = null, - val executedAt: Long = 0 + val executedAt: Long = 0, + /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ + val connectionId: Long? = null ) /** @@ -117,7 +153,9 @@ data class BackupNotification( val isRead: Boolean = false, val isArchived: Boolean = false, val templateArgs: String? = null, - val createdAt: Long = 0 + val createdAt: Long = 0, + /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ + val connectionId: Long? = null ) /** @@ -134,16 +172,24 @@ data class BackupWithdrawal( val fee: String, val status: String, val errorMessage: String? = null, - val createdAt: Long = 0 + val createdAt: Long = 0, + /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ + val connectionId: Long? = null ) /** * Serializable withdrawal threshold for backup. + * + * v1 carries [exchange] (Exchange enum string); v2 carries [connectionId] referencing + * a row in [BackupPayload.connections]. Both fields are kept so a v2 backup can still + * be parsed by older code that only reads [exchange]. */ data class BackupWithdrawalThreshold( val crypto: String, val exchange: String, - val thresholdAmount: String + val thresholdAmount: String, + /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ + val connectionId: Long? = null ) /** diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt index 1838ae8..084da6a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt @@ -152,6 +152,8 @@ enum class DcaFrequency( data class DcaPlan( val id: Long = 0, val exchange: Exchange, + /** FK to ExchangeConnectionEntity.id — every plan belongs to one connection. */ + val connectionId: Long, val crypto: String, val fiat: String, val amount: BigDecimal, // Base amount (strategy may modify) @@ -185,6 +187,8 @@ data class Transaction( val id: Long = 0, val planId: Long, val exchange: Exchange, + /** Optional connection reference; null for legacy or post-deletion. */ + val connectionId: Long? = null, val crypto: String, val fiat: String, val fiatAmount: BigDecimal, @@ -266,17 +270,24 @@ data class AppNotification( val planId: Long? = null, val crypto: String? = null, val exchange: Exchange? = null, + val connectionId: Long? = null, val isRead: Boolean, val isArchived: Boolean = false, val createdAt: Instant ) /** - * Withdrawal threshold configuration + * Withdrawal threshold configuration — per (crypto, connection) pair. + * + * `exchange` is denormalized from the parent connection so UI can group/display by exchange + * without joining; it is filled in at the ViewModel layer when loading thresholds. + * `connectionName` may be empty if the connection has no custom name. */ data class WithdrawalThreshold( val crypto: String, + val connectionId: Long, val exchange: Exchange, + val connectionName: String, val thresholdAmount: BigDecimal ) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt index 5ee9750..e411fdc 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt @@ -2,6 +2,7 @@ package com.accbot.dca.domain.usecase import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.DcaPlanEntity +import com.accbot.dca.data.local.ExchangeConnectionDao import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.domain.model.DcaFrequency import com.accbot.dca.domain.model.DcaStrategy @@ -14,8 +15,23 @@ import javax.inject.Inject class CreateDcaPlanUseCase @Inject constructor( private val dcaPlanDao: DcaPlanDao, + private val exchangeConnectionDao: ExchangeConnectionDao, private val userPreferences: UserPreferences ) { + /** + * @param connectionId optional explicit connection. If null, the use case picks the + * default (first) connection of [exchange]. If no connection exists for that + * exchange, throws [IllegalStateException] — callers must ensure credentials are set + * up first (the AddPlan/AddExchange flow does this via [ValidateAndSaveCredentialsUseCase] + * which creates the connection alongside the credentials). + * + * Auto-creation of an empty connection here was removed: it produced "ghost" + * connections without credentials, which then made the next AddExchange flow + * unnecessarily prompt for a name (since the ghost counted as the 1st connection). + * + * @throws IllegalStateException when no connection exists for [exchange] and + * [connectionId] is null. + */ suspend fun execute( exchange: Exchange, crypto: String, @@ -26,7 +42,8 @@ class CreateDcaPlanUseCase @Inject constructor( strategy: DcaStrategy, withdrawalEnabled: Boolean = false, withdrawalAddress: String? = null, - targetAmount: BigDecimal? = null + targetAmount: BigDecimal? = null, + connectionId: Long? = null ) { val now = Instant.now() val nextExecution = if (frequency == DcaFrequency.CUSTOM && cronExpression != null) { @@ -36,8 +53,15 @@ class CreateDcaPlanUseCase @Inject constructor( now.plus(Duration.ofMinutes(frequency.intervalMinutes)) } + val resolvedConnectionId = connectionId + ?: exchangeConnectionDao.getDefaultByExchange(exchange)?.id + ?: throw IllegalStateException( + "No connection exists for $exchange — set up credentials first via AddExchange flow" + ) + val plan = DcaPlanEntity( exchange = exchange, + connectionId = resolvedConnectionId, crypto = crypto, fiat = fiat, amount = amount, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt index fc4da10..acb5508 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ResolvePendingTransactionsUseCase.kt @@ -30,7 +30,15 @@ class ResolvePendingTransactionsUseCase @Inject constructor( for (tx in pendingTransactions) { try { - val credentials = credentialsStore.getCredentials(tx.exchange, isSandbox) ?: continue + // Use the transaction's connectionId (set by migration); fall back to a + // legacy lookup by exchange enum if it's null (very old transactions + // somehow not backfilled by v18→v19 migration). + @Suppress("DEPRECATION") + val credentials = if (tx.connectionId != null) { + credentialsStore.getCredentials(tx.connectionId, isSandbox) + } else { + credentialsStore.getCredentials(tx.exchange, isSandbox) + } ?: continue val api = exchangeApiFactory.create(credentials) val orderId = tx.exchangeOrderId ?: continue diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateAndSaveCredentialsUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateAndSaveCredentialsUseCase.kt index 29866cc..4f8facd 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateAndSaveCredentialsUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateAndSaveCredentialsUseCase.kt @@ -2,6 +2,7 @@ package com.accbot.dca.domain.usecase import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.data.repository.ExchangeConnectionRepository import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.ExchangeCredentials import com.accbot.dca.exchange.ExchangeApiFactory @@ -12,25 +13,30 @@ import javax.inject.Inject * Result of credential validation and save operation. */ sealed class CredentialValidationResult { - data object Success : CredentialValidationResult() + /** Credentials valid and saved. Carries the resulting connectionId so callers can navigate. */ + data class Success(val connectionId: Long) : CredentialValidationResult() data class Error(val message: String) : CredentialValidationResult() data object NetworkError : CredentialValidationResult() } /** - * Use case for validating and saving exchange credentials. - * Extracts common credential validation logic from ViewModels for better testability - * and to eliminate code duplication across AddPlanViewModel, AddExchangeViewModel, - * and OnboardingViewModel. + * Use case for validating and saving exchange credentials, connection-aware. * - * This use case: - * 1. Creates ExchangeCredentials from input - * 2. Validates credentials with the exchange API - * 3. Saves valid credentials to secure storage + * Workflow: + * 1. Validates required fields (API key, secret, optional clientId for Coinmate) + * 2. Resolves or creates a connection (envelope) for these credentials + * 3. Calls the exchange API to verify the credentials are usable + * 4. On success: saves credentials under the connection's id + * 5. On failure: if a connection was just created for this call, deletes it as rollback + * + * Phase 7 (UI rewrite) will pass an explicit `connectionName` to differentiate envelopes + * on the same exchange. Until then, callers omit the name and the use case uses or + * creates the default (empty-named) connection per exchange. */ class ValidateAndSaveCredentialsUseCase @Inject constructor( private val exchangeApiFactory: ExchangeApiFactory, private val credentialsStore: CredentialsStore, + private val connectionRepository: ExchangeConnectionRepository, private val userPreferences: UserPreferences ) { /** @@ -41,14 +47,21 @@ class ValidateAndSaveCredentialsUseCase @Inject constructor( * @param apiSecret The API secret * @param passphrase Optional passphrase (required for some exchanges like KuCoin) * @param clientId Optional client ID (required for Coinmate) - * @return CredentialValidationResult.Success if valid and saved, Error otherwise + * @param connectionName Optional connection name. When null, the use case uses or + * creates the default (empty-named) connection for [exchange]. Phase 7 UI will pass + * an explicit name to create separate envelopes. + * @param existingConnectionId If non-null, save credentials against this existing + * connection (for "edit credentials" flows). When null, a new connection may be + * created. */ suspend fun execute( exchange: Exchange, apiKey: String, apiSecret: String, passphrase: String? = null, - clientId: String? = null + clientId: String? = null, + connectionName: String? = null, + existingConnectionId: Long? = null ): CredentialValidationResult { // Validate required fields if (apiKey.isBlank() || apiSecret.isBlank()) { @@ -69,6 +82,10 @@ class ValidateAndSaveCredentialsUseCase @Inject constructor( clientId = clientId?.trim()?.takeIf { it.isNotBlank() } ) + // Resolve target connection. Track whether we created it ourselves so we can + // roll back on validation failure. + val (connectionId, createdHere) = resolveConnection(exchange, connectionName, existingConnectionId) + return try { val isSandbox = userPreferences.isSandboxMode() @@ -76,19 +93,23 @@ class ValidateAndSaveCredentialsUseCase @Inject constructor( val isValid = api.validateCredentials() if (isValid) { - credentialsStore.saveCredentials(credentials, isSandbox) - CredentialValidationResult.Success + credentialsStore.saveCredentials(connectionId, credentials, isSandbox) + CredentialValidationResult.Success(connectionId) } else { + if (createdHere) connectionRepository.delete(connectionId, deletePlans = false) val hint = if (isSandbox) { " Make sure you are using API keys generated on the exchange's sandbox/testnet (not production keys)." } else "" CredentialValidationResult.Error("Invalid API credentials.$hint") } } catch (e: UnknownHostException) { + if (createdHere) connectionRepository.delete(connectionId, deletePlans = false) CredentialValidationResult.NetworkError } catch (e: java.io.IOException) { + if (createdHere) connectionRepository.delete(connectionId, deletePlans = false) CredentialValidationResult.NetworkError } catch (e: Exception) { + if (createdHere) connectionRepository.delete(connectionId, deletePlans = false) val isSandbox = userPreferences.isSandboxMode() val hint = if (isSandbox) { "\n\nNote: Sandbox mode requires separate API keys from the exchange's testnet environment." @@ -96,4 +117,26 @@ class ValidateAndSaveCredentialsUseCase @Inject constructor( CredentialValidationResult.Error("${e.message ?: "Failed to validate credentials"}$hint") } } + + /** + * @return Pair(connectionId, createdHere) — true if this call created a new connection + * row that should be rolled back on validation failure. + */ + private suspend fun resolveConnection( + exchange: Exchange, + connectionName: String?, + existingConnectionId: Long? + ): Pair { + if (existingConnectionId != null) { + return existingConnectionId to false + } + if (connectionName == null) { + // Legacy path: use or create default (empty-named) connection + val existing = connectionRepository.getDefaultByExchange(exchange) + if (existing != null) return existing.id to false + return connectionRepository.create(exchange, "") to true + } + // Explicit name (Phase 7 path): always create a new connection. + return connectionRepository.create(exchange, connectionName) to true + } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt index 0100a09..97535c8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt @@ -6,7 +6,9 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.res.stringResource import com.accbot.dca.R import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.ExchangeConnectionEntity import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.data.repository.ExchangeConnectionRepository import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.ExchangeFilter import com.accbot.dca.domain.model.ExchangeInstructions @@ -30,6 +32,34 @@ data class CredentialFormState( val apiKey: String = "", val apiSecret: String = "", val passphrase: String = "", + /** + * v2.8+: optional connection name for distinguishing multiple envelopes on the same + * exchange (e.g. "Hlavní", "Spoření"). Required when [requireConnectionName] is true, + * which is set by the ViewModel based on existing connection count. + */ + val connectionName: String = "", + val requireConnectionName: Boolean = false, + val existingConnectionsForExchange: List = emptyList(), + /** + * Full list of existing connection entities for the selected exchange. Used by the + * AddPlan flow to show a picker when the user has 1+ connections — they can pick an + * existing envelope instead of being forced to enter new credentials. + */ + val existingConnections: List = emptyList(), + /** + * If non-null, the user picked an existing connection from [existingConnections] (or + * it was auto-selected because exactly one existed). Plan creation should use this + * connection directly without re-validating credentials. When null, the user is in + * "create new connection" mode and must fill the credentials form. + */ + val selectedConnectionId: Long? = null, + /** + * True between [selectExchange]/[initWithExchange] and the async load of existing + * connections completing. The UI must disable the Validate button while this is true, + * otherwise the user could race past the duplicate-name check and create a duplicate + * empty-named connection. + */ + val isLoadingExchangeContext: Boolean = false, val isValidatingCredentials: Boolean = false, val credentialsValid: Boolean = false, val credentialsError: String? = null, @@ -58,7 +88,8 @@ class CredentialFormDelegate( private val credentialsStore: CredentialsStore, private val validateAndSaveCredentialsUseCase: ValidateAndSaveCredentialsUseCase, private val userPreferences: UserPreferences, - private val coroutineScope: CoroutineScope + private val coroutineScope: CoroutineScope, + private val connectionRepository: ExchangeConnectionRepository? = null ) { private val _state = MutableStateFlow(CredentialFormState()) val state: StateFlow = _state.asStateFlow() @@ -79,40 +110,122 @@ class CredentialFormDelegate( /** Initialize with a pre-selected exchange and load existing credentials. */ fun initWithExchange(exchange: Exchange) { val isSandbox = _state.value.isSandboxMode - val credentials = credentialsStore.getCredentials(exchange, isSandbox) val instructions = ExchangeInstructionsProvider.getInstructions(exchange, isSandbox) + // Pre-fill instructions/exchange synchronously, then load credentials + connections async _state.update { it.copy( selectedExchange = exchange, selectedExchangeInstructions = instructions, - hasCredentials = credentials != null, - credentialsValid = credentials != null, - apiKey = credentials?.apiKey ?: "", - apiSecret = credentials?.apiSecret ?: "", - passphrase = credentials?.passphrase ?: "", - clientId = credentials?.clientId ?: "", + isLoadingExchangeContext = true, credentialsError = null, credentialsErrorRes = 0 ) } + coroutineScope.launch { + @Suppress("DEPRECATION") + val credentials = credentialsStore.getCredentials(exchange, isSandbox) + val existing = connectionRepository?.getByExchange(exchange) ?: emptyList() + // Auto-select on single existing connection so the legacy "first plan after + // onboarding" path keeps working without user interaction. + val autoSelectedId = if (existing.size == 1) existing.first().id else null + _state.update { + it.copy( + hasCredentials = credentials != null || autoSelectedId != null, + credentialsValid = credentials != null || autoSelectedId != null, + apiKey = credentials?.apiKey ?: "", + apiSecret = credentials?.apiSecret ?: "", + passphrase = credentials?.passphrase ?: "", + clientId = credentials?.clientId ?: "", + requireConnectionName = existing.isNotEmpty(), + existingConnectionsForExchange = existing.map { c -> c.name }, + existingConnections = existing, + selectedConnectionId = autoSelectedId, + isLoadingExchangeContext = false + ) + } + } } fun selectExchange(exchange: Exchange) { val isSandbox = _state.value.isSandboxMode - val hasCredentials = credentialsStore.hasCredentials(exchange, isSandbox) val instructions = ExchangeInstructionsProvider.getInstructions(exchange, isSandbox) + // Set isLoadingExchangeContext = true synchronously so the Validate button is + // immediately disabled — prevents race where user clicks Validate before the + // existing-connections lookup completes. _state.update { state -> state.copy( selectedExchange = exchange, selectedExchangeInstructions = instructions, - hasCredentials = hasCredentials, - credentialsValid = hasCredentials, clientId = "", apiKey = "", apiSecret = "", passphrase = "", + connectionName = "", + requireConnectionName = false, + existingConnectionsForExchange = emptyList(), + existingConnections = emptyList(), + selectedConnectionId = null, + isLoadingExchangeContext = true, credentialsError = null, credentialsErrorRes = 0 ) } + coroutineScope.launch { + val existing = connectionRepository?.getByExchange(exchange) ?: emptyList() + // Auto-select when exactly ONE connection exists — user doesn't need a picker + // for the trivial case. With 0 connections, fall through to credentials form. + // With 2+ connections, leave selectedConnectionId null and let the UI render a + // picker so the user can choose between envelopes (Hlavní vs Spoření). + val autoSelectedId = if (existing.size == 1) existing.first().id else null + _state.update { + it.copy( + hasCredentials = autoSelectedId != null, + credentialsValid = autoSelectedId != null, + requireConnectionName = existing.isNotEmpty(), + existingConnectionsForExchange = existing.map { c -> c.name }, + existingConnections = existing, + selectedConnectionId = autoSelectedId, + isLoadingExchangeContext = false + ) + } + } + } + + /** + * User picked an existing connection from [CredentialFormState.existingConnections]. + * Skips the credentials form — plan creation will reuse the existing envelope. + */ + fun selectExistingConnection(connectionId: Long) { + _state.update { + it.copy( + selectedConnectionId = connectionId, + hasCredentials = true, + credentialsValid = true, + credentialsError = null, credentialsErrorRes = 0 + ) + } + } + + /** + * User chose "create a new connection" instead of picking an existing one. Clears + * any auto-selected connection and reveals the credentials form. + */ + fun startNewConnection() { + _state.update { + it.copy( + selectedConnectionId = null, + hasCredentials = false, + credentialsValid = false, + clientId = "", + apiKey = "", + apiSecret = "", + passphrase = "", + connectionName = "", + credentialsError = null, credentialsErrorRes = 0 + ) + } + } + + fun setConnectionName(value: String) { + _state.update { it.copy(connectionName = value, credentialsError = null, credentialsErrorRes = 0) } } fun setClientId(value: String) { @@ -134,9 +247,23 @@ class CredentialFormDelegate( fun validateAndSaveCredentials(onSuccess: () -> Unit) { val state = _state.value if (state.isValidatingCredentials) return + // Defensive: should never fire because UI disables Validate while loading, + // but guards against direct programmatic calls. + if (state.isLoadingExchangeContext) return val exchange = state.selectedExchange ?: return + // Validate connection name when required (2+ connections on this exchange) + val trimmedName = state.connectionName.trim() + if (state.requireConnectionName && trimmedName.isEmpty()) { + _state.update { it.copy(credentialsErrorRes = R.string.exchanges_connection_name_required) } + return + } + if (trimmedName.isNotEmpty() && trimmedName in state.existingConnectionsForExchange) { + _state.update { it.copy(credentialsErrorRes = R.string.exchanges_connection_name_taken) } + return + } + coroutineScope.launch { _state.update { it.copy(isValidatingCredentials = true, credentialsError = null, credentialsErrorRes = 0) } @@ -145,7 +272,8 @@ class CredentialFormDelegate( apiKey = state.apiKey, apiSecret = state.apiSecret, passphrase = state.passphrase.takeIf { it.isNotBlank() }, - clientId = state.clientId.takeIf { it.isNotBlank() } + clientId = state.clientId.takeIf { it.isNotBlank() }, + connectionName = trimmedName.takeIf { it.isNotEmpty() } ) when (result) { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/navigation/Screen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/navigation/Screen.kt index f0b2372..1847da2 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/navigation/Screen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/navigation/Screen.kt @@ -29,12 +29,23 @@ sealed class Screen(val route: String) { } // Exchange screens data object ExchangeManagement : Screen("exchanges/manage") - data object ExchangeDetail : Screen("exchanges/detail/{exchange}?autoImport={autoImport}") { - fun createRoute(exchangeName: String, autoImport: Boolean = false): String { - return if (autoImport) "exchanges/detail/$exchangeName?autoImport=true" - else "exchanges/detail/$exchangeName" + + /** + * Detail of a single exchange connection (envelope). Keyed by connectionId + * (Long autoincrement from ExchangeConnectionEntity). + */ + data object ExchangeDetail : Screen("exchanges/detail/{connectionId}?autoImport={autoImport}") { + fun createRoute(connectionId: Long, autoImport: Boolean = false): String { + return if (autoImport) "exchanges/detail/$connectionId?autoImport=true" + else "exchanges/detail/$connectionId" } } + + /** + * Add exchange flow. Optional pre-selected exchange (when triggered from a tile in + * ExchangeManagement). Phase 7+ allows multiple connections per exchange so the + * filter on "exchanges without credentials" was removed. + */ data object AddExchange : Screen("exchanges/add?exchange={exchange}") { fun createRoute(exchangeName: String? = null): String { return if (exchangeName != null) "exchanges/add?exchange=$exchangeName" else "exchanges/add" @@ -63,6 +74,7 @@ sealed class Screen(val route: String) { const val PLAN_ID_ARG = "planId" const val TRANSACTION_ID_ARG = "transactionId" const val EXCHANGE_ARG = "exchange" + const val CONNECTION_ID_ARG = "connectionId" } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt index 785611b..d159c8e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt @@ -3,6 +3,7 @@ package com.accbot.dca.presentation.screens import android.content.Intent import android.net.Uri import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.rememberScrollState @@ -38,7 +39,7 @@ import com.accbot.dca.presentation.plan.PlanFormContent fun AddPlanScreen( onNavigateBack: () -> Unit, onPlanCreated: () -> Unit, - onNavigateToExchangeDetail: ((String) -> Unit)? = null, + onNavigateToExchangeManagement: (() -> Unit)? = null, viewModel: AddPlanViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -93,9 +94,10 @@ fun AddPlanScreen( confirmButton = { TextButton(onClick = { viewModel.dismissImportDialog() - uiState.credentialForm.selectedExchange?.let { exchange -> - onNavigateToExchangeDetail?.invoke(exchange.name) - } + // Phase 7+: navigate to Exchange Management list. User picks the + // specific connection envelope to import balances into. (Was previously + // a direct deep-link to ExchangeDetail with autoImport=true.) + onNavigateToExchangeManagement?.invoke() }) { Text(stringResource(R.string.import_api_title)) } @@ -198,39 +200,87 @@ fun AddPlanScreen( ) } - // API Credentials + // Connection picker / credentials form val cred = uiState.credentialForm - if (cred.selectedExchange != null && !cred.hasCredentials) { - // Exchange setup instructions card - if (cred.selectedExchangeInstructions != null) { - if (cred.isSandboxMode) { - SandboxCredentialsInfoCard( - exchange = cred.selectedExchange!!, - instructions = cred.selectedExchangeInstructions!! - ) - } else { - ExchangeInstructionsCard( - exchange = cred.selectedExchange!!, - instructions = cred.selectedExchangeInstructions!! + if (cred.selectedExchange != null) { + val hasMultipleExisting = cred.existingConnections.size >= 2 + val isCreatingNew = cred.selectedConnectionId == null && cred.existingConnections.isNotEmpty() + + // Picker: shown when 2+ existing connections so the user can pick which + // envelope this plan should target (and switch to "create new" if needed). + if (hasMultipleExisting) { + SectionTitle(stringResource(R.string.add_plan_pick_connection)) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + cred.existingConnections.forEach { connection -> + ConnectionPickerRow( + label = if (connection.name.isNotBlank()) connection.name + else stringResource(R.string.exchanges_default_connection_label), + selected = cred.selectedConnectionId == connection.id, + onClick = { viewModel.credentialForm.selectExistingConnection(connection.id) } + ) + } + ConnectionPickerRow( + label = stringResource(R.string.add_plan_create_new_connection), + selected = cred.selectedConnectionId == null, + onClick = { viewModel.credentialForm.startNewConnection() } ) } } - SectionTitle(stringResource(R.string.add_plan_api_credentials)) - // Use reusable CredentialsInputCard component - CredentialsInputCard( - exchange = cred.selectedExchange!!, - clientId = cred.clientId, - apiKey = cred.apiKey, - apiSecret = cred.apiSecret, - passphrase = cred.passphrase, - onClientIdChange = viewModel.credentialForm::setClientId, - onApiKeyChange = viewModel.credentialForm::setApiKey, - onApiSecretChange = viewModel.credentialForm::setApiSecret, - onPassphraseChange = viewModel.credentialForm::setPassphrase, - errorMessage = uiState.errorMessage, - isValidating = uiState.isLoading - ) + // Credentials entry form: shown only when the user is creating a NEW connection + // (no existing connection, or explicitly chose "Create new" from the picker). + val showCredentialForm = cred.selectedConnectionId == null + if (showCredentialForm) { + if (cred.selectedExchangeInstructions != null) { + if (cred.isSandboxMode) { + SandboxCredentialsInfoCard( + exchange = cred.selectedExchange!!, + instructions = cred.selectedExchangeInstructions!! + ) + } else { + ExchangeInstructionsCard( + exchange = cred.selectedExchange!!, + instructions = cred.selectedExchangeInstructions!! + ) + } + } + + SectionTitle(stringResource(R.string.add_plan_api_credentials)) + + // Connection name input (required when there's already 1+ connections on this exchange) + if (cred.requireConnectionName || cred.existingConnections.isNotEmpty()) { + OutlinedTextField( + value = cred.connectionName, + onValueChange = viewModel.credentialForm::setConnectionName, + label = { Text(stringResource(R.string.exchanges_connection_name_label)) }, + placeholder = { Text(stringResource(R.string.exchanges_connection_name_hint)) }, + singleLine = true, + isError = cred.requireConnectionName && cred.connectionName.isBlank(), + modifier = Modifier.fillMaxWidth() + ) + if (cred.requireConnectionName) { + Text( + text = stringResource(R.string.exchanges_connection_name_required), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + CredentialsInputCard( + exchange = cred.selectedExchange!!, + clientId = cred.clientId, + apiKey = cred.apiKey, + apiSecret = cred.apiSecret, + passphrase = cred.passphrase, + onClientIdChange = viewModel.credentialForm::setClientId, + onApiKeyChange = viewModel.credentialForm::setApiKey, + onApiSecretChange = viewModel.credentialForm::setApiSecret, + onPassphraseChange = viewModel.credentialForm::setPassphrase, + errorMessage = uiState.errorMessage, + isValidating = uiState.isLoading + ) + } } // Plan form (crypto, fiat, amount, frequency, strategy, withdrawal, target) @@ -276,3 +326,41 @@ fun AddPlanScreen( } // Box } } + +/** + * Single row in the connection picker (radio-like): a row with a leading RadioButton, + * a label and a clickable surface. Used to pick which existing connection a new plan + * should target, or to switch to "create new connection" mode. + */ +@Composable +private fun ConnectionPickerRow( + label: String, + selected: Boolean, + onClick: () -> Unit +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(role = androidx.compose.ui.semantics.Role.RadioButton, onClick = onClick), + shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), + color = if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surface, + tonalElevation = if (selected) 2.dp else 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + RadioButton(selected = selected, onClick = null) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface + ) + } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt index 53968a0..799c4da 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.data.repository.ExchangeConnectionRepository import com.accbot.dca.domain.model.DcaFrequency import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.supportsApiImport @@ -43,10 +44,13 @@ data class AddPlanUiState( get() { val cred = credentialForm if (cred.selectedExchange == null) return false - if (!cred.hasCredentials) { - if (cred.apiKey.isBlank() || cred.apiSecret.isBlank()) return false - if (cred.selectedExchange == Exchange.COINMATE && cred.clientId.isBlank()) return false - } + // Path A: existing connection picked -> credentials already exist, skip checks + if (cred.selectedConnectionId != null) return planForm.isFormValid + // Path B: creating a new connection -> validate API key fields + if (cred.apiKey.isBlank() || cred.apiSecret.isBlank()) return false + if (cred.selectedExchange == Exchange.COINMATE && cred.clientId.isBlank()) return false + // When 1+ existing connections, the new one must be named so picker can distinguish + if (cred.requireConnectionName && cred.connectionName.isBlank()) return false return planForm.isFormValid } } @@ -57,12 +61,19 @@ class AddPlanViewModel @Inject constructor( private val validateAndSaveCredentialsUseCase: ValidateAndSaveCredentialsUseCase, private val createDcaPlanUseCase: CreateDcaPlanUseCase, private val userPreferences: UserPreferences, + private val connectionRepository: ExchangeConnectionRepository, calculateMonthlyCost: CalculateMonthlyCostUseCase, minOrderSizeRepository: MinOrderSizeRepository ) : ViewModel() { val planForm = PlanFormDelegate(calculateMonthlyCost, minOrderSizeRepository, viewModelScope) - val credentialForm = CredentialFormDelegate(credentialsStore, validateAndSaveCredentialsUseCase, userPreferences, viewModelScope) + val credentialForm = CredentialFormDelegate( + credentialsStore = credentialsStore, + validateAndSaveCredentialsUseCase = validateAndSaveCredentialsUseCase, + userPreferences = userPreferences, + coroutineScope = viewModelScope, + connectionRepository = connectionRepository + ) private val _localState = MutableStateFlow(AddPlanUiState()) @@ -97,23 +108,28 @@ class AddPlanViewModel @Inject constructor( _localState.update { it.copy(isLoading = true, errorMessage = null) } try { - // Validate and save credentials if new - if (!cred.hasCredentials) { + // Two paths: + // A) User picked an existing connection (auto-selected when only one + // exists, or chosen explicitly via picker when 2+ exist) -> reuse it, + // skip the validate-and-save step entirely. + // B) User is creating a new connection -> validate API keys, save under + // a fresh connection, then use the resulting connectionId. + val targetConnectionId: Long = if (cred.selectedConnectionId != null) { + cred.selectedConnectionId + } else { val result = validateAndSaveCredentialsUseCase.execute( exchange = exchange, apiKey = cred.apiKey, apiSecret = cred.apiSecret, passphrase = cred.passphrase.takeIf { it.isNotBlank() }, - clientId = cred.clientId.takeIf { it.isNotBlank() } + clientId = cred.clientId.takeIf { it.isNotBlank() }, + connectionName = cred.connectionName.trim().takeIf { it.isNotEmpty() } ) when (result) { is CredentialValidationResult.Error -> { _localState.update { - it.copy( - isLoading = false, - errorMessage = result.message - ) + it.copy(isLoading = false, errorMessage = result.message) } return@launch } @@ -122,14 +138,13 @@ class AddPlanViewModel @Inject constructor( _localState.update { it.copy(isLoading = false) } return@launch } - is CredentialValidationResult.Success -> { - // Credentials validated and saved, continue with plan creation - } + is CredentialValidationResult.Success -> result.connectionId } } createDcaPlanUseCase.execute( exchange = exchange, + connectionId = targetConnectionId, crypto = form.selectedCrypto, fiat = form.selectedFiat, amount = form.amount.toBigDecimal(), @@ -141,7 +156,9 @@ class AddPlanViewModel @Inject constructor( targetAmount = form.targetAmount.toBigDecimalOrNull() ) - val shouldOfferImport = !cred.hasCredentials && exchange.supportsApiImport + // Only offer the API import flow when this was a freshly created connection. + val wasNewConnection = cred.selectedConnectionId == null + val shouldOfferImport = wasNewConnection && exchange.supportsApiImport _localState.update { it.copy( isLoading = false, isSuccess = !shouldOfferImport, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt index 935ad11..623151f 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt @@ -83,7 +83,7 @@ fun DashboardScreen( onNavigateToSettings: () -> Unit, onNavigateToPlanDetails: ((Long) -> Unit)? = null, onNavigateToPortfolio: ((String, String) -> Unit)? = null, - onNavigateToExchangeDetail: ((String) -> Unit)? = null, + onNavigateToExchangeManagement: (() -> Unit)? = null, viewModel: DashboardViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -189,12 +189,9 @@ fun DashboardScreen( HoldingsPager( holdings = uiState.holdings, onHoldingClick = onNavigateToPortfolio, - onImportViaApi = onNavigateToExchangeDetail?.let { nav -> - { - val exchanges = uiState.activePlans.map { it.plan.exchange.name }.distinct() - if (exchanges.size == 1) nav(exchanges.first()) else nav("") - } - }, + // Phase 7+: route to Exchange Management list rather than deep-linking + // into a specific connection. User picks the connection there. + onImportViaApi = onNavigateToExchangeManagement?.let { nav -> { nav() } }, compact = true ) @@ -308,12 +305,8 @@ fun DashboardScreen( HoldingsPager( holdings = uiState.holdings, onHoldingClick = onNavigateToPortfolio, - onImportViaApi = onNavigateToExchangeDetail?.let { nav -> - { - val exchanges = uiState.activePlans.map { it.plan.exchange.name }.distinct() - if (exchanges.size == 1) nav(exchanges.first()) else nav("") - } - } + // Phase 7+: route to Exchange Management list rather than deep-linking. + onImportViaApi = onNavigateToExchangeManagement?.let { nav -> { nav() } } ) } @@ -1017,7 +1010,11 @@ internal fun DcaPlanCard( color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - text = plan.exchange.displayName, + // Render connection name as suffix when present (Phase 8 multi-connection) + text = if (planWithBalance.connectionName.isNotBlank()) + "${plan.exchange.displayName} — ${planWithBalance.connectionName}" + else + plan.exchange.displayName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt index cb392af..cff7f21 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt @@ -8,6 +8,7 @@ import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.ExchangeBalanceDao import com.accbot.dca.data.local.ExchangeBalanceEntity +import com.accbot.dca.data.local.ExchangeConnectionDao import com.accbot.dca.data.local.CryptoFiatHolding import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.UserPreferences @@ -63,7 +64,9 @@ data class DcaPlanWithBalance( val isOverWithdrawalThreshold: Boolean = false, val exchangeCryptoBalance: BigDecimal? = null, val accumulatedCrypto: BigDecimal? = null, - val strategyMultiplier: StrategyMultiplierResult? = null + val strategyMultiplier: StrategyMultiplierResult? = null, + /** Connection name for display (empty if the connection has no custom name). */ + val connectionName: String = "" ) @Immutable @@ -119,6 +122,7 @@ class DashboardViewModel @Inject constructor( private val credentialsStore: CredentialsStore, private val exchangeBalanceDao: ExchangeBalanceDao, private val withdrawalThresholdDao: WithdrawalThresholdDao, + private val exchangeConnectionDao: ExchangeConnectionDao, private val calculateStrategyMultiplier: CalculateStrategyMultiplierUseCase ) : AndroidViewModel(application) { @@ -172,13 +176,27 @@ class DashboardViewModel @Inject constructor( launch { DcaAlarmScheduler.scheduleNextAlarm(application) } } + // Pre-load connection names for all unique connectionIds in one batch. + // Avoids one DB call per plan during the map below. + val connectionNames: Map = plans + .map { it.connectionId } + .distinct() + .mapNotNull { id -> + exchangeConnectionDao.getById(id)?.let { id to it.name } + } + .toMap() + val plansWithBalance = plans.map { plan -> val accumulated = if (plan.targetAmount != null) { try { BigDecimal(transactionDao.getAccumulatedCryptoByPlan(plan.id)) } catch (_: Exception) { null } } else null - DcaPlanWithBalance(plan = plan, accumulatedCrypto = accumulated) + DcaPlanWithBalance( + plan = plan, + accumulatedCrypto = accumulated, + connectionName = connectionNames[plan.connectionId] ?: "" + ) } // Merge existing prices into fresh holdings to avoid KPI flash @@ -324,28 +342,29 @@ class DashboardViewModel @Inject constructor( val thresholdDays = userPreferences.getLowBalanceThresholdDays() val existingAccumulated = _uiState.value.activePlans.associate { it.plan.id to it.accumulatedCrypto } - // Group by exchange+fiat to avoid duplicate API calls + // Group by connection+fiat to avoid duplicate API calls. Each connection (envelope) + // has independent balances even if two connections target the same exchange. val balanceCache = mutableMapOf() val plansWithBalance = plans.map { plan -> if (!plan.isEnabled) return@map DcaPlanWithBalance(plan = plan, accumulatedCrypto = existingAccumulated[plan.id]) - val balanceKey = "${plan.exchange}_${plan.fiat}" + val balanceKey = "${plan.connectionId}_${plan.fiat}" val balance = balanceCache.getOrPut(balanceKey) { try { - val credentials = credentialsStore.getCredentials(plan.exchange, isSandbox) + val credentials = credentialsStore.getCredentials(plan.connectionId, isSandbox) ?: return@getOrPut null val api = exchangeApiFactory.create(credentials) val fetchedBalance = withTimeoutOrNull(10_000) { api.getBalance(plan.fiat) } - // Cache in DB + // Cache in DB per (connectionId, currency) if (fetchedBalance != null) { exchangeBalanceDao.insertBalance( ExchangeBalanceEntity( - id = balanceKey, - exchange = plan.exchange, + connectionId = plan.connectionId, currency = plan.fiat, + exchange = plan.exchange, balance = fetchedBalance, lastUpdated = Instant.now() ) @@ -353,22 +372,22 @@ class DashboardViewModel @Inject constructor( } fetchedBalance } catch (e: Exception) { - Log.e(TAG, "Error fetching balance for ${plan.exchange}/${plan.fiat}", e) + Log.e(TAG, "Error fetching balance for connection=${plan.connectionId}/${plan.fiat}", e) // Try cached balance from DB try { - exchangeBalanceDao.getBalance(balanceKey)?.balance + exchangeBalanceDao.getBalance(plan.connectionId, plan.fiat)?.balance } catch (_: Exception) { null } } } // Check withdrawal threshold using live crypto balance from exchange val withdrawalThreshold = try { - withdrawalThresholdDao.getThresholdAmount(plan.exchange, plan.crypto) + withdrawalThresholdDao.getThresholdAmount(plan.connectionId, plan.crypto) } catch (_: Exception) { null } - val cryptoBalanceKey = "${plan.exchange}_${plan.crypto}" + val cryptoBalanceKey = "${plan.connectionId}_${plan.crypto}" val exchangeCryptoBalance = balanceCache.getOrPut(cryptoBalanceKey) { try { - val creds = credentialsStore.getCredentials(plan.exchange, isSandbox) + val creds = credentialsStore.getCredentials(plan.connectionId, isSandbox) ?: return@getOrPut null val api = exchangeApiFactory.create(creds) withTimeoutOrNull(10_000) { api.getBalance(plan.crypto) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt index 16139e0..63afca4 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsScreen.kt @@ -441,7 +441,7 @@ fun SettingsScreen( item { SettingsCard( title = stringResource(R.string.settings_manage_exchanges), - subtitle = stringResource(R.string.settings_exchanges_connected, uiState.configuredExchanges.size), + subtitle = stringResource(R.string.settings_exchanges_connected, uiState.connectionCount), icon = Icons.Default.AccountBalance, onClick = { onNavigateToExchanges?.invoke() } ) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt index f3aa207..8d0d9eb 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt @@ -10,6 +10,7 @@ import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.DailyPriceDao import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.ExchangeBalanceDao +import com.accbot.dca.data.local.ExchangeConnectionDao import com.accbot.dca.data.local.MonthlySummaryDao import com.accbot.dca.data.local.NotificationDao import com.accbot.dca.data.local.OnboardingPreferences @@ -36,6 +37,12 @@ import javax.inject.Inject @Immutable data class SettingsUiState( val configuredExchanges: List = emptyList(), + /** + * Total number of exchange *connections* (envelopes) — can exceed + * `configuredExchanges.size` when the user has multiple connections on the same + * exchange (e.g. two Coinmate sub-accounts). + */ + val connectionCount: Int = 0, val isBatteryOptimized: Boolean = true, val isSandboxMode: Boolean = false, val showRestartDialog: Boolean = false, @@ -70,7 +77,8 @@ class SettingsViewModel @Inject constructor( private val monthlySummaryDao: MonthlySummaryDao, private val dailyPriceDao: DailyPriceDao, private val withdrawalDao: WithdrawalDao, - private val withdrawalThresholdDao: WithdrawalThresholdDao + private val withdrawalThresholdDao: WithdrawalThresholdDao, + private val exchangeConnectionDao: ExchangeConnectionDao ) : AndroidViewModel(application) { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -81,6 +89,25 @@ class SettingsViewModel @Inject constructor( loadSettings() loadWithdrawalThresholds() loadDataCounts() + observeConnections() + } + + /** + * Reactive flow on the connection list so the Settings card subtitle ("X connections + * connected") and the legacy `configuredExchanges` field stay in sync as the user + * adds or removes envelopes — no manual reload needed. + */ + private fun observeConnections() { + viewModelScope.launch { + exchangeConnectionDao.getAllFlow().collect { connections -> + _uiState.update { + it.copy( + connectionCount = connections.size, + configuredExchanges = connections.map { c -> c.exchange }.distinct() + ) + } + } + } } /** Re-read non-reactive data (credentials, battery, counts). Called on ON_RESUME. */ @@ -91,14 +118,14 @@ class SettingsViewModel @Inject constructor( private fun loadSettings() { val isSandbox = userPreferences.isSandboxMode() - val configuredExchanges = credentialsStore.getConfiguredExchanges(isSandbox) - val powerManager = application.getSystemService(android.content.Context.POWER_SERVICE) as PowerManager val isBatteryOptimized = !powerManager.isIgnoringBatteryOptimizations(application.packageName) + // Sync UI state immediately for non-suspend prefs values. + // Note: `configuredExchanges` and `connectionCount` are populated reactively by + // [observeConnections] from the connection DAO flow — no manual lookup here. _uiState.update { it.copy( - configuredExchanges = configuredExchanges, isBatteryOptimized = isBatteryOptimized, isSandboxMode = isSandbox, lowBalanceThresholdDays = userPreferences.getLowBalanceThresholdDays(), @@ -205,10 +232,19 @@ class SettingsViewModel @Inject constructor( private fun loadWithdrawalThresholds() { viewModelScope.launch { - // Collect available crypto/exchange pairs from enabled plans - dcaPlanDao.getAllPlans().combine(withdrawalThresholdDao.getAll()) { plans, thresholds -> + // 3-way combine on plans, thresholds AND connections so the UI also reflects + // connection renames immediately. Pre-loading connections as a Map + // avoids the previous N+1 lookup-per-threshold pattern that ran on every emit. + kotlinx.coroutines.flow.combine( + dcaPlanDao.getAllPlans(), + withdrawalThresholdDao.getAll(), + exchangeConnectionDao.getAllFlow() + ) { plans, thresholds, connections -> + val connectionsById = connections.associateBy { it.id } val pairs = plans.map { it.crypto to it.exchange }.distinct() - val thresholdDomains = thresholds.map { it.toDomain() } + val thresholdDomains = thresholds.mapNotNull { entity -> + connectionsById[entity.connectionId]?.let { entity.toDomain(it) } + } Pair(pairs, thresholdDomains) }.collect { (pairs, thresholds) -> _uiState.update { @@ -221,17 +257,28 @@ class SettingsViewModel @Inject constructor( } } + /** + * Resolve the default connection for an exchange. Phase 1 / Phase 7 stop-gap: + * the Settings UI still picks an [Exchange] enum, so we map it to the first + * connection of that exchange. After Fáze 7 lands, the UI will pick a connection + * directly and this helper goes away. + */ + private suspend fun resolveDefaultConnectionId(exchange: Exchange): Long? = + exchangeConnectionDao.getDefaultByExchange(exchange)?.id + fun setWithdrawalThreshold(crypto: String, exchange: Exchange, amount: BigDecimal) { viewModelScope.launch { + val connectionId = resolveDefaultConnectionId(exchange) ?: return@launch withdrawalThresholdDao.upsert( - WithdrawalThresholdEntity(crypto = crypto, exchange = exchange, thresholdAmount = amount) + WithdrawalThresholdEntity(crypto = crypto, connectionId = connectionId, thresholdAmount = amount) ) } } fun removeWithdrawalThreshold(crypto: String, exchange: Exchange) { viewModelScope.launch { - withdrawalThresholdDao.delete(crypto, exchange) + val connectionId = resolveDefaultConnectionId(exchange) ?: return@launch + withdrawalThresholdDao.delete(crypto, connectionId) } } @@ -269,8 +316,11 @@ class SettingsViewModel @Inject constructor( fun removeExchangeCredentials(exchange: Exchange) { val isSandbox = userPreferences.isSandboxMode() - credentialsStore.deleteCredentials(exchange, isSandbox) - loadSettings() + viewModelScope.launch { + @Suppress("DEPRECATION") + credentialsStore.deleteCredentials(exchange, isSandbox) + loadSettings() + } } fun deleteAllData() { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeScreen.kt index c12c369..db81fe5 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeScreen.kt @@ -54,7 +54,7 @@ import com.accbot.dca.presentation.ui.theme.successColor fun AddExchangeScreen( onNavigateBack: () -> Unit, onExchangeAdded: () -> Unit, - onNavigateToExchangeDetail: ((String) -> Unit)? = null, + onNavigateToExchangeManagement: (() -> Unit)? = null, viewModel: AddExchangeViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -70,9 +70,9 @@ fun AddExchangeScreen( confirmButton = { TextButton(onClick = { viewModel.dismissImportOffer() - uiState.credentialForm.selectedExchange?.let { exchange -> - onNavigateToExchangeDetail?.invoke(exchange.name) - } + // Phase 7+: route through Exchange Management list to pick the + // specific connection to import balances into. + onNavigateToExchangeManagement?.invoke() }) { Text(stringResource(R.string.import_api_title)) } @@ -135,12 +135,17 @@ fun AddExchangeScreen( apiKey = uiState.credentialForm.apiKey, apiSecret = uiState.credentialForm.apiSecret, passphrase = uiState.credentialForm.passphrase, + connectionName = uiState.credentialForm.connectionName, + requireConnectionName = uiState.credentialForm.requireConnectionName, + existingConnectionNames = uiState.credentialForm.existingConnectionsForExchange, + isLoadingExchangeContext = uiState.credentialForm.isLoadingExchangeContext, isValidating = uiState.credentialForm.isValidatingCredentials, error = uiState.credentialForm.resolvedCredentialsError, onClientIdChange = viewModel.credentialForm::setClientId, onApiKeyChange = viewModel.credentialForm::setApiKey, onApiSecretChange = viewModel.credentialForm::setApiSecret, onPassphraseChange = viewModel.credentialForm::setPassphrase, + onConnectionNameChange = viewModel.credentialForm::setConnectionName, onValidate = { viewModel.validateAndSave(onExchangeAdded) }, modifier = Modifier.padding(paddingValues) ) @@ -439,12 +444,17 @@ private fun CredentialsStep( apiKey: String, apiSecret: String, passphrase: String, + connectionName: String, + requireConnectionName: Boolean, + existingConnectionNames: List, + isLoadingExchangeContext: Boolean, isValidating: Boolean, error: String?, onClientIdChange: (String) -> Unit, onApiKeyChange: (String) -> Unit, onApiSecretChange: (String) -> Unit, onPassphraseChange: (String) -> Unit, + onConnectionNameChange: (String) -> Unit, onValidate: () -> Unit, modifier: Modifier = Modifier ) { @@ -460,6 +470,29 @@ private fun CredentialsStep( color = MaterialTheme.colorScheme.onSurfaceVariant ) + // Connection name input — required when there's already at least one connection + // on this exchange (i.e. user is adding a 2nd "envelope") + if (requireConnectionName || existingConnectionNames.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = connectionName, + onValueChange = onConnectionNameChange, + label = { Text(stringResource(R.string.exchanges_connection_name_label)) }, + placeholder = { Text(stringResource(R.string.exchanges_connection_name_hint)) }, + singleLine = true, + isError = requireConnectionName && connectionName.isBlank(), + modifier = Modifier.fillMaxWidth() + ) + if (requireConnectionName && existingConnectionNames.isNotEmpty()) { + Text( + text = stringResource(R.string.exchanges_connection_name_required), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) // Use reusable CredentialsInputCard component @@ -479,14 +512,18 @@ private fun CredentialsStep( Spacer(modifier = Modifier.height(24.dp)) - // Validate button + // Validate button — disabled while existing-connections lookup is in flight + // (otherwise user could race past the duplicate-name check). + val nameOk = !requireConnectionName || connectionName.isNotBlank() Button( onClick = onValidate, modifier = Modifier .fillMaxWidth() .height(56.dp), enabled = areCredentialsComplete(exchange, clientId, apiKey, apiSecret, passphrase) && - !isValidating, + nameOk && + !isValidating && + !isLoadingExchangeContext, colors = ButtonDefaults.buttonColors(containerColor = accentColor()) ) { if (isValidating) { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeViewModel.kt index 59b1af3..14b0929 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeViewModel.kt @@ -11,6 +11,7 @@ import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.DcaPlanEntity import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.data.repository.ExchangeConnectionRepository import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.ExchangeInstructions import com.accbot.dca.domain.model.ExchangeInstructionsProvider @@ -51,6 +52,7 @@ data class AddExchangeUiState( val isSuccess: Boolean = false, val isSandboxMode: Boolean = false, val plansForExchange: List = emptyList(), + val availableExchanges: List = emptyList(), val showImportOffer: Boolean = false, val isApiImporting: Boolean = false, val apiImportProgress: String = "", @@ -68,10 +70,17 @@ class AddExchangeViewModel @Inject constructor( private val dcaPlanDao: DcaPlanDao, private val importTradeHistoryUseCase: ImportTradeHistoryUseCase, private val exchangeApiFactory: ExchangeApiFactory, + private val connectionRepository: ExchangeConnectionRepository, savedStateHandle: SavedStateHandle ) : ViewModel() { - val credentialForm = CredentialFormDelegate(credentialsStore, validateAndSaveCredentialsUseCase, userPreferences, viewModelScope) + val credentialForm = CredentialFormDelegate( + credentialsStore = credentialsStore, + validateAndSaveCredentialsUseCase = validateAndSaveCredentialsUseCase, + userPreferences = userPreferences, + coroutineScope = viewModelScope, + connectionRepository = connectionRepository + ) private val _localState = MutableStateFlow(AddExchangeUiState()) val uiState: StateFlow = combine( @@ -86,6 +95,12 @@ class AddExchangeViewModel @Inject constructor( _localState.update { it.copy(isSandboxMode = userPreferences.isSandboxMode()) } credentialForm.initialize() + // Compute supported exchanges (filter on sandbox + experimental flags) + viewModelScope.launch { + val exchanges = computeSupportedExchanges() + _localState.update { it.copy(availableExchanges = exchanges) } + } + // If exchange was passed via navigation, auto-select it val exchangeName = savedStateHandle.get("exchange") if (exchangeName != null) { @@ -97,6 +112,19 @@ class AddExchangeViewModel @Inject constructor( } } + /** + * List of exchanges shown in the SELECTION step. After Phase 7+ users can add + * multiple connections per exchange, so we no longer filter out exchanges that + * already have credentials — every supported exchange is always selectable. + */ + private suspend fun computeSupportedExchanges(): List { + val isSandbox = userPreferences.isSandboxMode() + val showExperimental = userPreferences.areExperimentalExchangesEnabled() + return Exchange.entries + .filter { !isSandbox || it.supportsSandbox() } + .filter { showExperimental || it.isStable } + } + fun selectExchange(exchange: Exchange) { credentialForm.selectExchange(exchange) _localState.update { @@ -143,6 +171,7 @@ class AddExchangeViewModel @Inject constructor( try { val isSandbox = userPreferences.isSandboxMode() + @Suppress("DEPRECATION") val credentials = credentialsStore.getCredentials(exchange, isSandbox) if (credentials == null) { _localState.update { it.copy( @@ -242,14 +271,12 @@ class AddExchangeViewModel @Inject constructor( return false } - fun getAvailableExchanges(): List { - val isSandbox = userPreferences.isSandboxMode() - val showExperimental = userPreferences.areExperimentalExchangesEnabled() - return Exchange.entries - .filter { !credentialsStore.hasCredentials(it, isSandbox) } - .filter { !isSandbox || it.supportsSandbox() } - .filter { showExperimental || it.isStable } - } + /** + * Synchronous accessor for the available exchanges currently in UI state. + * Initial population happens in [init] via [computeAvailableExchanges]; UI should + * read [uiState] for reactive updates instead of calling this. + */ + fun getAvailableExchanges(): List = _localState.value.availableExchanges fun getInstructionsForExchange(exchange: Exchange): ExchangeInstructions { val isSandbox = userPreferences.isSandboxMode() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailViewModel.kt index 873b1ae..19e3291 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailViewModel.kt @@ -8,8 +8,10 @@ import androidx.lifecycle.viewModelScope import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.DcaPlanEntity +import com.accbot.dca.data.local.ExchangeConnectionEntity import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.data.repository.ExchangeConnectionRepository import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.usecase.ApiImportProgress import com.accbot.dca.domain.usecase.ApiImportResultState @@ -28,6 +30,7 @@ import javax.inject.Inject @Immutable data class ExchangeDetailUiState( + val connection: ExchangeConnectionEntity? = null, val exchange: Exchange? = null, val credentialsExpanded: Boolean = false, val plans: List = emptyList(), @@ -46,6 +49,7 @@ class ExchangeDetailViewModel @Inject constructor( private val credentialsStore: CredentialsStore, private val userPreferences: UserPreferences, private val validateAndSaveCredentialsUseCase: ValidateAndSaveCredentialsUseCase, + private val connectionRepository: ExchangeConnectionRepository, private val dcaPlanDao: DcaPlanDao, private val transactionDao: TransactionDao, private val importTradeHistoryUseCase: ImportTradeHistoryUseCase, @@ -53,7 +57,13 @@ class ExchangeDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle ) : ViewModel() { - val credentialForm = CredentialFormDelegate(credentialsStore, validateAndSaveCredentialsUseCase, userPreferences, viewModelScope) + val credentialForm = CredentialFormDelegate( + credentialsStore = credentialsStore, + validateAndSaveCredentialsUseCase = validateAndSaveCredentialsUseCase, + userPreferences = userPreferences, + coroutineScope = viewModelScope, + connectionRepository = connectionRepository + ) private val _localState = MutableStateFlow(ExchangeDetailUiState()) val uiState: StateFlow = combine( @@ -64,22 +74,20 @@ class ExchangeDetailViewModel @Inject constructor( init { credentialForm.initialize() - val exchangeName = savedStateHandle.get("exchange") + // v7+: route is keyed by connectionId (Long). + val connectionId = savedStateHandle.get("connectionId") val autoImport = savedStateHandle.get("autoImport") ?: false - val exchange = exchangeName?.let { name -> - Exchange.entries.find { it.name == name } - } - - if (exchange != null) { - _localState.update { it.copy(exchange = exchange) } - credentialForm.initWithExchange(exchange) - // Load plans for this exchange - var autoImportTriggered = false + if (connectionId != null) { viewModelScope.launch { - dcaPlanDao.getPlansByExchange(exchange).collect { plans -> + val connection = connectionRepository.getById(connectionId) ?: return@launch + _localState.update { it.copy(connection = connection, exchange = connection.exchange) } + credentialForm.initWithExchange(connection.exchange) + + // Load plans for this connection (was: per exchange — now per connection envelope) + var autoImportTriggered = false + dcaPlanDao.getPlansByConnection(connectionId).collect { plans -> _localState.update { it.copy(plans = plans) } - // Auto-trigger import when navigated from import offer dialog if (autoImport && !autoImportTriggered && plans.isNotEmpty()) { autoImportTriggered = true importViaApi() @@ -126,6 +134,7 @@ class ExchangeDetailViewModel @Inject constructor( try { val isSandbox = userPreferences.isSandboxMode() + @Suppress("DEPRECATION") val credentials = credentialsStore.getCredentials(exchange, isSandbox) if (credentials == null) { _localState.update { it.copy( @@ -203,14 +212,16 @@ class ExchangeDetailViewModel @Inject constructor( _localState.update { it.copy(apiImportResult = null) } } + /** + * Delete this connection (envelope) and its associated plans/credentials/balances/ + * thresholds. Transactions are kept for history (their `connectionId` becomes orphaned; + * the History UI falls back to the [Exchange] enum label). + */ fun removeExchange(onRemoved: () -> Unit) { val state = _localState.value - val exchange = state.exchange ?: return + val connection = state.connection ?: return viewModelScope.launch { - // Delete transactions, plans and credentials for this exchange - transactionDao.deleteTransactionsByExchange(exchange) - dcaPlanDao.deletePlansByExchange(exchange) - credentialsStore.deleteCredentials(exchange, credentialForm.state.value.isSandboxMode) + connectionRepository.delete(connection.id, deletePlans = true) onRemoved() } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementScreen.kt index d9ac74a..4d7bf66 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementScreen.kt @@ -37,7 +37,7 @@ import com.accbot.dca.presentation.components.SectionHeader fun ExchangeManagementScreen( onNavigateBack: () -> Unit, onNavigateToAddExchange: (String?) -> Unit, - onNavigateToExchangeDetail: (String) -> Unit = {}, + onNavigateToExchangeDetail: (Long) -> Unit = {}, viewModel: ExchangeManagementViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -58,7 +58,7 @@ fun ExchangeManagementScreen( DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { - viewModel.loadConnectedExchanges() + viewModel.refreshFlags() } } lifecycleOwner.lifecycle.addObserver(observer) @@ -73,11 +73,14 @@ fun ExchangeManagementScreen( ) } ) { paddingValues -> + // Group connections by exchange so the user can see envelopes per exchange. + val groupedConnections = remember(uiState.connections) { + uiState.connections.groupBy { it.exchange } + } val availableExchanges = Exchange.entries - .filter { it !in uiState.connectedExchanges } .filter { uiState.showExperimental || it.isStable } - if (uiState.connectedExchanges.isEmpty() && availableExchanges.isEmpty()) { + if (uiState.connections.isEmpty() && availableExchanges.isEmpty()) { Box( modifier = Modifier .fillMaxSize() @@ -101,18 +104,25 @@ fun ExchangeManagementScreen( verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(vertical = 8.dp) ) { - // Connected exchanges section - if (uiState.connectedExchanges.isNotEmpty()) { + // Connected: render one tile per connection (envelope), grouped by exchange. + // The tile subtitle shows the connection name when set, so users with multiple + // envelopes ("Hlavní", "Spoření") can tell them apart. + if (uiState.connections.isNotEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { SectionHeader(title = stringResource(R.string.exchanges_connected)) } - items(uiState.connectedExchanges, key = { it.name }) { exchange -> + items(uiState.connections, key = { it.id }) { connection -> + val subtitle = if (connection.name.isNotBlank()) { + connection.name + } else { + stringResource(R.string.common_connected) + } ExchangeSelectionTile( - exchange = exchange, + exchange = connection.exchange, isConnected = true, - subtitle = stringResource(R.string.common_connected), - onClick = { onNavigateToExchangeDetail(exchange.name) } + subtitle = subtitle, + onClick = { onNavigateToExchangeDetail(connection.id) } ) } @@ -121,15 +131,22 @@ fun ExchangeManagementScreen( } } - // Available exchanges section + // Available exchanges section — show ALL exchanges (no longer filtered by + // "has credentials"), so the user can add a second connection on Coinmate. item(span = { GridItemSpan(maxLineSpan) }) { SectionHeader(title = stringResource(R.string.exchanges_available)) } if (availableExchanges.isNotEmpty()) { items(availableExchanges, key = { it.name }) { exchange -> + // Subtitle hint when there's already at least one connection on this exchange + val existingCount = groupedConnections[exchange]?.size ?: 0 + val subtitle = if (existingCount > 0) { + stringResource(R.string.exchanges_add_another, existingCount) + } else null ExchangeSelectionTile( exchange = exchange, + subtitle = subtitle, onClick = { onNavigateToAddExchange(exchange.name) } ) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementViewModel.kt index da90cc7..88933b9 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementViewModel.kt @@ -2,21 +2,24 @@ package com.accbot.dca.presentation.screens.exchanges import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.ExchangeConnectionEntity import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.data.repository.ExchangeConnectionRepository import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.isStable import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import androidx.compose.runtime.Immutable import javax.inject.Inject +/** + * UI state for the exchange management screen. Lists individual connections (envelopes) + * rather than exchanges, since v2.8 a single exchange can have multiple credential sets. + */ @Immutable data class ExchangeManagementUiState( - val connectedExchanges: List = emptyList(), + val connections: List = emptyList(), val isLoading: Boolean = false, val isSandboxMode: Boolean = false, val showExperimental: Boolean = false @@ -24,7 +27,7 @@ data class ExchangeManagementUiState( @HiltViewModel class ExchangeManagementViewModel @Inject constructor( - private val credentialsStore: CredentialsStore, + private val connectionRepository: ExchangeConnectionRepository, private val userPreferences: UserPreferences ) : ViewModel() { @@ -32,15 +35,25 @@ class ExchangeManagementViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() init { - loadConnectedExchanges() + // Reactive flow on connections so additions/deletions in detail screens reflect + // here without an explicit reload. + viewModelScope.launch { + connectionRepository.observeAll().collect { connections -> + _uiState.update { it.copy(connections = connections) } + } + } + refreshFlags() } - fun loadConnectedExchanges() { + /** + * Re-read non-reactive prefs (sandbox mode, experimental flag) on screen resume. + * The connections list itself is reactive via [ExchangeConnectionRepository.observeAll]. + */ + fun refreshFlags() { viewModelScope.launch { - val isSandbox = withContext(Dispatchers.IO) { userPreferences.isSandboxMode() } - val connected = withContext(Dispatchers.IO) { credentialsStore.getConfiguredExchanges(isSandbox) } - val showExperimental = withContext(Dispatchers.IO) { userPreferences.areExperimentalExchangesEnabled() } - _uiState.update { it.copy(connectedExchanges = connected, isSandboxMode = isSandbox, showExperimental = showExperimental) } + val isSandbox = userPreferences.isSandboxMode() + val showExperimental = userPreferences.areExperimentalExchangesEnabled() + _uiState.update { it.copy(isSandboxMode = isSandbox, showExperimental = showExperimental) } } } @@ -49,11 +62,10 @@ class ExchangeManagementViewModel @Inject constructor( _uiState.update { it.copy(showExperimental = enabled) } } - fun removeExchange(exchange: Exchange) { - viewModelScope.launch { - val isSandbox = withContext(Dispatchers.IO) { userPreferences.isSandboxMode() } - withContext(Dispatchers.IO) { credentialsStore.deleteCredentials(exchange, isSandbox) } - loadConnectedExchanges() - } - } + /** + * Display label for a connection — exchange display name plus optional custom name. + * E.g. "Coinmate" or "Coinmate — Spoření". + */ + fun displayLabel(connection: ExchangeConnectionEntity): String = + connectionRepository.displayLabel(connection) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/OnboardingViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/OnboardingViewModel.kt index b934914..6c2df1f 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/OnboardingViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/OnboardingViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.OnboardingPreferences import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.data.repository.ExchangeConnectionRepository import com.accbot.dca.domain.model.DcaFrequency import com.accbot.dca.domain.usecase.CalculateMonthlyCostUseCase import com.accbot.dca.domain.usecase.CreateDcaPlanUseCase @@ -42,12 +43,19 @@ class OnboardingViewModel @Inject constructor( private val validateAndSaveCredentialsUseCase: ValidateAndSaveCredentialsUseCase, private val createDcaPlanUseCase: CreateDcaPlanUseCase, private val userPreferences: UserPreferences, + private val connectionRepository: ExchangeConnectionRepository, calculateMonthlyCost: CalculateMonthlyCostUseCase, minOrderSizeRepository: MinOrderSizeRepository ) : ViewModel() { val planForm = PlanFormDelegate(calculateMonthlyCost, minOrderSizeRepository, viewModelScope) - val credentialForm = CredentialFormDelegate(credentialsStore, validateAndSaveCredentialsUseCase, userPreferences, viewModelScope) + val credentialForm = CredentialFormDelegate( + credentialsStore = credentialsStore, + validateAndSaveCredentialsUseCase = validateAndSaveCredentialsUseCase, + userPreferences = userPreferences, + coroutineScope = viewModelScope, + connectionRepository = connectionRepository + ) private val _localState = MutableStateFlow(OnboardingUiState()) @@ -60,17 +68,21 @@ class OnboardingViewModel @Inject constructor( init { credentialForm.initialize() - // Detect already-configured exchange (e.g. credentials saved on ExchangeSetupScreen). - val isSandbox = userPreferences.isSandboxMode() - val configured = credentialsStore.getConfiguredExchanges(isSandbox).firstOrNull() _localState.update { it.copy( planCreated = onboardingPreferences.isPlanCreatedDuringOnboarding() ) } - if (configured != null) { - credentialForm.initWithExchange(configured) - planForm.initFromExchange(configured) + // Detect already-configured exchange (e.g. credentials saved on ExchangeSetupScreen). + // Lookup is async because the legacy Exchange-keyed shim queries the connection DAO. + viewModelScope.launch { + val isSandbox = userPreferences.isSandboxMode() + @Suppress("DEPRECATION") + val configured = credentialsStore.getConfiguredExchanges(isSandbox).firstOrNull() + if (configured != null) { + credentialForm.initWithExchange(configured) + planForm.initFromExchange(configured) + } } } @@ -137,7 +149,8 @@ class OnboardingViewModel @Inject constructor( onboardingPreferences.setPlanCreatedDuringOnboarding(false) // cleanup temp flag } - fun hasConfiguredExchange(): Boolean { + suspend fun hasConfiguredExchange(): Boolean { + @Suppress("DEPRECATION") return credentialsStore.getConfiguredExchanges().isNotEmpty() } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt index e002cb7..be58ecc 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt @@ -176,7 +176,7 @@ class PlanDetailsViewModel @Inject constructor( _uiState.update { it.copy(isBalanceLoading = true) } try { val isSandbox = userPreferences.isSandboxMode() - val credentials = credentialsStore.getCredentials(plan.exchange, isSandbox) + val credentials = credentialsStore.getCredentials(plan.connectionId, isSandbox) if (credentials != null) { val api = exchangeApiFactory.create(credentials) val balance = withTimeoutOrNull(10_000) { api.getBalance(plan.fiat) } @@ -273,7 +273,7 @@ class PlanDetailsViewModel @Inject constructor( try { val isSandbox = userPreferences.isSandboxMode() - val credentials = credentialsStore.getCredentials(plan.exchange, isSandbox) + val credentials = credentialsStore.getCredentials(plan.connectionId, isSandbox) if (credentials == null) { _uiState.update { it.copy( isApiImporting = false, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt index 96ac98f..fd2b45c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/ui/theme/Theme.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.SideEffect import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat @@ -141,9 +140,11 @@ fun AccBotTheme( val view = LocalView.current if (!view.isInEditMode) { SideEffect { + // Note: window.statusBarColor / navigationBarColor are deprecated in API 35 + // and ignored when targeting Android 15+. Edge-to-edge is enabled in + // MainActivity via enableEdgeToEdge(); we only adjust the icon appearance + // (light/dark) here to match the active theme. val window = (view.context as Activity).window - window.statusBarColor = colorScheme.background.toArgb() - window.navigationBarColor = colorScheme.background.toArgb() val insetsController = WindowCompat.getInsetsController(window, view) insetsController.isAppearanceLightStatusBars = !darkTheme insetsController.isAppearanceLightNavigationBars = !darkTheme diff --git a/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt b/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt index 7089d72..53bc7e1 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt @@ -9,6 +9,7 @@ import android.os.Build import androidx.core.app.NotificationCompat import com.accbot.dca.MainActivity import com.accbot.dca.R +import com.accbot.dca.data.local.ExchangeConnectionDao import com.accbot.dca.data.local.NotificationDao import com.accbot.dca.data.local.NotificationEntity import com.accbot.dca.data.local.NotificationTemplateArgs @@ -21,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.math.BigDecimal import javax.inject.Inject import javax.inject.Singleton @@ -28,11 +30,34 @@ import javax.inject.Singleton @Singleton class NotificationService @Inject constructor( @ApplicationContext private val context: Context, - private val notificationDao: NotificationDao + private val notificationDao: NotificationDao, + private val exchangeConnectionDao: ExchangeConnectionDao ) { private val persistScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + /** + * Render a label like "Coinmate" or "Coinmate — Spoření" for use in notification + * titles. If [connectionId] is set and the connection has a non-empty name, the + * label includes it; otherwise the bare exchange display name is returned. + * + * Suspend so callers can `await` the DAO lookup without blocking. All callers of + * `show*` methods are already in suspend contexts (DcaWorker.doWork, viewModelScope + * coroutines), so this is non-invasive. + */ + private suspend fun connectionLabel(connectionId: Long?, exchange: Exchange?): String? { + val exchangeLabel = exchange?.displayName ?: return null + if (connectionId == null) return exchangeLabel + val connection = withContext(Dispatchers.IO) { + exchangeConnectionDao.getById(connectionId) + } + return if (connection != null && connection.name.isNotBlank()) { + "$exchangeLabel — ${connection.name}" + } else { + exchangeLabel + } + } + init { createNotificationChannels() } @@ -107,7 +132,7 @@ class NotificationService @Inject constructor( * Uses a unique notification ID per plan so multiple plan notifications are all visible. * @param pending If true, shows a "confirming" message instead of crypto amount (for PENDING orders) */ - fun showPurchaseNotification( + suspend fun showPurchaseNotification( crypto: String, cryptoAmount: BigDecimal, fiatAmount: BigDecimal, @@ -116,6 +141,7 @@ class NotificationService @Inject constructor( planId: Long = 0, pending: Boolean = false, exchange: Exchange? = null, + connectionId: Long? = null, scheduledAt: java.time.Instant? = null, executedAt: java.time.Instant? = null ) { @@ -145,19 +171,27 @@ class NotificationService @Inject constructor( val (title, text) = NotificationRenderer.render(context, args) + // Prefix the title with the connection label so users with multiple + // envelopes (e.g. "Coinmate Spoření") can tell which connection executed. + val label = connectionLabel(connectionId, exchange) + val displayedTitle = if (!label.isNullOrBlank() && label != exchange?.displayName) { + "$label · $title" + } else title + val sysNotifId = notificationIdForPlan(NOTIFICATION_ID_PURCHASE, planId) persistAndShow( sysNotifId = sysNotifId, channel = CHANNEL_PURCHASE, - title = title, + title = displayedTitle, text = text, entity = NotificationEntity( type = NotificationType.PURCHASE, - title = title, + title = displayedTitle, message = text, planId = planId.takeIf { it > 0 }, crypto = crypto, exchange = exchange, + connectionId = connectionId, systemNotificationId = sysNotifId, templateArgs = args.toJson() ) @@ -168,11 +202,12 @@ class NotificationService @Inject constructor( * Show error notification. * Uses a unique notification ID per plan so multiple error notifications are all visible. */ - fun showErrorNotification( + suspend fun showErrorNotification( title: String? = null, message: String? = null, planId: Long = 0, exchange: Exchange? = null, + connectionId: Long? = null, crypto: String? = null, templateArgs: NotificationTemplateArgs? = null ) { @@ -181,19 +216,24 @@ class NotificationService @Inject constructor( } else { (title ?: "") to (message ?: "") } + val label = connectionLabel(connectionId, exchange) + val displayedTitle = if (!label.isNullOrBlank() && label != exchange?.displayName) { + "$label · $t" + } else t val sysNotifId = notificationIdForPlan(NOTIFICATION_ID_ERROR, planId) persistAndShow( sysNotifId = sysNotifId, channel = CHANNEL_ERROR, - title = t, + title = displayedTitle, text = m, entity = NotificationEntity( type = NotificationType.ERROR, - title = t, + title = displayedTitle, message = m, planId = planId.takeIf { it > 0 }, crypto = crypto, exchange = exchange, + connectionId = connectionId, systemNotificationId = sysNotifId, templateArgs = templateArgs?.toJson() ) @@ -204,12 +244,20 @@ class NotificationService @Inject constructor( * Show low balance warning notification. * Uses a unique notification ID per plan so multiple warnings are all visible. */ - fun showLowBalanceNotification(exchange: String, fiat: String, remainingDays: Double, planId: Long = 0) { - val title = context.getString(R.string.notification_low_balance_title, exchange) + suspend fun showLowBalanceNotification( + exchange: String, + fiat: String, + remainingDays: Double, + planId: Long = 0, + connectionId: Long? = null + ) { + // Resolve connection name (if any) and display "Coinmate Spoření" instead of just "Coinmate" + val displayLabel = connectionLabel(connectionId, null)?.takeIf { it.isNotBlank() } ?: exchange + val title = context.getString(R.string.notification_low_balance_title, displayLabel) val daysText = if (remainingDays < 1) context.getString(R.string.notification_low_balance_less_1_day) else context.getString(R.string.notification_low_balance_days, remainingDays.toInt()) val text = context.getString(R.string.notification_low_balance_text, daysText, fiat) val args = NotificationTemplateArgs.LowBalance( - exchangeName = exchange, + exchangeName = displayLabel, fiat = fiat, remainingDays = remainingDays ) @@ -224,6 +272,7 @@ class NotificationService @Inject constructor( title = title, message = text, planId = planId.takeIf { it > 0 }, + connectionId = connectionId, systemNotificationId = sysNotifId, templateArgs = args.toJson() ) @@ -233,19 +282,21 @@ class NotificationService @Inject constructor( /** * Show withdrawal threshold notification. */ - fun showWithdrawalThresholdNotification( + suspend fun showWithdrawalThresholdNotification( crypto: String, exchange: String, amount: BigDecimal, threshold: BigDecimal, - planId: Long + planId: Long, + connectionId: Long? = null ) { + val displayLabel = connectionLabel(connectionId, null)?.takeIf { it.isNotBlank() } ?: exchange val title = context.getString(R.string.notification_withdrawal_threshold_title) - val text = context.getString(R.string.notification_withdrawal_threshold_text, amount.toPlainString(), crypto, exchange) + val text = context.getString(R.string.notification_withdrawal_threshold_text, amount.toPlainString(), crypto, displayLabel) val args = NotificationTemplateArgs.WithdrawalThreshold( amount = amount.toPlainString(), crypto = crypto, - exchangeName = exchange + exchangeName = displayLabel ) val sysNotifId = notificationIdForPlan(NOTIFICATION_ID_WITHDRAWAL_THRESHOLD, planId) persistAndShow( @@ -259,6 +310,7 @@ class NotificationService @Inject constructor( message = text, planId = planId.takeIf { it > 0 }, crypto = crypto, + connectionId = connectionId, systemNotificationId = sysNotifId, templateArgs = args.toJson() ) @@ -268,16 +320,18 @@ class NotificationService @Inject constructor( /** * Show notification for missed purchases after prolonged offline period. */ - fun showMissedPurchasesNotification( + suspend fun showMissedPurchasesNotification( crypto: String, exchangeName: String, missedCount: Int, planId: Long, - exchange: Exchange? = null + exchange: Exchange? = null, + connectionId: Long? = null ) { + val displayLabel = connectionLabel(connectionId, exchange)?.takeIf { it.isNotBlank() } ?: exchangeName val args = NotificationTemplateArgs.MissedPurchases( crypto = crypto, - exchangeName = exchangeName, + exchangeName = displayLabel, missedCount = missedCount, planId = planId ) @@ -295,6 +349,7 @@ class NotificationService @Inject constructor( planId = planId.takeIf { it > 0 }, crypto = crypto, exchange = exchange, + connectionId = connectionId, systemNotificationId = sysNotifId, templateArgs = args.toJson() ) @@ -304,18 +359,20 @@ class NotificationService @Inject constructor( /** * Show notification for network retry (offline purchase failure). */ - fun showNetworkRetryNotification( + suspend fun showNetworkRetryNotification( crypto: String, exchangeName: String, errorMessage: String, nextRetryAt: java.time.Instant, attemptCount: Int, planId: Long, - exchange: Exchange? = null + exchange: Exchange? = null, + connectionId: Long? = null ) { + val displayLabel = connectionLabel(connectionId, exchange)?.takeIf { it.isNotBlank() } ?: exchangeName val args = NotificationTemplateArgs.NetworkRetry( crypto = crypto, - exchangeName = exchangeName, + exchangeName = displayLabel, errorMessage = errorMessage, nextRetryAtEpochMs = nextRetryAt.toEpochMilli(), attemptCount = attemptCount, @@ -335,6 +392,7 @@ class NotificationService @Inject constructor( planId = planId.takeIf { it > 0 }, crypto = crypto, exchange = exchange, + connectionId = connectionId, systemNotificationId = sysNotifId, templateArgs = args.toJson() ) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt index 3b6ebd6..2c5b4a8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt @@ -98,6 +98,8 @@ class DcaWorker @AssistedInject constructor( Log.d(TAG, "Plan ${plan.id} reached target ${plan.targetAmount}, auto-disabled") notificationService.showErrorNotification( planId = plan.id, + exchange = plan.exchange, + connectionId = plan.connectionId, templateArgs = NotificationTemplateArgs.TargetReached( targetAmount = plan.targetAmount.toPlainString(), crypto = plan.crypto @@ -107,11 +109,11 @@ class DcaWorker @AssistedInject constructor( } } - // Get credentials (using current sandbox mode) + // Get credentials for this plan's connection (using current sandbox mode) val isSandbox = userPreferences.isSandboxMode() - val credentials = credentialsStore.getCredentials(plan.exchange, isSandbox) + val credentials = credentialsStore.getCredentials(plan.connectionId, isSandbox) if (credentials == null) { - Log.e(TAG, "No credentials for ${plan.exchange} (sandbox=$isSandbox)") + Log.e(TAG, "No credentials for connection ${plan.connectionId} (${plan.exchange}, sandbox=$isSandbox)") continue } @@ -138,6 +140,7 @@ class DcaWorker @AssistedInject constructor( val transaction = TransactionEntity( planId = plan.id, exchange = plan.exchange, + connectionId = plan.connectionId, crypto = plan.crypto, fiat = plan.fiat, fiatAmount = purchaseAmount, @@ -158,6 +161,8 @@ class DcaWorker @AssistedInject constructor( notificationService.showErrorNotification( planId = plan.id, + exchange = plan.exchange, + connectionId = plan.connectionId, templateArgs = NotificationTemplateArgs.BelowMinimum( crypto = plan.crypto, purchaseAmount = purchaseAmount.toPlainString(), @@ -231,7 +236,8 @@ class DcaWorker @AssistedInject constructor( exchangeName = plan.exchange.displayName, missedCount = missed, planId = plan.id, - exchange = plan.exchange + exchange = plan.exchange, + connectionId = plan.connectionId ) } } catch (_: Exception) {} @@ -241,6 +247,7 @@ class DcaWorker @AssistedInject constructor( val transaction = TransactionEntity( planId = plan.id, exchange = plan.exchange, + connectionId = plan.connectionId, crypto = plan.crypto, fiat = plan.fiat, fiatAmount = finalResult.transaction.fiatAmount, @@ -279,6 +286,7 @@ class DcaWorker @AssistedInject constructor( plan.id, pending = isPending, exchange = plan.exchange, + connectionId = plan.connectionId, scheduledAt = scheduledTime, executedAt = if (scheduledTime != null) executedNow else null ) @@ -296,7 +304,7 @@ class DcaWorker @AssistedInject constructor( } else { plan.frequency.intervalMinutes } - checkLowBalance(api, plan.exchange.displayName, plan.fiat, plan.amount, effectiveInterval, plan.id) + checkLowBalance(api, plan.exchange.displayName, plan.fiat, plan.amount, effectiveInterval, plan.id, plan.connectionId) } is DcaResult.Error -> { @@ -320,7 +328,8 @@ class DcaWorker @AssistedInject constructor( nextRetryAt = retryTime, attemptCount = 1, planId = plan.id, - exchange = plan.exchange + exchange = plan.exchange, + connectionId = plan.connectionId ) } } catch (e: Exception) { @@ -335,6 +344,7 @@ class DcaWorker @AssistedInject constructor( val transaction = TransactionEntity( planId = plan.id, exchange = plan.exchange, + connectionId = plan.connectionId, crypto = plan.crypto, fiat = plan.fiat, fiatAmount = plan.amount, @@ -360,6 +370,8 @@ class DcaWorker @AssistedInject constructor( notificationService.showErrorNotification( planId = plan.id, + exchange = plan.exchange, + connectionId = plan.connectionId, templateArgs = NotificationTemplateArgs.Error( crypto = plan.crypto, errorMessage = finalResult.message @@ -418,7 +430,8 @@ class DcaWorker @AssistedInject constructor( private suspend fun checkWithdrawalThreshold(plan: DcaPlanEntity, api: ExchangeApi) { try { - val threshold = database.withdrawalThresholdDao().getThresholdAmount(plan.exchange, plan.crypto) ?: return + // Per-connection threshold lookup; the plan carries connectionId since migration v18→v19. + val threshold = database.withdrawalThresholdDao().getThresholdAmount(plan.connectionId, plan.crypto) ?: return val cryptoBalance = withTimeoutOrNull(10_000) { api.getBalance(plan.crypto) } ?: return if (cryptoBalance >= threshold) { notificationService.showWithdrawalThresholdNotification( @@ -426,7 +439,8 @@ class DcaWorker @AssistedInject constructor( exchange = plan.exchange.displayName, amount = cryptoBalance, threshold = threshold, - planId = plan.id + planId = plan.id, + connectionId = plan.connectionId ) } } catch (e: Exception) { @@ -440,7 +454,8 @@ class DcaWorker @AssistedInject constructor( fiat: String, planAmount: BigDecimal, intervalMinutes: Long, - planId: Long + planId: Long, + connectionId: Long ) { try { val balance = api.getBalance(fiat) ?: return @@ -448,7 +463,7 @@ class DcaWorker @AssistedInject constructor( val remainingDays = (remainingExec.toLong() * intervalMinutes) / 1440.0 val thresholdDays = userPreferences.getLowBalanceThresholdDays() if (remainingDays < thresholdDays) { - notificationService.showLowBalanceNotification(exchangeName, fiat, remainingDays, planId) + notificationService.showLowBalanceNotification(exchangeName, fiat, remainingDays, planId, connectionId) Log.w(TAG, "Low balance on $exchangeName: ~$remainingDays days of $fiat remaining") } } catch (e: Exception) { @@ -569,8 +584,16 @@ class DcaWorker @AssistedInject constructor( /** * Run DCA from an alarm trigger. - * Creates an expedited OneTimeWorkRequest that respects nextExecutionAt checks + * Creates a OneTimeWorkRequest that respects nextExecutionAt checks * (does NOT set KEY_FORCE_RUN). + * + * Note: deliberately NOT using setExpedited(). On Android 11 and below, expedited + * work runs as a foreground service, and when this chain is reachable from a + * BOOT_COMPLETED broadcast (BootReceiver re-arms the alarm after boot, the alarm + * fires shortly after, and triggers this work), Google Play flags it as starting + * a restricted "dataSync" foreground service from BOOT_COMPLETED — which is not + * allowed for apps targeting Android 15+. The alarm wakes the device anyway, so + * regular OneTimeWorkRequest runs immediately. */ private const val ALARM_WORK_NAME = "dca_alarm_execution" @@ -578,14 +601,13 @@ class DcaWorker @AssistedInject constructor( // No network constraint – worker must run even when offline so it can // show a network-retry notification instead of silently waiting. val oneTimeWorkRequest = OneTimeWorkRequestBuilder() - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) .build() WorkManager.getInstance(context) .enqueueUniqueWork(ALARM_WORK_NAME, ExistingWorkPolicy.REPLACE, oneTimeWorkRequest) - Log.d(TAG, "DCA alarm-triggered work enqueued (expedited, unique=$ALARM_WORK_NAME)") + Log.d(TAG, "DCA alarm-triggered work enqueued (unique=$ALARM_WORK_NAME)") } /** diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index e113a78..5099810 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -98,7 +98,7 @@ Nastavení ÚČTY NA BURZÁCH Správa burz - %1$d burz(a) připojeno + %1$d připojení NASTAVENÍ SYSTÉMU Optimalizace baterie Vypnuto – klepněte pro povolení neomezené aktivity na pozadí @@ -345,6 +345,14 @@ Přidat burzu Připojené burzy Dostupné burzy + %1$d připojení — přidat další + Název připojení (volitelné) + Při více připojeních je název povinný + Tento název už pro tuto burzu existuje + např. Hlavní, Spoření, Dlouhodobé… + Výchozí + Vyber připojení + Vytvořit nové připojení… Odebrat burzu Opravdu chcete odebrat %1$s? Tím se smaže veškerá historie transakcí a API přihlašovací údaje pro tuto burzu. Opravdu chcete odebrat %1$s? Tato burza má %2$d aktivní(ch) DCA plán(ů). Odebrání burzy smaže všechny přidružené DCA plány, historii transakcí a API přihlašovací údaje. diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 04d5e7a..16329ff 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -97,7 +97,7 @@ Settings EXCHANGE ACCOUNTS Manage Exchanges - %1$d exchange(s) connected + %1$d connection(s) SYSTEM SETTINGS Battery Optimization Disabled - tap to enable unrestricted background @@ -344,6 +344,14 @@ Add Exchange Connected Exchanges Available Exchanges + %1$d connection(s) — add another + Connection name (optional) + Name is required when you have multiple connections + This name is already used for another connection on this exchange + e.g. Hlavní, Spoření, Long-term… + Default + Pick connection + Create new connection… Remove Exchange Are you sure you want to remove %1$s? This will delete all transaction history and API credentials for this exchange. Are you sure you want to remove %1$s? This exchange has %2$d active DCA plan(s). Removing the exchange will delete all associated DCA plans, transaction history and API credentials. diff --git a/accbot-android/app/src/main/res/values/themes.xml b/accbot-android/app/src/main/res/values/themes.xml index 21bcf3d..1818012 100644 --- a/accbot-android/app/src/main/res/values/themes.xml +++ b/accbot-android/app/src/main/res/values/themes.xml @@ -1,8 +1,11 @@ From 50e69ec8dd9cfe1d13c81284ece45f4d9c165ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 10 Apr 2026 08:36:25 +0200 Subject: [PATCH 02/26] Android: UI polish - connection labels, rename, picker colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExchangeManagement: subtitle unified to connection name or "Výchozí" for unnamed connections (was "Připojeno" which was redundant in the "Connected" section) - ExchangeDetail: inline rename via pencil icon under avatar. Tapping opens a text field; save calls connectionRepository.rename(). Top bar title includes connection name when set. - AddPlan picker: accent-colored selection (green / sandbox orange at 15% alpha) instead of Material3 default purple primaryContainer. Radio dot and text also use accent color for consistency. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dca/presentation/screens/AddPlanScreen.kt | 19 ++++-- .../screens/exchanges/ExchangeDetailScreen.kt | 63 ++++++++++++++++--- .../exchanges/ExchangeDetailViewModel.kt | 13 ++++ .../exchanges/ExchangeManagementScreen.kt | 8 +-- .../app/src/main/res/values-cs/strings.xml | 1 + .../app/src/main/res/values/strings.xml | 1 + 6 files changed, 86 insertions(+), 19 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt index d159c8e..fb131db 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt @@ -329,8 +329,8 @@ fun AddPlanScreen( /** * Single row in the connection picker (radio-like): a row with a leading RadioButton, - * a label and a clickable surface. Used to pick which existing connection a new plan - * should target, or to switch to "create new connection" mode. + * a label and a clickable surface. Uses the app's accent color (green / sandbox orange) + * so it fits the AccBot palette instead of the Material3 default purple. */ @Composable private fun ConnectionPickerRow( @@ -338,12 +338,13 @@ private fun ConnectionPickerRow( selected: Boolean, onClick: () -> Unit ) { + val accent = com.accbot.dca.presentation.ui.theme.accentColor() Surface( modifier = Modifier .fillMaxWidth() .clickable(role = androidx.compose.ui.semantics.Role.RadioButton, onClick = onClick), shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), - color = if (selected) MaterialTheme.colorScheme.primaryContainer + color = if (selected) accent.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surface, tonalElevation = if (selected) 2.dp else 0.dp ) { @@ -354,12 +355,18 @@ private fun ConnectionPickerRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - RadioButton(selected = selected, onClick = null) + RadioButton( + selected = selected, + onClick = null, + colors = RadioButtonDefaults.colors( + selectedColor = accent, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) Text( text = label, style = MaterialTheme.typography.bodyLarge, - color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer - else MaterialTheme.colorScheme.onSurface + color = if (selected) accent else MaterialTheme.colorScheme.onSurface ) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailScreen.kt index d51a323..51b4680 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.* import androidx.compose.runtime.* @@ -118,11 +119,24 @@ fun ExchangeDetailScreen( ) } + val connection = uiState.connection + val connectionDisplayName = connection?.name?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.exchanges_default_connection_label) + val topBarTitle = if (connection?.name?.isNotBlank() == true) { + "${exchange.displayName} - ${connection.name}" + } else { + exchange.displayName + } + + // Inline rename state + var isRenaming by remember { mutableStateOf(false) } + var renameText by remember(connection?.name) { mutableStateOf(connection?.name ?: "") } + Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, topBar = { AccBotTopAppBar( - title = exchange.displayName, + title = topBarTitle, onNavigateBack = onNavigateBack ) } @@ -150,12 +164,47 @@ fun ExchangeDetailScreen( isConnected = true ) Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.common_connected), - style = MaterialTheme.typography.bodyMedium, - color = successColor(), - fontWeight = FontWeight.SemiBold - ) + + // Connection name (editable inline) + if (isRenaming) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = renameText, + onValueChange = { renameText = it }, + singleLine = true, + placeholder = { Text(stringResource(R.string.exchanges_connection_name_hint)) }, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = { + viewModel.renameConnection(renameText.trim()) + isRenaming = false + }) { + Text(stringResource(R.string.common_save)) + } + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { isRenaming = true } + ) { + Text( + text = connectionDisplayName, + style = MaterialTheme.typography.bodyMedium, + color = successColor(), + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.common_rename), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } Spacer(modifier = Modifier.height(24.dp)) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailViewModel.kt index 19e3291..213cc5c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailViewModel.kt @@ -97,6 +97,19 @@ class ExchangeDetailViewModel @Inject constructor( } } + /** + * Rename this connection's display label. Empty string resets to "Default". + */ + fun renameConnection(newName: String) { + val connection = _localState.value.connection ?: return + viewModelScope.launch { + connectionRepository.rename(connection.id, newName) + // Reload the connection so UI reflects the change + val updated = connectionRepository.getById(connection.id) + _localState.update { it.copy(connection = updated) } + } + } + fun toggleCredentials() { _localState.update { it.copy(credentialsExpanded = !it.credentialsExpanded) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementScreen.kt index 4d7bf66..ca04ac8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementScreen.kt @@ -113,15 +113,11 @@ fun ExchangeManagementScreen( } items(uiState.connections, key = { it.id }) { connection -> - val subtitle = if (connection.name.isNotBlank()) { - connection.name - } else { - stringResource(R.string.common_connected) - } ExchangeSelectionTile( exchange = connection.exchange, isConnected = true, - subtitle = subtitle, + subtitle = if (connection.name.isNotBlank()) connection.name + else stringResource(R.string.exchanges_default_connection_label), onClick = { onNavigateToExchangeDetail(connection.id) } ) } diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 5099810..e452190 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -5,6 +5,7 @@ Zrušit Smazat Uložit + Přejmenovat Zkusit znovu Přeskočit Vymazat diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 16329ff..f1680e3 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -7,6 +7,7 @@ Cancel Delete Save + Rename Retry Skip Clear From 4d70a4ee6b20db153716135a355f9265a4f612f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 10 Apr 2026 09:34:39 +0200 Subject: [PATCH 03/26] Android: plan naming, reorder + connection label tweak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema (Room v19 -> v20): - DcaPlanEntity gains `name TEXT DEFAULT ''` and `displayOrder INTEGER DEFAULT 0` columns. Simple ALTER TABLE migration, no table recreate. Dashboard: - Plans sorted by displayOrder ASC, then createdAt DESC (was only createdAt). - DcaPlanCard shows custom plan name above "BTC/EUR" in accent color when set. - Arrow-up / arrow-down buttons on each card for manual reorder (swap displayOrder values). First plan hides up arrow, last hides down. PlanDetailsScreen: - Inline editable plan name in the header card. If no name is set, a subtle "Add plan name" link appears. Tapping opens a text field with save button. - renamePlan() in PlanDetailsViewModel persists via DcaPlanDao.renamePlan(). DashboardViewModel: - reorderPlan(fromIndex, toIndex) swaps displayOrder between two plans. Connection label fix: - "Výchozí" changed to "Výchozí připojení" / "Default connection" for unnamed exchange connections (clearer when shown alongside named ones). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/accbot/dca/data/local/Daos.kt | 11 +++- .../com/accbot/dca/data/local/DcaDatabase.kt | 13 +++- .../com/accbot/dca/data/local/Entities.kt | 6 +- .../accbot/dca/data/local/EntityMappers.kt | 4 +- .../com/accbot/dca/domain/model/Models.kt | 8 ++- .../presentation/screens/DashboardScreen.kt | 65 ++++++++++++++++--- .../screens/DashboardViewModel.kt | 15 +++++ .../screens/plans/PlanDetailsScreen.kt | 59 +++++++++++++++++ .../screens/plans/PlanDetailsViewModel.kt | 8 +++ .../app/src/main/res/values-cs/strings.xml | 6 +- .../app/src/main/res/values/strings.xml | 6 +- 11 files changed, 184 insertions(+), 17 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt index bb0354c..6f370f4 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt @@ -38,7 +38,7 @@ interface ExchangeConnectionDao { @Dao interface DcaPlanDao { - @Query("SELECT * FROM dca_plans ORDER BY createdAt DESC") + @Query("SELECT * FROM dca_plans ORDER BY displayOrder ASC, createdAt DESC") fun getAllPlans(): Flow> @Query("SELECT * FROM dca_plans WHERE isEnabled = 1") @@ -121,6 +121,15 @@ interface DcaPlanDao { @Query("UPDATE dca_plans SET missedPurchaseCount = 0 WHERE id = :planId") suspend fun resetMissedPurchaseCount(planId: Long) + @Query("UPDATE dca_plans SET name = :name WHERE id = :planId") + suspend fun renamePlan(planId: Long, name: String) + + @Query("UPDATE dca_plans SET displayOrder = :displayOrder WHERE id = :planId") + suspend fun updateDisplayOrder(planId: Long, displayOrder: Int) + + @Query("SELECT * FROM dca_plans ORDER BY displayOrder ASC, createdAt DESC") + suspend fun getAllPlansOnceOrdered(): List + } @Dao diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt index cc576dc..c4e5f11 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt @@ -20,7 +20,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase WithdrawalThresholdEntity::class, ExchangeConnectionEntity::class ], - version = 19, + version = 20, exportSchema = true ) @TypeConverters(Converters::class) @@ -365,6 +365,15 @@ abstract class DcaDatabase : RoomDatabase() { } } + // Migration from version 19 to 20: Add name and displayOrder columns to dca_plans + // for custom plan labels and manual ordering on the Dashboard. + private val MIGRATION_19_20 = object : Migration(19, 20) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE dca_plans ADD COLUMN name TEXT NOT NULL DEFAULT ''") + database.execSQL("ALTER TABLE dca_plans ADD COLUMN displayOrder INTEGER NOT NULL DEFAULT 0") + } + } + // Migration from version 9 to 10: Add notifications and withdrawal_thresholds tables private val MIGRATION_9_10 = object : Migration(9, 10) { override fun migrate(database: SupportSQLiteDatabase) { @@ -467,7 +476,7 @@ abstract class DcaDatabase : RoomDatabase() { DcaDatabase::class.java, databaseName ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20) // Only allow destructive migration on app downgrade, never on failed upgrade // This protects user's transaction history from accidental deletion .fallbackToDestructiveMigrationOnDowngrade() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt index 162c606..155dbbf 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt @@ -161,6 +161,8 @@ data class DcaPlanEntity( * for new plans. Resolved via [ExchangeConnectionDao] at plan creation time. */ val connectionId: Long = 0, + /** Optional custom label. Empty string = no label (UI shows "BTC/EUR" as default). */ + val name: String = "", val crypto: String, val fiat: String, val amount: BigDecimal, @@ -177,7 +179,9 @@ data class DcaPlanEntity( val networkRetryCount: Int = 0, val nextNetworkRetryAt: Instant? = null, val originalScheduledAt: Instant? = null, - val missedPurchaseCount: Int = 0 + val missedPurchaseCount: Int = 0, + /** Order for Dashboard display. Lower values shown first. */ + val displayOrder: Int = 0 ) /** diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt index 58a659b..f051652 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/EntityMappers.kt @@ -9,6 +9,7 @@ fun DcaPlanEntity.toDomain() = DcaPlan( id = id, exchange = exchange, connectionId = connectionId, + name = name, crypto = crypto, fiat = fiat, amount = amount, @@ -21,7 +22,8 @@ fun DcaPlanEntity.toDomain() = DcaPlan( createdAt = createdAt, lastExecutedAt = lastExecutedAt, nextExecutionAt = nextExecutionAt, - targetAmount = targetAmount + targetAmount = targetAmount, + displayOrder = displayOrder ) fun TransactionEntity.toDomain() = Transaction( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt index 084da6a..d8d3f20 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt @@ -152,8 +152,10 @@ enum class DcaFrequency( data class DcaPlan( val id: Long = 0, val exchange: Exchange, - /** FK to ExchangeConnectionEntity.id — every plan belongs to one connection. */ + /** FK to ExchangeConnectionEntity.id - every plan belongs to one connection. */ val connectionId: Long, + /** Optional custom label. Empty = UI shows "BTC/EUR" as default title. */ + val name: String = "", val crypto: String, val fiat: String, val amount: BigDecimal, // Base amount (strategy may modify) @@ -166,7 +168,9 @@ data class DcaPlan( val createdAt: Instant = Instant.now(), val lastExecutedAt: Instant? = null, val nextExecutionAt: Instant? = null, - val targetAmount: BigDecimal? = null + val targetAmount: BigDecimal? = null, + /** Order for Dashboard display. Lower values shown first. */ + val displayOrder: Int = 0 ) /** diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt index 623151f..fae47b2 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState @@ -241,11 +242,13 @@ fun DashboardScreen( EmptyPlansCard(onAddPlan = onNavigateToPlans) } } else { - items(uiState.activePlans, key = { it.plan.id }) { planWithBalance -> + itemsIndexed(uiState.activePlans, key = { _, p -> p.plan.id }) { index, planWithBalance -> DcaPlanCard( planWithBalance = planWithBalance, onToggle = { viewModel.togglePlan(planWithBalance.plan.id) }, onClick = { onNavigateToPlanDetails?.invoke(planWithBalance.plan.id) }, + onMoveUp = if (index > 0) {{ viewModel.reorderPlan(index, index - 1) }} else null, + onMoveDown = if (index < uiState.activePlans.lastIndex) {{ viewModel.reorderPlan(index, index + 1) }} else null, currentTime = currentTime ) } @@ -936,6 +939,8 @@ internal fun DcaPlanCard( planWithBalance: DcaPlanWithBalance, onToggle: () -> Unit, onClick: (() -> Unit)? = null, + onMoveUp: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, currentTime: Long = System.currentTimeMillis() ) { val plan = planWithBalance.plan @@ -964,6 +969,15 @@ internal fun DcaPlanCard( CryptoIcon(crypto = plan.crypto) Spacer(modifier = Modifier.width(12.dp)) Column { + // Custom plan label (if set) + if (plan.name.isNotBlank()) { + Text( + text = plan.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, + color = accentCol + ) + } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -1133,14 +1147,49 @@ internal fun DcaPlanCard( } } } - Switch( - checked = plan.isEnabled, - onCheckedChange = { onToggle() }, - colors = SwitchDefaults.colors( - checkedThumbColor = successCol, - checkedTrackColor = successCol.copy(alpha = 0.5f) + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Reorder arrows (only when both callbacks are provided = reorder mode) + if (onMoveUp != null || onMoveDown != null) { + Row { + if (onMoveUp != null) { + IconButton( + onClick = onMoveUp, + modifier = Modifier.size(28.dp) + ) { + Icon( + Icons.Default.KeyboardArrowUp, + contentDescription = stringResource(R.string.common_move_up), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + if (onMoveDown != null) { + IconButton( + onClick = onMoveDown, + modifier = Modifier.size(28.dp) + ) { + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = stringResource(R.string.common_move_down), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + Switch( + checked = plan.isEnabled, + onCheckedChange = { onToggle() }, + colors = SwitchDefaults.colors( + checkedThumbColor = successCol, + checkedTrackColor = successCol.copy(alpha = 0.5f) + ) ) - ) + } } } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt index cff7f21..0157e13 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt @@ -577,6 +577,21 @@ class DashboardViewModel @Inject constructor( } } + /** + * Move a plan up or down in the dashboard display order. + * Swaps displayOrder values between the plan at [fromIndex] and [toIndex]. + */ + fun reorderPlan(fromIndex: Int, toIndex: Int) { + val plans = _uiState.value.activePlans + if (fromIndex !in plans.indices || toIndex !in plans.indices) return + val fromPlan = plans[fromIndex].plan + val toPlan = plans[toIndex].plan + viewModelScope.launch { + dcaPlanDao.updateDisplayOrder(fromPlan.id, toPlan.displayOrder) + dcaPlanDao.updateDisplayOrder(toPlan.id, fromPlan.displayOrder) + } + } + fun runDcaNow() { DcaWorker.runNow(application) _uiState.update { it.copy(runNowTriggered = true) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt index d459bcb..21b477a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt @@ -253,6 +253,65 @@ fun PlanDetailsScreen( Spacer(modifier = Modifier.height(16.dp)) + // Editable plan name (inline) + var isRenamingPlan by rememberSaveable { mutableStateOf(false) } + var planNameText by rememberSaveable(plan.name) { mutableStateOf(plan.name) } + + if (isRenamingPlan) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = planNameText, + onValueChange = { planNameText = it }, + singleLine = true, + placeholder = { Text(stringResource(R.string.plan_details_name_hint)) }, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = { + viewModel.renamePlan(planNameText.trim()) + isRenamingPlan = false + }) { + Text(stringResource(R.string.common_save)) + } + } + } else if (plan.name.isNotBlank()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { isRenamingPlan = true } + ) { + Text( + text = plan.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = accentColor() + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.common_rename), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + // No name yet - show "add name" link + TextButton(onClick = { isRenamingPlan = true }) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.plan_details_add_name), + style = MaterialTheme.typography.bodySmall + ) + } + } + Text( text = "${plan.crypto}/${plan.fiat}", style = MaterialTheme.typography.titleLarge, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt index be58ecc..2b5732f 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt @@ -212,6 +212,14 @@ class PlanDetailsViewModel @Inject constructor( } } + fun renamePlan(newName: String) { + viewModelScope.launch { + dcaPlanDao.renamePlan(planId, newName) + val updatedPlan = dcaPlanDao.getPlanById(planId)?.toDomain() + _uiState.update { it.copy(plan = updatedPlan) } + } + } + fun togglePlanEnabled() { viewModelScope.launch { val plan = _uiState.value.plan ?: return@launch diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index e452190..9db44b8 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -6,6 +6,10 @@ Smazat Uložit Přejmenovat + Posunout nahoru + Posunout dolů + např. Dlouhodobé spoření, Měsíční BTC… + Přidat název plánu Zkusit znovu Přeskočit Vymazat @@ -351,7 +355,7 @@ Při více připojeních je název povinný Tento název už pro tuto burzu existuje např. Hlavní, Spoření, Dlouhodobé… - Výchozí + Výchozí připojení Vyber připojení Vytvořit nové připojení… Odebrat burzu diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index f1680e3..c854506 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -8,6 +8,10 @@ Delete Save Rename + Move up + Move down + e.g. Long-term savings, Monthly BTC… + Add plan name Retry Skip Clear @@ -350,7 +354,7 @@ Name is required when you have multiple connections This name is already used for another connection on this exchange e.g. Hlavní, Spoření, Long-term… - Default + Default connection Pick connection Create new connection… Remove Exchange From 614bb94e981efffb8a86979680730b351af375a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 10 Apr 2026 09:49:27 +0200 Subject: [PATCH 04/26] Android: multi-plan warnings + fiat min-amount fix Import warning: - ImportConfigDialog shows a warning card when the connection has other DCA plans, explaining that import fetches ALL trades from the exchange account (not just this plan's). Duplicate transactions are filtered automatically but the user should be aware of the shared-account nature. - PlanDetailsViewModel computes otherPlansOnSameConnection count from DcaPlanDao.countPlansByConnection. AddPlan multi-plan warning: - When user picks a connection that already has plans, a similar warning card appears above the plan form in AddPlanScreen. - CredentialFormDelegate.selectExistingConnection loads plan count via DcaPlanDao (new optional constructor parameter). Fiat min-amount bug fix: - PlanFormDelegate.selectFiat now bumps the amount to the new fiat's static minimum if the current amount is below it. Previously switching EUR->CZK kept the amount at 2 (EUR minimum) even though CZK minimum is 50. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ImportConfigDialog.kt | 22 ++++++++++++++++++- .../credentials/CredentialFormDelegate.kt | 13 +++++++++-- .../dca/presentation/plan/PlanFormDelegate.kt | 15 ++++++++++++- .../dca/presentation/screens/AddPlanScreen.kt | 20 +++++++++++++++++ .../presentation/screens/AddPlanViewModel.kt | 5 ++++- .../screens/plans/PlanDetailsScreen.kt | 3 ++- .../screens/plans/PlanDetailsViewModel.kt | 11 ++++++++-- .../app/src/main/res/values-cs/strings.xml | 2 ++ .../app/src/main/res/values/strings.xml | 2 ++ 9 files changed, 85 insertions(+), 8 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ImportConfigDialog.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ImportConfigDialog.kt index 9ebe4c0..8178f7d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ImportConfigDialog.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ImportConfigDialog.kt @@ -24,7 +24,10 @@ fun ImportConfigDialog( sinceMillis: Long?, onSinceDateChanged: (Long?) -> Unit, onConfirm: () -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, + /** When > 0, shows a warning that this connection has other plans and the import + * will fetch ALL trades from the exchange account, not just this plan's trades. */ + otherPlansOnSameConnection: Int = 0 ) { var showDatePicker by remember { mutableStateOf(false) } val dateFormatter = remember { @@ -69,6 +72,23 @@ fun ImportConfigDialog( Text(pluralStringResource(R.plurals.import_api_dialog_text, planCount, planCount)) Spacer(modifier = Modifier.height(16.dp)) } + // Multi-plan warning + if (otherPlansOnSameConnection > 0) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.import_api_multi_plan_warning, otherPlansOnSameConnection), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(12.dp) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } Text( text = stringResource(R.string.import_api_from_label), style = MaterialTheme.typography.labelMedium, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt index 97535c8..61d64f1 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt @@ -53,6 +53,8 @@ data class CredentialFormState( * "create new connection" mode and must fill the credentials form. */ val selectedConnectionId: Long? = null, + /** Number of existing plans on the selected connection (for multi-plan warning). */ + val existingPlansOnSelectedConnection: Int = 0, /** * True between [selectExchange]/[initWithExchange] and the async load of existing * connections completing. The UI must disable the Validate button while this is true, @@ -89,7 +91,8 @@ class CredentialFormDelegate( private val validateAndSaveCredentialsUseCase: ValidateAndSaveCredentialsUseCase, private val userPreferences: UserPreferences, private val coroutineScope: CoroutineScope, - private val connectionRepository: ExchangeConnectionRepository? = null + private val connectionRepository: ExchangeConnectionRepository? = null, + private val dcaPlanDao: com.accbot.dca.data.local.DcaPlanDao? = null ) { private val _state = MutableStateFlow(CredentialFormState()) val state: StateFlow = _state.asStateFlow() @@ -191,7 +194,8 @@ class CredentialFormDelegate( /** * User picked an existing connection from [CredentialFormState.existingConnections]. - * Skips the credentials form — plan creation will reuse the existing envelope. + * Skips the credentials form - plan creation will reuse the existing envelope. + * Also loads the number of existing plans on that connection for a multi-plan warning. */ fun selectExistingConnection(connectionId: Long) { _state.update { @@ -199,9 +203,14 @@ class CredentialFormDelegate( selectedConnectionId = connectionId, hasCredentials = true, credentialsValid = true, + existingPlansOnSelectedConnection = 0, credentialsError = null, credentialsErrorRes = 0 ) } + coroutineScope.launch { + val planCount = dcaPlanDao?.countPlansByConnection(connectionId) ?: 0 + _state.update { it.copy(existingPlansOnSelectedConnection = planCount) } + } } /** diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt index 5b58d3d..0439dcf 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt @@ -80,7 +80,20 @@ class PlanFormDelegate( } fun selectFiat(fiat: String) { - _state.update { it.copy(selectedFiat = fiat) } + val exchange = currentExchange + val staticMin = exchange?.minOrderSize?.get(fiat) + _state.update { + val currentAmount = it.amount.toBigDecimalOrNull() + // If the current amount is below the new fiat's minimum (or was the old + // fiat's default minimum), bump it up so the user doesn't unknowingly + // submit an under-minimum plan. + val newAmount = if (staticMin != null && (currentAmount == null || currentAmount < staticMin)) { + staticMin.stripTrailingZeros().toPlainString() + } else { + it.amount + } + it.copy(selectedFiat = fiat, amount = newAmount) + } updateMonthlyCostEstimate() updateMinOrderSize() } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt index fb131db..30aca5c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt @@ -283,6 +283,26 @@ fun AddPlanScreen( } } + // Multi-plan warning (when adding to a connection that already has plans) + if (cred.selectedConnectionId != null && cred.existingPlansOnSelectedConnection > 0) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource( + R.string.add_plan_multi_plan_warning, + cred.existingPlansOnSelectedConnection + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(12.dp) + ) + } + } + // Plan form (crypto, fiat, amount, frequency, strategy, withdrawal, target) if (cred.selectedExchange != null) { PlanFormContent( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt index 799c4da..934e077 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt @@ -3,6 +3,7 @@ package com.accbot.dca.presentation.screens import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.data.repository.ExchangeConnectionRepository import com.accbot.dca.domain.model.DcaFrequency @@ -62,6 +63,7 @@ class AddPlanViewModel @Inject constructor( private val createDcaPlanUseCase: CreateDcaPlanUseCase, private val userPreferences: UserPreferences, private val connectionRepository: ExchangeConnectionRepository, + private val dcaPlanDao: DcaPlanDao, calculateMonthlyCost: CalculateMonthlyCostUseCase, minOrderSizeRepository: MinOrderSizeRepository ) : ViewModel() { @@ -72,7 +74,8 @@ class AddPlanViewModel @Inject constructor( validateAndSaveCredentialsUseCase = validateAndSaveCredentialsUseCase, userPreferences = userPreferences, coroutineScope = viewModelScope, - connectionRepository = connectionRepository + connectionRepository = connectionRepository, + dcaPlanDao = dcaPlanDao ) private val _localState = MutableStateFlow(AddPlanUiState()) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt index 21b477a..1841d36 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsScreen.kt @@ -177,7 +177,8 @@ fun PlanDetailsScreen( sinceMillis = uiState.importSinceMillis, onSinceDateChanged = { viewModel.setImportSinceDate(it) }, onConfirm = { viewModel.confirmImport() }, - onDismiss = { viewModel.dismissImportDialog() } + onDismiss = { viewModel.dismissImportDialog() }, + otherPlansOnSameConnection = uiState.otherPlansOnSameConnection ) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt index 2b5732f..6694db4 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt @@ -54,7 +54,9 @@ data class PlanDetailsUiState( val apiImportProgress: String = "", val apiImportResult: ApiImportResultState? = null, val showImportDialog: Boolean = false, - val importSinceMillis: Long? = null + val importSinceMillis: Long? = null, + /** Number of OTHER plans on the same connection. When > 0, import dialog shows a warning. */ + val otherPlansOnSameConnection: Int = 0 ) @HiltViewModel @@ -95,6 +97,10 @@ class PlanDetailsViewModel @Inject constructor( val plan = planEntity.toDomain() + // Check how many OTHER plans share the same connection (for import warning) + val totalPlansOnConnection = dcaPlanDao.countPlansByConnection(planEntity.connectionId) + val otherPlans = (totalPlansOnConnection - 1).coerceAtLeast(0) + // Load transactions for this plan transactionDao.getTransactionsByPlan(planId).collect { transactionEntities -> val transactions = transactionEntities.map { it.toDomain() } @@ -121,7 +127,8 @@ class PlanDetailsViewModel @Inject constructor( averagePrice = averagePrice, transactionCount = completedTransactions.size, timeUntilNextExecution = timeUntilNext, - isLoading = false + isLoading = false, + otherPlansOnSameConnection = otherPlans ) } diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 9db44b8..4a63ea4 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -10,6 +10,8 @@ Posunout dolů např. Dlouhodobé spoření, Měsíční BTC… Přidat název plánu + Toto pripojeni ma %1$d dalsi(ch) plan(u). Import stahne VSECHNY obchody z uctu burzy, ne jen obchody tohoto planu. Duplicitni transakce se filtruji automaticky. + Toto pripojeni uz ma %1$d plan(u). Vsechny plany na jednom pripojeni sdili jeden ucet burzy, takze transakce importovane pres API budou viditelne ve vsech planech. Zkusit znovu Přeskočit Vymazat diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index c854506..be02bdd 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -12,6 +12,8 @@ Move down e.g. Long-term savings, Monthly BTC… Add plan name + This connection has %1$d other plan(s). Import will fetch ALL trades from the exchange account, not just trades from this plan. Duplicate transactions are filtered automatically. + This connection already has %1$d plan(s). All plans on the same connection share one exchange account, so API-imported transactions will be visible across all plans. Retry Skip Clear From 1e1471db3727299e1a2d16626e58c7186c081039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 10 Apr 2026 09:54:31 +0200 Subject: [PATCH 05/26] Replace em-dashes with plain hyphens, fix missing Czech diacritics Replaces all em-dash characters (U+2014) with plain hyphens in code comments, string resources and Kotlin string literals across all files changed by the multi-connection feature. Also fixes two Czech warning strings (import_api_multi_plan_warning, add_plan_multi_plan_warning) that were missing diacritics. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/accbot/dca/recording/EmulatorSetupTest.kt | 2 +- .../dca/screenshots/ScreenshotCaptureTest.kt | 14 +++++++------- .../accbot/dca/screenshots/ScreenshotSetupTest.kt | 12 ++++++------ .../main/java/com/accbot/dca/AccBotApplication.kt | 4 ++-- .../accbot/dca/data/local/BackupDataRestorer.kt | 8 ++++---- .../com/accbot/dca/data/local/CredentialsStore.kt | 6 +++--- .../java/com/accbot/dca/data/local/DcaDatabase.kt | 14 +++++++------- .../java/com/accbot/dca/data/local/Entities.kt | 4 ++-- .../repository/ExchangeConnectionRepository.kt | 14 +++++++------- .../src/main/java/com/accbot/dca/di/AppModule.kt | 2 +- .../java/com/accbot/dca/domain/model/Models.kt | 2 +- .../dca/domain/usecase/CreateDcaPlanUseCase.kt | 4 ++-- .../usecase/ValidateAndSaveCredentialsUseCase.kt | 2 +- .../credentials/CredentialFormDelegate.kt | 6 +++--- .../dca/presentation/screens/DashboardScreen.kt | 2 +- .../dca/presentation/screens/SettingsViewModel.kt | 6 +++--- .../screens/exchanges/AddExchangeScreen.kt | 4 ++-- .../screens/exchanges/AddExchangeViewModel.kt | 2 +- .../screens/exchanges/ExchangeDetailViewModel.kt | 2 +- .../screens/exchanges/ExchangeManagementScreen.kt | 2 +- .../exchanges/ExchangeManagementViewModel.kt | 4 ++-- .../com/accbot/dca/service/NotificationService.kt | 4 ++-- .../main/java/com/accbot/dca/worker/DcaWorker.kt | 2 +- .../app/src/main/res/values-cs/strings.xml | 6 +++--- accbot-android/app/src/main/res/values/strings.xml | 2 +- 25 files changed, 65 insertions(+), 65 deletions(-) diff --git a/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/recording/EmulatorSetupTest.kt b/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/recording/EmulatorSetupTest.kt index d5eb290..dcf1f84 100644 --- a/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/recording/EmulatorSetupTest.kt +++ b/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/recording/EmulatorSetupTest.kt @@ -65,7 +65,7 @@ class EmulatorSetupTest { val saved = credentialsStore.saveCredentials(binanceConnectionId, credentials, isSandbox = true) assert(saved) { "Failed to save Binance sandbox credentials" } - // DCA plan is NOT created here — it will be created via UI in ForegroundServiceDemoTest + // DCA plan is NOT created here - it will be created via UI in ForegroundServiceDemoTest // Verify setup val hasCredentials = credentialsStore.hasCredentials(binanceConnectionId, isSandbox = true) diff --git a/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotCaptureTest.kt b/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotCaptureTest.kt index 7f3a82d..cdf4ce9 100644 --- a/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotCaptureTest.kt +++ b/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotCaptureTest.kt @@ -56,8 +56,8 @@ import java.time.LocalDate * includes it in screenshot filenames. * * Produces 8 screenshots per run: - * 00_welcome_{locale} — Welcome/onboarding screen (clean install) - * 01–07_*_{locale} — Main app screens (with populated data) + * 00_welcome_{locale} - Welcome/onboarding screen (clean install) + * 01–07_*_{locale} - Main app screens (with populated data) * * Run: * ``` @@ -162,10 +162,10 @@ class ScreenshotCaptureTest { composeRule.waitForIdle() Thread.sleep(3000) - // 1. Dashboard — holdings pager, active plans, Market Pulse + // 1. Dashboard - holdings pager, active plans, Market Pulse capture("01_dashboard_$locale") - // 2. Portfolio — navigate to BTC/EUR page, Price line only + // 2. Portfolio - navigate to BTC/EUR page, Price line only clickNav(R.string.nav_portfolio) composeRule.waitForIdle() Thread.sleep(2000) @@ -211,7 +211,7 @@ class ScreenshotCaptureTest { Thread.sleep(500) capture("05_settings_$locale") - // 6. Plan Details — navigate to Dashboard, tap BTC plan card + // 6. Plan Details - navigate to Dashboard, tap BTC plan card clickNav(R.string.nav_dashboard) composeRule.waitForIdle() Thread.sleep(500) @@ -233,7 +233,7 @@ class ScreenshotCaptureTest { Thread.sleep(3000) capture("06_plan_details_$locale") - // 7. History — back via TopAppBar arrow (device.pressBack exits app on API 36) + // 7. History - back via TopAppBar arrow (device.pressBack exits app on API 36) val backLabel = composeRule.activity.getString(R.string.common_back) composeRule.onNode(hasContentDescription(backLabel) and hasClickAction()).performClick() composeRule.waitForIdle() @@ -313,7 +313,7 @@ class ScreenshotCaptureTest { ExchangeConnectionEntity(exchange = Exchange.BINANCE, name = "") ) - // Credentials (dummy — app won't call APIs during screenshots). + // Credentials (dummy - app won't call APIs during screenshots). creds.saveCredentials( connectionId = coinmateConnectionId, credentials = ExchangeCredentials(Exchange.COINMATE, "demo_key", "demo_secret", clientId = "12345"), diff --git a/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotSetupTest.kt b/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotSetupTest.kt index 5995b81..fd98714 100644 --- a/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotSetupTest.kt +++ b/accbot-android/app/src/androidTest/kotlin/com/accbot/dca/screenshots/ScreenshotSetupTest.kt @@ -58,7 +58,7 @@ class ScreenshotSetupTest { prefs.setMarketPulseEnabled(true) prefs.setMarketPulseExpanded(true) - // 2. Room DB — prod database (constructed first because CredentialsStore needs the DAO) + // 2. Room DB - prod database (constructed first because CredentialsStore needs the DAO) val db = DcaDatabase.getInstance(context, isSandbox = false) val creds = CredentialsStore(context, db.exchangeConnectionDao()) @@ -78,7 +78,7 @@ class ScreenshotSetupTest { ExchangeConnectionEntity(exchange = Exchange.BINANCE, name = "") ) - // Credentials (dummy — app won't call APIs during screenshots). + // Credentials (dummy - app won't call APIs during screenshots). // Use the connection-keyed API directly to avoid the legacy shim's auto-create. creds.saveCredentials( connectionId = coinmateConnectionId, @@ -117,7 +117,7 @@ class ScreenshotSetupTest { ) ) - // Daily prices — real historical data from CryptoCompare + // Daily prices - real historical data from CryptoCompare val totalDays = HistoricalPrices.BTC_EUR.size // 201 val btcPrices = HistoricalPrices.BTC_EUR.mapIndexed { i, price -> @@ -140,7 +140,7 @@ class ScreenshotSetupTest { } db.dailyPriceDao().insertPrices(ethPrices) - // BTC transactions — daily over 180 days + // BTC transactions - daily over 180 days val btcTxCount = 180 val btcTransactions = (0 until btcTxCount).map { i -> val daysAgo = btcTxCount.toLong() - i @@ -160,7 +160,7 @@ class ScreenshotSetupTest { } db.transactionDao().insertTransactions(btcTransactions) - // ETH transactions — weekly over 180 days = ~26 transactions + // ETH transactions - weekly over 180 days = ~26 transactions val ethTxCount = 26 val ethTransactions = (0 until ethTxCount).map { i -> val daysAgo = btcTxCount.toLong() - (i * 7).toLong() @@ -180,7 +180,7 @@ class ScreenshotSetupTest { } db.transactionDao().insertTransactions(ethTransactions) - // Exchange balances — calculated from accumulated crypto + // Exchange balances - calculated from accumulated crypto val totalBtcAccumulated = btcTransactions.sumOf { it.cryptoAmount } val totalEthAccumulated = ethTransactions.sumOf { it.cryptoAmount } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt b/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt index 1b56dc1..947a8cc 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/AccBotApplication.kt @@ -50,11 +50,11 @@ class AccBotApplication : Application(), Configuration.Provider { // CredentialsStore v2→v3 migration: re-key credentials from // `credentials_${env}_${EXCHANGE}` to `credentials_v3_${env}_${connectionId}`. // Needs Room DB access (to look up the connection per exchange) so it can't run - // inside the encryptedPrefs lazy init. Idempotent — safe to call every launch. + // inside the encryptedPrefs lazy init. Idempotent - safe to call every launch. // // BLOCKING: must complete before any background worker (DcaWorker) tries to load // credentials by connectionId. The migration is fast (single-digit milliseconds for - // ~14 keys) and runs once per upgrade — acceptable startup cost. The previous + // ~14 keys) and runs once per upgrade - acceptable startup cost. The previous // background-launch version had a race window where the alarm-triggered DcaWorker // could fire between Room migration and CredentialsStore migration completion, // failing to find credentials under the new key. diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt index 7e13521..0fe83f4 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt @@ -41,7 +41,7 @@ class BackupDataRestorer @Inject constructor( ExchangeConnectionEntity(exchange = exchange, name = "") ) } catch (e: android.database.sqlite.SQLiteConstraintException) { - // Concurrent insert won the race — re-fetch and use the existing row. + // Concurrent insert won the race - re-fetch and use the existing row. exchangeConnectionDao.getDefaultByExchange(exchange)?.id ?: throw IllegalStateException("Failed to resolve default connection for $exchange", e) } @@ -81,7 +81,7 @@ class BackupDataRestorer @Inject constructor( database.withTransaction { // Replace mode: wipe all existing DB data first. // NOTE: deleting plans last (after transactions) preserves any existing FK - // assumptions. Connections are NOT wiped — we preserve them and let merge + // assumptions. Connections are NOT wiped - we preserve them and let merge // dedupe by (exchange, name). if (restoreMode == RestoreMode.Replace) { transactionDao.deleteAllTransactions() @@ -93,7 +93,7 @@ class BackupDataRestorer @Inject constructor( // 0. Connections (v2): create or dedupe by (exchange, name). // The unique index on (exchange, name) means duplicate inserts raise - // SQLiteConstraintException — we catch and re-fetch. + // SQLiteConstraintException - we catch and re-fetch. for (conn in payload.connections) { val exchange = try { Exchange.valueOf(conn.exchange) } catch (_: Exception) { continue } val existing = exchangeConnectionDao.getByExchange(exchange) @@ -226,7 +226,7 @@ class BackupDataRestorer @Inject constructor( // Outside transaction: restore credentials. // Pre-validation above guarantees all entries parse cleanly. Remap each // backup-local connectionId to the freshly inserted local one and save. - // Failures here are logged but don't roll back the DB — at this point the + // Failures here are logged but don't roll back the DB - at this point the // restore is "best effort committed" and partial credentials is recoverable // (user can re-enter API keys via AddExchange). val isSandbox = userPreferences.isSandboxMode() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/CredentialsStore.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/CredentialsStore.kt index 1d8a42f..9329f59 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/CredentialsStore.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/CredentialsStore.kt @@ -120,7 +120,7 @@ class CredentialsStore @Inject constructor( encryptedPrefs.edit().putBoolean(KEY_MIGRATION_V3_DONE, true).commit() Log.d(TAG, "CredentialsStore v2→v3 migration complete") } catch (e: Exception) { - // Don't set the flag — next launch will retry. Log so we notice. + // Don't set the flag - next launch will retry. Log so we notice. Log.e(TAG, "CredentialsStore v2→v3 migration failed; will retry next launch", e) } } @@ -259,7 +259,7 @@ class CredentialsStore @Inject constructor( return getCredentials(connectionId, isSandbox) } - @Deprecated("Use saveCredentials(connectionId, credentials, isSandbox) — explicitly create a connection first") + @Deprecated("Use saveCredentials(connectionId, credentials, isSandbox) - explicitly create a connection first") suspend fun saveCredentials(credentials: ExchangeCredentials, isSandbox: Boolean = false): Boolean { // Resolve or create a default connection for this exchange so legacy // "save credentials by exchange" callers (Phase 7 candidates) keep working. @@ -272,7 +272,7 @@ class CredentialsStore @Inject constructor( ) ) } catch (_: android.database.sqlite.SQLiteConstraintException) { - // Race lost — another caller just created the default. Re-fetch. + // Race lost - another caller just created the default. Re-fetch. currentEnvConnectionDao.getDefaultByExchange(credentials.exchange)?.id ?: return false } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt index c4e5f11..4227cfe 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt @@ -267,7 +267,7 @@ abstract class DcaDatabase : RoomDatabase() { ) } - // 3) dca_plans — add connectionId column and backfill from default connection + // 3) dca_plans - add connectionId column and backfill from default connection database.execSQL("ALTER TABLE dca_plans ADD COLUMN connectionId INTEGER NOT NULL DEFAULT 0") database.execSQL( "UPDATE dca_plans SET connectionId = " + @@ -276,7 +276,7 @@ abstract class DcaDatabase : RoomDatabase() { database.execSQL("CREATE INDEX IF NOT EXISTS index_dca_plans_connectionId ON dca_plans(connectionId)") // Sanity assertion: any plan with connectionId = 0 means an exchange enum - // existed in dca_plans but no row was created in exchange_connections — bug. + // existed in dca_plans but no row was created in exchange_connections - bug. database.query("SELECT COUNT(*) FROM dca_plans WHERE connectionId = 0").use { c -> if (c.moveToFirst() && c.getInt(0) > 0) { throw IllegalStateException( @@ -286,7 +286,7 @@ abstract class DcaDatabase : RoomDatabase() { } } - // 4) transactions — nullable connectionId, no FK + // 4) transactions - nullable connectionId, no FK database.execSQL("ALTER TABLE transactions ADD COLUMN connectionId INTEGER DEFAULT NULL") database.execSQL( "UPDATE transactions SET connectionId = " + @@ -294,14 +294,14 @@ abstract class DcaDatabase : RoomDatabase() { ) database.execSQL("CREATE INDEX IF NOT EXISTS index_transactions_connectionId ON transactions(connectionId)") - // 5) withdrawals — nullable connectionId, no FK + // 5) withdrawals - nullable connectionId, no FK database.execSQL("ALTER TABLE withdrawals ADD COLUMN connectionId INTEGER DEFAULT NULL") database.execSQL( "UPDATE withdrawals SET connectionId = " + "(SELECT id FROM exchange_connections WHERE exchange = withdrawals.exchange LIMIT 1)" ) - // 6) withdrawal_thresholds — recreate with new PK (crypto, connectionId). + // 6) withdrawal_thresholds - recreate with new PK (crypto, connectionId). // No FOREIGN KEY constraint here: the entity declaration in Entities.kt // doesn't declare one (Room schema validation requires the migrated table // to match the entity exactly), and FK enforcement (`PRAGMA foreign_keys`) @@ -329,7 +329,7 @@ abstract class DcaDatabase : RoomDatabase() { database.execSQL("ALTER TABLE withdrawal_thresholds_new RENAME TO withdrawal_thresholds") database.execSQL("CREATE INDEX IF NOT EXISTS index_withdrawal_thresholds_connectionId ON withdrawal_thresholds(connectionId)") - // 7) exchange_balances — recreate with new composite PK (connectionId, currency) + // 7) exchange_balances - recreate with new composite PK (connectionId, currency) database.execSQL( """ CREATE TABLE exchange_balances_new ( @@ -355,7 +355,7 @@ abstract class DcaDatabase : RoomDatabase() { database.execSQL("CREATE INDEX IF NOT EXISTS index_exchange_balances_connectionId ON exchange_balances(connectionId)") database.execSQL("CREATE INDEX IF NOT EXISTS index_exchange_balances_exchange ON exchange_balances(exchange)") - // 8) notifications — nullable connectionId, no FK + // 8) notifications - nullable connectionId, no FK database.execSQL("ALTER TABLE notifications ADD COLUMN connectionId INTEGER DEFAULT NULL") database.execSQL( "UPDATE notifications SET connectionId = " + diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt index 155dbbf..d98b377 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt @@ -102,7 +102,7 @@ class Converters { } /** - * Exchange connection entity — represents one set of API credentials for one exchange. + * Exchange connection entity - represents one set of API credentials for one exchange. * Multiple connections can target the same exchange enum (e.g. two Coinmate sub-accounts * as "Hlavní" and "Spoření" envelopes). The actual API key/secret is stored separately * in [CredentialsStore], keyed by this entity's [id]. @@ -132,7 +132,7 @@ data class ExchangeConnectionEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val exchange: Exchange, - /** Empty string means "no custom name" — UI displays the exchange display name only. */ + /** Empty string means "no custom name" - UI displays the exchange display name only. */ val name: String = "", val createdAt: Instant = Instant.now(), val displayOrder: Int = 0 diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/repository/ExchangeConnectionRepository.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/repository/ExchangeConnectionRepository.kt index 12ca649..9848552 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/repository/ExchangeConnectionRepository.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/repository/ExchangeConnectionRepository.kt @@ -22,7 +22,7 @@ import javax.inject.Singleton * "Hlavní" and "Spoření"). Each connection has its own credentials (in [CredentialsStore]), * its own balance cache, and its own withdrawal thresholds. * - * Production and sandbox connections live in separate Room databases — this repository + * Production and sandbox connections live in separate Room databases - this repository * operates against whichever DB is currently active for the running app. */ @Singleton @@ -74,13 +74,13 @@ class ExchangeConnectionRepository @Inject constructor( /** * Delete a connection and manually cascade to all dependent rows. Cleanup order: * 1. (optional) DCA plans referencing this connection - * 2. Withdrawal thresholds (manual — `PRAGMA foreign_keys` is disabled in Room + * 2. Withdrawal thresholds (manual - `PRAGMA foreign_keys` is disabled in Room * so the schema-level `ON DELETE CASCADE` is a no-op) * 3. Balance cache rows * 4. Encrypted credentials in [CredentialsStore] * 5. The connection row itself * - * Transaction history is *not* deleted — its `connectionId` becomes orphaned + * Transaction history is *not* deleted - its `connectionId` becomes orphaned * (nullable, no FK), and the UI falls back to the [Exchange] enum for the label. * * @param deletePlans if true, also deletes any DCA plans tied to this connection. @@ -100,7 +100,7 @@ class ExchangeConnectionRepository @Inject constructor( if (deletePlans && planCount > 0) { dcaPlanDao.deletePlansByConnection(connectionId) } - // Manual cascade — FK enforcement is currently disabled. + // Manual cascade - FK enforcement is currently disabled. withdrawalThresholdDao.deleteByConnection(connectionId) exchangeBalanceDao.deleteBalancesByConnection(connectionId) credentialsStore.deleteCredentials(connectionId, isSandbox) @@ -108,12 +108,12 @@ class ExchangeConnectionRepository @Inject constructor( } /** - * Compute a "display label" for a connection — exchange name plus optional custom name. - * E.g. "Coinmate" (no name) or "Coinmate — Spoření". + * Compute a "display label" for a connection - exchange name plus optional custom name. + * E.g. "Coinmate" (no name) or "Coinmate - Spoření". */ fun displayLabel(connection: ExchangeConnectionEntity): String { return if (connection.name.isNotBlank()) { - "${connection.exchange.displayName} — ${connection.name}" + "${connection.exchange.displayName} - ${connection.name}" } else { connection.exchange.displayName } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt b/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt index 9abe83f..8b47deb 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/di/AppModule.kt @@ -96,7 +96,7 @@ object AppModule { } // CredentialsStore now uses @Inject constructor (needs ExchangeConnectionDao for legacy - // Exchange-keyed shims). Hilt provides it automatically — no manual @Provides needed. + // Exchange-keyed shims). Hilt provides it automatically - no manual @Provides needed. @Provides @Singleton diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt index d8d3f20..f5923fe 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt @@ -281,7 +281,7 @@ data class AppNotification( ) /** - * Withdrawal threshold configuration — per (crypto, connection) pair. + * Withdrawal threshold configuration - per (crypto, connection) pair. * * `exchange` is denormalized from the parent connection so UI can group/display by exchange * without joining; it is filled in at the ViewModel layer when loading thresholds. diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt index e411fdc..c4297d3 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt @@ -21,7 +21,7 @@ class CreateDcaPlanUseCase @Inject constructor( /** * @param connectionId optional explicit connection. If null, the use case picks the * default (first) connection of [exchange]. If no connection exists for that - * exchange, throws [IllegalStateException] — callers must ensure credentials are set + * exchange, throws [IllegalStateException] - callers must ensure credentials are set * up first (the AddPlan/AddExchange flow does this via [ValidateAndSaveCredentialsUseCase] * which creates the connection alongside the credentials). * @@ -56,7 +56,7 @@ class CreateDcaPlanUseCase @Inject constructor( val resolvedConnectionId = connectionId ?: exchangeConnectionDao.getDefaultByExchange(exchange)?.id ?: throw IllegalStateException( - "No connection exists for $exchange — set up credentials first via AddExchange flow" + "No connection exists for $exchange - set up credentials first via AddExchange flow" ) val plan = DcaPlanEntity( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateAndSaveCredentialsUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateAndSaveCredentialsUseCase.kt index 4f8facd..0a605ab 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateAndSaveCredentialsUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateAndSaveCredentialsUseCase.kt @@ -119,7 +119,7 @@ class ValidateAndSaveCredentialsUseCase @Inject constructor( } /** - * @return Pair(connectionId, createdHere) — true if this call created a new connection + * @return Pair(connectionId, createdHere) - true if this call created a new connection * row that should be rolled back on validation failure. */ private suspend fun resolveConnection( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt index 61d64f1..45eb704 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt @@ -42,7 +42,7 @@ data class CredentialFormState( val existingConnectionsForExchange: List = emptyList(), /** * Full list of existing connection entities for the selected exchange. Used by the - * AddPlan flow to show a picker when the user has 1+ connections — they can pick an + * AddPlan flow to show a picker when the user has 1+ connections - they can pick an * existing envelope instead of being forced to enter new credentials. */ val existingConnections: List = emptyList(), @@ -152,7 +152,7 @@ class CredentialFormDelegate( val isSandbox = _state.value.isSandboxMode val instructions = ExchangeInstructionsProvider.getInstructions(exchange, isSandbox) // Set isLoadingExchangeContext = true synchronously so the Validate button is - // immediately disabled — prevents race where user clicks Validate before the + // immediately disabled - prevents race where user clicks Validate before the // existing-connections lookup completes. _state.update { state -> state.copy( @@ -173,7 +173,7 @@ class CredentialFormDelegate( } coroutineScope.launch { val existing = connectionRepository?.getByExchange(exchange) ?: emptyList() - // Auto-select when exactly ONE connection exists — user doesn't need a picker + // Auto-select when exactly ONE connection exists - user doesn't need a picker // for the trivial case. With 0 connections, fall through to credentials form. // With 2+ connections, leave selectedConnectionId null and let the UI render a // picker so the user can choose between envelopes (Hlavní vs Spoření). diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt index fae47b2..4606708 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt @@ -1026,7 +1026,7 @@ internal fun DcaPlanCard( Text( // Render connection name as suffix when present (Phase 8 multi-connection) text = if (planWithBalance.connectionName.isNotBlank()) - "${plan.exchange.displayName} — ${planWithBalance.connectionName}" + "${plan.exchange.displayName} - ${planWithBalance.connectionName}" else plan.exchange.displayName, style = MaterialTheme.typography.bodySmall, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt index 8d0d9eb..a5ee07e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/SettingsViewModel.kt @@ -38,7 +38,7 @@ import javax.inject.Inject data class SettingsUiState( val configuredExchanges: List = emptyList(), /** - * Total number of exchange *connections* (envelopes) — can exceed + * Total number of exchange *connections* (envelopes) - can exceed * `configuredExchanges.size` when the user has multiple connections on the same * exchange (e.g. two Coinmate sub-accounts). */ @@ -95,7 +95,7 @@ class SettingsViewModel @Inject constructor( /** * Reactive flow on the connection list so the Settings card subtitle ("X connections * connected") and the legacy `configuredExchanges` field stay in sync as the user - * adds or removes envelopes — no manual reload needed. + * adds or removes envelopes - no manual reload needed. */ private fun observeConnections() { viewModelScope.launch { @@ -123,7 +123,7 @@ class SettingsViewModel @Inject constructor( // Sync UI state immediately for non-suspend prefs values. // Note: `configuredExchanges` and `connectionCount` are populated reactively by - // [observeConnections] from the connection DAO flow — no manual lookup here. + // [observeConnections] from the connection DAO flow - no manual lookup here. _uiState.update { it.copy( isBatteryOptimized = isBatteryOptimized, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeScreen.kt index db81fe5..9c3e9e4 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeScreen.kt @@ -470,7 +470,7 @@ private fun CredentialsStep( color = MaterialTheme.colorScheme.onSurfaceVariant ) - // Connection name input — required when there's already at least one connection + // Connection name input - required when there's already at least one connection // on this exchange (i.e. user is adding a 2nd "envelope") if (requireConnectionName || existingConnectionNames.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) @@ -512,7 +512,7 @@ private fun CredentialsStep( Spacer(modifier = Modifier.height(24.dp)) - // Validate button — disabled while existing-connections lookup is in flight + // Validate button - disabled while existing-connections lookup is in flight // (otherwise user could race past the duplicate-name check). val nameOk = !requireConnectionName || connectionName.isNotBlank() Button( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeViewModel.kt index 14b0929..25975da 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/AddExchangeViewModel.kt @@ -115,7 +115,7 @@ class AddExchangeViewModel @Inject constructor( /** * List of exchanges shown in the SELECTION step. After Phase 7+ users can add * multiple connections per exchange, so we no longer filter out exchanges that - * already have credentials — every supported exchange is always selectable. + * already have credentials - every supported exchange is always selectable. */ private suspend fun computeSupportedExchanges(): List { val isSandbox = userPreferences.isSandboxMode() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailViewModel.kt index 213cc5c..ff3e1ce 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeDetailViewModel.kt @@ -84,7 +84,7 @@ class ExchangeDetailViewModel @Inject constructor( _localState.update { it.copy(connection = connection, exchange = connection.exchange) } credentialForm.initWithExchange(connection.exchange) - // Load plans for this connection (was: per exchange — now per connection envelope) + // Load plans for this connection (was: per exchange - now per connection envelope) var autoImportTriggered = false dcaPlanDao.getPlansByConnection(connectionId).collect { plans -> _localState.update { it.copy(plans = plans) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementScreen.kt index ca04ac8..9eb93d8 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementScreen.kt @@ -127,7 +127,7 @@ fun ExchangeManagementScreen( } } - // Available exchanges section — show ALL exchanges (no longer filtered by + // Available exchanges section - show ALL exchanges (no longer filtered by // "has credentials"), so the user can add a second connection on Coinmate. item(span = { GridItemSpan(maxLineSpan) }) { SectionHeader(title = stringResource(R.string.exchanges_available)) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementViewModel.kt index 88933b9..8dff85f 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/exchanges/ExchangeManagementViewModel.kt @@ -63,8 +63,8 @@ class ExchangeManagementViewModel @Inject constructor( } /** - * Display label for a connection — exchange display name plus optional custom name. - * E.g. "Coinmate" or "Coinmate — Spoření". + * Display label for a connection - exchange display name plus optional custom name. + * E.g. "Coinmate" or "Coinmate - Spoření". */ fun displayLabel(connection: ExchangeConnectionEntity): String = connectionRepository.displayLabel(connection) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt b/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt index 53bc7e1..dd66adb 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt @@ -37,7 +37,7 @@ class NotificationService @Inject constructor( private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager /** - * Render a label like "Coinmate" or "Coinmate — Spoření" for use in notification + * Render a label like "Coinmate" or "Coinmate - Spoření" for use in notification * titles. If [connectionId] is set and the connection has a non-empty name, the * label includes it; otherwise the bare exchange display name is returned. * @@ -52,7 +52,7 @@ class NotificationService @Inject constructor( exchangeConnectionDao.getById(connectionId) } return if (connection != null && connection.name.isNotBlank()) { - "$exchangeLabel — ${connection.name}" + "$exchangeLabel - ${connection.name}" } else { exchangeLabel } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt index 2c5b4a8..ec65254 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt @@ -591,7 +591,7 @@ class DcaWorker @AssistedInject constructor( * work runs as a foreground service, and when this chain is reachable from a * BOOT_COMPLETED broadcast (BootReceiver re-arms the alarm after boot, the alarm * fires shortly after, and triggers this work), Google Play flags it as starting - * a restricted "dataSync" foreground service from BOOT_COMPLETED — which is not + * a restricted "dataSync" foreground service from BOOT_COMPLETED - which is not * allowed for apps targeting Android 15+. The alarm wakes the device anyway, so * regular OneTimeWorkRequest runs immediately. */ diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 4a63ea4..0d54022 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -10,8 +10,8 @@ Posunout dolů např. Dlouhodobé spoření, Měsíční BTC… Přidat název plánu - Toto pripojeni ma %1$d dalsi(ch) plan(u). Import stahne VSECHNY obchody z uctu burzy, ne jen obchody tohoto planu. Duplicitni transakce se filtruji automaticky. - Toto pripojeni uz ma %1$d plan(u). Vsechny plany na jednom pripojeni sdili jeden ucet burzy, takze transakce importovane pres API budou viditelne ve vsech planech. + Toto připojení má %1$d další(ch) plán(ů). Import stáhne VŠECHNY obchody z účtu burzy, ne jen obchody tohoto plánu. Duplicitní transakce se filtrují automaticky. + Toto připojení už má %1$d plán(ů). Všechny plány na jednom připojení sdílí jeden účet burzy, takže transakce importované přes API budou viditelné ve všech plánech. Zkusit znovu Přeskočit Vymazat @@ -352,7 +352,7 @@ Přidat burzu Připojené burzy Dostupné burzy - %1$d připojení — přidat další + %1$d připojení - přidat další Název připojení (volitelné) Při více připojeních je název povinný Tento název už pro tuto burzu existuje diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index be02bdd..52bf363 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -351,7 +351,7 @@ Add Exchange Connected Exchanges Available Exchanges - %1$d connection(s) — add another + %1$d connection(s) - add another Connection name (optional) Name is required when you have multiple connections This name is already used for another connection on this exchange From 2b9d3683cddd2c0e47916ccd927dcbcf9c537943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 10 Apr 2026 12:20:05 +0200 Subject: [PATCH 06/26] feat(dao): add batch displayOrder update and getMaxDisplayOrder Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/java/com/accbot/dca/data/local/Daos.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt index 6f370f4..0d66e69 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt @@ -130,6 +130,16 @@ interface DcaPlanDao { @Query("SELECT * FROM dca_plans ORDER BY displayOrder ASC, createdAt DESC") suspend fun getAllPlansOnceOrdered(): List + @Query("SELECT COALESCE(MAX(displayOrder), -1) FROM dca_plans") + suspend fun getMaxDisplayOrder(): Int + + @Transaction + suspend fun updateAllDisplayOrders(planOrders: List>) { + for ((planId, order) in planOrders) { + updateDisplayOrder(planId, order) + } + } + } @Dao From 5e083cba401bcddaa78626d8d88773e77fa06782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 10 Apr 2026 12:20:57 +0200 Subject: [PATCH 07/26] feat(plan): assign sequential displayOrder to new plans --- .../com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt index c4297d3..030241c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt @@ -59,6 +59,8 @@ class CreateDcaPlanUseCase @Inject constructor( "No connection exists for $exchange - set up credentials first via AddExchange flow" ) + val nextDisplayOrder = dcaPlanDao.getMaxDisplayOrder() + 1 + val plan = DcaPlanEntity( exchange = exchange, connectionId = resolvedConnectionId, @@ -73,7 +75,8 @@ class CreateDcaPlanUseCase @Inject constructor( withdrawalAddress = withdrawalAddress, createdAt = now, nextExecutionAt = nextExecution, - targetAmount = targetAmount + targetAmount = targetAmount, + displayOrder = nextDisplayOrder ) dcaPlanDao.insertPlan(plan) From 9130212c417dbd4cf7b22e369f492c7aea460f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 10 Apr 2026 12:21:45 +0200 Subject: [PATCH 08/26] feat(viewmodel): replace reorderPlan with reorderPlans for drag & drop --- .../screens/DashboardViewModel.kt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt index 0157e13..d52c87c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt @@ -578,17 +578,21 @@ class DashboardViewModel @Inject constructor( } /** - * Move a plan up or down in the dashboard display order. - * Swaps displayOrder values between the plan at [fromIndex] and [toIndex]. + * Move a plan from [fromIndex] to [toIndex] in the dashboard display order. + * Re-assigns sequential displayOrder values (0, 1, 2, ...) to all plans. */ - fun reorderPlan(fromIndex: Int, toIndex: Int) { - val plans = _uiState.value.activePlans + fun reorderPlans(fromIndex: Int, toIndex: Int) { + val plans = _uiState.value.activePlans.toMutableList() if (fromIndex !in plans.indices || toIndex !in plans.indices) return - val fromPlan = plans[fromIndex].plan - val toPlan = plans[toIndex].plan + if (fromIndex == toIndex) return + val moved = plans.removeAt(fromIndex) + plans.add(toIndex, moved) + // Update UI immediately for responsive feel + _uiState.update { it.copy(activePlans = plans) } + // Persist new order + val planOrders = plans.mapIndexed { index, pwb -> pwb.plan.id to index } viewModelScope.launch { - dcaPlanDao.updateDisplayOrder(fromPlan.id, toPlan.displayOrder) - dcaPlanDao.updateDisplayOrder(toPlan.id, fromPlan.displayOrder) + dcaPlanDao.updateAllDisplayOrders(planOrders) } } From 757b9fc72bf1e5c6dc3bf55c53858c94e073342d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 10 Apr 2026 12:26:46 +0200 Subject: [PATCH 09/26] feat(dashboard): add drag & drop reorder with drag handle on plan cards Replace arrow-button reordering with long-press drag handle on each DcaPlanCard. PlanDragState tracks drag offset and triggers reorder when the card crosses the midpoint of a neighbor. Both portrait and landscape LazyColumn layouts are updated. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../presentation/screens/DashboardScreen.kt | 160 +++++++++++++----- 1 file changed, 119 insertions(+), 41 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt index 4606708..770271b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.Canvas import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -37,8 +38,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.Constraints import kotlin.math.roundToInt import androidx.compose.ui.platform.LocalConfiguration @@ -51,6 +54,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -76,6 +80,57 @@ import java.math.BigDecimal import java.math.RoundingMode import kotlinx.coroutines.delay +/** + * Remembers drag-to-reorder state for a LazyColumn of plan cards. + */ +@Composable +private fun rememberPlanDragState( + onReorder: (from: Int, to: Int) -> Unit +): PlanDragState { + return remember { PlanDragState(onReorder) } +} + +private class PlanDragState( + private val onReorder: (Int, Int) -> Unit +) { + var draggedIndex by mutableIntStateOf(-1) + private set + var dragOffset by mutableFloatStateOf(0f) + private set + private var itemHeights = mutableMapOf() + + fun registerItemHeight(index: Int, height: Int) { + itemHeights[index] = height + } + + fun startDrag(index: Int) { + draggedIndex = index + dragOffset = 0f + } + + fun drag(delta: Float) { + if (draggedIndex < 0) return + dragOffset += delta + + val draggedHeight = itemHeights[draggedIndex] ?: return + // Check if we've dragged past the midpoint of the next/previous item + if (dragOffset > draggedHeight * 0.5f && draggedIndex < itemHeights.size - 1) { + onReorder(draggedIndex, draggedIndex + 1) + draggedIndex = draggedIndex + 1 + dragOffset -= draggedHeight + } else if (dragOffset < -draggedHeight * 0.5f && draggedIndex > 0) { + onReorder(draggedIndex, draggedIndex - 1) + draggedIndex = draggedIndex - 1 + dragOffset += draggedHeight + } + } + + fun endDrag() { + draggedIndex = -1 + dragOffset = 0f + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun DashboardScreen( @@ -214,6 +269,9 @@ fun DashboardScreen( } // Right column: DCA Plans + val landscapeDragState = rememberPlanDragState { from, to -> + viewModel.reorderPlans(from, to) + } LazyColumn( modifier = Modifier.weight(0.5f), verticalArrangement = Arrangement.spacedBy(16.dp) @@ -247,9 +305,13 @@ fun DashboardScreen( planWithBalance = planWithBalance, onToggle = { viewModel.togglePlan(planWithBalance.plan.id) }, onClick = { onNavigateToPlanDetails?.invoke(planWithBalance.plan.id) }, - onMoveUp = if (index > 0) {{ viewModel.reorderPlan(index, index - 1) }} else null, - onMoveDown = if (index < uiState.activePlans.lastIndex) {{ viewModel.reorderPlan(index, index + 1) }} else null, - currentTime = currentTime + currentTime = currentTime, + isDragging = index == landscapeDragState.draggedIndex, + dragOffset = if (index == landscapeDragState.draggedIndex) landscapeDragState.dragOffset else 0f, + onRegisterHeight = { height -> landscapeDragState.registerItemHeight(index, height) }, + onDragStart = { landscapeDragState.startDrag(index) }, + onDrag = { delta -> landscapeDragState.drag(delta) }, + onDragEnd = { landscapeDragState.endDrag() } ) } } @@ -261,6 +323,9 @@ fun DashboardScreen( } } else { // Portrait: single column + val portraitDragState = rememberPlanDragState { from, to -> + viewModel.reorderPlans(from, to) + } LazyColumn( modifier = Modifier .fillMaxSize() @@ -350,12 +415,18 @@ fun DashboardScreen( EmptyPlansCard(onAddPlan = onNavigateToPlans) } } else { - items(uiState.activePlans, key = { it.plan.id }) { planWithBalance -> + itemsIndexed(uiState.activePlans, key = { _, p -> p.plan.id }) { index, planWithBalance -> DcaPlanCard( planWithBalance = planWithBalance, onToggle = { viewModel.togglePlan(planWithBalance.plan.id) }, onClick = { onNavigateToPlanDetails?.invoke(planWithBalance.plan.id) }, - currentTime = currentTime + currentTime = currentTime, + isDragging = index == portraitDragState.draggedIndex, + dragOffset = if (index == portraitDragState.draggedIndex) portraitDragState.dragOffset else 0f, + onRegisterHeight = { height -> portraitDragState.registerItemHeight(index, height) }, + onDragStart = { portraitDragState.startDrag(index) }, + onDrag = { delta -> portraitDragState.drag(delta) }, + onDragEnd = { portraitDragState.endDrag() } ) } } @@ -939,9 +1010,13 @@ internal fun DcaPlanCard( planWithBalance: DcaPlanWithBalance, onToggle: () -> Unit, onClick: (() -> Unit)? = null, - onMoveUp: (() -> Unit)? = null, - onMoveDown: (() -> Unit)? = null, - currentTime: Long = System.currentTimeMillis() + currentTime: Long = System.currentTimeMillis(), + isDragging: Boolean = false, + dragOffset: Float = 0f, + onRegisterHeight: ((Int) -> Unit)? = null, + onDragStart: (() -> Unit)? = null, + onDrag: ((Float) -> Unit)? = null, + onDragEnd: (() -> Unit)? = null ) { val plan = planWithBalance.plan val successCol = successColor() @@ -950,7 +1025,19 @@ internal fun DcaPlanCard( Card( modifier = Modifier .fillMaxWidth() - .then(if (onClick != null) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier), + .zIndex(if (isDragging) 1f else 0f) + .graphicsLayer { + translationY = dragOffset + if (isDragging) { + shadowElevation = 8f + scaleX = 1.02f + scaleY = 1.02f + } + } + .onGloballyPositioned { coordinates -> + onRegisterHeight?.invoke(coordinates.size.height) + } + .then(if (onClick != null && !isDragging) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ) @@ -958,7 +1045,7 @@ internal fun DcaPlanCard( Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(start = 4.dp, end = 16.dp, top = 16.dp, bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -966,6 +1053,28 @@ internal fun DcaPlanCard( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f) ) { + // Drag handle + if (onDragStart != null) { + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = "Reorder", + modifier = Modifier + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { onDragStart() }, + onDrag = { change, dragAmount -> + change.consume() + onDrag?.invoke(dragAmount.y) + }, + onDragEnd = { onDragEnd?.invoke() }, + onDragCancel = { onDragEnd?.invoke() } + ) + } + .padding(end = 8.dp) + .size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } CryptoIcon(crypto = plan.crypto) Spacer(modifier = Modifier.width(12.dp)) Column { @@ -1150,37 +1259,6 @@ internal fun DcaPlanCard( Column( horizontalAlignment = Alignment.CenterHorizontally ) { - // Reorder arrows (only when both callbacks are provided = reorder mode) - if (onMoveUp != null || onMoveDown != null) { - Row { - if (onMoveUp != null) { - IconButton( - onClick = onMoveUp, - modifier = Modifier.size(28.dp) - ) { - Icon( - Icons.Default.KeyboardArrowUp, - contentDescription = stringResource(R.string.common_move_up), - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - if (onMoveDown != null) { - IconButton( - onClick = onMoveDown, - modifier = Modifier.size(28.dp) - ) { - Icon( - Icons.Default.KeyboardArrowDown, - contentDescription = stringResource(R.string.common_move_down), - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } Switch( checked = plan.isEnabled, onCheckedChange = { onToggle() }, From 44da0018c2f5f28cb8eef6095367dc22b2395cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Fri, 10 Apr 2026 15:08:05 +0200 Subject: [PATCH 10/26] fix(dashboard): fix stale drag index after reorder using rememberUpdatedState Also: hide drag handle until long-press activates drag, apply detectDragGesturesAfterLongPress on entire card instead of small icon. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../presentation/screens/DashboardScreen.kt | 109 ++++++++++-------- 1 file changed, 59 insertions(+), 50 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt index 770271b..e0f8fdb 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt @@ -81,56 +81,59 @@ import java.math.RoundingMode import kotlinx.coroutines.delay /** - * Remembers drag-to-reorder state for a LazyColumn of plan cards. + * State holder for drag-to-reorder in a LazyColumn of plan cards. + * Long-press on a card activates drag mode; dragging past the midpoint + * of an adjacent item triggers a swap. */ -@Composable -private fun rememberPlanDragState( - onReorder: (from: Int, to: Int) -> Unit -): PlanDragState { - return remember { PlanDragState(onReorder) } -} - private class PlanDragState( private val onReorder: (Int, Int) -> Unit ) { var draggedIndex by mutableIntStateOf(-1) - private set var dragOffset by mutableFloatStateOf(0f) - private set - private var itemHeights = mutableMapOf() + private var accumulatedOffset = 0f + private var itemHeight = 0 - fun registerItemHeight(index: Int, height: Int) { - itemHeights[index] = height - } - - fun startDrag(index: Int) { + fun startDrag(index: Int, heightPx: Int) { draggedIndex = index dragOffset = 0f + accumulatedOffset = 0f + itemHeight = heightPx } - fun drag(delta: Float) { - if (draggedIndex < 0) return - dragOffset += delta + fun drag(delta: Float, totalItems: Int) { + if (draggedIndex < 0 || itemHeight == 0) return + accumulatedOffset += delta + dragOffset = accumulatedOffset - val draggedHeight = itemHeights[draggedIndex] ?: return - // Check if we've dragged past the midpoint of the next/previous item - if (dragOffset > draggedHeight * 0.5f && draggedIndex < itemHeights.size - 1) { + // Swap when dragged past midpoint of adjacent item + val threshold = itemHeight * 0.5f + if (accumulatedOffset > threshold && draggedIndex < totalItems - 1) { onReorder(draggedIndex, draggedIndex + 1) - draggedIndex = draggedIndex + 1 - dragOffset -= draggedHeight - } else if (dragOffset < -draggedHeight * 0.5f && draggedIndex > 0) { + draggedIndex += 1 + accumulatedOffset -= itemHeight + dragOffset = accumulatedOffset + } else if (accumulatedOffset < -threshold && draggedIndex > 0) { onReorder(draggedIndex, draggedIndex - 1) - draggedIndex = draggedIndex - 1 - dragOffset += draggedHeight + draggedIndex -= 1 + accumulatedOffset += itemHeight + dragOffset = accumulatedOffset } } fun endDrag() { draggedIndex = -1 dragOffset = 0f + accumulatedOffset = 0f } } +@Composable +private fun rememberPlanDragState( + onReorder: (from: Int, to: Int) -> Unit +): PlanDragState { + return remember { PlanDragState(onReorder) } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun DashboardScreen( @@ -308,9 +311,8 @@ fun DashboardScreen( currentTime = currentTime, isDragging = index == landscapeDragState.draggedIndex, dragOffset = if (index == landscapeDragState.draggedIndex) landscapeDragState.dragOffset else 0f, - onRegisterHeight = { height -> landscapeDragState.registerItemHeight(index, height) }, - onDragStart = { landscapeDragState.startDrag(index) }, - onDrag = { delta -> landscapeDragState.drag(delta) }, + onDragStart = { heightPx -> landscapeDragState.startDrag(index, heightPx) }, + onDrag = { delta -> landscapeDragState.drag(delta, uiState.activePlans.size) }, onDragEnd = { landscapeDragState.endDrag() } ) } @@ -423,9 +425,8 @@ fun DashboardScreen( currentTime = currentTime, isDragging = index == portraitDragState.draggedIndex, dragOffset = if (index == portraitDragState.draggedIndex) portraitDragState.dragOffset else 0f, - onRegisterHeight = { height -> portraitDragState.registerItemHeight(index, height) }, - onDragStart = { portraitDragState.startDrag(index) }, - onDrag = { delta -> portraitDragState.drag(delta) }, + onDragStart = { heightPx -> portraitDragState.startDrag(index, heightPx) }, + onDrag = { delta -> portraitDragState.drag(delta, uiState.activePlans.size) }, onDragEnd = { portraitDragState.endDrag() } ) } @@ -1013,8 +1014,7 @@ internal fun DcaPlanCard( currentTime: Long = System.currentTimeMillis(), isDragging: Boolean = false, dragOffset: Float = 0f, - onRegisterHeight: ((Int) -> Unit)? = null, - onDragStart: (() -> Unit)? = null, + onDragStart: ((heightPx: Int) -> Unit)? = null, onDrag: ((Float) -> Unit)? = null, onDragEnd: (() -> Unit)? = null ) { @@ -1022,6 +1022,11 @@ internal fun DcaPlanCard( val successCol = successColor() val accentCol = accentColor() val context = LocalContext.current + var cardHeight by remember { mutableIntStateOf(0) } + // Keep references fresh so pointerInput(Unit) always calls the latest lambdas + val currentOnDragStart by rememberUpdatedState(onDragStart) + val currentOnDrag by rememberUpdatedState(onDrag) + val currentOnDragEnd by rememberUpdatedState(onDragEnd) Card( modifier = Modifier .fillMaxWidth() @@ -1035,8 +1040,23 @@ internal fun DcaPlanCard( } } .onGloballyPositioned { coordinates -> - onRegisterHeight?.invoke(coordinates.size.height) + cardHeight = coordinates.size.height } + .then( + if (onDragStart != null) { + Modifier.pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { currentOnDragStart?.invoke(cardHeight) }, + onDrag = { change, dragAmount -> + change.consume() + currentOnDrag?.invoke(dragAmount.y) + }, + onDragEnd = { currentOnDragEnd?.invoke() }, + onDragCancel = { currentOnDragEnd?.invoke() } + ) + } + } else Modifier + ) .then(if (onClick != null && !isDragging) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface @@ -1045,7 +1065,7 @@ internal fun DcaPlanCard( Row( modifier = Modifier .fillMaxWidth() - .padding(start = 4.dp, end = 16.dp, top = 16.dp, bottom = 16.dp), + .padding(start = if (isDragging) 4.dp else 16.dp, end = 16.dp, top = 16.dp, bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -1053,23 +1073,12 @@ internal fun DcaPlanCard( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f) ) { - // Drag handle - if (onDragStart != null) { + // Drag handle - only visible while dragging + if (isDragging) { Icon( imageVector = Icons.Default.DragHandle, contentDescription = "Reorder", modifier = Modifier - .pointerInput(Unit) { - detectDragGesturesAfterLongPress( - onDragStart = { onDragStart() }, - onDrag = { change, dragAmount -> - change.consume() - onDrag?.invoke(dragAmount.y) - }, - onDragEnd = { onDragEnd?.invoke() }, - onDragCancel = { onDragEnd?.invoke() } - ) - } .padding(end = 8.dp) .size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) From 864b468559c1b5f1bfa35687071c9238adc355c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 11 Apr 2026 06:37:03 +0200 Subject: [PATCH 11/26] feat(portfolio): refactor from per-pair to per-plan views with plan toggles Co-Authored-By: Claude Opus 4.6 (1M context) --- .../screens/portfolio/PortfolioViewModel.kt | 144 +++++++++++------- 1 file changed, 88 insertions(+), 56 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index bda616e..fad8be2 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -4,6 +4,7 @@ import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.TransactionEntity // TransactionStatus filtering now done in DAO query @@ -25,9 +26,12 @@ enum class DenominationMode { FIAT, CRYPTO } sealed class PairPage { data class Aggregate(val fiat: String) : PairPage() - data class SinglePair(val crypto: String, val fiat: String) : PairPage() + data class Plan(val planId: Long, val name: String, val crypto: String, val fiat: String) : PairPage() } +@Immutable +data class PlanInfo(val id: Long, val name: String, val crypto: String, val fiat: String) + @Immutable data class PortfolioUiState( val chartData: List = emptyList(), @@ -36,8 +40,6 @@ data class PortfolioUiState( val availableMonths: List = emptyList(), val canNavigatePrev: Boolean = false, val canNavigateNext: Boolean = false, - val selectedExchangeFilter: String? = null, - val availableExchanges: List = emptyList(), val pages: List = emptyList(), val selectedPageIndex: Int = 0, val denominationMode: DenominationMode = DenominationMode.FIAT, @@ -49,13 +51,17 @@ data class PortfolioUiState( val isLoading: Boolean = true, val isChartLoading: Boolean = false, val isPriceSyncing: Boolean = false, - val error: String? = null + val error: String? = null, + val allPlans: List = emptyList(), + val visiblePlanIds: Set = emptySet(), + val showTotalLine: Boolean = true ) @HiltViewModel class PortfolioViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val transactionDao: TransactionDao, + private val dcaPlanDao: DcaPlanDao, private val syncDailyPricesUseCase: SyncDailyPricesUseCase, private val calculateChartDataUseCase: CalculateChartDataUseCase ) : ViewModel() { @@ -88,33 +94,44 @@ class PortfolioViewModel @Inject constructor( portfolioJob = viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } try { - // Use pre-filtered, sorted query (avoids loading failed/pending into memory) val completed = transactionDao.getCompletedTransactionsOrdered() completedTransactions = completed - val exchanges = completed.map { it.exchange.name }.distinct().sorted() - val pairs = completed.map { it.crypto to it.fiat }.distinct() - val pairsByFiat = pairs.groupBy { it.second } + // Load plans for page building + val allDbPlans = dcaPlanDao.getAllPlansOnceOrdered() + val planInfos = allDbPlans.map { p -> + PlanInfo( + id = p.id, + name = p.name.ifBlank { "${p.crypto}/${p.fiat}" }, + crypto = p.crypto, + fiat = p.fiat + ) + } + + // Build pages: aggregate per fiat (if 2+ plans in same fiat), then per plan + val plansByFiat = planInfos.groupBy { it.fiat } val pages = mutableListOf() - for ((fiat, fiatPairs) in pairsByFiat) { - if (fiatPairs.size >= 2) { + for ((fiat, fiatPlans) in plansByFiat) { + if (fiatPlans.size >= 2) { pages.add(PairPage.Aggregate(fiat)) } } - for (pair in pairs) { - pages.add(PairPage.SinglePair(pair.first, pair.second)) + for (plan in planInfos) { + pages.add(PairPage.Plan(plan.id, plan.name, plan.crypto, plan.fiat)) } val pageIndex = if (initialCrypto != null && initialFiat != null) { - val idx = pages.indexOfFirst { it is PairPage.SinglePair && it.crypto == initialCrypto && it.fiat == initialFiat } + val idx = pages.indexOfFirst { it is PairPage.Plan && it.crypto == initialCrypto && it.fiat == initialFiat } if (idx >= 0) idx else 0 } else 0 _uiState.update { state -> state.copy( - availableExchanges = exchanges, pages = pages, selectedPageIndex = pageIndex, + allPlans = planInfos, + visiblePlanIds = emptySet(), + showTotalLine = true, isLoading = false ) } @@ -126,10 +143,7 @@ class PortfolioViewModel @Inject constructor( throw e } catch (e: Exception) { _uiState.update { - it.copy( - isLoading = false, - error = e.message ?: "Failed to load portfolio" - ) + it.copy(isLoading = false, error = e.message ?: "Failed to load portfolio") } } } @@ -141,27 +155,34 @@ class PortfolioViewModel @Inject constructor( try { val completed = transactionDao.getCompletedTransactionsOrdered() completedTransactions = completed - val exchanges = completed.map { it.exchange.name }.distinct().sorted() - val pairs = completed.map { it.crypto to it.fiat }.distinct() - val pairsByFiat = pairs.groupBy { it.second } + val allDbPlans = dcaPlanDao.getAllPlansOnceOrdered() + val planInfos = allDbPlans.map { p -> + PlanInfo( + id = p.id, + name = p.name.ifBlank { "${p.crypto}/${p.fiat}" }, + crypto = p.crypto, + fiat = p.fiat + ) + } + + val plansByFiat = planInfos.groupBy { it.fiat } val pages = mutableListOf() - for ((fiat, fiatPairs) in pairsByFiat) { - if (fiatPairs.size >= 2) { + for ((fiat, fiatPlans) in plansByFiat) { + if (fiatPlans.size >= 2) { pages.add(PairPage.Aggregate(fiat)) } } - for (pair in pairs) { - pages.add(PairPage.SinglePair(pair.first, pair.second)) + for (plan in planInfos) { + pages.add(PairPage.Plan(plan.id, plan.name, plan.crypto, plan.fiat)) } _uiState.update { state -> - // Keep current page index if still valid, otherwise reset to 0 val pageIndex = state.selectedPageIndex.coerceIn(0, (pages.size - 1).coerceAtLeast(0)) state.copy( - availableExchanges = exchanges, pages = pages, - selectedPageIndex = pageIndex + selectedPageIndex = pageIndex, + allPlans = planInfos ) } updateNavigationState() @@ -216,7 +237,7 @@ class PortfolioViewModel @Inject constructor( if (yearIdx > 0) { val prevYear = years[yearIdx - 1] val prevMonths = calculateChartDataUseCase.getAvailableMonths( - getFilteredTransactions(), prevYear + getFilteredTransactions(getCurrentPlanId()), prevYear ) if (prevMonths.isNotEmpty()) { ChartZoomLevel.Month(prevYear, prevMonths.last()) @@ -261,7 +282,7 @@ class PortfolioViewModel @Inject constructor( val nextYear = years[yearIdx + 1] if (nextYear <= today.year) { val nextMonths = calculateChartDataUseCase.getAvailableMonths( - getFilteredTransactions(), nextYear + getFilteredTransactions(getCurrentPlanId()), nextYear ) if (nextMonths.isNotEmpty()) { val nextYm = java.time.YearMonth.of(nextYear, nextMonths.first()) @@ -281,17 +302,6 @@ class PortfolioViewModel @Inject constructor( loadChartData() } - fun selectExchangeFilter(exchange: String?) { - _uiState.update { it.copy( - selectedExchangeFilter = exchange, - selectedPageIndex = 0, - denominationMode = DenominationMode.FIAT, - zoomLevel = ChartZoomLevel.Overview - ) } - updateNavigationState() - loadChartData() - } - fun selectPairPage(index: Int) { val page = _uiState.value.pages.getOrNull(index) val newMode = if (page is PairPage.Aggregate) DenominationMode.FIAT else _uiState.value.denominationMode @@ -299,15 +309,31 @@ class PortfolioViewModel @Inject constructor( selectedPageIndex = index, denominationMode = newMode, visibleSeries = setOf(0, 1), - zoomLevel = ChartZoomLevel.Overview + zoomLevel = ChartZoomLevel.Overview, + visiblePlanIds = emptySet(), + showTotalLine = true ) } updateNavigationState() loadChartData() } + fun togglePlanVisibility(planId: Long) { + _uiState.update { state -> + val current = state.visiblePlanIds + val toggled = if (planId in current) current - planId else current + planId + state.copy(visiblePlanIds = toggled) + } + loadChartData() + } + + fun toggleTotalLine() { + _uiState.update { it.copy(showTotalLine = !it.showTotalLine) } + loadChartData() + } + fun toggleDenomination() { val page = _uiState.value.pages.getOrNull(_uiState.value.selectedPageIndex) - if (page !is PairPage.SinglePair) return + if (page !is PairPage.Plan) return _uiState.update { it.copy( denominationMode = if (it.denominationMode == DenominationMode.FIAT) DenominationMode.CRYPTO else DenominationMode.FIAT, @@ -347,15 +373,21 @@ class PortfolioViewModel @Inject constructor( } } - private fun getFilteredTransactions(): List { - val state = _uiState.value - return completedTransactions.filter { tx -> - state.selectedExchangeFilter == null || tx.exchange.name == state.selectedExchangeFilter + private fun getFilteredTransactions(planId: Long? = null): List { + return if (planId != null) { + completedTransactions.filter { it.planId == planId } + } else { + completedTransactions } } + private fun getCurrentPlanId(): Long? { + val page = _uiState.value.pages.getOrNull(_uiState.value.selectedPageIndex) + return (page as? PairPage.Plan)?.planId + } + private fun updateNavigationState() { - val filteredTxs = getFilteredTransactions() + val filteredTxs = getFilteredTransactions(getCurrentPlanId()) val state = _uiState.value val years = calculateChartDataUseCase.getAvailableYears(filteredTxs) val today = LocalDate.now() @@ -408,16 +440,16 @@ class PortfolioViewModel @Inject constructor( val state = _uiState.value val page = state.pages.getOrNull(state.selectedPageIndex) - val (crypto, fiat) = when (page) { - is PairPage.Aggregate -> null to page.fiat - is PairPage.SinglePair -> page.crypto to page.fiat - null -> null to null + val (crypto, fiat, planId) = when (page) { + is PairPage.Aggregate -> Triple(null, page.fiat, null) + is PairPage.Plan -> Triple(page.crypto, page.fiat, page.planId) + null -> Triple(null, null, null) } - val data = if (crypto == null && fiat == null) { + val data = if (fiat == null) { emptyList() } else { - val filteredTxs = getFilteredTransactions() + val filteredTxs = getFilteredTransactions(planId) calculateChartDataUseCase.calculate( transactions = filteredTxs, crypto = crypto, @@ -427,9 +459,9 @@ class PortfolioViewModel @Inject constructor( } val txCount = completedTransactions.count { tx -> + (planId == null || tx.planId == planId) && (crypto == null || tx.crypto == crypto) && - (fiat == null || tx.fiat == fiat) && - (state.selectedExchangeFilter == null || tx.exchange.name == state.selectedExchangeFilter) + (fiat == null || tx.fiat == fiat) } _uiState.update { it.copy( From 614ec3e5a485a9767e3e6bb52288c31279f59de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 11 Apr 2026 06:40:53 +0200 Subject: [PATCH 12/26] feat(portfolio-screen): replace exchange filter with per-plan chips and Plan pages Co-Authored-By: Claude Opus 4.6 (1M context) --- .../screens/portfolio/PortfolioScreen.kt | 103 ++++++++++-------- 1 file changed, 55 insertions(+), 48 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index 6448faf..b1ab37b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -87,7 +87,7 @@ fun PortfolioScreen( val hasAnyData = chartData.isNotEmpty() val hasData = chartData.size >= 2 val currentPage = uiState.pages.getOrNull(uiState.selectedPageIndex) - val isSinglePair = currentPage is PairPage.SinglePair + val isPlan = currentPage is PairPage.Plan // Scrub-to-inspect state var scrubbedIndex by remember { mutableIntStateOf(-1) } @@ -103,7 +103,7 @@ fun PortfolioScreen( val pairLabel = when (currentPage) { is PairPage.Aggregate -> stringResource(R.string.chart_all_fiat, currentPage.fiat) - is PairPage.SinglePair -> "${currentPage.crypto}/${currentPage.fiat}" + is PairPage.Plan -> currentPage.name null -> "" } @@ -127,7 +127,7 @@ fun PortfolioScreen( } val legendEntries = rememberLegendEntries( denominationMode = uiState.denominationMode, - isSinglePair = isSinglePair, + isPlan = isPlan, currentPairCrypto = uiState.currentPairCrypto ) PortfolioLineChart( @@ -232,17 +232,19 @@ fun PortfolioScreen( if (hasAnyData) { LandscapeKpiContent( uiState = uiState, - isSinglePair = isSinglePair, + isPlan = isPlan, scrubbedDataPoint = scrubbedDataPoint ) } - // Exchange filter - if (uiState.availableExchanges.size > 1) { - ExchangeFilterRow( - exchanges = uiState.availableExchanges, - selectedExchange = uiState.selectedExchangeFilter, - onExchangeSelected = { viewModel.selectExchangeFilter(it) } + // Plan filter chips + if (currentPage is PairPage.Aggregate && uiState.allPlans.any { it.fiat == currentPage.fiat }) { + PlanFilterChips( + plans = uiState.allPlans.filter { it.fiat == currentPage.fiat }, + visiblePlanIds = uiState.visiblePlanIds, + showTotalLine = uiState.showTotalLine, + onTogglePlan = { viewModel.togglePlanVisibility(it) }, + onToggleTotal = { viewModel.toggleTotalLine() } ) } } @@ -298,7 +300,8 @@ fun PortfolioScreen( onZoomOut = { viewModel.zoomOut() }, onNavigatePrev = { viewModel.navigatePrev() }, onNavigateNext = { viewModel.navigateNext() }, - onExchangeFilterSelected = { viewModel.selectExchangeFilter(it) }, + onTogglePlanVisibility = { viewModel.togglePlanVisibility(it) }, + onToggleTotalLine = { viewModel.toggleTotalLine() }, onPairPageSelected = { viewModel.selectPairPage(it) }, onToggleSeriesVisibility = { viewModel.toggleSeriesVisibility(it) }, onRefresh = { viewModel.syncPricesAndLoadChart() }, @@ -319,7 +322,8 @@ internal fun PortfolioContent( onZoomOut: () -> Unit, onNavigatePrev: () -> Unit, onNavigateNext: () -> Unit, - onExchangeFilterSelected: (String?) -> Unit, + onTogglePlanVisibility: (Long) -> Unit, + onToggleTotalLine: () -> Unit, onPairPageSelected: (Int) -> Unit, onToggleSeriesVisibility: (Int) -> Unit, onRefresh: () -> Unit, @@ -344,7 +348,7 @@ internal fun PortfolioContent( } val currentPage = uiState.pages.getOrNull(uiState.selectedPageIndex) - val isSinglePair = currentPage is PairPage.SinglePair + val isPlan = currentPage is PairPage.Plan // Scrub-to-inspect state (ephemeral, local to composable) var scrubbedIndex by remember { mutableIntStateOf(-1) } @@ -410,7 +414,7 @@ internal fun PortfolioContent( val pageItem = uiState.pages.getOrNull(page) val pairLabel = when (pageItem) { is PairPage.Aggregate -> stringResource(R.string.chart_all_fiat, pageItem.fiat) - is PairPage.SinglePair -> "${pageItem.crypto}/${pageItem.fiat}" + is PairPage.Plan -> pageItem.name null -> "" } @@ -430,7 +434,7 @@ internal fun PortfolioContent( Spacer(Modifier.height(8.dp)) KpiCardContent( uiState = uiState, - isSinglePair = pageItem is PairPage.SinglePair, + isPlan = pageItem is PairPage.Plan, scrubbedDataPoint = scrubbedDataPoint ) } @@ -479,7 +483,7 @@ internal fun PortfolioContent( val pageItem = uiState.pages.firstOrNull() val pairLabel = when (pageItem) { is PairPage.Aggregate -> stringResource(R.string.chart_all_fiat, pageItem.fiat) - is PairPage.SinglePair -> "${pageItem.crypto}/${pageItem.fiat}" + is PairPage.Plan -> pageItem.name null -> "" } Text( @@ -492,7 +496,7 @@ internal fun PortfolioContent( Spacer(Modifier.height(8.dp)) KpiCardContent( uiState = uiState, - isSinglePair = pageItem is PairPage.SinglePair, + isPlan = pageItem is PairPage.Plan, scrubbedDataPoint = scrubbedDataPoint ) } @@ -548,7 +552,7 @@ internal fun PortfolioContent( item { val legendEntries = rememberLegendEntries( denominationMode = uiState.denominationMode, - isSinglePair = isSinglePair, + isPlan = isPlan, currentPairCrypto = uiState.currentPairCrypto ) InteractiveChartLegend( @@ -584,13 +588,16 @@ internal fun PortfolioContent( ) } - // Exchange filter chips - if (uiState.availableExchanges.size > 1) { + // Plan filter chips (only on aggregate page) + val currentPageForFilter = uiState.pages.getOrNull(uiState.selectedPageIndex) + if (currentPageForFilter is PairPage.Aggregate && uiState.allPlans.any { it.fiat == currentPageForFilter.fiat }) { item { - ExchangeFilterRow( - exchanges = uiState.availableExchanges, - selectedExchange = uiState.selectedExchangeFilter, - onExchangeSelected = onExchangeFilterSelected + PlanFilterChips( + plans = uiState.allPlans.filter { it.fiat == currentPageForFilter.fiat }, + visiblePlanIds = uiState.visiblePlanIds, + showTotalLine = uiState.showTotalLine, + onTogglePlan = onTogglePlanVisibility, + onToggleTotal = onToggleTotalLine ) } } @@ -605,7 +612,7 @@ internal fun PortfolioContent( @Composable private fun rememberLegendEntries( denominationMode: DenominationMode, - isSinglePair: Boolean, + isPlan: Boolean, currentPairCrypto: String? ): List { val (line1, line2) = when (denominationMode) { @@ -616,11 +623,11 @@ private fun rememberLegendEntries( val cryptoPriceLabel = stringResource(R.string.chart_crypto_price, crypto) val accumulatedCryptoLabel = stringResource(R.string.chart_accumulated_crypto, crypto) val avgBuyPriceLabel = stringResource(R.string.chart_avg_buy_price) - return remember(denominationMode, isSinglePair, currentPairCrypto) { + return remember(denominationMode, isPlan, currentPairCrypto) { buildList { add(LegendEntry(0, line1, Primary)) add(LegendEntry(1, line2, androidx.compose.ui.graphics.Color(0xFF888888))) - if (isSinglePair && denominationMode == DenominationMode.FIAT) { + if (isPlan && denominationMode == DenominationMode.FIAT) { add(LegendEntry(2, cryptoPriceLabel, btcPriceColor)) add(LegendEntry(4, avgBuyPriceLabel, avgBuyPriceColor)) add(LegendEntry(3, accumulatedCryptoLabel, accumulatedCryptoColor)) @@ -881,7 +888,7 @@ private fun calculatePeriodRoi(uiState: PortfolioUiState): Pair, - selectedExchange: String?, - onExchangeSelected: (String?) -> Unit +private fun PlanFilterChips( + plans: List, + visiblePlanIds: Set, + showTotalLine: Boolean, + onTogglePlan: (Long) -> Unit, + onToggleTotal: () -> Unit ) { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { item { FilterChip( - selected = selectedExchange == null, - onClick = { onExchangeSelected(null) }, - label = { Text(stringResource(R.string.chart_filter_all_exchanges)) } + selected = showTotalLine, + onClick = onToggleTotal, + label = { Text("Celkem") } ) } - items(exchanges) { exchange -> + items(plans) { plan -> FilterChip( - selected = exchange == selectedExchange, - onClick = { onExchangeSelected(exchange) }, - label = { Text(exchange) } + selected = plan.id in visiblePlanIds, + onClick = { onTogglePlan(plan.id) }, + label = { Text(plan.name) } ) } } From 97c7af736f59ab4ef021f53e0c34350103a9be41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 11 Apr 2026 06:55:02 +0200 Subject: [PATCH 13/26] feat(dashboard): add disable confirmation dialog + show "Paused" for disabled plans - When toggling a plan OFF, show confirmation dialog before disabling - When plan is disabled, show "Paused"/"Pozastaveno" instead of next execution time - Re-enabling a plan works immediately without confirmation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../presentation/screens/DashboardScreen.kt | 41 ++++++++++++++++++- .../app/src/main/res/values-cs/strings.xml | 4 ++ .../app/src/main/res/values/strings.xml | 4 ++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt index e0f8fdb..d2bcacc 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt @@ -1023,6 +1023,28 @@ internal fun DcaPlanCard( val accentCol = accentColor() val context = LocalContext.current var cardHeight by remember { mutableIntStateOf(0) } + var showDisableDialog by remember { mutableStateOf(false) } + + if (showDisableDialog) { + AlertDialog( + onDismissRequest = { showDisableDialog = false }, + title = { Text(stringResource(R.string.dashboard_disable_plan_title)) }, + text = { Text(stringResource(R.string.dashboard_disable_plan_message, "${plan.crypto}/${plan.fiat}")) }, + confirmButton = { + TextButton(onClick = { + showDisableDialog = false + onToggle() + }) { + Text(stringResource(R.string.dashboard_disable_plan_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { showDisableDialog = false }) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } // Keep references fresh so pointerInput(Unit) always calls the latest lambdas val currentOnDragStart by rememberUpdatedState(onDragStart) val currentOnDrag by rememberUpdatedState(onDrag) @@ -1150,7 +1172,14 @@ internal fun DcaPlanCard( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) - if (plan.isEnabled && plan.nextExecutionAt != null) { + if (!plan.isEnabled) { + Text( + text = stringResource(R.string.dashboard_plan_paused), + style = MaterialTheme.typography.bodySmall, + color = Warning, + fontWeight = FontWeight.Medium + ) + } else if (plan.nextExecutionAt != null) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) @@ -1270,7 +1299,15 @@ internal fun DcaPlanCard( ) { Switch( checked = plan.isEnabled, - onCheckedChange = { onToggle() }, + onCheckedChange = { newValue -> + if (!newValue) { + // Disabling: show confirmation dialog + showDisableDialog = true + } else { + // Enabling: no confirmation needed + onToggle() + } + }, colors = SwitchDefaults.colors( checkedThumbColor = successCol, checkedTrackColor = successCol.copy(alpha = 0.5f) diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 0d54022..140ac5d 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -72,6 +72,10 @@ Aktuální cena Moje DCA plány Další: %1$s + Pozastaveno + Deaktivovat plán? + DCA nákupy pro %1$s budou pozastaveny, dokud plán znovu neaktivujete. + Deaktivovat ~%1$s zbývá ~%1$s zbývá (%2$d nák.) Zatím žádné DCA plány diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 52bf363..a84f35d 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -73,6 +73,10 @@ Current Price My DCA Plans Next: %1$s + Paused + Disable plan? + DCA purchases for %1$s will be paused until you re-enable the plan. + Disable ~%1$s remaining ~%1$s remaining (%2$d exec) No DCA Plans Yet From d1d0f69c6582626747a944ecdc12a13a54dfd365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 11 Apr 2026 07:08:56 +0200 Subject: [PATCH 14/26] fix(plan-details): show "Paused" instead of next execution time for disabled plans Co-Authored-By: Claude Opus 4.6 (1M context) --- .../presentation/screens/plans/PlanDetailsViewModel.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt index 6694db4..0f252c4 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/PlanDetailsViewModel.kt @@ -16,6 +16,7 @@ import com.accbot.dca.domain.usecase.ApiImportResultState import com.accbot.dca.domain.usecase.ImportTradeHistoryUseCase import com.accbot.dca.exchange.ExchangeApiFactory import com.accbot.dca.presentation.utils.NumberFormatters +import com.accbot.dca.R import com.accbot.dca.presentation.utils.TimeUtils import androidx.compose.runtime.Immutable import dagger.hilt.android.lifecycle.HiltViewModel @@ -115,8 +116,12 @@ class PlanDetailsViewModel @Inject constructor( BigDecimal.ZERO } - // Calculate time until next execution - val timeUntilNext = TimeUtils.formatTimeUntil(plan.nextExecutionAt, context) + // Calculate time until next execution (only when plan is enabled) + val timeUntilNext = if (plan.isEnabled) { + TimeUtils.formatTimeUntil(plan.nextExecutionAt, context) + } else { + context.getString(R.string.dashboard_plan_paused) + } _uiState.update { state -> state.copy( From d8208989bea766313acfb10698c698f07af13852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 11 Apr 2026 18:08:50 +0200 Subject: [PATCH 15/26] revert: undo per-plan portfolio pages, restore per-pair views Reverting to prepare for a different approach: per-plan lines within per-pair views instead of separate per-plan pages. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../screens/portfolio/PortfolioScreen.kt | 103 ++++++------- .../screens/portfolio/PortfolioViewModel.kt | 144 +++++++----------- 2 files changed, 104 insertions(+), 143 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index b1ab37b..6448faf 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -87,7 +87,7 @@ fun PortfolioScreen( val hasAnyData = chartData.isNotEmpty() val hasData = chartData.size >= 2 val currentPage = uiState.pages.getOrNull(uiState.selectedPageIndex) - val isPlan = currentPage is PairPage.Plan + val isSinglePair = currentPage is PairPage.SinglePair // Scrub-to-inspect state var scrubbedIndex by remember { mutableIntStateOf(-1) } @@ -103,7 +103,7 @@ fun PortfolioScreen( val pairLabel = when (currentPage) { is PairPage.Aggregate -> stringResource(R.string.chart_all_fiat, currentPage.fiat) - is PairPage.Plan -> currentPage.name + is PairPage.SinglePair -> "${currentPage.crypto}/${currentPage.fiat}" null -> "" } @@ -127,7 +127,7 @@ fun PortfolioScreen( } val legendEntries = rememberLegendEntries( denominationMode = uiState.denominationMode, - isPlan = isPlan, + isSinglePair = isSinglePair, currentPairCrypto = uiState.currentPairCrypto ) PortfolioLineChart( @@ -232,19 +232,17 @@ fun PortfolioScreen( if (hasAnyData) { LandscapeKpiContent( uiState = uiState, - isPlan = isPlan, + isSinglePair = isSinglePair, scrubbedDataPoint = scrubbedDataPoint ) } - // Plan filter chips - if (currentPage is PairPage.Aggregate && uiState.allPlans.any { it.fiat == currentPage.fiat }) { - PlanFilterChips( - plans = uiState.allPlans.filter { it.fiat == currentPage.fiat }, - visiblePlanIds = uiState.visiblePlanIds, - showTotalLine = uiState.showTotalLine, - onTogglePlan = { viewModel.togglePlanVisibility(it) }, - onToggleTotal = { viewModel.toggleTotalLine() } + // Exchange filter + if (uiState.availableExchanges.size > 1) { + ExchangeFilterRow( + exchanges = uiState.availableExchanges, + selectedExchange = uiState.selectedExchangeFilter, + onExchangeSelected = { viewModel.selectExchangeFilter(it) } ) } } @@ -300,8 +298,7 @@ fun PortfolioScreen( onZoomOut = { viewModel.zoomOut() }, onNavigatePrev = { viewModel.navigatePrev() }, onNavigateNext = { viewModel.navigateNext() }, - onTogglePlanVisibility = { viewModel.togglePlanVisibility(it) }, - onToggleTotalLine = { viewModel.toggleTotalLine() }, + onExchangeFilterSelected = { viewModel.selectExchangeFilter(it) }, onPairPageSelected = { viewModel.selectPairPage(it) }, onToggleSeriesVisibility = { viewModel.toggleSeriesVisibility(it) }, onRefresh = { viewModel.syncPricesAndLoadChart() }, @@ -322,8 +319,7 @@ internal fun PortfolioContent( onZoomOut: () -> Unit, onNavigatePrev: () -> Unit, onNavigateNext: () -> Unit, - onTogglePlanVisibility: (Long) -> Unit, - onToggleTotalLine: () -> Unit, + onExchangeFilterSelected: (String?) -> Unit, onPairPageSelected: (Int) -> Unit, onToggleSeriesVisibility: (Int) -> Unit, onRefresh: () -> Unit, @@ -348,7 +344,7 @@ internal fun PortfolioContent( } val currentPage = uiState.pages.getOrNull(uiState.selectedPageIndex) - val isPlan = currentPage is PairPage.Plan + val isSinglePair = currentPage is PairPage.SinglePair // Scrub-to-inspect state (ephemeral, local to composable) var scrubbedIndex by remember { mutableIntStateOf(-1) } @@ -414,7 +410,7 @@ internal fun PortfolioContent( val pageItem = uiState.pages.getOrNull(page) val pairLabel = when (pageItem) { is PairPage.Aggregate -> stringResource(R.string.chart_all_fiat, pageItem.fiat) - is PairPage.Plan -> pageItem.name + is PairPage.SinglePair -> "${pageItem.crypto}/${pageItem.fiat}" null -> "" } @@ -434,7 +430,7 @@ internal fun PortfolioContent( Spacer(Modifier.height(8.dp)) KpiCardContent( uiState = uiState, - isPlan = pageItem is PairPage.Plan, + isSinglePair = pageItem is PairPage.SinglePair, scrubbedDataPoint = scrubbedDataPoint ) } @@ -483,7 +479,7 @@ internal fun PortfolioContent( val pageItem = uiState.pages.firstOrNull() val pairLabel = when (pageItem) { is PairPage.Aggregate -> stringResource(R.string.chart_all_fiat, pageItem.fiat) - is PairPage.Plan -> pageItem.name + is PairPage.SinglePair -> "${pageItem.crypto}/${pageItem.fiat}" null -> "" } Text( @@ -496,7 +492,7 @@ internal fun PortfolioContent( Spacer(Modifier.height(8.dp)) KpiCardContent( uiState = uiState, - isPlan = pageItem is PairPage.Plan, + isSinglePair = pageItem is PairPage.SinglePair, scrubbedDataPoint = scrubbedDataPoint ) } @@ -552,7 +548,7 @@ internal fun PortfolioContent( item { val legendEntries = rememberLegendEntries( denominationMode = uiState.denominationMode, - isPlan = isPlan, + isSinglePair = isSinglePair, currentPairCrypto = uiState.currentPairCrypto ) InteractiveChartLegend( @@ -588,16 +584,13 @@ internal fun PortfolioContent( ) } - // Plan filter chips (only on aggregate page) - val currentPageForFilter = uiState.pages.getOrNull(uiState.selectedPageIndex) - if (currentPageForFilter is PairPage.Aggregate && uiState.allPlans.any { it.fiat == currentPageForFilter.fiat }) { + // Exchange filter chips + if (uiState.availableExchanges.size > 1) { item { - PlanFilterChips( - plans = uiState.allPlans.filter { it.fiat == currentPageForFilter.fiat }, - visiblePlanIds = uiState.visiblePlanIds, - showTotalLine = uiState.showTotalLine, - onTogglePlan = onTogglePlanVisibility, - onToggleTotal = onToggleTotalLine + ExchangeFilterRow( + exchanges = uiState.availableExchanges, + selectedExchange = uiState.selectedExchangeFilter, + onExchangeSelected = onExchangeFilterSelected ) } } @@ -612,7 +605,7 @@ internal fun PortfolioContent( @Composable private fun rememberLegendEntries( denominationMode: DenominationMode, - isPlan: Boolean, + isSinglePair: Boolean, currentPairCrypto: String? ): List { val (line1, line2) = when (denominationMode) { @@ -623,11 +616,11 @@ private fun rememberLegendEntries( val cryptoPriceLabel = stringResource(R.string.chart_crypto_price, crypto) val accumulatedCryptoLabel = stringResource(R.string.chart_accumulated_crypto, crypto) val avgBuyPriceLabel = stringResource(R.string.chart_avg_buy_price) - return remember(denominationMode, isPlan, currentPairCrypto) { + return remember(denominationMode, isSinglePair, currentPairCrypto) { buildList { add(LegendEntry(0, line1, Primary)) add(LegendEntry(1, line2, androidx.compose.ui.graphics.Color(0xFF888888))) - if (isPlan && denominationMode == DenominationMode.FIAT) { + if (isSinglePair && denominationMode == DenominationMode.FIAT) { add(LegendEntry(2, cryptoPriceLabel, btcPriceColor)) add(LegendEntry(4, avgBuyPriceLabel, avgBuyPriceColor)) add(LegendEntry(3, accumulatedCryptoLabel, accumulatedCryptoColor)) @@ -888,7 +881,7 @@ private fun calculatePeriodRoi(uiState: PortfolioUiState): Pair, - visiblePlanIds: Set, - showTotalLine: Boolean, - onTogglePlan: (Long) -> Unit, - onToggleTotal: () -> Unit +private fun ExchangeFilterRow( + exchanges: List, + selectedExchange: String?, + onExchangeSelected: (String?) -> Unit ) { - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { item { FilterChip( - selected = showTotalLine, - onClick = onToggleTotal, - label = { Text("Celkem") } + selected = selectedExchange == null, + onClick = { onExchangeSelected(null) }, + label = { Text(stringResource(R.string.chart_filter_all_exchanges)) } ) } - items(plans) { plan -> + items(exchanges) { exchange -> FilterChip( - selected = plan.id in visiblePlanIds, - onClick = { onTogglePlan(plan.id) }, - label = { Text(plan.name) } + selected = exchange == selectedExchange, + onClick = { onExchangeSelected(exchange) }, + label = { Text(exchange) } ) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index fad8be2..bda616e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -4,7 +4,6 @@ import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.TransactionEntity // TransactionStatus filtering now done in DAO query @@ -26,12 +25,9 @@ enum class DenominationMode { FIAT, CRYPTO } sealed class PairPage { data class Aggregate(val fiat: String) : PairPage() - data class Plan(val planId: Long, val name: String, val crypto: String, val fiat: String) : PairPage() + data class SinglePair(val crypto: String, val fiat: String) : PairPage() } -@Immutable -data class PlanInfo(val id: Long, val name: String, val crypto: String, val fiat: String) - @Immutable data class PortfolioUiState( val chartData: List = emptyList(), @@ -40,6 +36,8 @@ data class PortfolioUiState( val availableMonths: List = emptyList(), val canNavigatePrev: Boolean = false, val canNavigateNext: Boolean = false, + val selectedExchangeFilter: String? = null, + val availableExchanges: List = emptyList(), val pages: List = emptyList(), val selectedPageIndex: Int = 0, val denominationMode: DenominationMode = DenominationMode.FIAT, @@ -51,17 +49,13 @@ data class PortfolioUiState( val isLoading: Boolean = true, val isChartLoading: Boolean = false, val isPriceSyncing: Boolean = false, - val error: String? = null, - val allPlans: List = emptyList(), - val visiblePlanIds: Set = emptySet(), - val showTotalLine: Boolean = true + val error: String? = null ) @HiltViewModel class PortfolioViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val transactionDao: TransactionDao, - private val dcaPlanDao: DcaPlanDao, private val syncDailyPricesUseCase: SyncDailyPricesUseCase, private val calculateChartDataUseCase: CalculateChartDataUseCase ) : ViewModel() { @@ -94,44 +88,33 @@ class PortfolioViewModel @Inject constructor( portfolioJob = viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } try { + // Use pre-filtered, sorted query (avoids loading failed/pending into memory) val completed = transactionDao.getCompletedTransactionsOrdered() completedTransactions = completed + val exchanges = completed.map { it.exchange.name }.distinct().sorted() + val pairs = completed.map { it.crypto to it.fiat }.distinct() - // Load plans for page building - val allDbPlans = dcaPlanDao.getAllPlansOnceOrdered() - val planInfos = allDbPlans.map { p -> - PlanInfo( - id = p.id, - name = p.name.ifBlank { "${p.crypto}/${p.fiat}" }, - crypto = p.crypto, - fiat = p.fiat - ) - } - - // Build pages: aggregate per fiat (if 2+ plans in same fiat), then per plan - val plansByFiat = planInfos.groupBy { it.fiat } + val pairsByFiat = pairs.groupBy { it.second } val pages = mutableListOf() - for ((fiat, fiatPlans) in plansByFiat) { - if (fiatPlans.size >= 2) { + for ((fiat, fiatPairs) in pairsByFiat) { + if (fiatPairs.size >= 2) { pages.add(PairPage.Aggregate(fiat)) } } - for (plan in planInfos) { - pages.add(PairPage.Plan(plan.id, plan.name, plan.crypto, plan.fiat)) + for (pair in pairs) { + pages.add(PairPage.SinglePair(pair.first, pair.second)) } val pageIndex = if (initialCrypto != null && initialFiat != null) { - val idx = pages.indexOfFirst { it is PairPage.Plan && it.crypto == initialCrypto && it.fiat == initialFiat } + val idx = pages.indexOfFirst { it is PairPage.SinglePair && it.crypto == initialCrypto && it.fiat == initialFiat } if (idx >= 0) idx else 0 } else 0 _uiState.update { state -> state.copy( + availableExchanges = exchanges, pages = pages, selectedPageIndex = pageIndex, - allPlans = planInfos, - visiblePlanIds = emptySet(), - showTotalLine = true, isLoading = false ) } @@ -143,7 +126,10 @@ class PortfolioViewModel @Inject constructor( throw e } catch (e: Exception) { _uiState.update { - it.copy(isLoading = false, error = e.message ?: "Failed to load portfolio") + it.copy( + isLoading = false, + error = e.message ?: "Failed to load portfolio" + ) } } } @@ -155,34 +141,27 @@ class PortfolioViewModel @Inject constructor( try { val completed = transactionDao.getCompletedTransactionsOrdered() completedTransactions = completed + val exchanges = completed.map { it.exchange.name }.distinct().sorted() + val pairs = completed.map { it.crypto to it.fiat }.distinct() - val allDbPlans = dcaPlanDao.getAllPlansOnceOrdered() - val planInfos = allDbPlans.map { p -> - PlanInfo( - id = p.id, - name = p.name.ifBlank { "${p.crypto}/${p.fiat}" }, - crypto = p.crypto, - fiat = p.fiat - ) - } - - val plansByFiat = planInfos.groupBy { it.fiat } + val pairsByFiat = pairs.groupBy { it.second } val pages = mutableListOf() - for ((fiat, fiatPlans) in plansByFiat) { - if (fiatPlans.size >= 2) { + for ((fiat, fiatPairs) in pairsByFiat) { + if (fiatPairs.size >= 2) { pages.add(PairPage.Aggregate(fiat)) } } - for (plan in planInfos) { - pages.add(PairPage.Plan(plan.id, plan.name, plan.crypto, plan.fiat)) + for (pair in pairs) { + pages.add(PairPage.SinglePair(pair.first, pair.second)) } _uiState.update { state -> + // Keep current page index if still valid, otherwise reset to 0 val pageIndex = state.selectedPageIndex.coerceIn(0, (pages.size - 1).coerceAtLeast(0)) state.copy( + availableExchanges = exchanges, pages = pages, - selectedPageIndex = pageIndex, - allPlans = planInfos + selectedPageIndex = pageIndex ) } updateNavigationState() @@ -237,7 +216,7 @@ class PortfolioViewModel @Inject constructor( if (yearIdx > 0) { val prevYear = years[yearIdx - 1] val prevMonths = calculateChartDataUseCase.getAvailableMonths( - getFilteredTransactions(getCurrentPlanId()), prevYear + getFilteredTransactions(), prevYear ) if (prevMonths.isNotEmpty()) { ChartZoomLevel.Month(prevYear, prevMonths.last()) @@ -282,7 +261,7 @@ class PortfolioViewModel @Inject constructor( val nextYear = years[yearIdx + 1] if (nextYear <= today.year) { val nextMonths = calculateChartDataUseCase.getAvailableMonths( - getFilteredTransactions(getCurrentPlanId()), nextYear + getFilteredTransactions(), nextYear ) if (nextMonths.isNotEmpty()) { val nextYm = java.time.YearMonth.of(nextYear, nextMonths.first()) @@ -302,6 +281,17 @@ class PortfolioViewModel @Inject constructor( loadChartData() } + fun selectExchangeFilter(exchange: String?) { + _uiState.update { it.copy( + selectedExchangeFilter = exchange, + selectedPageIndex = 0, + denominationMode = DenominationMode.FIAT, + zoomLevel = ChartZoomLevel.Overview + ) } + updateNavigationState() + loadChartData() + } + fun selectPairPage(index: Int) { val page = _uiState.value.pages.getOrNull(index) val newMode = if (page is PairPage.Aggregate) DenominationMode.FIAT else _uiState.value.denominationMode @@ -309,31 +299,15 @@ class PortfolioViewModel @Inject constructor( selectedPageIndex = index, denominationMode = newMode, visibleSeries = setOf(0, 1), - zoomLevel = ChartZoomLevel.Overview, - visiblePlanIds = emptySet(), - showTotalLine = true + zoomLevel = ChartZoomLevel.Overview ) } updateNavigationState() loadChartData() } - fun togglePlanVisibility(planId: Long) { - _uiState.update { state -> - val current = state.visiblePlanIds - val toggled = if (planId in current) current - planId else current + planId - state.copy(visiblePlanIds = toggled) - } - loadChartData() - } - - fun toggleTotalLine() { - _uiState.update { it.copy(showTotalLine = !it.showTotalLine) } - loadChartData() - } - fun toggleDenomination() { val page = _uiState.value.pages.getOrNull(_uiState.value.selectedPageIndex) - if (page !is PairPage.Plan) return + if (page !is PairPage.SinglePair) return _uiState.update { it.copy( denominationMode = if (it.denominationMode == DenominationMode.FIAT) DenominationMode.CRYPTO else DenominationMode.FIAT, @@ -373,21 +347,15 @@ class PortfolioViewModel @Inject constructor( } } - private fun getFilteredTransactions(planId: Long? = null): List { - return if (planId != null) { - completedTransactions.filter { it.planId == planId } - } else { - completedTransactions + private fun getFilteredTransactions(): List { + val state = _uiState.value + return completedTransactions.filter { tx -> + state.selectedExchangeFilter == null || tx.exchange.name == state.selectedExchangeFilter } } - private fun getCurrentPlanId(): Long? { - val page = _uiState.value.pages.getOrNull(_uiState.value.selectedPageIndex) - return (page as? PairPage.Plan)?.planId - } - private fun updateNavigationState() { - val filteredTxs = getFilteredTransactions(getCurrentPlanId()) + val filteredTxs = getFilteredTransactions() val state = _uiState.value val years = calculateChartDataUseCase.getAvailableYears(filteredTxs) val today = LocalDate.now() @@ -440,16 +408,16 @@ class PortfolioViewModel @Inject constructor( val state = _uiState.value val page = state.pages.getOrNull(state.selectedPageIndex) - val (crypto, fiat, planId) = when (page) { - is PairPage.Aggregate -> Triple(null, page.fiat, null) - is PairPage.Plan -> Triple(page.crypto, page.fiat, page.planId) - null -> Triple(null, null, null) + val (crypto, fiat) = when (page) { + is PairPage.Aggregate -> null to page.fiat + is PairPage.SinglePair -> page.crypto to page.fiat + null -> null to null } - val data = if (fiat == null) { + val data = if (crypto == null && fiat == null) { emptyList() } else { - val filteredTxs = getFilteredTransactions(planId) + val filteredTxs = getFilteredTransactions() calculateChartDataUseCase.calculate( transactions = filteredTxs, crypto = crypto, @@ -459,9 +427,9 @@ class PortfolioViewModel @Inject constructor( } val txCount = completedTransactions.count { tx -> - (planId == null || tx.planId == planId) && (crypto == null || tx.crypto == crypto) && - (fiat == null || tx.fiat == fiat) + (fiat == null || tx.fiat == fiat) && + (state.selectedExchangeFilter == null || tx.exchange.name == state.selectedExchangeFilter) } _uiState.update { it.copy( From 2d633e2c2c601b2c3ec52d53ac049673a17bf504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 11 Apr 2026 18:15:48 +0200 Subject: [PATCH 16/26] feat(portfolio): add per-plan value lines within per-pair chart views When viewing a SinglePair page (e.g. BTC/EUR) with 2+ DCA plans, the chart now shows individual plan portfolio-value lines alongside the pair total. Each plan line is toggleable via a dedicated legend. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ChartComponents.kt | 39 ++++++++++- .../screens/portfolio/PortfolioScreen.kt | 68 +++++++++++++++++++ .../screens/portfolio/PortfolioViewModel.kt | 59 +++++++++++++++- 3 files changed, 162 insertions(+), 4 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt index d31f462..583ba82 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt @@ -24,6 +24,7 @@ import android.util.Log import com.accbot.dca.domain.usecase.ChartDataPoint import com.accbot.dca.domain.usecase.ChartZoomLevel import com.accbot.dca.presentation.screens.portfolio.DenominationMode +import com.accbot.dca.presentation.screens.portfolio.PlanLineInfo import com.accbot.dca.presentation.ui.theme.Primary import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom @@ -62,6 +63,15 @@ internal val btcPriceColor = Color(0xFFF7931A) internal val accumulatedCryptoColor = Color(0xFF4CAF50) internal val avgBuyPriceColor = Color(0xFF9C27B0) +internal val planLineColors = listOf( + Color(0xFFFF6B6B), // red + Color(0xFF4ECDC4), // teal + Color(0xFFFFD93D), // yellow + Color(0xFF6C63FF), // purple + Color(0xFFFF8A65), // orange + Color(0xFF81C784), // green +) + data class LegendEntry( val seriesIndex: Int, val label: String, @@ -139,6 +149,8 @@ fun PortfolioLineChart( fiatSymbol: String = "", cryptoSymbol: String = "", visibleSeries: Set = setOf(0, 1), + planLines: List = emptyList(), + visiblePlanLineIds: Set = emptySet(), zoomLevel: ChartZoomLevel = ChartZoomLevel.Overview, onScrub: (Int?) -> Unit = {}, modifier: Modifier = Modifier @@ -149,7 +161,7 @@ fun PortfolioLineChart( val hasRightAxis = cryptoSymbol.isNotEmpty() && 3 in visibleSeries // Update model when data, denomination, or visibility changes - LaunchedEffect(chartData, denominationMode, visibleSeries) { + LaunchedEffect(chartData, denominationMode, visibleSeries, planLines, visiblePlanLineIds) { try { modelProducer.runTransaction { // Layer 1: left axis (portfolio value, cost basis, crypto price – all fiat) @@ -166,7 +178,15 @@ fun PortfolioLineChart( if (1 in visibleSeries) series(series1) if (2 in visibleSeries) series(chartData.map { it.price.toFloat() }) if (4 in visibleSeries) series(chartData.map { it.avgBuyPrice.toFloat() }) - if (setOf(0, 1, 2, 4).none { it in visibleSeries }) { + // Per-plan value lines + for (planLine in planLines) { + if (planLine.planId in visiblePlanLineIds && planLine.values.size == chartData.size) { + series(planLine.values) + } + } + if (setOf(0, 1, 2, 4).none { it in visibleSeries } && + planLines.none { it.planId in visiblePlanLineIds && it.values.size == chartData.size } + ) { series(List(chartData.size) { 0f }) } } @@ -224,12 +244,27 @@ fun PortfolioLineChart( fill = LineCartesianLayer.LineFill.single(fill(Color.Transparent)) ) + // Pre-create plan line styles (max 6 plans) + val planLineStyle0 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[0]))) + val planLineStyle1 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[1]))) + val planLineStyle2 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[2]))) + val planLineStyle3 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[3]))) + val planLineStyle4 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[4]))) + val planLineStyle5 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[5]))) + val planLineStyles = listOf(planLineStyle0, planLineStyle1, planLineStyle2, planLineStyle3, planLineStyle4, planLineStyle5) + // Build visible line lists for each layer val leftLines = buildList { if (0 in visibleSeries) add(valueLine) if (1 in visibleSeries) add(costBasisLine) if (2 in visibleSeries) add(priceLine) if (4 in visibleSeries) add(avgBuyPriceLine) + // Per-plan line styles + planLines.forEachIndexed { idx, planLine -> + if (planLine.planId in visiblePlanLineIds && planLine.values.size == chartData.size) { + add(planLineStyles[idx % planLineStyles.size]) + } + } if (isEmpty()) add(hiddenLine) } val rightLines = buildList { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index 6448faf..6ebd8dd 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -137,6 +138,8 @@ fun PortfolioScreen( fiatSymbol = uiState.currentPairFiat ?: "EUR", cryptoSymbol = uiState.currentPairCrypto ?: "", visibleSeries = uiState.visibleSeries, + planLines = uiState.planLines, + visiblePlanLineIds = uiState.visiblePlanLineIds, zoomLevel = uiState.zoomLevel, onScrub = { idx -> scrubbedIndex = idx ?: -1 }, modifier = Modifier @@ -151,6 +154,14 @@ fun PortfolioScreen( onToggleSeries = { viewModel.toggleSeriesVisibility(it) }, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) ) + // Per-plan legend (landscape) + if (uiState.planLines.isNotEmpty()) { + PlanLinesLegend( + planLines = uiState.planLines, + visiblePlanLineIds = uiState.visiblePlanLineIds, + onToggle = { viewModel.togglePlanLineVisibility(it) } + ) + } // Zoom header + drill-down chips Column( @@ -301,6 +312,7 @@ fun PortfolioScreen( onExchangeFilterSelected = { viewModel.selectExchangeFilter(it) }, onPairPageSelected = { viewModel.selectPairPage(it) }, onToggleSeriesVisibility = { viewModel.toggleSeriesVisibility(it) }, + onTogglePlanLineVisibility = { viewModel.togglePlanLineVisibility(it) }, onRefresh = { viewModel.syncPricesAndLoadChart() }, onChartTouching = onChartTouching, modifier = Modifier.padding(paddingValues) @@ -322,6 +334,7 @@ internal fun PortfolioContent( onExchangeFilterSelected: (String?) -> Unit, onPairPageSelected: (Int) -> Unit, onToggleSeriesVisibility: (Int) -> Unit, + onTogglePlanLineVisibility: (Long) -> Unit, onRefresh: () -> Unit, onChartTouching: (Boolean) -> Unit = {}, modifier: Modifier = Modifier @@ -524,6 +537,8 @@ internal fun PortfolioContent( fiatSymbol = uiState.currentPairFiat ?: "EUR", cryptoSymbol = uiState.currentPairCrypto ?: "", visibleSeries = uiState.visibleSeries, + planLines = uiState.planLines, + visiblePlanLineIds = uiState.visiblePlanLineIds, zoomLevel = uiState.zoomLevel, onScrub = { idx -> scrubbedIndex = idx ?: -1 }, modifier = Modifier.fillMaxWidth() @@ -556,6 +571,15 @@ internal fun PortfolioContent( visibleSeries = uiState.visibleSeries, onToggleSeries = onToggleSeriesVisibility ) + // Per-plan legend entries + if (uiState.planLines.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + PlanLinesLegend( + planLines = uiState.planLines, + visiblePlanLineIds = uiState.visiblePlanLineIds, + onToggle = onTogglePlanLineVisibility + ) + } } } @@ -1183,6 +1207,50 @@ private fun LandscapeKpiContent( } } +@Composable +private fun PlanLinesLegend( + planLines: List, + visiblePlanLineIds: Set, + onToggle: (Long) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + planLines.chunked(2).forEach { row -> + Row(horizontalArrangement = Arrangement.Center) { + row.forEachIndexed { i, planLine -> + if (i > 0) Spacer(Modifier.width(24.dp)) + val colorIndex = planLines.indexOf(planLine) + val color = planLineColors[colorIndex % planLineColors.size] + val enabled = planLine.planId in visiblePlanLineIds + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { onToggle(planLine.planId) } + .padding(4.dp) + ) { + Box( + Modifier + .size(12.dp) + .clip(CircleShape) + .background(if (enabled) color else color.copy(alpha = 0.3f)) + ) + Spacer(Modifier.width(6.dp)) + Text( + planLine.name, + style = MaterialTheme.typography.bodySmall, + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + textDecoration = if (enabled) null else TextDecoration.LineThrough + ) + } + } + } + } + } +} + @Composable private fun ExchangeFilterRow( exchanges: List, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index bda616e..ae1e62d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -4,6 +4,7 @@ import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.TransactionEntity // TransactionStatus filtering now done in DAO query @@ -28,6 +29,13 @@ sealed class PairPage { data class SinglePair(val crypto: String, val fiat: String) : PairPage() } +@Immutable +data class PlanLineInfo( + val planId: Long, + val name: String, + val values: List = emptyList() // aligned to main chartData indices +) + @Immutable data class PortfolioUiState( val chartData: List = emptyList(), @@ -46,6 +54,8 @@ data class PortfolioUiState( val totalTransactions: Int = 0, val visibleSeries: Set = setOf(0, 1), val scrubbedIndex: Int? = null, + val planLines: List = emptyList(), + val visiblePlanLineIds: Set = emptySet(), val isLoading: Boolean = true, val isChartLoading: Boolean = false, val isPriceSyncing: Boolean = false, @@ -56,6 +66,7 @@ data class PortfolioUiState( class PortfolioViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val transactionDao: TransactionDao, + private val dcaPlanDao: DcaPlanDao, private val syncDailyPricesUseCase: SyncDailyPricesUseCase, private val calculateChartDataUseCase: CalculateChartDataUseCase ) : ViewModel() { @@ -299,7 +310,9 @@ class PortfolioViewModel @Inject constructor( selectedPageIndex = index, denominationMode = newMode, visibleSeries = setOf(0, 1), - zoomLevel = ChartZoomLevel.Overview + zoomLevel = ChartZoomLevel.Overview, + planLines = emptyList(), + visiblePlanLineIds = emptySet() ) } updateNavigationState() loadChartData() @@ -328,6 +341,14 @@ class PortfolioViewModel @Inject constructor( } } + fun togglePlanLineVisibility(planId: Long) { + _uiState.update { state -> + val current = state.visiblePlanLineIds + val toggled = if (planId in current) current - planId else current + planId + state.copy(visiblePlanLineIds = toggled) + } + } + fun syncPricesAndLoadChart() { refreshTransactionsAndPairs(force = true) loadChartData() @@ -414,10 +435,10 @@ class PortfolioViewModel @Inject constructor( null -> null to null } + val filteredTxs = getFilteredTransactions() val data = if (crypto == null && fiat == null) { emptyList() } else { - val filteredTxs = getFilteredTransactions() calculateChartDataUseCase.calculate( transactions = filteredTxs, crypto = crypto, @@ -426,6 +447,39 @@ class PortfolioViewModel @Inject constructor( ) } + // Calculate per-plan lines for SinglePair pages + val planLinesList = if (page is PairPage.SinglePair && data.isNotEmpty()) { + try { + val plans = dcaPlanDao.getAllPlansOnceOrdered() + .filter { it.crypto == crypto && it.fiat == fiat } + if (plans.size >= 2) { + val mainEpochDays = data.map { it.epochDay } + plans.mapNotNull { plan -> + val planTxs = filteredTxs.filter { it.planId == plan.id } + if (planTxs.isEmpty()) return@mapNotNull null + val planData = calculateChartDataUseCase.calculate( + transactions = planTxs, + crypto = crypto, + fiat = fiat, + zoomLevel = state.zoomLevel + ) + // Align to main chart's epoch days + val planByDay = planData.associateBy { it.epochDay } + var lastValue = 0f + val aligned = mainEpochDays.map { day -> + val v = planByDay[day]?.portfolioValue?.toFloat() + if (v != null) { lastValue = v; v } else lastValue + } + PlanLineInfo( + planId = plan.id, + name = plan.name.ifBlank { "${plan.crypto}/${plan.fiat} #${plan.id}" }, + values = aligned + ) + } + } else emptyList() + } catch (_: Exception) { emptyList() } + } else emptyList() + val txCount = completedTransactions.count { tx -> (crypto == null || tx.crypto == crypto) && (fiat == null || tx.fiat == fiat) && @@ -437,6 +491,7 @@ class PortfolioViewModel @Inject constructor( currentPairCrypto = crypto, currentPairFiat = fiat, totalTransactions = txCount, + planLines = planLinesList, isChartLoading = false ) } } catch (e: OutOfMemoryError) { From 08ffa44ce043df27cd1796098b610782289f0030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 12 Apr 2026 10:10:06 +0200 Subject: [PATCH 17/26] feat(portfolio): replace exchange filter with plan chips, show per-plan lines on aggregate Co-Authored-By: Claude Opus 4.6 (1M context) --- .../screens/portfolio/PortfolioScreen.kt | 68 +++++----- .../screens/portfolio/PortfolioViewModel.kt | 117 ++++++++++-------- .../com/accbot/dca/screenshots/SampleData.kt | 5 +- 3 files changed, 96 insertions(+), 94 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index 6ebd8dd..c381186 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape @@ -88,7 +89,7 @@ fun PortfolioScreen( val hasAnyData = chartData.isNotEmpty() val hasData = chartData.size >= 2 val currentPage = uiState.pages.getOrNull(uiState.selectedPageIndex) - val isSinglePair = currentPage is PairPage.SinglePair + val isSinglePair = currentPage is PairPage.Plan // Scrub-to-inspect state var scrubbedIndex by remember { mutableIntStateOf(-1) } @@ -104,7 +105,7 @@ fun PortfolioScreen( val pairLabel = when (currentPage) { is PairPage.Aggregate -> stringResource(R.string.chart_all_fiat, currentPage.fiat) - is PairPage.SinglePair -> "${currentPage.crypto}/${currentPage.fiat}" + is PairPage.Plan -> currentPage.name null -> "" } @@ -248,12 +249,12 @@ fun PortfolioScreen( ) } - // Exchange filter - if (uiState.availableExchanges.size > 1) { - ExchangeFilterRow( - exchanges = uiState.availableExchanges, - selectedExchange = uiState.selectedExchangeFilter, - onExchangeSelected = { viewModel.selectExchangeFilter(it) } + // Plan chip row + if (uiState.pages.size > 1) { + PlanChipRow( + pages = uiState.pages, + selectedIndex = uiState.selectedPageIndex, + onPageSelected = { viewModel.selectPairPage(it) } ) } } @@ -309,7 +310,6 @@ fun PortfolioScreen( onZoomOut = { viewModel.zoomOut() }, onNavigatePrev = { viewModel.navigatePrev() }, onNavigateNext = { viewModel.navigateNext() }, - onExchangeFilterSelected = { viewModel.selectExchangeFilter(it) }, onPairPageSelected = { viewModel.selectPairPage(it) }, onToggleSeriesVisibility = { viewModel.toggleSeriesVisibility(it) }, onTogglePlanLineVisibility = { viewModel.togglePlanLineVisibility(it) }, @@ -331,7 +331,6 @@ internal fun PortfolioContent( onZoomOut: () -> Unit, onNavigatePrev: () -> Unit, onNavigateNext: () -> Unit, - onExchangeFilterSelected: (String?) -> Unit, onPairPageSelected: (Int) -> Unit, onToggleSeriesVisibility: (Int) -> Unit, onTogglePlanLineVisibility: (Long) -> Unit, @@ -357,7 +356,7 @@ internal fun PortfolioContent( } val currentPage = uiState.pages.getOrNull(uiState.selectedPageIndex) - val isSinglePair = currentPage is PairPage.SinglePair + val isSinglePair = currentPage is PairPage.Plan // Scrub-to-inspect state (ephemeral, local to composable) var scrubbedIndex by remember { mutableIntStateOf(-1) } @@ -423,7 +422,7 @@ internal fun PortfolioContent( val pageItem = uiState.pages.getOrNull(page) val pairLabel = when (pageItem) { is PairPage.Aggregate -> stringResource(R.string.chart_all_fiat, pageItem.fiat) - is PairPage.SinglePair -> "${pageItem.crypto}/${pageItem.fiat}" + is PairPage.Plan -> pageItem.name null -> "" } @@ -443,7 +442,7 @@ internal fun PortfolioContent( Spacer(Modifier.height(8.dp)) KpiCardContent( uiState = uiState, - isSinglePair = pageItem is PairPage.SinglePair, + isSinglePair = pageItem is PairPage.Plan, scrubbedDataPoint = scrubbedDataPoint ) } @@ -492,7 +491,7 @@ internal fun PortfolioContent( val pageItem = uiState.pages.firstOrNull() val pairLabel = when (pageItem) { is PairPage.Aggregate -> stringResource(R.string.chart_all_fiat, pageItem.fiat) - is PairPage.SinglePair -> "${pageItem.crypto}/${pageItem.fiat}" + is PairPage.Plan -> pageItem.name null -> "" } Text( @@ -505,7 +504,7 @@ internal fun PortfolioContent( Spacer(Modifier.height(8.dp)) KpiCardContent( uiState = uiState, - isSinglePair = pageItem is PairPage.SinglePair, + isSinglePair = pageItem is PairPage.Plan, scrubbedDataPoint = scrubbedDataPoint ) } @@ -608,13 +607,13 @@ internal fun PortfolioContent( ) } - // Exchange filter chips - if (uiState.availableExchanges.size > 1) { + // Plan chip row (replaces exchange filter) + if (uiState.pages.size > 1) { item { - ExchangeFilterRow( - exchanges = uiState.availableExchanges, - selectedExchange = uiState.selectedExchangeFilter, - onExchangeSelected = onExchangeFilterSelected + PlanChipRow( + pages = uiState.pages, + selectedIndex = uiState.selectedPageIndex, + onPageSelected = onPairPageSelected ) } } @@ -1252,26 +1251,23 @@ private fun PlanLinesLegend( } @Composable -private fun ExchangeFilterRow( - exchanges: List, - selectedExchange: String?, - onExchangeSelected: (String?) -> Unit +private fun PlanChipRow( + pages: List, + selectedIndex: Int, + onPageSelected: (Int) -> Unit ) { LazyRow( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - item { - FilterChip( - selected = selectedExchange == null, - onClick = { onExchangeSelected(null) }, - label = { Text(stringResource(R.string.chart_filter_all_exchanges)) } - ) - } - items(exchanges) { exchange -> + itemsIndexed(pages) { index, page -> + val label = when (page) { + is PairPage.Aggregate -> stringResource(R.string.chart_all_fiat, page.fiat) + is PairPage.Plan -> page.name + } FilterChip( - selected = exchange == selectedExchange, - onClick = { onExchangeSelected(exchange) }, - label = { Text(exchange) } + selected = index == selectedIndex, + onClick = { onPageSelected(index) }, + label = { Text(label) } ) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index ae1e62d..1fcce04 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -26,7 +26,7 @@ enum class DenominationMode { FIAT, CRYPTO } sealed class PairPage { data class Aggregate(val fiat: String) : PairPage() - data class SinglePair(val crypto: String, val fiat: String) : PairPage() + data class Plan(val planId: Long, val name: String, val crypto: String, val fiat: String) : PairPage() } @Immutable @@ -44,8 +44,6 @@ data class PortfolioUiState( val availableMonths: List = emptyList(), val canNavigatePrev: Boolean = false, val canNavigateNext: Boolean = false, - val selectedExchangeFilter: String? = null, - val availableExchanges: List = emptyList(), val pages: List = emptyList(), val selectedPageIndex: Int = 0, val denominationMode: DenominationMode = DenominationMode.FIAT, @@ -102,28 +100,34 @@ class PortfolioViewModel @Inject constructor( // Use pre-filtered, sorted query (avoids loading failed/pending into memory) val completed = transactionDao.getCompletedTransactionsOrdered() completedTransactions = completed - val exchanges = completed.map { it.exchange.name }.distinct().sorted() - val pairs = completed.map { it.crypto to it.fiat }.distinct() - val pairsByFiat = pairs.groupBy { it.second } + // Load all plans (including disabled) for page building + val allDbPlans = dcaPlanDao.getAllPlansOnceOrdered() + + // Build pages: aggregate per fiat (if 2+ plans in same fiat), then per plan + val plansByFiat = allDbPlans.groupBy { it.fiat } val pages = mutableListOf() - for ((fiat, fiatPairs) in pairsByFiat) { - if (fiatPairs.size >= 2) { + for ((fiat, fiatPlans) in plansByFiat) { + if (fiatPlans.size >= 2) { pages.add(PairPage.Aggregate(fiat)) } } - for (pair in pairs) { - pages.add(PairPage.SinglePair(pair.first, pair.second)) + for (plan in allDbPlans) { + pages.add(PairPage.Plan( + planId = plan.id, + name = plan.name.ifBlank { "${plan.crypto}/${plan.fiat}" }, + crypto = plan.crypto, + fiat = plan.fiat + )) } val pageIndex = if (initialCrypto != null && initialFiat != null) { - val idx = pages.indexOfFirst { it is PairPage.SinglePair && it.crypto == initialCrypto && it.fiat == initialFiat } + val idx = pages.indexOfFirst { it is PairPage.Plan && it.crypto == initialCrypto && it.fiat == initialFiat } if (idx >= 0) idx else 0 } else 0 _uiState.update { state -> state.copy( - availableExchanges = exchanges, pages = pages, selectedPageIndex = pageIndex, isLoading = false @@ -152,25 +156,28 @@ class PortfolioViewModel @Inject constructor( try { val completed = transactionDao.getCompletedTransactionsOrdered() completedTransactions = completed - val exchanges = completed.map { it.exchange.name }.distinct().sorted() - val pairs = completed.map { it.crypto to it.fiat }.distinct() - val pairsByFiat = pairs.groupBy { it.second } + val allDbPlans = dcaPlanDao.getAllPlansOnceOrdered() + val plansByFiat = allDbPlans.groupBy { it.fiat } val pages = mutableListOf() - for ((fiat, fiatPairs) in pairsByFiat) { - if (fiatPairs.size >= 2) { + for ((fiat, fiatPlans) in plansByFiat) { + if (fiatPlans.size >= 2) { pages.add(PairPage.Aggregate(fiat)) } } - for (pair in pairs) { - pages.add(PairPage.SinglePair(pair.first, pair.second)) + for (plan in allDbPlans) { + pages.add(PairPage.Plan( + planId = plan.id, + name = plan.name.ifBlank { "${plan.crypto}/${plan.fiat}" }, + crypto = plan.crypto, + fiat = plan.fiat + )) } _uiState.update { state -> // Keep current page index if still valid, otherwise reset to 0 val pageIndex = state.selectedPageIndex.coerceIn(0, (pages.size - 1).coerceAtLeast(0)) state.copy( - availableExchanges = exchanges, pages = pages, selectedPageIndex = pageIndex ) @@ -292,17 +299,6 @@ class PortfolioViewModel @Inject constructor( loadChartData() } - fun selectExchangeFilter(exchange: String?) { - _uiState.update { it.copy( - selectedExchangeFilter = exchange, - selectedPageIndex = 0, - denominationMode = DenominationMode.FIAT, - zoomLevel = ChartZoomLevel.Overview - ) } - updateNavigationState() - loadChartData() - } - fun selectPairPage(index: Int) { val page = _uiState.value.pages.getOrNull(index) val newMode = if (page is PairPage.Aggregate) DenominationMode.FIAT else _uiState.value.denominationMode @@ -320,7 +316,7 @@ class PortfolioViewModel @Inject constructor( fun toggleDenomination() { val page = _uiState.value.pages.getOrNull(_uiState.value.selectedPageIndex) - if (page !is PairPage.SinglePair) return + if (page !is PairPage.Plan) return _uiState.update { it.copy( denominationMode = if (it.denominationMode == DenominationMode.FIAT) DenominationMode.CRYPTO else DenominationMode.FIAT, @@ -369,10 +365,7 @@ class PortfolioViewModel @Inject constructor( } private fun getFilteredTransactions(): List { - val state = _uiState.value - return completedTransactions.filter { tx -> - state.selectedExchangeFilter == null || tx.exchange.name == state.selectedExchangeFilter - } + return completedTransactions } private fun updateNavigationState() { @@ -429,14 +422,19 @@ class PortfolioViewModel @Inject constructor( val state = _uiState.value val page = state.pages.getOrNull(state.selectedPageIndex) - val (crypto, fiat) = when (page) { - is PairPage.Aggregate -> null to page.fiat - is PairPage.SinglePair -> page.crypto to page.fiat - null -> null to null + val (crypto, fiat, planId) = when (page) { + is PairPage.Aggregate -> Triple(null, page.fiat, null) + is PairPage.Plan -> Triple(page.crypto, page.fiat, page.planId) + null -> Triple(null, null, null) } - val filteredTxs = getFilteredTransactions() - val data = if (crypto == null && fiat == null) { + val filteredTxs = if (planId != null) { + completedTransactions.filter { it.planId == planId } + } else { + completedTransactions + } + + val data = if (fiat == null) { emptyList() } else { calculateChartDataUseCase.calculate( @@ -447,23 +445,33 @@ class PortfolioViewModel @Inject constructor( ) } - // Calculate per-plan lines for SinglePair pages - val planLinesList = if (page is PairPage.SinglePair && data.isNotEmpty()) { + // Calculate per-plan lines + val planLinesList = if (data.isNotEmpty()) { try { - val plans = dcaPlanDao.getAllPlansOnceOrdered() - .filter { it.crypto == crypto && it.fiat == fiat } - if (plans.size >= 2) { + val relevantPlans = when (page) { + is PairPage.Aggregate -> { + // All plans in this fiat + dcaPlanDao.getAllPlansOnceOrdered().filter { it.fiat == page.fiat } + } + is PairPage.Plan -> { + // All plans for this specific pair + dcaPlanDao.getAllPlansOnceOrdered() + .filter { it.crypto == page.crypto && it.fiat == page.fiat } + } + null -> emptyList() + } + if (relevantPlans.size >= 2) { val mainEpochDays = data.map { it.epochDay } - plans.mapNotNull { plan -> - val planTxs = filteredTxs.filter { it.planId == plan.id } + relevantPlans.mapNotNull { plan -> + val planTxs = completedTransactions.filter { it.planId == plan.id } if (planTxs.isEmpty()) return@mapNotNull null val planData = calculateChartDataUseCase.calculate( transactions = planTxs, - crypto = crypto, - fiat = fiat, + crypto = plan.crypto, + fiat = plan.fiat, zoomLevel = state.zoomLevel ) - // Align to main chart's epoch days + // Align to main chart's epoch days via forward-fill val planByDay = planData.associateBy { it.epochDay } var lastValue = 0f val aligned = mainEpochDays.map { day -> @@ -480,10 +488,9 @@ class PortfolioViewModel @Inject constructor( } catch (_: Exception) { emptyList() } } else emptyList() - val txCount = completedTransactions.count { tx -> + val txCount = filteredTxs.count { tx -> (crypto == null || tx.crypto == crypto) && - (fiat == null || tx.fiat == fiat) && - (state.selectedExchangeFilter == null || tx.exchange.name == state.selectedExchangeFilter) + (fiat == null || tx.fiat == fiat) } _uiState.update { it.copy( diff --git a/accbot-android/app/src/screenshotTest/kotlin/com/accbot/dca/screenshots/SampleData.kt b/accbot-android/app/src/screenshotTest/kotlin/com/accbot/dca/screenshots/SampleData.kt index f42cf6f..62cbca8 100644 --- a/accbot-android/app/src/screenshotTest/kotlin/com/accbot/dca/screenshots/SampleData.kt +++ b/accbot-android/app/src/screenshotTest/kotlin/com/accbot/dca/screenshots/SampleData.kt @@ -203,15 +203,14 @@ object SampleData { availableYears = listOf(2024, 2025, 2026), pages = listOf( PairPage.Aggregate("EUR"), - PairPage.SinglePair("BTC", "EUR"), - PairPage.SinglePair("ETH", "EUR") + PairPage.Plan(planId = 1L, name = "BTC/EUR", crypto = "BTC", fiat = "EUR"), + PairPage.Plan(planId = 2L, name = "ETH/EUR", crypto = "ETH", fiat = "EUR") ), selectedPageIndex = 0, denominationMode = DenominationMode.FIAT, currentPairCrypto = null, currentPairFiat = "EUR", totalTransactions = 199, - availableExchanges = listOf("Coinmate", "Binance"), isLoading = false, isChartLoading = false ) From 0a339b43663d4e9f17098fdbb8e218975c4641f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 12 Apr 2026 10:35:35 +0200 Subject: [PATCH 18/26] feat(portfolio): multi-series per-plan legend (value+invested), rename totals - Plan pages no longer show redundant per-plan lines - Aggregate pages show value+invested per plan in legend, toggleable independently - Invested line uses same plan color with 0.4 alpha - Main legend on aggregate shows "Total value" / "Total invested" (Celkem) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ChartComponents.kt | 63 ++++++--- .../screens/portfolio/PortfolioScreen.kt | 128 ++++++++++++------ .../screens/portfolio/PortfolioViewModel.kt | 45 +++--- .../app/src/main/res/values-cs/strings.xml | 4 + .../app/src/main/res/values/strings.xml | 4 + 5 files changed, 156 insertions(+), 88 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt index 583ba82..6c889d6 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt @@ -25,6 +25,7 @@ import com.accbot.dca.domain.usecase.ChartDataPoint import com.accbot.dca.domain.usecase.ChartZoomLevel import com.accbot.dca.presentation.screens.portfolio.DenominationMode import com.accbot.dca.presentation.screens.portfolio.PlanLineInfo +import com.accbot.dca.presentation.screens.portfolio.PlanLineType import com.accbot.dca.presentation.ui.theme.Primary import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom @@ -150,7 +151,7 @@ fun PortfolioLineChart( cryptoSymbol: String = "", visibleSeries: Set = setOf(0, 1), planLines: List = emptyList(), - visiblePlanLineIds: Set = emptySet(), + visiblePlanLines: Set> = emptySet(), zoomLevel: ChartZoomLevel = ChartZoomLevel.Overview, onScrub: (Int?) -> Unit = {}, modifier: Modifier = Modifier @@ -161,7 +162,7 @@ fun PortfolioLineChart( val hasRightAxis = cryptoSymbol.isNotEmpty() && 3 in visibleSeries // Update model when data, denomination, or visibility changes - LaunchedEffect(chartData, denominationMode, visibleSeries, planLines, visiblePlanLineIds) { + LaunchedEffect(chartData, denominationMode, visibleSeries, planLines, visiblePlanLines) { try { modelProducer.runTransaction { // Layer 1: left axis (portfolio value, cost basis, crypto price – all fiat) @@ -178,15 +179,21 @@ fun PortfolioLineChart( if (1 in visibleSeries) series(series1) if (2 in visibleSeries) series(chartData.map { it.price.toFloat() }) if (4 in visibleSeries) series(chartData.map { it.avgBuyPrice.toFloat() }) - // Per-plan value lines + // Per-plan lines (value + invested per plan, only when visible) + var anyPlanSeriesAdded = false for (planLine in planLines) { - if (planLine.planId in visiblePlanLineIds && planLine.values.size == chartData.size) { - series(planLine.values) + val valueKey = planLine.planId to PlanLineType.VALUE + if (valueKey in visiblePlanLines && planLine.valueSeries.size == chartData.size) { + series(planLine.valueSeries) + anyPlanSeriesAdded = true + } + val investedKey = planLine.planId to PlanLineType.INVESTED + if (investedKey in visiblePlanLines && planLine.investedSeries.size == chartData.size) { + series(planLine.investedSeries) + anyPlanSeriesAdded = true } } - if (setOf(0, 1, 2, 4).none { it in visibleSeries } && - planLines.none { it.planId in visiblePlanLineIds && it.values.size == chartData.size } - ) { + if (setOf(0, 1, 2, 4).none { it in visibleSeries } && !anyPlanSeriesAdded) { series(List(chartData.size) { 0f }) } } @@ -244,14 +251,23 @@ fun PortfolioLineChart( fill = LineCartesianLayer.LineFill.single(fill(Color.Transparent)) ) - // Pre-create plan line styles (max 6 plans) - val planLineStyle0 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[0]))) - val planLineStyle1 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[1]))) - val planLineStyle2 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[2]))) - val planLineStyle3 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[3]))) - val planLineStyle4 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[4]))) - val planLineStyle5 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[5]))) - val planLineStyles = listOf(planLineStyle0, planLineStyle1, planLineStyle2, planLineStyle3, planLineStyle4, planLineStyle5) + // Pre-create plan line styles (max 6 plans) - value lines (solid, full color) + val planValueStyle0 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[0]))) + val planValueStyle1 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[1]))) + val planValueStyle2 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[2]))) + val planValueStyle3 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[3]))) + val planValueStyle4 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[4]))) + val planValueStyle5 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[5]))) + val planValueStyles = listOf(planValueStyle0, planValueStyle1, planValueStyle2, planValueStyle3, planValueStyle4, planValueStyle5) + + // Invested lines (lighter/translucent, same base color) + val planInvestedStyle0 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[0].copy(alpha = 0.4f)))) + val planInvestedStyle1 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[1].copy(alpha = 0.4f)))) + val planInvestedStyle2 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[2].copy(alpha = 0.4f)))) + val planInvestedStyle3 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[3].copy(alpha = 0.4f)))) + val planInvestedStyle4 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[4].copy(alpha = 0.4f)))) + val planInvestedStyle5 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[5].copy(alpha = 0.4f)))) + val planInvestedStyles = listOf(planInvestedStyle0, planInvestedStyle1, planInvestedStyle2, planInvestedStyle3, planInvestedStyle4, planInvestedStyle5) // Build visible line lists for each layer val leftLines = buildList { @@ -259,11 +275,18 @@ fun PortfolioLineChart( if (1 in visibleSeries) add(costBasisLine) if (2 in visibleSeries) add(priceLine) if (4 in visibleSeries) add(avgBuyPriceLine) - // Per-plan line styles - planLines.forEachIndexed { idx, planLine -> - if (planLine.planId in visiblePlanLineIds && planLine.values.size == chartData.size) { - add(planLineStyles[idx % planLineStyles.size]) + // Per-plan line styles (value + invested share the same color index per plan) + var planStyleIdx = 0 + planLines.forEach { planLine -> + val valueKey = planLine.planId to PlanLineType.VALUE + val investedKey = planLine.planId to PlanLineType.INVESTED + if (valueKey in visiblePlanLines && planLine.valueSeries.size == chartData.size) { + add(planValueStyles[planStyleIdx % planValueStyles.size]) + } + if (investedKey in visiblePlanLines && planLine.investedSeries.size == chartData.size) { + add(planInvestedStyles[planStyleIdx % planInvestedStyles.size]) } + planStyleIdx++ // increment per plan, not per line, so value and invested share the color } if (isEmpty()) add(hiddenLine) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index c381186..0d79fdf 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -130,7 +130,8 @@ fun PortfolioScreen( val legendEntries = rememberLegendEntries( denominationMode = uiState.denominationMode, isSinglePair = isSinglePair, - currentPairCrypto = uiState.currentPairCrypto + currentPairCrypto = uiState.currentPairCrypto, + isAggregate = currentPage is PairPage.Aggregate ) PortfolioLineChart( chartData = chartData, @@ -140,7 +141,7 @@ fun PortfolioScreen( cryptoSymbol = uiState.currentPairCrypto ?: "", visibleSeries = uiState.visibleSeries, planLines = uiState.planLines, - visiblePlanLineIds = uiState.visiblePlanLineIds, + visiblePlanLines = uiState.visiblePlanLines, zoomLevel = uiState.zoomLevel, onScrub = { idx -> scrubbedIndex = idx ?: -1 }, modifier = Modifier @@ -159,8 +160,8 @@ fun PortfolioScreen( if (uiState.planLines.isNotEmpty()) { PlanLinesLegend( planLines = uiState.planLines, - visiblePlanLineIds = uiState.visiblePlanLineIds, - onToggle = { viewModel.togglePlanLineVisibility(it) } + visiblePlanLines = uiState.visiblePlanLines, + onToggle = { id, type -> viewModel.togglePlanLineVisibility(id, type) } ) } @@ -312,7 +313,7 @@ fun PortfolioScreen( onNavigateNext = { viewModel.navigateNext() }, onPairPageSelected = { viewModel.selectPairPage(it) }, onToggleSeriesVisibility = { viewModel.toggleSeriesVisibility(it) }, - onTogglePlanLineVisibility = { viewModel.togglePlanLineVisibility(it) }, + onTogglePlanLineVisibility = { id, type -> viewModel.togglePlanLineVisibility(id, type) }, onRefresh = { viewModel.syncPricesAndLoadChart() }, onChartTouching = onChartTouching, modifier = Modifier.padding(paddingValues) @@ -333,7 +334,7 @@ internal fun PortfolioContent( onNavigateNext: () -> Unit, onPairPageSelected: (Int) -> Unit, onToggleSeriesVisibility: (Int) -> Unit, - onTogglePlanLineVisibility: (Long) -> Unit, + onTogglePlanLineVisibility: (Long, PlanLineType) -> Unit, onRefresh: () -> Unit, onChartTouching: (Boolean) -> Unit = {}, modifier: Modifier = Modifier @@ -537,7 +538,7 @@ internal fun PortfolioContent( cryptoSymbol = uiState.currentPairCrypto ?: "", visibleSeries = uiState.visibleSeries, planLines = uiState.planLines, - visiblePlanLineIds = uiState.visiblePlanLineIds, + visiblePlanLines = uiState.visiblePlanLines, zoomLevel = uiState.zoomLevel, onScrub = { idx -> scrubbedIndex = idx ?: -1 }, modifier = Modifier.fillMaxWidth() @@ -563,7 +564,8 @@ internal fun PortfolioContent( val legendEntries = rememberLegendEntries( denominationMode = uiState.denominationMode, isSinglePair = isSinglePair, - currentPairCrypto = uiState.currentPairCrypto + currentPairCrypto = uiState.currentPairCrypto, + isAggregate = currentPage is PairPage.Aggregate ) InteractiveChartLegend( entries = legendEntries, @@ -575,7 +577,7 @@ internal fun PortfolioContent( Spacer(Modifier.height(4.dp)) PlanLinesLegend( planLines = uiState.planLines, - visiblePlanLineIds = uiState.visiblePlanLineIds, + visiblePlanLines = uiState.visiblePlanLines, onToggle = onTogglePlanLineVisibility ) } @@ -629,17 +631,24 @@ internal fun PortfolioContent( private fun rememberLegendEntries( denominationMode: DenominationMode, isSinglePair: Boolean, - currentPairCrypto: String? + currentPairCrypto: String?, + isAggregate: Boolean = false ): List { - val (line1, line2) = when (denominationMode) { - DenominationMode.FIAT -> stringResource(R.string.chart_portfolio_value) to stringResource(R.string.chart_cost_basis) - DenominationMode.CRYPTO -> stringResource(R.string.chart_legend_crypto_held) to stringResource(R.string.chart_legend_invested_equiv) + val line1 = when { + isAggregate && denominationMode == DenominationMode.FIAT -> stringResource(R.string.chart_total_value) + denominationMode == DenominationMode.FIAT -> stringResource(R.string.chart_portfolio_value) + else -> stringResource(R.string.chart_legend_crypto_held) + } + val line2 = when { + isAggregate && denominationMode == DenominationMode.FIAT -> stringResource(R.string.chart_total_invested) + denominationMode == DenominationMode.FIAT -> stringResource(R.string.chart_cost_basis) + else -> stringResource(R.string.chart_legend_invested_equiv) } val crypto = currentPairCrypto ?: "BTC" val cryptoPriceLabel = stringResource(R.string.chart_crypto_price, crypto) val accumulatedCryptoLabel = stringResource(R.string.chart_accumulated_crypto, crypto) val avgBuyPriceLabel = stringResource(R.string.chart_avg_buy_price) - return remember(denominationMode, isSinglePair, currentPairCrypto) { + return remember(denominationMode, isSinglePair, currentPairCrypto, isAggregate, line1, line2) { buildList { add(LegendEntry(0, line1, Primary)) add(LegendEntry(1, line2, androidx.compose.ui.graphics.Color(0xFF888888))) @@ -1209,41 +1218,70 @@ private fun LandscapeKpiContent( @Composable private fun PlanLinesLegend( planLines: List, - visiblePlanLineIds: Set, - onToggle: (Long) -> Unit + visiblePlanLines: Set>, + onToggle: (Long, PlanLineType) -> Unit ) { + val planLineColors = com.accbot.dca.presentation.components.planLineColors Column( modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) ) { - planLines.chunked(2).forEach { row -> - Row(horizontalArrangement = Arrangement.Center) { - row.forEachIndexed { i, planLine -> - if (i > 0) Spacer(Modifier.width(24.dp)) - val colorIndex = planLines.indexOf(planLine) - val color = planLineColors[colorIndex % planLineColors.size] - val enabled = planLine.planId in visiblePlanLineIds - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { onToggle(planLine.planId) } - .padding(4.dp) - ) { - Box( - Modifier - .size(12.dp) - .clip(CircleShape) - .background(if (enabled) color else color.copy(alpha = 0.3f)) - ) - Spacer(Modifier.width(6.dp)) - Text( - planLine.name, - style = MaterialTheme.typography.bodySmall, - color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - textDecoration = if (enabled) null else TextDecoration.LineThrough - ) - } + planLines.forEachIndexed { index, planLine -> + val baseColor = planLineColors[index % planLineColors.size] + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + // Value entry + val valueEnabled = (planLine.planId to PlanLineType.VALUE) in visiblePlanLines + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { onToggle(planLine.planId, PlanLineType.VALUE) } + .padding(4.dp) + ) { + Box( + Modifier + .size(12.dp) + .clip(CircleShape) + .background(if (valueEnabled) baseColor else baseColor.copy(alpha = 0.3f)) + ) + Spacer(Modifier.width(6.dp)) + Text( + stringResource(R.string.chart_plan_value, planLine.name), + style = MaterialTheme.typography.bodySmall, + color = if (valueEnabled) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + textDecoration = if (valueEnabled) null else TextDecoration.LineThrough + ) + } + + Spacer(Modifier.width(16.dp)) + + // Invested entry + val investedEnabled = (planLine.planId to PlanLineType.INVESTED) in visiblePlanLines + val investedColor = baseColor.copy(alpha = 0.4f) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { onToggle(planLine.planId, PlanLineType.INVESTED) } + .padding(4.dp) + ) { + Box( + Modifier + .size(12.dp) + .clip(CircleShape) + .background(if (investedEnabled) investedColor else investedColor.copy(alpha = 0.3f)) + ) + Spacer(Modifier.width(6.dp)) + Text( + stringResource(R.string.chart_plan_invested, planLine.name), + style = MaterialTheme.typography.bodySmall, + color = if (investedEnabled) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + textDecoration = if (investedEnabled) null else TextDecoration.LineThrough + ) } } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index 1fcce04..5d04676 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -29,11 +29,14 @@ sealed class PairPage { data class Plan(val planId: Long, val name: String, val crypto: String, val fiat: String) : PairPage() } +enum class PlanLineType { VALUE, INVESTED } + @Immutable data class PlanLineInfo( val planId: Long, val name: String, - val values: List = emptyList() // aligned to main chartData indices + val valueSeries: List = emptyList(), // aligned to main chartData indices + val investedSeries: List = emptyList() // aligned to main chartData indices ) @Immutable @@ -53,7 +56,7 @@ data class PortfolioUiState( val visibleSeries: Set = setOf(0, 1), val scrubbedIndex: Int? = null, val planLines: List = emptyList(), - val visiblePlanLineIds: Set = emptySet(), + val visiblePlanLines: Set> = emptySet(), val isLoading: Boolean = true, val isChartLoading: Boolean = false, val isPriceSyncing: Boolean = false, @@ -308,7 +311,7 @@ class PortfolioViewModel @Inject constructor( visibleSeries = setOf(0, 1), zoomLevel = ChartZoomLevel.Overview, planLines = emptyList(), - visiblePlanLineIds = emptySet() + visiblePlanLines = emptySet() ) } updateNavigationState() loadChartData() @@ -337,11 +340,12 @@ class PortfolioViewModel @Inject constructor( } } - fun togglePlanLineVisibility(planId: Long) { + fun togglePlanLineVisibility(planId: Long, type: PlanLineType) { _uiState.update { state -> - val current = state.visiblePlanLineIds - val toggled = if (planId in current) current - planId else current + planId - state.copy(visiblePlanLineIds = toggled) + val key = planId to type + val current = state.visiblePlanLines + val toggled = if (key in current) current - key else current + key + state.copy(visiblePlanLines = toggled) } } @@ -445,21 +449,10 @@ class PortfolioViewModel @Inject constructor( ) } - // Calculate per-plan lines - val planLinesList = if (data.isNotEmpty()) { + // Calculate per-plan lines (only for Aggregate pages - Plan pages show a single plan's main line) + val planLinesList = if (page is PairPage.Aggregate && data.isNotEmpty()) { try { - val relevantPlans = when (page) { - is PairPage.Aggregate -> { - // All plans in this fiat - dcaPlanDao.getAllPlansOnceOrdered().filter { it.fiat == page.fiat } - } - is PairPage.Plan -> { - // All plans for this specific pair - dcaPlanDao.getAllPlansOnceOrdered() - .filter { it.crypto == page.crypto && it.fiat == page.fiat } - } - null -> emptyList() - } + val relevantPlans = dcaPlanDao.getAllPlansOnceOrdered().filter { it.fiat == page.fiat } if (relevantPlans.size >= 2) { val mainEpochDays = data.map { it.epochDay } relevantPlans.mapNotNull { plan -> @@ -474,14 +467,20 @@ class PortfolioViewModel @Inject constructor( // Align to main chart's epoch days via forward-fill val planByDay = planData.associateBy { it.epochDay } var lastValue = 0f - val aligned = mainEpochDays.map { day -> + var lastInvested = 0f + val valueAligned = mainEpochDays.map { day -> val v = planByDay[day]?.portfolioValue?.toFloat() if (v != null) { lastValue = v; v } else lastValue } + val investedAligned = mainEpochDays.map { day -> + val v = planByDay[day]?.totalInvested?.toFloat() + if (v != null) { lastInvested = v; v } else lastInvested + } PlanLineInfo( planId = plan.id, name = plan.name.ifBlank { "${plan.crypto}/${plan.fiat} #${plan.id}" }, - values = aligned + valueSeries = valueAligned, + investedSeries = investedAligned ) } } else emptyList() diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 140ac5d..ba29463 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -341,6 +341,10 @@ Investováno Hodnota portfolia Investováno + Celkem hodnota + Celkem investováno + Hodnota %1$s + Investováno %1$s Držené krypto Ekvivalent investice Prům. cena diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index a84f35d..5681e2a 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -340,6 +340,10 @@ Invested Portfolio Value Invested + Total value + Total invested + Value %1$s + Invested %1$s Crypto Held Invested Equiv. Avg. Price From dbb2fc23c4c96f469ff15e96630d20a3f9ff8fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 12 Apr 2026 10:55:55 +0200 Subject: [PATCH 19/26] feat(portfolio): persist selected chip across app restarts Stable page identifier (agg: or plan:) survives plan add/remove/reorder. Explicit deep-link navigation from dashboard still takes priority. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../accbot/dca/data/local/UserPreferences.kt | 16 +++++++++ .../screens/portfolio/PortfolioViewModel.kt | 36 ++++++++++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt index 5c55b2e..0f428b4 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt @@ -211,6 +211,21 @@ class UserPreferences @Inject constructor( prefs.edit().putBoolean(KEY_SANDBOX_MODE, enabled).commit() } + // ==================== Portfolio ==================== + + /** + * Get the persisted portfolio page identifier (e.g., "agg:EUR" or "plan:123"). + * Returns null if not set. The identifier is stable across plan add/remove/reorder, + * unlike a raw index. + */ + fun getPortfolioSelectedPageId(): String? { + return prefs.getString(KEY_PORTFOLIO_SELECTED_PAGE, null) + } + + fun setPortfolioSelectedPageId(pageId: String) { + prefs.edit().putString(KEY_PORTFOLIO_SELECTED_PAGE, pageId).apply() + } + companion object { private const val PREFS_NAME = "accbot_user_prefs" private const val KEY_APP_THEME = "app_theme" @@ -226,5 +241,6 @@ class UserPreferences @Inject constructor( private const val KEY_MARKET_PULSE_ENABLED = "market_pulse_enabled" private const val KEY_MARKET_PULSE_EXPANDED = "market_pulse_expanded" private const val KEY_EXPERIMENTAL_EXCHANGES = "experimental_exchanges_enabled" + private const val KEY_PORTFOLIO_SELECTED_PAGE = "portfolio_selected_page" } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index 5d04676..5f42089 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.TransactionEntity +import com.accbot.dca.data.local.UserPreferences // TransactionStatus filtering now done in DAO query import com.accbot.dca.domain.usecase.CalculateChartDataUseCase import com.accbot.dca.domain.usecase.ChartDataPoint @@ -69,7 +70,8 @@ class PortfolioViewModel @Inject constructor( private val transactionDao: TransactionDao, private val dcaPlanDao: DcaPlanDao, private val syncDailyPricesUseCase: SyncDailyPricesUseCase, - private val calculateChartDataUseCase: CalculateChartDataUseCase + private val calculateChartDataUseCase: CalculateChartDataUseCase, + private val userPreferences: UserPreferences ) : ViewModel() { private val initialCrypto: String? = savedStateHandle["crypto"] @@ -89,6 +91,15 @@ class PortfolioViewModel @Inject constructor( companion object { private const val STALENESS_THRESHOLD_MS = 5 * 60 * 1000L // 5 minutes private const val TRANSACTION_REFRESH_THRESHOLD_MS = 30 * 1000L // 30 seconds + + /** + * Stable identifier for a PairPage used for persisting the selected chip + * across app restarts. Format: "agg:" for Aggregate, "plan:" for Plan. + */ + private fun pageIdOf(page: PairPage): String = when (page) { + is PairPage.Aggregate -> "agg:${page.fiat}" + is PairPage.Plan -> "plan:${page.planId}" + } } init { @@ -124,10 +135,21 @@ class PortfolioViewModel @Inject constructor( )) } - val pageIndex = if (initialCrypto != null && initialFiat != null) { - val idx = pages.indexOfFirst { it is PairPage.Plan && it.crypto == initialCrypto && it.fiat == initialFiat } - if (idx >= 0) idx else 0 - } else 0 + val pageIndex = when { + initialCrypto != null && initialFiat != null -> { + // Explicit deep-link from dashboard takes priority + val idx = pages.indexOfFirst { it is PairPage.Plan && it.crypto == initialCrypto && it.fiat == initialFiat } + if (idx >= 0) idx else 0 + } + else -> { + // Restore from preferences + val savedId = userPreferences.getPortfolioSelectedPageId() + if (savedId != null) { + val idx = pages.indexOfFirst { pageIdOf(it) == savedId } + if (idx >= 0) idx else 0 + } else 0 + } + } _uiState.update { state -> state.copy( @@ -313,6 +335,10 @@ class PortfolioViewModel @Inject constructor( planLines = emptyList(), visiblePlanLines = emptySet() ) } + // Persist selection so the same chip is restored on next app launch + if (page != null) { + userPreferences.setPortfolioSelectedPageId(pageIdOf(page)) + } updateNavigationState() loadChartData() } From 0a3b32b5ee3a7b598e54e29e2404b1e8b1be292c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 12 Apr 2026 11:02:52 +0200 Subject: [PATCH 20/26] fix: sync portfolio pager with chip selection + show plan names in Run Now sheet - Portfolio: tapping a chip now scrolls the HorizontalPager to that page so KPI card updates - Run Now sheet: show custom plan name above crypto/fiat pair when set Co-Authored-By: Claude Opus 4.6 (1M context) --- .../accbot/dca/presentation/screens/DashboardScreen.kt | 9 +++++++++ .../presentation/screens/portfolio/PortfolioScreen.kt | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt index d2bcacc..6f4d735 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt @@ -1572,6 +1572,15 @@ private fun RunNowBottomSheet( ) Spacer(modifier = Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f)) { + // Show custom plan name (if set) above the pair label + if (plan.name.isNotBlank()) { + Text( + text = plan.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index 0d79fdf..1e17b58 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -349,12 +349,18 @@ internal fun PortfolioContent( pageCount = { pageCount } ) - // Sync pager with ViewModel + // Sync pager -> ViewModel (swipe changes page) LaunchedEffect(pagerState.currentPage) { if (pagerState.currentPage != uiState.selectedPageIndex) { onPairPageSelected(pagerState.currentPage) } } + // Sync ViewModel -> pager (chip tap changes page) + LaunchedEffect(uiState.selectedPageIndex) { + if (pagerState.currentPage != uiState.selectedPageIndex) { + pagerState.animateScrollToPage(uiState.selectedPageIndex) + } + } val currentPage = uiState.pages.getOrNull(uiState.selectedPageIndex) val isSinglePair = currentPage is PairPage.Plan From f4cc5b86fe5b2c285773ce1f6e2742cdc52a0b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 12 Apr 2026 14:26:47 +0200 Subject: [PATCH 21/26] fix(portfolio): use settledPage to avoid pager race condition on chip tap Previously, tapping a chip to jump 2+ pages triggered intermediate currentPage updates during animation, which caused the reverse-sync LaunchedEffect to overwrite the target page mid-animation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../presentation/screens/portfolio/PortfolioScreen.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index 1e17b58..5f5b7da 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -349,10 +349,11 @@ internal fun PortfolioContent( pageCount = { pageCount } ) - // Sync pager -> ViewModel (swipe changes page) - LaunchedEffect(pagerState.currentPage) { - if (pagerState.currentPage != uiState.selectedPageIndex) { - onPairPageSelected(pagerState.currentPage) + // Sync pager -> ViewModel (only after pager settles, not during animation) + // Using settledPage avoids intermediate values from programmatic scroll animations + LaunchedEffect(pagerState.settledPage) { + if (pagerState.settledPage != uiState.selectedPageIndex) { + onPairPageSelected(pagerState.settledPage) } } // Sync ViewModel -> pager (chip tap changes page) From 4ce2a36b19b578a4e92a23fced560f2b6c993bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 12 Apr 2026 14:54:52 +0200 Subject: [PATCH 22/26] feat(portfolio): add advanced legend section with per-plan avg buy/accumulated and per-crypto totals Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ChartComponents.kt | 182 ++++++++++++++- .../screens/portfolio/PortfolioScreen.kt | 208 ++++++++++++++---- .../screens/portfolio/PortfolioViewModel.kt | 94 +++++++- .../app/src/main/res/values-cs/strings.xml | 6 + .../app/src/main/res/values/strings.xml | 6 + 5 files changed, 440 insertions(+), 56 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt index 6c889d6..5dda469 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.unit.sp import android.util.Log import com.accbot.dca.domain.usecase.ChartDataPoint import com.accbot.dca.domain.usecase.ChartZoomLevel +import com.accbot.dca.presentation.screens.portfolio.CryptoGroupLineInfo +import com.accbot.dca.presentation.screens.portfolio.CryptoGroupLineType import com.accbot.dca.presentation.screens.portfolio.DenominationMode import com.accbot.dca.presentation.screens.portfolio.PlanLineInfo import com.accbot.dca.presentation.screens.portfolio.PlanLineType @@ -73,6 +75,20 @@ internal val planLineColors = listOf( Color(0xFF81C784), // green ) +internal val cryptoGroupColors = mapOf( + "BTC" to Color(0xFFF7931A), + "ETH" to Color(0xFF627EEA), + "LTC" to Color(0xFFA6A9AA), + "BCH" to Color(0xFF8DC351), + "XRP" to Color(0xFF00A5E0), + "ADA" to Color(0xFF0033AD), + "SOL" to Color(0xFF9945FF), + "DOT" to Color(0xFFE6007A), +) +internal val defaultCryptoGroupColor = Color(0xFF888888) +internal fun colorForCrypto(crypto: String): Color = + cryptoGroupColors[crypto] ?: defaultCryptoGroupColor + data class LegendEntry( val seriesIndex: Int, val label: String, @@ -152,6 +168,8 @@ fun PortfolioLineChart( visibleSeries: Set = setOf(0, 1), planLines: List = emptyList(), visiblePlanLines: Set> = emptySet(), + cryptoGroupLines: List = emptyList(), + visibleCryptoGroupLines: Set> = emptySet(), zoomLevel: ChartZoomLevel = ChartZoomLevel.Overview, onScrub: (Int?) -> Unit = {}, modifier: Modifier = Modifier @@ -159,10 +177,26 @@ fun PortfolioLineChart( if (chartData.isEmpty()) return val modelProducer = remember { CartesianChartModelProducer() } - val hasRightAxis = cryptoSymbol.isNotEmpty() && 3 in visibleSeries + val hasRightAxis = (cryptoSymbol.isNotEmpty() && 3 in visibleSeries) || + planLines.any { planLine -> + (planLine.planId to PlanLineType.ACCUMULATED) in visiblePlanLines && + planLine.accumulatedSeries.size == chartData.size + } || + cryptoGroupLines.any { cgLine -> + (cgLine.crypto to CryptoGroupLineType.TOTAL_ACCUMULATED) in visibleCryptoGroupLines && + cgLine.totalAccumulatedSeries.size == chartData.size + } // Update model when data, denomination, or visibility changes - LaunchedEffect(chartData, denominationMode, visibleSeries, planLines, visiblePlanLines) { + LaunchedEffect( + chartData, + denominationMode, + visibleSeries, + planLines, + visiblePlanLines, + cryptoGroupLines, + visibleCryptoGroupLines + ) { try { modelProducer.runTransaction { // Layer 1: left axis (portfolio value, cost basis, crypto price – all fiat) @@ -180,27 +214,65 @@ fun PortfolioLineChart( if (2 in visibleSeries) series(chartData.map { it.price.toFloat() }) if (4 in visibleSeries) series(chartData.map { it.avgBuyPrice.toFloat() }) // Per-plan lines (value + invested per plan, only when visible) - var anyPlanSeriesAdded = false + var anyLeftSeriesAdded = false for (planLine in planLines) { val valueKey = planLine.planId to PlanLineType.VALUE if (valueKey in visiblePlanLines && planLine.valueSeries.size == chartData.size) { series(planLine.valueSeries) - anyPlanSeriesAdded = true + anyLeftSeriesAdded = true } val investedKey = planLine.planId to PlanLineType.INVESTED if (investedKey in visiblePlanLines && planLine.investedSeries.size == chartData.size) { series(planLine.investedSeries) - anyPlanSeriesAdded = true + anyLeftSeriesAdded = true + } + } + // Per-plan avg buy price (left axis, fiat) + for (planLine in planLines) { + val key = planLine.planId to PlanLineType.AVG_BUY_PRICE + if (key in visiblePlanLines && planLine.avgBuyPriceSeries.size == chartData.size) { + series(planLine.avgBuyPriceSeries) + anyLeftSeriesAdded = true + } + } + // Per-crypto price (left axis, fiat) + for (cgLine in cryptoGroupLines) { + val key = cgLine.crypto to CryptoGroupLineType.PRICE + if (key in visibleCryptoGroupLines && cgLine.priceSeries.size == chartData.size) { + series(cgLine.priceSeries) + anyLeftSeriesAdded = true } } - if (setOf(0, 1, 2, 4).none { it in visibleSeries } && !anyPlanSeriesAdded) { + if (setOf(0, 1, 2, 4).none { it in visibleSeries } && !anyLeftSeriesAdded) { series(List(chartData.size) { 0f }) } } // Layer 2: right axis (accumulated crypto – BTC units) lineSeries { - if (3 in visibleSeries) series(chartData.map { it.cumulativeCrypto.toFloat() }) - else series(List(chartData.size) { 0f }) + var anyRightSeriesAdded = false + if (3 in visibleSeries) { + series(chartData.map { it.cumulativeCrypto.toFloat() }) + anyRightSeriesAdded = true + } + // Per-plan accumulated (right axis, crypto amount) + for (planLine in planLines) { + val key = planLine.planId to PlanLineType.ACCUMULATED + if (key in visiblePlanLines && planLine.accumulatedSeries.size == chartData.size) { + series(planLine.accumulatedSeries) + anyRightSeriesAdded = true + } + } + // Per-crypto total accumulated (right axis, crypto amount) + for (cgLine in cryptoGroupLines) { + val key = cgLine.crypto to CryptoGroupLineType.TOTAL_ACCUMULATED + if (key in visibleCryptoGroupLines && cgLine.totalAccumulatedSeries.size == chartData.size) { + series(cgLine.totalAccumulatedSeries) + anyRightSeriesAdded = true + } + } + if (!anyRightSeriesAdded) { + series(List(chartData.size) { 0f }) + } } } } catch (e: OutOfMemoryError) { @@ -269,6 +341,68 @@ fun PortfolioLineChart( val planInvestedStyle5 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[5].copy(alpha = 0.4f)))) val planInvestedStyles = listOf(planInvestedStyle0, planInvestedStyle1, planInvestedStyle2, planInvestedStyle3, planInvestedStyle4, planInvestedStyle5) + // Avg buy price lines (alpha 0.7) + val planAvgBuyStyle0 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[0].copy(alpha = 0.7f)))) + val planAvgBuyStyle1 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[1].copy(alpha = 0.7f)))) + val planAvgBuyStyle2 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[2].copy(alpha = 0.7f)))) + val planAvgBuyStyle3 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[3].copy(alpha = 0.7f)))) + val planAvgBuyStyle4 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[4].copy(alpha = 0.7f)))) + val planAvgBuyStyle5 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[5].copy(alpha = 0.7f)))) + val planAvgBuyStyles = listOf(planAvgBuyStyle0, planAvgBuyStyle1, planAvgBuyStyle2, planAvgBuyStyle3, planAvgBuyStyle4, planAvgBuyStyle5) + + // Accumulated lines (alpha 0.85) + val planAccumulatedStyle0 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[0].copy(alpha = 0.85f)))) + val planAccumulatedStyle1 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[1].copy(alpha = 0.85f)))) + val planAccumulatedStyle2 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[2].copy(alpha = 0.85f)))) + val planAccumulatedStyle3 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[3].copy(alpha = 0.85f)))) + val planAccumulatedStyle4 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[4].copy(alpha = 0.85f)))) + val planAccumulatedStyle5 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[5].copy(alpha = 0.85f)))) + val planAccumulatedStyles = listOf(planAccumulatedStyle0, planAccumulatedStyle1, planAccumulatedStyle2, planAccumulatedStyle3, planAccumulatedStyle4, planAccumulatedStyle5) + + // Crypto group price line styles (full alpha) - pre-allocated for known cryptos + val btcPriceStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFFF7931A)))) + val ethPriceStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFF627EEA)))) + val ltcPriceStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFFA6A9AA)))) + val bchPriceStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFF8DC351)))) + val xrpPriceStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFF00A5E0)))) + val adaPriceStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFF0033AD)))) + val solPriceStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFF9945FF)))) + val dotPriceStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFFE6007A)))) + val defaultCryptoPriceStyle = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(defaultCryptoGroupColor))) + + val cryptoPriceStylesMap = mapOf( + "BTC" to btcPriceStyleLine, + "ETH" to ethPriceStyleLine, + "LTC" to ltcPriceStyleLine, + "BCH" to bchPriceStyleLine, + "XRP" to xrpPriceStyleLine, + "ADA" to adaPriceStyleLine, + "SOL" to solPriceStyleLine, + "DOT" to dotPriceStyleLine, + ) + + // Crypto group accumulated line styles (alpha 0.6) + val btcAccStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFFF7931A).copy(alpha = 0.6f)))) + val ethAccStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFF627EEA).copy(alpha = 0.6f)))) + val ltcAccStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFFA6A9AA).copy(alpha = 0.6f)))) + val bchAccStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFF8DC351).copy(alpha = 0.6f)))) + val xrpAccStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFF00A5E0).copy(alpha = 0.6f)))) + val adaAccStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFF0033AD).copy(alpha = 0.6f)))) + val solAccStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFF9945FF).copy(alpha = 0.6f)))) + val dotAccStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFFE6007A).copy(alpha = 0.6f)))) + val defaultCryptoAccStyle = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(defaultCryptoGroupColor.copy(alpha = 0.6f)))) + + val cryptoAccStylesMap = mapOf( + "BTC" to btcAccStyleLine, + "ETH" to ethAccStyleLine, + "LTC" to ltcAccStyleLine, + "BCH" to bchAccStyleLine, + "XRP" to xrpAccStyleLine, + "ADA" to adaAccStyleLine, + "SOL" to solAccStyleLine, + "DOT" to dotAccStyleLine, + ) + // Build visible line lists for each layer val leftLines = buildList { if (0 in visibleSeries) add(valueLine) @@ -288,10 +422,42 @@ fun PortfolioLineChart( } planStyleIdx++ // increment per plan, not per line, so value and invested share the color } + // Per-plan avg buy price styles + var planStyleIdxAvg = 0 + planLines.forEach { planLine -> + val key = planLine.planId to PlanLineType.AVG_BUY_PRICE + if (key in visiblePlanLines && planLine.avgBuyPriceSeries.size == chartData.size) { + add(planAvgBuyStyles[planStyleIdxAvg % planAvgBuyStyles.size]) + } + planStyleIdxAvg++ + } + // Per-crypto price styles + cryptoGroupLines.forEach { cgLine -> + val key = cgLine.crypto to CryptoGroupLineType.PRICE + if (key in visibleCryptoGroupLines && cgLine.priceSeries.size == chartData.size) { + add(cryptoPriceStylesMap[cgLine.crypto] ?: defaultCryptoPriceStyle) + } + } if (isEmpty()) add(hiddenLine) } val rightLines = buildList { if (3 in visibleSeries) add(accumulatedLine) + // Per-plan accumulated styles + var planStyleIdxAcc = 0 + planLines.forEach { planLine -> + val key = planLine.planId to PlanLineType.ACCUMULATED + if (key in visiblePlanLines && planLine.accumulatedSeries.size == chartData.size) { + add(planAccumulatedStyles[planStyleIdxAcc % planAccumulatedStyles.size]) + } + planStyleIdxAcc++ + } + // Per-crypto accumulated styles + cryptoGroupLines.forEach { cgLine -> + val key = cgLine.crypto to CryptoGroupLineType.TOTAL_ACCUMULATED + if (key in visibleCryptoGroupLines && cgLine.totalAccumulatedSeries.size == chartData.size) { + add(cryptoAccStylesMap[cgLine.crypto] ?: defaultCryptoAccStyle) + } + } if (isEmpty()) add(hiddenLine) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index 5f5b7da..94f1384 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -142,6 +142,8 @@ fun PortfolioScreen( visibleSeries = uiState.visibleSeries, planLines = uiState.planLines, visiblePlanLines = uiState.visiblePlanLines, + cryptoGroupLines = uiState.cryptoGroupLines, + visibleCryptoGroupLines = uiState.visibleCryptoGroupLines, zoomLevel = uiState.zoomLevel, onScrub = { idx -> scrubbedIndex = idx ?: -1 }, modifier = Modifier @@ -157,11 +159,16 @@ fun PortfolioScreen( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) ) // Per-plan legend (landscape) - if (uiState.planLines.isNotEmpty()) { + if (uiState.planLines.isNotEmpty() || uiState.cryptoGroupLines.isNotEmpty()) { PlanLinesLegend( planLines = uiState.planLines, visiblePlanLines = uiState.visiblePlanLines, - onToggle = { id, type -> viewModel.togglePlanLineVisibility(id, type) } + onToggle = { id, type -> viewModel.togglePlanLineVisibility(id, type) }, + cryptoGroupLines = uiState.cryptoGroupLines, + visibleCryptoGroupLines = uiState.visibleCryptoGroupLines, + onToggleCryptoGroup = { crypto, type -> viewModel.toggleCryptoGroupLineVisibility(crypto, type) }, + isAdvancedExpanded = uiState.isAdvancedLegendExpanded, + onToggleAdvanced = { viewModel.toggleAdvancedLegendExpanded() } ) } @@ -314,6 +321,8 @@ fun PortfolioScreen( onPairPageSelected = { viewModel.selectPairPage(it) }, onToggleSeriesVisibility = { viewModel.toggleSeriesVisibility(it) }, onTogglePlanLineVisibility = { id, type -> viewModel.togglePlanLineVisibility(id, type) }, + onToggleCryptoGroupLineVisibility = { crypto, type -> viewModel.toggleCryptoGroupLineVisibility(crypto, type) }, + onToggleAdvancedLegend = { viewModel.toggleAdvancedLegendExpanded() }, onRefresh = { viewModel.syncPricesAndLoadChart() }, onChartTouching = onChartTouching, modifier = Modifier.padding(paddingValues) @@ -335,6 +344,8 @@ internal fun PortfolioContent( onPairPageSelected: (Int) -> Unit, onToggleSeriesVisibility: (Int) -> Unit, onTogglePlanLineVisibility: (Long, PlanLineType) -> Unit, + onToggleCryptoGroupLineVisibility: (String, CryptoGroupLineType) -> Unit, + onToggleAdvancedLegend: () -> Unit, onRefresh: () -> Unit, onChartTouching: (Boolean) -> Unit = {}, modifier: Modifier = Modifier @@ -546,6 +557,8 @@ internal fun PortfolioContent( visibleSeries = uiState.visibleSeries, planLines = uiState.planLines, visiblePlanLines = uiState.visiblePlanLines, + cryptoGroupLines = uiState.cryptoGroupLines, + visibleCryptoGroupLines = uiState.visibleCryptoGroupLines, zoomLevel = uiState.zoomLevel, onScrub = { idx -> scrubbedIndex = idx ?: -1 }, modifier = Modifier.fillMaxWidth() @@ -580,12 +593,17 @@ internal fun PortfolioContent( onToggleSeries = onToggleSeriesVisibility ) // Per-plan legend entries - if (uiState.planLines.isNotEmpty()) { + if (uiState.planLines.isNotEmpty() || uiState.cryptoGroupLines.isNotEmpty()) { Spacer(Modifier.height(4.dp)) PlanLinesLegend( planLines = uiState.planLines, visiblePlanLines = uiState.visiblePlanLines, - onToggle = onTogglePlanLineVisibility + onToggle = onTogglePlanLineVisibility, + cryptoGroupLines = uiState.cryptoGroupLines, + visibleCryptoGroupLines = uiState.visibleCryptoGroupLines, + onToggleCryptoGroup = onToggleCryptoGroupLineVisibility, + isAdvancedExpanded = uiState.isAdvancedLegendExpanded, + onToggleAdvanced = onToggleAdvancedLegend ) } } @@ -1222,11 +1240,64 @@ private fun LandscapeKpiContent( } } +private val legendCryptoDisplayColors = mapOf( + "BTC" to androidx.compose.ui.graphics.Color(0xFFF7931A), + "ETH" to androidx.compose.ui.graphics.Color(0xFF627EEA), + "LTC" to androidx.compose.ui.graphics.Color(0xFFA6A9AA), + "BCH" to androidx.compose.ui.graphics.Color(0xFF8DC351), + "XRP" to androidx.compose.ui.graphics.Color(0xFF00A5E0), + "ADA" to androidx.compose.ui.graphics.Color(0xFF0033AD), + "SOL" to androidx.compose.ui.graphics.Color(0xFF9945FF), + "DOT" to androidx.compose.ui.graphics.Color(0xFFE6007A), +) +private fun legendColorFor(crypto: String): androidx.compose.ui.graphics.Color = + legendCryptoDisplayColors[crypto] ?: androidx.compose.ui.graphics.Color(0xFF888888) + +@Composable +private fun LegendDot(color: androidx.compose.ui.graphics.Color, enabled: Boolean) { + Box( + Modifier + .size(12.dp) + .clip(CircleShape) + .background(if (enabled) color else color.copy(alpha = 0.3f)) + ) +} + +@Composable +private fun LegendTextEntry( + color: androidx.compose.ui.graphics.Color, + label: String, + enabled: Boolean, + onClick: () -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { onClick() } + .padding(4.dp) + ) { + LegendDot(color, enabled) + Spacer(Modifier.width(6.dp)) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + textDecoration = if (enabled) null else TextDecoration.LineThrough + ) + } +} + @Composable private fun PlanLinesLegend( planLines: List, visiblePlanLines: Set>, - onToggle: (Long, PlanLineType) -> Unit + onToggle: (Long, PlanLineType) -> Unit, + cryptoGroupLines: List = emptyList(), + visibleCryptoGroupLines: Set> = emptySet(), + onToggleCryptoGroup: (String, CryptoGroupLineType) -> Unit = { _, _ -> }, + isAdvancedExpanded: Boolean = false, + onToggleAdvanced: () -> Unit = {} ) { val planLineColors = com.accbot.dca.presentation.components.planLineColors Column( @@ -1234,60 +1305,111 @@ private fun PlanLinesLegend( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp) ) { + // Primary section: value + invested per plan planLines.forEachIndexed { index, planLine -> val baseColor = planLineColors[index % planLineColors.size] Row( horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth() ) { - // Value entry val valueEnabled = (planLine.planId to PlanLineType.VALUE) in visiblePlanLines + LegendTextEntry( + color = baseColor, + label = stringResource(R.string.chart_plan_value, planLine.name), + enabled = valueEnabled, + onClick = { onToggle(planLine.planId, PlanLineType.VALUE) } + ) + + Spacer(Modifier.width(16.dp)) + + val investedEnabled = (planLine.planId to PlanLineType.INVESTED) in visiblePlanLines + LegendTextEntry( + color = baseColor.copy(alpha = 0.4f), + label = stringResource(R.string.chart_plan_invested, planLine.name), + enabled = investedEnabled, + onClick = { onToggle(planLine.planId, PlanLineType.INVESTED) } + ) + } + } + + // Advanced toggle button (shown only when we have any advanced content) + if (planLines.isNotEmpty() || cryptoGroupLines.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggleAdvanced() } + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(if (isAdvancedExpanded) R.string.chart_advanced_hide else R.string.chart_advanced_show), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(4.dp)) + Icon( + if (isAdvancedExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Advanced section content + if (isAdvancedExpanded) { + // Per-crypto-group rows: Cena + Celkem akumulováno per unique crypto + cryptoGroupLines.forEach { cgLine -> + val cryptoColor = legendColorFor(cgLine.crypto) Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { onToggle(planLine.planId, PlanLineType.VALUE) } - .padding(4.dp) + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() ) { - Box( - Modifier - .size(12.dp) - .clip(CircleShape) - .background(if (valueEnabled) baseColor else baseColor.copy(alpha = 0.3f)) + val priceEnabled = (cgLine.crypto to CryptoGroupLineType.PRICE) in visibleCryptoGroupLines + LegendTextEntry( + color = cryptoColor, + label = stringResource(R.string.chart_crypto_price_label, cgLine.crypto), + enabled = priceEnabled, + onClick = { onToggleCryptoGroup(cgLine.crypto, CryptoGroupLineType.PRICE) } ) - Spacer(Modifier.width(6.dp)) - Text( - stringResource(R.string.chart_plan_value, planLine.name), - style = MaterialTheme.typography.bodySmall, - color = if (valueEnabled) MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - textDecoration = if (valueEnabled) null else TextDecoration.LineThrough + + Spacer(Modifier.width(16.dp)) + + val accEnabled = (cgLine.crypto to CryptoGroupLineType.TOTAL_ACCUMULATED) in visibleCryptoGroupLines + LegendTextEntry( + color = cryptoColor.copy(alpha = 0.6f), + label = stringResource(R.string.chart_total_accumulated_label, cgLine.crypto), + enabled = accEnabled, + onClick = { onToggleCryptoGroup(cgLine.crypto, CryptoGroupLineType.TOTAL_ACCUMULATED) } ) } + } - Spacer(Modifier.width(16.dp)) - - // Invested entry - val investedEnabled = (planLine.planId to PlanLineType.INVESTED) in visiblePlanLines - val investedColor = baseColor.copy(alpha = 0.4f) + // Per-plan advanced rows: Prům. nák. cena + Akumulováno + planLines.forEachIndexed { index, planLine -> + val baseColor = planLineColors[index % planLineColors.size] Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { onToggle(planLine.planId, PlanLineType.INVESTED) } - .padding(4.dp) + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() ) { - Box( - Modifier - .size(12.dp) - .clip(CircleShape) - .background(if (investedEnabled) investedColor else investedColor.copy(alpha = 0.3f)) + val avgEnabled = (planLine.planId to PlanLineType.AVG_BUY_PRICE) in visiblePlanLines + LegendTextEntry( + color = baseColor.copy(alpha = 0.7f), + label = stringResource(R.string.chart_plan_avg_buy, planLine.name), + enabled = avgEnabled, + onClick = { onToggle(planLine.planId, PlanLineType.AVG_BUY_PRICE) } ) - Spacer(Modifier.width(6.dp)) - Text( - stringResource(R.string.chart_plan_invested, planLine.name), - style = MaterialTheme.typography.bodySmall, - color = if (investedEnabled) MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - textDecoration = if (investedEnabled) null else TextDecoration.LineThrough + + Spacer(Modifier.width(16.dp)) + + val accEnabled = (planLine.planId to PlanLineType.ACCUMULATED) in visiblePlanLines + LegendTextEntry( + color = baseColor.copy(alpha = 0.85f), + label = stringResource(R.string.chart_plan_accumulated, planLine.name), + enabled = accEnabled, + onClick = { onToggle(planLine.planId, PlanLineType.ACCUMULATED) } ) } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index 5f42089..80ee362 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -30,14 +30,26 @@ sealed class PairPage { data class Plan(val planId: Long, val name: String, val crypto: String, val fiat: String) : PairPage() } -enum class PlanLineType { VALUE, INVESTED } +enum class PlanLineType { VALUE, INVESTED, AVG_BUY_PRICE, ACCUMULATED } @Immutable data class PlanLineInfo( val planId: Long, val name: String, + val crypto: String = "", val valueSeries: List = emptyList(), // aligned to main chartData indices - val investedSeries: List = emptyList() // aligned to main chartData indices + val investedSeries: List = emptyList(), // aligned to main chartData indices + val avgBuyPriceSeries: List = emptyList(), + val accumulatedSeries: List = emptyList() +) + +enum class CryptoGroupLineType { PRICE, TOTAL_ACCUMULATED } + +@Immutable +data class CryptoGroupLineInfo( + val crypto: String, + val priceSeries: List = emptyList(), + val totalAccumulatedSeries: List = emptyList() ) @Immutable @@ -58,6 +70,9 @@ data class PortfolioUiState( val scrubbedIndex: Int? = null, val planLines: List = emptyList(), val visiblePlanLines: Set> = emptySet(), + val cryptoGroupLines: List = emptyList(), + val visibleCryptoGroupLines: Set> = emptySet(), + val isAdvancedLegendExpanded: Boolean = false, val isLoading: Boolean = true, val isChartLoading: Boolean = false, val isPriceSyncing: Boolean = false, @@ -333,7 +348,10 @@ class PortfolioViewModel @Inject constructor( visibleSeries = setOf(0, 1), zoomLevel = ChartZoomLevel.Overview, planLines = emptyList(), - visiblePlanLines = emptySet() + visiblePlanLines = emptySet(), + cryptoGroupLines = emptyList(), + visibleCryptoGroupLines = emptySet(), + isAdvancedLegendExpanded = false ) } // Persist selection so the same chip is restored on next app launch if (page != null) { @@ -375,6 +393,19 @@ class PortfolioViewModel @Inject constructor( } } + fun toggleCryptoGroupLineVisibility(crypto: String, type: CryptoGroupLineType) { + _uiState.update { state -> + val key = crypto to type + val current = state.visibleCryptoGroupLines + val toggled = if (key in current) current - key else current + key + state.copy(visibleCryptoGroupLines = toggled) + } + } + + fun toggleAdvancedLegendExpanded() { + _uiState.update { it.copy(isAdvancedLegendExpanded = !it.isAdvancedLegendExpanded) } + } + fun syncPricesAndLoadChart() { refreshTransactionsAndPairs(force = true) loadChartData() @@ -475,10 +506,15 @@ class PortfolioViewModel @Inject constructor( ) } + // Compute relevantPlans once so both planLines and cryptoGroupLines can use it + val relevantPlans = if (page is PairPage.Aggregate) { + try { dcaPlanDao.getAllPlansOnceOrdered().filter { it.fiat == page.fiat } } + catch (_: Exception) { emptyList() } + } else emptyList() + // Calculate per-plan lines (only for Aggregate pages - Plan pages show a single plan's main line) val planLinesList = if (page is PairPage.Aggregate && data.isNotEmpty()) { try { - val relevantPlans = dcaPlanDao.getAllPlansOnceOrdered().filter { it.fiat == page.fiat } if (relevantPlans.size >= 2) { val mainEpochDays = data.map { it.epochDay } relevantPlans.mapNotNull { plan -> @@ -494,6 +530,8 @@ class PortfolioViewModel @Inject constructor( val planByDay = planData.associateBy { it.epochDay } var lastValue = 0f var lastInvested = 0f + var lastAvg = 0f + var lastAccum = 0f val valueAligned = mainEpochDays.map { day -> val v = planByDay[day]?.portfolioValue?.toFloat() if (v != null) { lastValue = v; v } else lastValue @@ -502,17 +540,62 @@ class PortfolioViewModel @Inject constructor( val v = planByDay[day]?.totalInvested?.toFloat() if (v != null) { lastInvested = v; v } else lastInvested } + val avgBuyAligned = mainEpochDays.map { day -> + val v = planByDay[day]?.avgBuyPrice?.toFloat() + if (v != null) { lastAvg = v; v } else lastAvg + } + val accumulatedAligned = mainEpochDays.map { day -> + val v = planByDay[day]?.cumulativeCrypto?.toFloat() + if (v != null) { lastAccum = v; v } else lastAccum + } PlanLineInfo( planId = plan.id, name = plan.name.ifBlank { "${plan.crypto}/${plan.fiat} #${plan.id}" }, + crypto = plan.crypto, valueSeries = valueAligned, - investedSeries = investedAligned + investedSeries = investedAligned, + avgBuyPriceSeries = avgBuyAligned, + accumulatedSeries = accumulatedAligned ) } } else emptyList() } catch (_: Exception) { emptyList() } } else emptyList() + // Calculate per-crypto-group lines (price + total accumulated per unique crypto within the aggregate) + val cryptoGroupLinesList = if (page is PairPage.Aggregate && data.isNotEmpty()) { + try { + val uniqueCryptos = relevantPlans.map { it.crypto }.distinct() + val mainEpochDays = data.map { it.epochDay } + uniqueCryptos.mapNotNull { crypto -> + val cryptoTxs = completedTransactions.filter { it.crypto == crypto && it.fiat == page.fiat } + if (cryptoTxs.isEmpty()) return@mapNotNull null + val cryptoChartData = calculateChartDataUseCase.calculate( + transactions = cryptoTxs, + crypto = crypto, + fiat = page.fiat, + zoomLevel = state.zoomLevel + ) + val byDay = cryptoChartData.associateBy { it.epochDay } + var lastPrice = 0f + var lastAccum = 0f + val priceAligned = mainEpochDays.map { day -> + val v = byDay[day]?.price?.toFloat() + if (v != null) { lastPrice = v; v } else lastPrice + } + val accAligned = mainEpochDays.map { day -> + val v = byDay[day]?.cumulativeCrypto?.toFloat() + if (v != null) { lastAccum = v; v } else lastAccum + } + CryptoGroupLineInfo( + crypto = crypto, + priceSeries = priceAligned, + totalAccumulatedSeries = accAligned + ) + } + } catch (_: Exception) { emptyList() } + } else emptyList() + val txCount = filteredTxs.count { tx -> (crypto == null || tx.crypto == crypto) && (fiat == null || tx.fiat == fiat) @@ -524,6 +607,7 @@ class PortfolioViewModel @Inject constructor( currentPairFiat = fiat, totalTransactions = txCount, planLines = planLinesList, + cryptoGroupLines = cryptoGroupLinesList, isChartLoading = false ) } } catch (e: OutOfMemoryError) { diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index ba29463..d04402a 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -345,6 +345,12 @@ Celkem investováno Hodnota %1$s Investováno %1$s + Prům. nák. cena %1$s + Akumulováno %1$s + Cena %1$s + Celkem akumulováno %1$s + Pokročilé + Skrýt pokročilé Držené krypto Ekvivalent investice Prům. cena diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 5681e2a..b5c3c63 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -344,6 +344,12 @@ Total invested Value %1$s Invested %1$s + Avg buy %1$s + Accumulated %1$s + Price %1$s + Total accumulated %1$s + Advanced + Hide advanced Crypto Held Invested Equiv. Avg. Price From 987b2bcca2965ad1635faa5de7ea88b40cc7c27b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 12 Apr 2026 15:10:48 +0200 Subject: [PATCH 23/26] =?UTF-8?q?feat(portfolio):=2016=20distinct=20colors?= =?UTF-8?q?=20per=20plan-metric=20combo=20+=20rename=20"Pokro=C4=8Dil?= =?UTF-8?q?=C3=A9"=20to=20"Dal=C5=A1=C3=AD=20metriky"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each plan-metric combination (value/invested/avg buy/accumulated) gets its own distinct color from a 16-color palette. Plan 0 uses colors 0..3, Plan 1 uses 4..7, etc. Cycles modulo 16 after 4 plans. - Legend labels use the same assignment so chart and legend colors match. - Section toggle renamed: "Pokročilé" -> "Další metriky" / "More metrics" Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ChartComponents.kt | 130 +++++++++--------- .../screens/portfolio/PortfolioScreen.kt | 14 +- .../app/src/main/res/values-cs/strings.xml | 4 +- .../app/src/main/res/values/strings.xml | 4 +- 4 files changed, 78 insertions(+), 74 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt index 5dda469..8970b23 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt @@ -66,15 +66,39 @@ internal val btcPriceColor = Color(0xFFF7931A) internal val accumulatedCryptoColor = Color(0xFF4CAF50) internal val avgBuyPriceColor = Color(0xFF9C27B0) -internal val planLineColors = listOf( - Color(0xFFFF6B6B), // red - Color(0xFF4ECDC4), // teal - Color(0xFFFFD93D), // yellow - Color(0xFF6C63FF), // purple - Color(0xFFFF8A65), // orange - Color(0xFF81C784), // green +/** + * 16 visually distinct colors for per-plan-metric lines. One color per + * (plan, metric) combination, cycling after 4 plans (4 plans * 4 metrics = 16). + */ +internal val distinctLineColors = listOf( + Color(0xFFE53935), // 0 red + Color(0xFF1E88E5), // 1 blue + Color(0xFF43A047), // 2 green + Color(0xFFFB8C00), // 3 orange + Color(0xFF8E24AA), // 4 purple + Color(0xFF00ACC1), // 5 cyan + Color(0xFFFDD835), // 6 yellow + Color(0xFF6D4C41), // 7 brown + Color(0xFFEC407A), // 8 pink + Color(0xFF00897B), // 9 teal + Color(0xFF3949AB), // 10 indigo + Color(0xFFF4511E), // 11 deep orange + Color(0xFF7CB342), // 12 light green + Color(0xFF5E35B1), // 13 deep purple + Color(0xFF546E7A), // 14 blue grey + Color(0xFFAFB42B), // 15 lime ) +/** Back-compat alias used by older code paths that only show one color per plan. */ +internal val planLineColors = distinctLineColors + +/** + * Assigns a distinct color index for each (plan index, metric) combination. + * Plan 0 gets indices 0..3, Plan 1 gets 4..7, etc. Cycles modulo 16. + */ +internal fun distinctColorIdx(planIdx: Int, metricOrdinal: Int): Int = + ((planIdx * 4) + metricOrdinal) % 16 + internal val cryptoGroupColors = mapOf( "BTC" to Color(0xFFF7931A), "ETH" to Color(0xFF627EEA), @@ -323,41 +347,30 @@ fun PortfolioLineChart( fill = LineCartesianLayer.LineFill.single(fill(Color.Transparent)) ) - // Pre-create plan line styles (max 6 plans) - value lines (solid, full color) - val planValueStyle0 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[0]))) - val planValueStyle1 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[1]))) - val planValueStyle2 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[2]))) - val planValueStyle3 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[3]))) - val planValueStyle4 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[4]))) - val planValueStyle5 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[5]))) - val planValueStyles = listOf(planValueStyle0, planValueStyle1, planValueStyle2, planValueStyle3, planValueStyle4, planValueStyle5) - - // Invested lines (lighter/translucent, same base color) - val planInvestedStyle0 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[0].copy(alpha = 0.4f)))) - val planInvestedStyle1 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[1].copy(alpha = 0.4f)))) - val planInvestedStyle2 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[2].copy(alpha = 0.4f)))) - val planInvestedStyle3 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[3].copy(alpha = 0.4f)))) - val planInvestedStyle4 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[4].copy(alpha = 0.4f)))) - val planInvestedStyle5 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[5].copy(alpha = 0.4f)))) - val planInvestedStyles = listOf(planInvestedStyle0, planInvestedStyle1, planInvestedStyle2, planInvestedStyle3, planInvestedStyle4, planInvestedStyle5) - - // Avg buy price lines (alpha 0.7) - val planAvgBuyStyle0 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[0].copy(alpha = 0.7f)))) - val planAvgBuyStyle1 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[1].copy(alpha = 0.7f)))) - val planAvgBuyStyle2 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[2].copy(alpha = 0.7f)))) - val planAvgBuyStyle3 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[3].copy(alpha = 0.7f)))) - val planAvgBuyStyle4 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[4].copy(alpha = 0.7f)))) - val planAvgBuyStyle5 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[5].copy(alpha = 0.7f)))) - val planAvgBuyStyles = listOf(planAvgBuyStyle0, planAvgBuyStyle1, planAvgBuyStyle2, planAvgBuyStyle3, planAvgBuyStyle4, planAvgBuyStyle5) - - // Accumulated lines (alpha 0.85) - val planAccumulatedStyle0 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[0].copy(alpha = 0.85f)))) - val planAccumulatedStyle1 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[1].copy(alpha = 0.85f)))) - val planAccumulatedStyle2 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[2].copy(alpha = 0.85f)))) - val planAccumulatedStyle3 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[3].copy(alpha = 0.85f)))) - val planAccumulatedStyle4 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[4].copy(alpha = 0.85f)))) - val planAccumulatedStyle5 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(planLineColors[5].copy(alpha = 0.85f)))) - val planAccumulatedStyles = listOf(planAccumulatedStyle0, planAccumulatedStyle1, planAccumulatedStyle2, planAccumulatedStyle3, planAccumulatedStyle4, planAccumulatedStyle5) + // Pre-create 16 distinct line styles (one per plan-metric combination, cycles after 4 plans). + // See distinctLineColors for the palette. Styles are used via distinctColorIdx(planIdx, metricOrdinal). + val distinctLine0 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[0]))) + val distinctLine1 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[1]))) + val distinctLine2 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[2]))) + val distinctLine3 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[3]))) + val distinctLine4 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[4]))) + val distinctLine5 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[5]))) + val distinctLine6 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[6]))) + val distinctLine7 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[7]))) + val distinctLine8 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[8]))) + val distinctLine9 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[9]))) + val distinctLine10 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[10]))) + val distinctLine11 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[11]))) + val distinctLine12 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[12]))) + val distinctLine13 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[13]))) + val distinctLine14 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[14]))) + val distinctLine15 = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(distinctLineColors[15]))) + val distinctLineStyles = listOf( + distinctLine0, distinctLine1, distinctLine2, distinctLine3, + distinctLine4, distinctLine5, distinctLine6, distinctLine7, + distinctLine8, distinctLine9, distinctLine10, distinctLine11, + distinctLine12, distinctLine13, distinctLine14, distinctLine15 + ) // Crypto group price line styles (full alpha) - pre-allocated for known cryptos val btcPriceStyleLine = LineCartesianLayer.rememberLine(fill = LineCartesianLayer.LineFill.single(fill(Color(0xFFF7931A)))) @@ -409,29 +422,22 @@ fun PortfolioLineChart( if (1 in visibleSeries) add(costBasisLine) if (2 in visibleSeries) add(priceLine) if (4 in visibleSeries) add(avgBuyPriceLine) - // Per-plan line styles (value + invested share the same color index per plan) - var planStyleIdx = 0 - planLines.forEach { planLine -> + // Per-plan lines (each plan+metric combo gets its own distinct color, cycles after 4 plans) + planLines.forEachIndexed { planIdx, planLine -> val valueKey = planLine.planId to PlanLineType.VALUE - val investedKey = planLine.planId to PlanLineType.INVESTED if (valueKey in visiblePlanLines && planLine.valueSeries.size == chartData.size) { - add(planValueStyles[planStyleIdx % planValueStyles.size]) + add(distinctLineStyles[distinctColorIdx(planIdx, PlanLineType.VALUE.ordinal)]) } + val investedKey = planLine.planId to PlanLineType.INVESTED if (investedKey in visiblePlanLines && planLine.investedSeries.size == chartData.size) { - add(planInvestedStyles[planStyleIdx % planInvestedStyles.size]) + add(distinctLineStyles[distinctColorIdx(planIdx, PlanLineType.INVESTED.ordinal)]) } - planStyleIdx++ // increment per plan, not per line, so value and invested share the color - } - // Per-plan avg buy price styles - var planStyleIdxAvg = 0 - planLines.forEach { planLine -> - val key = planLine.planId to PlanLineType.AVG_BUY_PRICE - if (key in visiblePlanLines && planLine.avgBuyPriceSeries.size == chartData.size) { - add(planAvgBuyStyles[planStyleIdxAvg % planAvgBuyStyles.size]) + val avgKey = planLine.planId to PlanLineType.AVG_BUY_PRICE + if (avgKey in visiblePlanLines && planLine.avgBuyPriceSeries.size == chartData.size) { + add(distinctLineStyles[distinctColorIdx(planIdx, PlanLineType.AVG_BUY_PRICE.ordinal)]) } - planStyleIdxAvg++ } - // Per-crypto price styles + // Per-crypto price styles (uses crypto brand colors, not distinct palette) cryptoGroupLines.forEach { cgLine -> val key = cgLine.crypto to CryptoGroupLineType.PRICE if (key in visibleCryptoGroupLines && cgLine.priceSeries.size == chartData.size) { @@ -442,14 +448,12 @@ fun PortfolioLineChart( } val rightLines = buildList { if (3 in visibleSeries) add(accumulatedLine) - // Per-plan accumulated styles - var planStyleIdxAcc = 0 - planLines.forEach { planLine -> + // Per-plan accumulated (one distinct color per plan-metric combo) + planLines.forEachIndexed { planIdx, planLine -> val key = planLine.planId to PlanLineType.ACCUMULATED if (key in visiblePlanLines && planLine.accumulatedSeries.size == chartData.size) { - add(planAccumulatedStyles[planStyleIdxAcc % planAccumulatedStyles.size]) + add(distinctLineStyles[distinctColorIdx(planIdx, PlanLineType.ACCUMULATED.ordinal)]) } - planStyleIdxAcc++ } // Per-crypto accumulated styles cryptoGroupLines.forEach { cgLine -> diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index 94f1384..d44b91c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -1299,7 +1299,9 @@ private fun PlanLinesLegend( isAdvancedExpanded: Boolean = false, onToggleAdvanced: () -> Unit = {} ) { - val planLineColors = com.accbot.dca.presentation.components.planLineColors + val distinctColors = com.accbot.dca.presentation.components.distinctLineColors + fun planMetricColor(planIdx: Int, metric: PlanLineType): androidx.compose.ui.graphics.Color = + distinctColors[com.accbot.dca.presentation.components.distinctColorIdx(planIdx, metric.ordinal)] Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, @@ -1307,14 +1309,13 @@ private fun PlanLinesLegend( ) { // Primary section: value + invested per plan planLines.forEachIndexed { index, planLine -> - val baseColor = planLineColors[index % planLineColors.size] Row( horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth() ) { val valueEnabled = (planLine.planId to PlanLineType.VALUE) in visiblePlanLines LegendTextEntry( - color = baseColor, + color = planMetricColor(index, PlanLineType.VALUE), label = stringResource(R.string.chart_plan_value, planLine.name), enabled = valueEnabled, onClick = { onToggle(planLine.planId, PlanLineType.VALUE) } @@ -1324,7 +1325,7 @@ private fun PlanLinesLegend( val investedEnabled = (planLine.planId to PlanLineType.INVESTED) in visiblePlanLines LegendTextEntry( - color = baseColor.copy(alpha = 0.4f), + color = planMetricColor(index, PlanLineType.INVESTED), label = stringResource(R.string.chart_plan_invested, planLine.name), enabled = investedEnabled, onClick = { onToggle(planLine.planId, PlanLineType.INVESTED) } @@ -1389,14 +1390,13 @@ private fun PlanLinesLegend( // Per-plan advanced rows: Prům. nák. cena + Akumulováno planLines.forEachIndexed { index, planLine -> - val baseColor = planLineColors[index % planLineColors.size] Row( horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth() ) { val avgEnabled = (planLine.planId to PlanLineType.AVG_BUY_PRICE) in visiblePlanLines LegendTextEntry( - color = baseColor.copy(alpha = 0.7f), + color = planMetricColor(index, PlanLineType.AVG_BUY_PRICE), label = stringResource(R.string.chart_plan_avg_buy, planLine.name), enabled = avgEnabled, onClick = { onToggle(planLine.planId, PlanLineType.AVG_BUY_PRICE) } @@ -1406,7 +1406,7 @@ private fun PlanLinesLegend( val accEnabled = (planLine.planId to PlanLineType.ACCUMULATED) in visiblePlanLines LegendTextEntry( - color = baseColor.copy(alpha = 0.85f), + color = planMetricColor(index, PlanLineType.ACCUMULATED), label = stringResource(R.string.chart_plan_accumulated, planLine.name), enabled = accEnabled, onClick = { onToggle(planLine.planId, PlanLineType.ACCUMULATED) } diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index d04402a..0c945e8 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -349,8 +349,8 @@ Akumulováno %1$s Cena %1$s Celkem akumulováno %1$s - Pokročilé - Skrýt pokročilé + Další metriky + Skrýt metriky Držené krypto Ekvivalent investice Prům. cena diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index b5c3c63..9686d80 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -348,8 +348,8 @@ Accumulated %1$s Price %1$s Total accumulated %1$s - Advanced - Hide advanced + More metrics + Hide metrics Crypto Held Invested Equiv. Avg. Price From f8009888aa7e4fc4131c9e7490f9e3c6688a4b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 12 Apr 2026 15:20:45 +0200 Subject: [PATCH 24/26] =?UTF-8?q?fix(portfolio):=20allow=20toggling=20off?= =?UTF-8?q?=20all=20base=20series=20(Celkem=20hodnota/investov=C3=A1no)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous guard prevented the last visible series from being hidden, which made "Celkem investováno" untoggleable if "Celkem hodnota" was already off. With plan lines and crypto group lines available on aggregate view, an empty base series set is valid. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dca/presentation/screens/portfolio/PortfolioViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index 80ee362..bd5759c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -379,7 +379,6 @@ class PortfolioViewModel @Inject constructor( _uiState.update { state -> val current = state.visibleSeries val toggled = if (seriesIndex in current) current - seriesIndex else current + seriesIndex - if (toggled.isEmpty()) return state.copy(visibleSeries = toggled) } } From 25187ca7d404201ad2983e1914d4c8635ded3036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 12 Apr 2026 15:33:26 +0200 Subject: [PATCH 25/26] feat(add-plan): optional name field when creating a DCA plan Shown in AddPlan and FirstPlan screens (onboarding). EditPlan keeps the existing separate rename dialog, so showNameField=false there. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dca/domain/usecase/CreateDcaPlanUseCase.kt | 4 +++- .../dca/presentation/plan/PlanFormContent.kt | 16 ++++++++++++++++ .../dca/presentation/plan/PlanFormDelegate.kt | 5 +++++ .../dca/presentation/screens/AddPlanScreen.kt | 1 + .../dca/presentation/screens/AddPlanViewModel.kt | 3 ++- .../screens/onboarding/FirstPlanScreen.kt | 1 + .../screens/onboarding/OnboardingViewModel.kt | 3 ++- .../presentation/screens/plans/EditPlanScreen.kt | 2 ++ 8 files changed, 32 insertions(+), 3 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt index 030241c..998b48e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt @@ -43,7 +43,8 @@ class CreateDcaPlanUseCase @Inject constructor( withdrawalEnabled: Boolean = false, withdrawalAddress: String? = null, targetAmount: BigDecimal? = null, - connectionId: Long? = null + connectionId: Long? = null, + name: String = "" ) { val now = Instant.now() val nextExecution = if (frequency == DcaFrequency.CUSTOM && cronExpression != null) { @@ -64,6 +65,7 @@ class CreateDcaPlanUseCase @Inject constructor( val plan = DcaPlanEntity( exchange = exchange, connectionId = resolvedConnectionId, + name = name.trim(), crypto = crypto, fiat = fiat, amount = amount, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt index 61abb81..800c6e6 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt @@ -45,6 +45,7 @@ fun PlanFormContent( state: PlanFormState, availableCryptos: List, availableFiats: List, + onNameChanged: (String) -> Unit, onCryptoSelected: (String) -> Unit, onFiatSelected: (String) -> Unit, onAmountChanged: (String) -> Unit, @@ -57,12 +58,27 @@ fun PlanFormContent( modifier: Modifier = Modifier, exchange: Exchange? = null, showCryptoFiatSelection: Boolean = true, + showNameField: Boolean = true, errorMessage: String? = null ) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(24.dp) ) { + // Optional plan name + if (showNameField) { + Column { + SectionTitle(stringResource(R.string.plan_details_add_name)) + OutlinedTextField( + value = state.name, + onValueChange = onNameChanged, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(stringResource(R.string.plan_details_name_hint)) }, + singleLine = true + ) + } + } + // Crypto selection if (showCryptoFiatSelection) { Column { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt index 0439dcf..d448e08 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt @@ -21,6 +21,7 @@ import java.math.BigDecimal @Immutable data class PlanFormState( + val name: String = "", val selectedCrypto: String = "BTC", val selectedFiat: String = "EUR", val amount: String = "100", @@ -73,6 +74,10 @@ class PlanFormDelegate( private var estimateJob: Job? = null private var currentExchange: Exchange? = null + fun setName(name: String) { + _state.update { it.copy(name = name) } + } + fun selectCrypto(crypto: String) { _state.update { it.copy(selectedCrypto = crypto) } updateMonthlyCostEstimate() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt index 30aca5c..b07cf6b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanScreen.kt @@ -309,6 +309,7 @@ fun AddPlanScreen( state = uiState.planForm, availableCryptos = cred.selectedExchange!!.supportedCryptos, availableFiats = cred.selectedExchange!!.supportedFiats, + onNameChanged = viewModel.planForm::setName, onCryptoSelected = viewModel.planForm::selectCrypto, onFiatSelected = viewModel.planForm::selectFiat, onAmountChanged = viewModel.planForm::setAmount, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt index 934e077..70044bb 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/AddPlanViewModel.kt @@ -156,7 +156,8 @@ class AddPlanViewModel @Inject constructor( strategy = form.selectedStrategy, withdrawalEnabled = form.withdrawalEnabled, withdrawalAddress = if (form.withdrawalEnabled) form.withdrawalAddress.trim() else null, - targetAmount = form.targetAmount.toBigDecimalOrNull() + targetAmount = form.targetAmount.toBigDecimalOrNull(), + name = form.name.trim() ) // Only offer the API import flow when this was a freshly created connection. diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/FirstPlanScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/FirstPlanScreen.kt index 7c6a4d7..7539c54 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/FirstPlanScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/FirstPlanScreen.kt @@ -93,6 +93,7 @@ fun FirstPlanScreen( state = uiState.planForm, availableCryptos = uiState.credentialForm.selectedExchange!!.supportedCryptos, availableFiats = uiState.credentialForm.selectedExchange!!.supportedFiats, + onNameChanged = viewModel.planForm::setName, onCryptoSelected = viewModel.planForm::selectCrypto, onFiatSelected = viewModel.planForm::selectFiat, onAmountChanged = viewModel.planForm::setAmount, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/OnboardingViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/OnboardingViewModel.kt index 6c2df1f..4530813 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/OnboardingViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/OnboardingViewModel.kt @@ -126,7 +126,8 @@ class OnboardingViewModel @Inject constructor( strategy = form.selectedStrategy, withdrawalEnabled = form.withdrawalEnabled, withdrawalAddress = if (form.withdrawalEnabled) form.withdrawalAddress.trim() else null, - targetAmount = form.targetAmount.toBigDecimalOrNull() + targetAmount = form.targetAmount.toBigDecimalOrNull(), + name = form.name.trim() ) onboardingPreferences.setPlanCreatedDuringOnboarding(true) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt index 9164e03..f150259 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanScreen.kt @@ -153,6 +153,8 @@ fun EditPlanScreen( availableCryptos = listOf(uiState.crypto), availableFiats = listOf(uiState.fiat), showCryptoFiatSelection = false, + showNameField = false, + onNameChanged = viewModel.planForm::setName, onCryptoSelected = viewModel.planForm::selectCrypto, onFiatSelected = viewModel.planForm::selectFiat, onAmountChanged = viewModel.planForm::setAmount, From bf3bffd9e58351caec6d78c57f190ab64d728553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 12 Apr 2026 16:38:33 +0200 Subject: [PATCH 26/26] fix(review): address code review findings on multi-connection envelopes Code review pass over the feature/multi-connection-envelopes branch surfaced a handful of concurrency, performance and resilience issues. This commit addresses all of them: Critical - Add @SerializedName to every Gson-serialized backup model and ExchangeCredentials. Release buildu with R8 field renaming was only safe because the enclosing packages are in proguard-rules.pro - belt-and- suspenders so future refactors can't silently break backup restore. - DashboardViewModel.reorderPlans: Mutex serializes display-order writes and the plan flow is distinctUntilChanged on content (ignoring displayOrder) so collectLatest no longer cancels in-flight balance/price fetches on every drag swap. - PortfolioViewModel.loadChartData: cache cachedDbPlans (no more N+1 DB queries on chart navigation) and run the heavy per-plan/per-crypto chart computation on Dispatchers.Default instead of the main thread. High - DashboardScreen PlanDragState now tracks plan ID instead of list index and resolves the live index on every drag event - immune to Flow emits reshuffling the list mid-drag. - BackupDataRestorer runs a post-restore integrity check and surfaces connections that ended up with plans but no credentials, so DcaWorker no longer silently loops forever on a half-restored backup. - PortfolioScreen adds a LaunchedEffect keyed on the structural page list that re-aligns the pager with selectedPageIndex when plans are added, removed or renamed. Medium - CredentialsStore.migrateV2ToV3ForEnv returns a success flag so partial failures no longer latch KEY_MIGRATION_V3_DONE; remaining exchanges get another chance on next launch. - DcaWorker now raises a "missing credentials" notification (new NotificationTemplateArgs.MissingCredentials template + strings) when a plan's connection has no stored API keys, with in-memory dedup so the user gets one notification per plan per worker lifetime. Low / bundle - TransactionDao.getByExchangeOrderIdAndConnection dedupes restore by (exchangeOrderId, connectionId) - two connections on the same exchange that share an orderId no longer collapse into one row. - PortfolioViewModel consumes deep-link initialCrypto/initialFiat once and clears them from SavedStateHandle so a process death restore no longer overrides the persisted chip selection. - DashboardViewModel folds exchange connection names into the main combine flow instead of making per-emit per-id getById lookups. Verified with ./gradlew :app:assembleDebug and ./gradlew :app:assembleRelease (R8 minify + lint pass; only packageRelease fails on the missing signing keystore, which is expected). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dca/data/local/BackupDataRestorer.kt | 60 +++- .../accbot/dca/data/local/CredentialsStore.kt | 23 +- .../java/com/accbot/dca/data/local/Daos.kt | 9 + .../data/local/NotificationTemplateArgs.kt | 23 ++ .../accbot/dca/domain/model/BackupModels.kt | 200 +++++------ .../com/accbot/dca/domain/model/Models.kt | 17 +- .../presentation/screens/DashboardScreen.kt | 77 +++-- .../screens/DashboardViewModel.kt | 57 ++- .../notifications/NotificationRenderer.kt | 12 + .../screens/portfolio/PortfolioScreen.kt | 22 ++ .../screens/portfolio/PortfolioViewModel.kt | 325 +++++++++++------- .../java/com/accbot/dca/worker/DcaWorker.kt | 30 ++ .../app/src/main/res/values-cs/strings.xml | 2 + .../app/src/main/res/values/strings.xml | 2 + 14 files changed, 569 insertions(+), 290 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt index 0fe83f4..5489b46 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt @@ -170,15 +170,22 @@ class BackupDataRestorer @Inject constructor( } } - // 2. Transactions with remapped planId (merge: skip duplicates by exchangeOrderId) + // 2. Transactions with remapped planId. + // Merge dedup is scoped to (exchangeOrderId, connectionId): two connections + // on the same exchange can legitimately share an orderId (e.g. main vs + // savings envelope, or after an API key rotation), so a global lookup + // would silently drop the second set. for (tx in payload.transactions) { val remappedPlanId = planIdMap[tx.planId] ?: tx.planId - if (restoreMode == RestoreMode.Merge && !tx.exchangeOrderId.isNullOrEmpty()) { - val existing = transactionDao.getByExchangeOrderId(tx.exchangeOrderId) - if (existing != null) continue // Already imported, skip - } val txExchange = Exchange.valueOf(tx.exchange) val connectionId = resolveConnectionForRestore(tx.connectionId, txExchange) + if (restoreMode == RestoreMode.Merge && !tx.exchangeOrderId.isNullOrEmpty()) { + val existing = transactionDao.getByExchangeOrderIdAndConnection( + tx.exchangeOrderId, + connectionId + ) + if (existing != null) continue // Already imported on this connection, skip + } transactionDao.insertTransaction(tx.toEntity(remappedPlanId, connectionId)) } @@ -245,8 +252,27 @@ class BackupDataRestorer @Inject constructor( } } - if (failedCredentials > 0) { - BackupResult.Success("Restored, but $failedCredentials credential set(s) could not be saved") + // Post-restore integrity check: find connections that now have plans but + // no credentials. This catches both the "backup had no credentials for this + // connection" case and the "credential save failed" case (e.g. app died + // between DB commit and credential save). Without this warning, DcaWorker + // would silently loop "no credentials" forever for the orphaned connection. + val orphanedConnections = findConnectionsMissingCredentials(isSandbox) + + val warnings = buildList { + if (failedCredentials > 0) { + add("$failedCredentials credential set(s) could not be saved") + } + if (orphanedConnections.isNotEmpty()) { + val names = orphanedConnections.joinToString(", ") { (exch, name) -> + if (name.isBlank()) exch.displayName else "${exch.displayName} ($name)" + } + add("missing API keys for: $names") + } + } + + if (warnings.isNotEmpty()) { + BackupResult.Success("Restored, but " + warnings.joinToString("; ")) } else { BackupResult.Success() } @@ -255,6 +281,26 @@ class BackupDataRestorer @Inject constructor( } } + /** + * Walk the DB looking for connections that have at least one plan but no credentials + * stored for the current environment. Returns a list of (exchange, connectionName) + * pairs to surface in the restore result message. + */ + private suspend fun findConnectionsMissingCredentials(isSandbox: Boolean): List> { + return try { + val plans = dcaPlanDao.getAllPlansOnce() + val connectionIdsWithPlans = plans.map { it.connectionId }.toSet() + if (connectionIdsWithPlans.isEmpty()) return emptyList() + connectionIdsWithPlans.mapNotNull { id -> + val conn = exchangeConnectionDao.getById(id) ?: return@mapNotNull null + if (credentialsStore.hasCredentials(id, isSandbox)) null + else conn.exchange to conn.name + } + } catch (_: Exception) { + emptyList() + } + } + /** * Internal pre-parsed credential record. Built BEFORE the DB transaction so any * malformed backup credential aborts the restore upfront, before plans are committed. diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/CredentialsStore.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/CredentialsStore.kt index 9329f59..c57964b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/CredentialsStore.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/CredentialsStore.kt @@ -115,20 +115,31 @@ class CredentialsStore @Inject constructor( Log.d(TAG, "Running CredentialsStore v2→v3 migration") try { - migrateV2ToV3ForEnv(prodDb, isSandbox = false) - migrateV2ToV3ForEnv(sandboxDb, isSandbox = true) - encryptedPrefs.edit().putBoolean(KEY_MIGRATION_V3_DONE, true).commit() - Log.d(TAG, "CredentialsStore v2→v3 migration complete") + val prodOk = migrateV2ToV3ForEnv(prodDb, isSandbox = false) + val sandboxOk = migrateV2ToV3ForEnv(sandboxDb, isSandbox = true) + if (prodOk && sandboxOk) { + encryptedPrefs.edit().putBoolean(KEY_MIGRATION_V3_DONE, true).commit() + Log.d(TAG, "CredentialsStore v2→v3 migration complete") + } else { + // Don't flag as done - next launch will retry any exchanges that failed. + Log.w(TAG, "CredentialsStore v2→v3 migration partial; will retry next launch") + } } catch (e: Exception) { // Don't set the flag - next launch will retry. Log so we notice. Log.e(TAG, "CredentialsStore v2→v3 migration failed; will retry next launch", e) } } - private suspend fun migrateV2ToV3ForEnv(db: DcaDatabase, isSandbox: Boolean) { + /** + * @return `true` if every exchange with a v2 key was successfully migrated (or had + * nothing to migrate). `false` if at least one exchange failed - caller must NOT + * set the migration-done flag so the remaining exchanges get another chance. + */ + private suspend fun migrateV2ToV3ForEnv(db: DcaDatabase, isSandbox: Boolean): Boolean { val v2Prefix = if (isSandbox) KEY_PREFIX_SANDBOX_V2 else KEY_PREFIX_PROD_V2 val v3Prefix = if (isSandbox) KEY_PREFIX_SANDBOX_V3 else KEY_PREFIX_PROD_V3 val connectionDao = db.exchangeConnectionDao() + var allOk = true for (exchange in Exchange.entries) { val oldKey = "$v2Prefix${exchange.name}" @@ -151,6 +162,7 @@ class CredentialsStore @Inject constructor( // re-fetch and use the existing row. connectionDao.getDefaultByExchange(exchange)?.id ?: run { Log.e(TAG, "Failed to resolve connection for ${exchange.name} after constraint", e) + allOk = false continue } } @@ -167,6 +179,7 @@ class CredentialsStore @Inject constructor( .commit() Log.d(TAG, "Migrated credentials for ${exchange.name} (sandbox=$isSandbox) → connectionId=$connectionId") } + return allOk } // ─────────────────────────────────────────────────────────────────────── diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt index 0d66e69..076a406 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt @@ -344,6 +344,15 @@ interface TransactionDao { @Query("SELECT * FROM transactions WHERE exchangeOrderId = :orderId LIMIT 1") suspend fun getByExchangeOrderId(orderId: String): TransactionEntity? + /** + * Connection-scoped lookup: find a transaction by exchange order id within a + * specific connection. Used by backup restore dedup so two connections + * (e.g. prod vs sandbox, or "main" vs "savings") that happen to share an + * exchangeOrderId don't collapse into one row. + */ + @Query("SELECT * FROM transactions WHERE exchangeOrderId = :orderId AND connectionId = :connectionId LIMIT 1") + suspend fun getByExchangeOrderIdAndConnection(orderId: String, connectionId: Long): TransactionEntity? + @Query("SELECT CAST(COALESCE(SUM(CAST(cryptoAmount AS REAL)), 0) AS TEXT) FROM transactions WHERE planId = :planId AND status = 'COMPLETED'") suspend fun getAccumulatedCryptoByPlan(planId: Long): String } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt index 556f963..973b844 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt @@ -160,6 +160,23 @@ sealed class NotificationTemplateArgs { }.toString() } + /** + * Plan can't execute because the API credentials for its connection are missing + * (deleted, never imported, or lost during a failed backup restore). + */ + data class MissingCredentials( + val crypto: String, + val exchangeName: String, + val connectionName: String + ) : NotificationTemplateArgs() { + override fun toJson(): String = JSONObject().apply { + put(KEY_TYPE, TYPE_MISSING_CREDENTIALS) + put("crypto", crypto) + put("exchangeName", exchangeName) + put("connectionName", connectionName) + }.toString() + } + companion object { private const val KEY_TYPE = "type" private const val TYPE_PURCHASE = "purchase" @@ -171,6 +188,7 @@ sealed class NotificationTemplateArgs { private const val TYPE_BELOW_MINIMUM = "below_minimum" private const val TYPE_NETWORK_RETRY = "network_retry" private const val TYPE_MISSED_PURCHASES = "missed_purchases" + private const val TYPE_MISSING_CREDENTIALS = "missing_credentials" fun fromJson(json: String): NotificationTemplateArgs? = try { val obj = JSONObject(json) @@ -230,6 +248,11 @@ sealed class NotificationTemplateArgs { attemptCount = obj.optInt("attemptCount", 1), planId = obj.optLong("planId", 0) ) + TYPE_MISSING_CREDENTIALS -> MissingCredentials( + crypto = obj.getString("crypto"), + exchangeName = obj.getString("exchangeName"), + connectionName = obj.optString("connectionName", "") + ) else -> null } } catch (_: Exception) { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt index db4fccc..0469b2d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt @@ -1,5 +1,6 @@ package com.accbot.dca.domain.model +import com.google.gson.annotations.SerializedName import java.time.Instant /** @@ -12,18 +13,23 @@ import java.time.Instant * fields on plans/transactions/withdrawals/notifications/credentials/thresholds so * multiple connections per exchange can roundtrip cleanly. v1 backups are still * restored by auto-creating one default connection per exchange. + * + * All fields in this file are annotated with @SerializedName so Gson deserialization + * survives R8 field renaming even if the containing package is ever moved outside the + * ProGuard keep rules. Without these, a release build could silently return null + * fields for restored backups. */ data class BackupEnvelope( - val format: String = FORMAT_IDENTIFIER, - val version: Int = CURRENT_VERSION, - val createdAt: Long = Instant.now().toEpochMilli(), - val appVersion: String = "", - val platform: String = "android", - val environment: String = "prod", - val encrypted: Boolean = true, - val compressed: Boolean = true, - val sections: List = emptyList(), - val data: String = "" // base64(salt ‖ IV ‖ ciphertext ‖ GCM-tag) or plain JSON + @SerializedName("format") val format: String = FORMAT_IDENTIFIER, + @SerializedName("version") val version: Int = CURRENT_VERSION, + @SerializedName("createdAt") val createdAt: Long = Instant.now().toEpochMilli(), + @SerializedName("appVersion") val appVersion: String = "", + @SerializedName("platform") val platform: String = "android", + @SerializedName("environment") val environment: String = "prod", + @SerializedName("encrypted") val encrypted: Boolean = true, + @SerializedName("compressed") val compressed: Boolean = true, + @SerializedName("sections") val sections: List = emptyList(), + @SerializedName("data") val data: String = "" // base64(salt ‖ IV ‖ ciphertext ‖ GCM-tag) or plain JSON ) { companion object { const val FORMAT_IDENTIFIER = "accbot-backup" @@ -39,15 +45,15 @@ data class BackupEnvelope( * time); during restore, BackupDataRestorer remaps these to fresh local IDs. */ data class BackupPayload( - val plans: List = emptyList(), - val settings: BackupSettings? = null, - val withdrawalThresholds: List = emptyList(), - val credentials: List = emptyList(), - val transactions: List = emptyList(), - val notifications: List = emptyList(), - val withdrawals: List = emptyList(), + @SerializedName("plans") val plans: List = emptyList(), + @SerializedName("settings") val settings: BackupSettings? = null, + @SerializedName("withdrawalThresholds") val withdrawalThresholds: List = emptyList(), + @SerializedName("credentials") val credentials: List = emptyList(), + @SerializedName("transactions") val transactions: List = emptyList(), + @SerializedName("notifications") val notifications: List = emptyList(), + @SerializedName("withdrawals") val withdrawals: List = emptyList(), /** v2+: list of [ExchangeConnectionEntity]-equivalent rows. Empty for legacy v1 backups. */ - val connections: List = emptyList() + @SerializedName("connections") val connections: List = emptyList() ) /** @@ -55,48 +61,48 @@ data class BackupPayload( */ data class BackupExchangeConnection( /** Source DB's autoincrement id at export time. Used as the join key for plans/etc. */ - val id: Long, - val exchange: String, - val name: String = "", - val createdAt: Long = 0, - val displayOrder: Int = 0 + @SerializedName("id") val id: Long, + @SerializedName("exchange") val exchange: String, + @SerializedName("name") val name: String = "", + @SerializedName("createdAt") val createdAt: Long = 0, + @SerializedName("displayOrder") val displayOrder: Int = 0 ) /** * Serializable DCA plan for backup (primitives/strings for platform independence). */ data class BackupPlan( - val id: Long, - val exchange: String, - val crypto: String, - val fiat: String, - val amount: String, // BigDecimal.toPlainString() - val frequency: String, // DcaFrequency.name - val cronExpression: String? = null, - val strategy: String = "Classic", // DcaStrategy DB string - val isEnabled: Boolean = true, - val withdrawalEnabled: Boolean = false, - val withdrawalAddress: String? = null, - val createdAt: Long = 0, // Instant epoch millis - val lastExecutedAt: Long? = null, - val nextExecutionAt: Long? = null, - val targetAmount: String? = null, // BigDecimal.toPlainString() + @SerializedName("id") val id: Long, + @SerializedName("exchange") val exchange: String, + @SerializedName("crypto") val crypto: String, + @SerializedName("fiat") val fiat: String, + @SerializedName("amount") val amount: String, // BigDecimal.toPlainString() + @SerializedName("frequency") val frequency: String, // DcaFrequency.name + @SerializedName("cronExpression") val cronExpression: String? = null, + @SerializedName("strategy") val strategy: String = "Classic", // DcaStrategy DB string + @SerializedName("isEnabled") val isEnabled: Boolean = true, + @SerializedName("withdrawalEnabled") val withdrawalEnabled: Boolean = false, + @SerializedName("withdrawalAddress") val withdrawalAddress: String? = null, + @SerializedName("createdAt") val createdAt: Long = 0, // Instant epoch millis + @SerializedName("lastExecutedAt") val lastExecutedAt: Long? = null, + @SerializedName("nextExecutionAt") val nextExecutionAt: Long? = null, + @SerializedName("targetAmount") val targetAmount: String? = null, // BigDecimal.toPlainString() /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ - val connectionId: Long? = null + @SerializedName("connectionId") val connectionId: Long? = null ) /** * Serializable user settings for backup. */ data class BackupSettings( - val appTheme: String = "DARK", - val notificationsEnabled: Boolean = true, - val purchaseNotifications: Boolean = true, - val errorNotifications: Boolean = true, - val weeklySummaryNotifications: Boolean = false, - val languageTag: String = "", - val biometricLockEnabled: Boolean = false, - val lowBalanceThresholdDays: Int = 2 + @SerializedName("appTheme") val appTheme: String = "DARK", + @SerializedName("notificationsEnabled") val notificationsEnabled: Boolean = true, + @SerializedName("purchaseNotifications") val purchaseNotifications: Boolean = true, + @SerializedName("errorNotifications") val errorNotifications: Boolean = true, + @SerializedName("weeklySummaryNotifications") val weeklySummaryNotifications: Boolean = false, + @SerializedName("languageTag") val languageTag: String = "", + @SerializedName("biometricLockEnabled") val biometricLockEnabled: Boolean = false, + @SerializedName("lowBalanceThresholdDays") val lowBalanceThresholdDays: Int = 2 ) /** @@ -107,74 +113,74 @@ data class BackupSettings( * to the default connection per exchange. */ data class BackupCredentials( - val exchange: String, - val apiKey: String, - val apiSecret: String, - val passphrase: String? = null, - val clientId: String? = null, + @SerializedName("exchange") val exchange: String, + @SerializedName("apiKey") val apiKey: String, + @SerializedName("apiSecret") val apiSecret: String, + @SerializedName("passphrase") val passphrase: String? = null, + @SerializedName("clientId") val clientId: String? = null, /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ - val connectionId: Long? = null + @SerializedName("connectionId") val connectionId: Long? = null ) /** * Serializable transaction for backup. */ data class BackupTransaction( - val id: Long, - val planId: Long, - val exchange: String, - val crypto: String, - val fiat: String, - val fiatAmount: String, - val cryptoAmount: String, - val price: String, - val fee: String, - val feeAsset: String = "", - val status: String, - val exchangeOrderId: String? = null, - val errorMessage: String? = null, - val warningMessage: String? = null, - val executedAt: Long = 0, + @SerializedName("id") val id: Long, + @SerializedName("planId") val planId: Long, + @SerializedName("exchange") val exchange: String, + @SerializedName("crypto") val crypto: String, + @SerializedName("fiat") val fiat: String, + @SerializedName("fiatAmount") val fiatAmount: String, + @SerializedName("cryptoAmount") val cryptoAmount: String, + @SerializedName("price") val price: String, + @SerializedName("fee") val fee: String, + @SerializedName("feeAsset") val feeAsset: String = "", + @SerializedName("status") val status: String, + @SerializedName("exchangeOrderId") val exchangeOrderId: String? = null, + @SerializedName("errorMessage") val errorMessage: String? = null, + @SerializedName("warningMessage") val warningMessage: String? = null, + @SerializedName("executedAt") val executedAt: Long = 0, /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ - val connectionId: Long? = null + @SerializedName("connectionId") val connectionId: Long? = null ) /** * Serializable notification for backup. */ data class BackupNotification( - val id: Long, - val type: String, - val title: String, - val message: String, - val planId: Long? = null, - val crypto: String? = null, - val exchange: String? = null, - val isRead: Boolean = false, - val isArchived: Boolean = false, - val templateArgs: String? = null, - val createdAt: Long = 0, + @SerializedName("id") val id: Long, + @SerializedName("type") val type: String, + @SerializedName("title") val title: String, + @SerializedName("message") val message: String, + @SerializedName("planId") val planId: Long? = null, + @SerializedName("crypto") val crypto: String? = null, + @SerializedName("exchange") val exchange: String? = null, + @SerializedName("isRead") val isRead: Boolean = false, + @SerializedName("isArchived") val isArchived: Boolean = false, + @SerializedName("templateArgs") val templateArgs: String? = null, + @SerializedName("createdAt") val createdAt: Long = 0, /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ - val connectionId: Long? = null + @SerializedName("connectionId") val connectionId: Long? = null ) /** * Serializable withdrawal for backup. */ data class BackupWithdrawal( - val id: Long, - val planId: Long, - val exchange: String, - val crypto: String, - val amount: String, - val address: String, - val txHash: String? = null, - val fee: String, - val status: String, - val errorMessage: String? = null, - val createdAt: Long = 0, + @SerializedName("id") val id: Long, + @SerializedName("planId") val planId: Long, + @SerializedName("exchange") val exchange: String, + @SerializedName("crypto") val crypto: String, + @SerializedName("amount") val amount: String, + @SerializedName("address") val address: String, + @SerializedName("txHash") val txHash: String? = null, + @SerializedName("fee") val fee: String, + @SerializedName("status") val status: String, + @SerializedName("errorMessage") val errorMessage: String? = null, + @SerializedName("createdAt") val createdAt: Long = 0, /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ - val connectionId: Long? = null + @SerializedName("connectionId") val connectionId: Long? = null ) /** @@ -185,11 +191,11 @@ data class BackupWithdrawal( * be parsed by older code that only reads [exchange]. */ data class BackupWithdrawalThreshold( - val crypto: String, - val exchange: String, - val thresholdAmount: String, + @SerializedName("crypto") val crypto: String, + @SerializedName("exchange") val exchange: String, + @SerializedName("thresholdAmount") val thresholdAmount: String, /** v2+: source connection id (backup-local). Null for legacy v1 backups. */ - val connectionId: Long? = null + @SerializedName("connectionId") val connectionId: Long? = null ) /** diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt index f5923fe..8f0485d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/Models.kt @@ -3,6 +3,7 @@ package com.accbot.dca.domain.model import androidx.annotation.DrawableRes import androidx.annotation.StringRes import com.accbot.dca.R +import com.google.gson.annotations.SerializedName import java.math.BigDecimal import java.time.Instant @@ -174,14 +175,18 @@ data class DcaPlan( ) /** - * API credentials - encrypted and stored locally only + * API credentials - encrypted and stored locally only. + * + * Fields are annotated with @SerializedName so Gson deserialization survives R8 field + * renaming. Without this, a release build could silently return null fields and every + * DCA purchase would fail with a cryptic "no credentials" error. */ data class ExchangeCredentials( - val exchange: Exchange, - val apiKey: String, - val apiSecret: String, - val passphrase: String? = null, // Some exchanges require this (KuCoin, Coinbase) - val clientId: String? = null // Coinmate requires separate Client ID + @SerializedName("exchange") val exchange: Exchange, + @SerializedName("apiKey") val apiKey: String, + @SerializedName("apiSecret") val apiSecret: String, + @SerializedName("passphrase") val passphrase: String? = null, // Some exchanges require this (KuCoin, Coinbase) + @SerializedName("clientId") val clientId: String? = null // Coinmate requires separate Client ID ) /** diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt index 6f4d735..6541d74 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardScreen.kt @@ -85,46 +85,61 @@ import kotlinx.coroutines.delay * Long-press on a card activates drag mode; dragging past the midpoint * of an adjacent item triggers a swap. */ +/** + * Drag state tracked by plan *ID* rather than by list index. Flow emits on the + * plan list (add/remove/toggle) are safe during drag: the caller re-resolves the + * current index from the live list on every drag event, so stale indices from an + * older composition never leak into the reorder call. + */ private class PlanDragState( private val onReorder: (Int, Int) -> Unit ) { - var draggedIndex by mutableIntStateOf(-1) + var draggedId by mutableLongStateOf(NO_DRAG) var dragOffset by mutableFloatStateOf(0f) private var accumulatedOffset = 0f private var itemHeight = 0 - fun startDrag(index: Int, heightPx: Int) { - draggedIndex = index + fun startDrag(planId: Long, heightPx: Int) { + draggedId = planId dragOffset = 0f accumulatedOffset = 0f itemHeight = heightPx } - fun drag(delta: Float, totalItems: Int) { - if (draggedIndex < 0 || itemHeight == 0) return + /** + * Advance the drag by [delta]. [currentIndex] must be freshly resolved from + * the live list at call time. If the dragged plan has disappeared from the + * list (currentIndex = -1) the call is a no-op. + */ + fun drag(delta: Float, currentIndex: Int, totalItems: Int) { + if (draggedId == NO_DRAG || itemHeight == 0 || currentIndex < 0) return accumulatedOffset += delta dragOffset = accumulatedOffset // Swap when dragged past midpoint of adjacent item val threshold = itemHeight * 0.5f - if (accumulatedOffset > threshold && draggedIndex < totalItems - 1) { - onReorder(draggedIndex, draggedIndex + 1) - draggedIndex += 1 + if (accumulatedOffset > threshold && currentIndex < totalItems - 1) { + onReorder(currentIndex, currentIndex + 1) accumulatedOffset -= itemHeight dragOffset = accumulatedOffset - } else if (accumulatedOffset < -threshold && draggedIndex > 0) { - onReorder(draggedIndex, draggedIndex - 1) - draggedIndex -= 1 + } else if (accumulatedOffset < -threshold && currentIndex > 0) { + onReorder(currentIndex, currentIndex - 1) accumulatedOffset += itemHeight dragOffset = accumulatedOffset } } fun endDrag() { - draggedIndex = -1 + draggedId = NO_DRAG dragOffset = 0f accumulatedOffset = 0f } + + fun isDragging(planId: Long): Boolean = draggedId == planId + + companion object { + const val NO_DRAG = -1L + } } @Composable @@ -303,16 +318,21 @@ fun DashboardScreen( EmptyPlansCard(onAddPlan = onNavigateToPlans) } } else { - itemsIndexed(uiState.activePlans, key = { _, p -> p.plan.id }) { index, planWithBalance -> + itemsIndexed(uiState.activePlans, key = { _, p -> p.plan.id }) { _, planWithBalance -> + val planId = planWithBalance.plan.id DcaPlanCard( planWithBalance = planWithBalance, - onToggle = { viewModel.togglePlan(planWithBalance.plan.id) }, - onClick = { onNavigateToPlanDetails?.invoke(planWithBalance.plan.id) }, + onToggle = { viewModel.togglePlan(planId) }, + onClick = { onNavigateToPlanDetails?.invoke(planId) }, currentTime = currentTime, - isDragging = index == landscapeDragState.draggedIndex, - dragOffset = if (index == landscapeDragState.draggedIndex) landscapeDragState.dragOffset else 0f, - onDragStart = { heightPx -> landscapeDragState.startDrag(index, heightPx) }, - onDrag = { delta -> landscapeDragState.drag(delta, uiState.activePlans.size) }, + isDragging = landscapeDragState.isDragging(planId), + dragOffset = if (landscapeDragState.isDragging(planId)) landscapeDragState.dragOffset else 0f, + onDragStart = { heightPx -> landscapeDragState.startDrag(planId, heightPx) }, + onDrag = { delta -> + val live = uiState.activePlans + val currentIdx = live.indexOfFirst { it.plan.id == planId } + landscapeDragState.drag(delta, currentIdx, live.size) + }, onDragEnd = { landscapeDragState.endDrag() } ) } @@ -417,16 +437,21 @@ fun DashboardScreen( EmptyPlansCard(onAddPlan = onNavigateToPlans) } } else { - itemsIndexed(uiState.activePlans, key = { _, p -> p.plan.id }) { index, planWithBalance -> + itemsIndexed(uiState.activePlans, key = { _, p -> p.plan.id }) { _, planWithBalance -> + val planId = planWithBalance.plan.id DcaPlanCard( planWithBalance = planWithBalance, - onToggle = { viewModel.togglePlan(planWithBalance.plan.id) }, - onClick = { onNavigateToPlanDetails?.invoke(planWithBalance.plan.id) }, + onToggle = { viewModel.togglePlan(planId) }, + onClick = { onNavigateToPlanDetails?.invoke(planId) }, currentTime = currentTime, - isDragging = index == portraitDragState.draggedIndex, - dragOffset = if (index == portraitDragState.draggedIndex) portraitDragState.dragOffset else 0f, - onDragStart = { heightPx -> portraitDragState.startDrag(index, heightPx) }, - onDrag = { delta -> portraitDragState.drag(delta, uiState.activePlans.size) }, + isDragging = portraitDragState.isDragging(planId), + dragOffset = if (portraitDragState.isDragging(planId)) portraitDragState.dragOffset else 0f, + onDragStart = { heightPx -> portraitDragState.startDrag(planId, heightPx) }, + onDrag = { delta -> + val live = uiState.activePlans + val currentIdx = live.indexOfFirst { it.plan.id == planId } + portraitDragState.drag(delta, currentIdx, live.size) + }, onDragEnd = { portraitDragState.endDrag() } ) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt index d52c87c..b5290f3 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt @@ -33,6 +33,8 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull import kotlin.coroutines.coroutineContext import java.math.BigDecimal @@ -140,6 +142,14 @@ class DashboardViewModel @Inject constructor( private var lastLoadedAt: Long = 0 private var lastMarketDataFetchedAt: Long = 0 + /** + * Serializes plan-reorder writes. Without this, rapid drags could enqueue two + * concurrent `updateAllDisplayOrders` calls with different snapshots of the list + * and Room's writer thread might commit them out of order, leaving the persisted + * order inconsistent with the user's final gesture. + */ + private val reorderMutex = Mutex() + init { loadData() } @@ -160,11 +170,27 @@ class DashboardViewModel @Inject constructor( } combine( - dcaPlanDao.getAllPlans(), - transactionDao.getHoldingsByPairFlow() - ) { planEntities, dbHoldings -> - Pair(planEntities, dbHoldings) - }.collectLatest { (planEntities, dbHoldings) -> + // Skip emits that only differ by displayOrder: after a drag-reorder the + // DAO Flow re-emits with identical content but a new ordering. Without + // this guard, collectLatest would cancel in-flight balance/price fetches + // on every swap and restart them from scratch (API spam + UI flicker). + dcaPlanDao.getAllPlans().distinctUntilChanged { old, new -> + val oldById = old.associateBy { it.id } + val newById = new.associateBy { it.id } + oldById.keys == newById.keys && + oldById.all { (id, entity) -> + val other = newById.getValue(id) + entity.copy(displayOrder = 0) == other.copy(displayOrder = 0) + } + }, + transactionDao.getHoldingsByPairFlow(), + // Fold connections into the combine so we don't do per-emit DB lookups + // for connection names. Re-emits naturally when the user renames a + // connection on the Exchange Management screen. + exchangeConnectionDao.getAllFlow() + ) { planEntities, dbHoldings, connections -> + Triple(planEntities, dbHoldings, connections) + }.collectLatest { (planEntities, dbHoldings, connections) -> refreshPricesJob?.cancel() val plans = planEntities.map { it.toDomain() } @@ -176,15 +202,7 @@ class DashboardViewModel @Inject constructor( launch { DcaAlarmScheduler.scheduleNextAlarm(application) } } - // Pre-load connection names for all unique connectionIds in one batch. - // Avoids one DB call per plan during the map below. - val connectionNames: Map = plans - .map { it.connectionId } - .distinct() - .mapNotNull { id -> - exchangeConnectionDao.getById(id)?.let { id to it.name } - } - .toMap() + val connectionNames: Map = connections.associate { it.id to it.name } val plansWithBalance = plans.map { plan -> val accumulated = if (plan.targetAmount != null) { @@ -580,6 +598,11 @@ class DashboardViewModel @Inject constructor( /** * Move a plan from [fromIndex] to [toIndex] in the dashboard display order. * Re-assigns sequential displayOrder values (0, 1, 2, ...) to all plans. + * + * Persistence is serialized through [reorderMutex] so rapid drags can't commit + * their snapshots out of order. The optimistic UI update still happens + * synchronously so the list feels responsive even while a previous write is + * still draining. */ fun reorderPlans(fromIndex: Int, toIndex: Int) { val plans = _uiState.value.activePlans.toMutableList() @@ -589,10 +612,12 @@ class DashboardViewModel @Inject constructor( plans.add(toIndex, moved) // Update UI immediately for responsive feel _uiState.update { it.copy(activePlans = plans) } - // Persist new order + // Persist new order (serialized) val planOrders = plans.mapIndexed { index, pwb -> pwb.plan.id to index } viewModelScope.launch { - dcaPlanDao.updateAllDisplayOrders(planOrders) + reorderMutex.withLock { + dcaPlanDao.updateAllDisplayOrders(planOrders) + } } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt index e50e978..f3765f5 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt @@ -134,6 +134,18 @@ object NotificationRenderer { ) title to message } + + is NotificationTemplateArgs.MissingCredentials -> { + val title = context.getString(R.string.notification_missing_credentials_title) + val label = if (args.connectionName.isBlank()) args.exchangeName + else "${args.exchangeName} (${args.connectionName})" + val message = context.getString( + R.string.notification_missing_credentials_text, + args.crypto, + label + ) + title to message + } } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index d44b91c..62a43d0 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -360,6 +360,20 @@ internal fun PortfolioContent( pageCount = { pageCount } ) + // Structural key for the page list: changes whenever a plan is added, removed, + // renamed, or re-ordered. Used to force-align the pager with the ViewModel's + // selectedPageIndex after plans change - without this, pagerState.currentPage + // keeps its stale value and the user ends up on the wrong chart after adding + // or deleting a plan. + val pagesKey = remember(uiState.pages) { + uiState.pages.joinToString("|") { page -> + when (page) { + is PairPage.Aggregate -> "agg:${page.fiat}" + is PairPage.Plan -> "plan:${page.planId}:${page.name}" + } + } + } + // Sync pager -> ViewModel (only after pager settles, not during animation) // Using settledPage avoids intermediate values from programmatic scroll animations LaunchedEffect(pagerState.settledPage) { @@ -373,6 +387,14 @@ internal fun PortfolioContent( pagerState.animateScrollToPage(uiState.selectedPageIndex) } } + // Re-align pager when the page list changes structure (plan added/removed/renamed). + // Jumps without animation so we don't flash through unrelated pages. + LaunchedEffect(pagesKey) { + val target = uiState.selectedPageIndex.coerceIn(0, (pageCount - 1).coerceAtLeast(0)) + if (pageCount > 0 && pagerState.currentPage != target) { + pagerState.scrollToPage(target) + } + } val currentPage = uiState.pages.getOrNull(uiState.selectedPageIndex) val isSinglePair = currentPage is PairPage.Plan diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt index bd5759c..2a634c2 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.accbot.dca.data.local.DcaPlanDao +import com.accbot.dca.data.local.DcaPlanEntity import com.accbot.dca.data.local.TransactionDao import com.accbot.dca.data.local.TransactionEntity import com.accbot.dca.data.local.UserPreferences @@ -16,9 +17,11 @@ import com.accbot.dca.domain.usecase.SyncDailyPricesUseCase import androidx.compose.runtime.Immutable import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.time.LocalDate import java.time.ZoneId import javax.inject.Inject @@ -89,13 +92,31 @@ class PortfolioViewModel @Inject constructor( private val userPreferences: UserPreferences ) : ViewModel() { - private val initialCrypto: String? = savedStateHandle["crypto"] - private val initialFiat: String? = savedStateHandle["fiat"] + // Consumed once on first loadPortfolio() and then nulled out so a process-death + // restore (which repopulates SavedStateHandle from the bundle) doesn't override + // the user's persisted chip selection. + private var initialCrypto: String? = savedStateHandle["crypto"] + private var initialFiat: String? = savedStateHandle["fiat"] + + init { + // Drop the deep-link args from SavedStateHandle so they don't survive process + // death and re-override the restored chip selection on the next recreate. + savedStateHandle.remove("crypto") + savedStateHandle.remove("fiat") + } private val _uiState = MutableStateFlow(PortfolioUiState()) val uiState: StateFlow = _uiState.asStateFlow() private var completedTransactions: List = emptyList() + /** + * Cached plan list used by [loadChartData] to build aggregate per-plan lines. + * Refreshed by [loadPortfolio] and [refreshTransactionsAndPairs]. Without this + * cache, every chart navigation (zoom, chip tap, navigate prev/next) would + * re-query the DB once per render, multiplying with per-plan chart calculation + * into an O(plans × days) blocking call on the main thread. + */ + private var cachedDbPlans: List = emptyList() private var portfolioJob: Job? = null private var syncJob: Job? = null @@ -132,6 +153,7 @@ class PortfolioViewModel @Inject constructor( // Load all plans (including disabled) for page building val allDbPlans = dcaPlanDao.getAllPlansOnceOrdered() + cachedDbPlans = allDbPlans // Build pages: aggregate per fiat (if 2+ plans in same fiat), then per plan val plansByFiat = allDbPlans.groupBy { it.fiat } @@ -150,10 +172,16 @@ class PortfolioViewModel @Inject constructor( )) } + val deepLinkCrypto = initialCrypto + val deepLinkFiat = initialFiat + // One-shot consumption: null after first use so subsequent + // forceRefresh/refreshIfStale calls honour the user's chip selection. + initialCrypto = null + initialFiat = null val pageIndex = when { - initialCrypto != null && initialFiat != null -> { + deepLinkCrypto != null && deepLinkFiat != null -> { // Explicit deep-link from dashboard takes priority - val idx = pages.indexOfFirst { it is PairPage.Plan && it.crypto == initialCrypto && it.fiat == initialFiat } + val idx = pages.indexOfFirst { it is PairPage.Plan && it.crypto == deepLinkCrypto && it.fiat == deepLinkFiat } if (idx >= 0) idx else 0 } else -> { @@ -198,6 +226,7 @@ class PortfolioViewModel @Inject constructor( completedTransactions = completed val allDbPlans = dcaPlanDao.getAllPlansOnceOrdered() + cachedDbPlans = allDbPlans val plansByFiat = allDbPlans.groupBy { it.fiat } val pages = mutableListOf() for ((fiat, fiatPlans) in plansByFiat) { @@ -478,147 +507,177 @@ class PortfolioViewModel @Inject constructor( chartJob?.cancel() chartJob = viewModelScope.launch { _uiState.update { it.copy(isChartLoading = true) } - try { - val state = _uiState.value - val page = state.pages.getOrNull(state.selectedPageIndex) - - val (crypto, fiat, planId) = when (page) { - is PairPage.Aggregate -> Triple(null, page.fiat, null) - is PairPage.Plan -> Triple(page.crypto, page.fiat, page.planId) - null -> Triple(null, null, null) + // Heavy chart calculations (per-plan and per-crypto-group line alignment) + // can run for hundreds of ms on datasets with many plans * many days. + // Offload to the Default dispatcher so we don't stall the main thread + // during zoom/scrub/navigate interactions. + val chartResult = try { + withContext(Dispatchers.Default) { + computeChartData() } + } catch (e: CancellationException) { + throw e + } catch (e: OutOfMemoryError) { + Log.e("PortfolioVM", "OOM calculating chart data", e) + _uiState.update { it.copy(isChartLoading = false, error = "Not enough memory for chart") } + return@launch + } catch (e: Exception) { + Log.e("PortfolioVM", "Error loading chart data", e) + _uiState.update { it.copy(isChartLoading = false) } + return@launch + } - val filteredTxs = if (planId != null) { - completedTransactions.filter { it.planId == planId } - } else { - completedTransactions - } + _uiState.update { it.copy( + chartData = chartResult.data, + currentPairCrypto = chartResult.crypto, + currentPairFiat = chartResult.fiat, + totalTransactions = chartResult.txCount, + planLines = chartResult.planLines, + cryptoGroupLines = chartResult.cryptoGroupLines, + isChartLoading = false + ) } + } + } - val data = if (fiat == null) { - emptyList() - } else { - calculateChartDataUseCase.calculate( - transactions = filteredTxs, - crypto = crypto, - fiat = fiat, - zoomLevel = state.zoomLevel - ) - } + private data class ChartComputeResult( + val data: List, + val crypto: String?, + val fiat: String?, + val txCount: Int, + val planLines: List, + val cryptoGroupLines: List + ) - // Compute relevantPlans once so both planLines and cryptoGroupLines can use it - val relevantPlans = if (page is PairPage.Aggregate) { - try { dcaPlanDao.getAllPlansOnceOrdered().filter { it.fiat == page.fiat } } - catch (_: Exception) { emptyList() } - } else emptyList() + private suspend fun computeChartData(): ChartComputeResult { + val state = _uiState.value + val page = state.pages.getOrNull(state.selectedPageIndex) - // Calculate per-plan lines (only for Aggregate pages - Plan pages show a single plan's main line) - val planLinesList = if (page is PairPage.Aggregate && data.isNotEmpty()) { - try { - if (relevantPlans.size >= 2) { - val mainEpochDays = data.map { it.epochDay } - relevantPlans.mapNotNull { plan -> - val planTxs = completedTransactions.filter { it.planId == plan.id } - if (planTxs.isEmpty()) return@mapNotNull null - val planData = calculateChartDataUseCase.calculate( - transactions = planTxs, - crypto = plan.crypto, - fiat = plan.fiat, - zoomLevel = state.zoomLevel - ) - // Align to main chart's epoch days via forward-fill - val planByDay = planData.associateBy { it.epochDay } - var lastValue = 0f - var lastInvested = 0f - var lastAvg = 0f - var lastAccum = 0f - val valueAligned = mainEpochDays.map { day -> - val v = planByDay[day]?.portfolioValue?.toFloat() - if (v != null) { lastValue = v; v } else lastValue - } - val investedAligned = mainEpochDays.map { day -> - val v = planByDay[day]?.totalInvested?.toFloat() - if (v != null) { lastInvested = v; v } else lastInvested - } - val avgBuyAligned = mainEpochDays.map { day -> - val v = planByDay[day]?.avgBuyPrice?.toFloat() - if (v != null) { lastAvg = v; v } else lastAvg - } - val accumulatedAligned = mainEpochDays.map { day -> - val v = planByDay[day]?.cumulativeCrypto?.toFloat() - if (v != null) { lastAccum = v; v } else lastAccum - } - PlanLineInfo( - planId = plan.id, - name = plan.name.ifBlank { "${plan.crypto}/${plan.fiat} #${plan.id}" }, - crypto = plan.crypto, - valueSeries = valueAligned, - investedSeries = investedAligned, - avgBuyPriceSeries = avgBuyAligned, - accumulatedSeries = accumulatedAligned - ) - } - } else emptyList() - } catch (_: Exception) { emptyList() } - } else emptyList() + val (crypto, fiat, planId) = when (page) { + is PairPage.Aggregate -> Triple(null, page.fiat, null) + is PairPage.Plan -> Triple(page.crypto, page.fiat, page.planId) + null -> Triple(null, null, null) + } - // Calculate per-crypto-group lines (price + total accumulated per unique crypto within the aggregate) - val cryptoGroupLinesList = if (page is PairPage.Aggregate && data.isNotEmpty()) { - try { - val uniqueCryptos = relevantPlans.map { it.crypto }.distinct() - val mainEpochDays = data.map { it.epochDay } - uniqueCryptos.mapNotNull { crypto -> - val cryptoTxs = completedTransactions.filter { it.crypto == crypto && it.fiat == page.fiat } - if (cryptoTxs.isEmpty()) return@mapNotNull null - val cryptoChartData = calculateChartDataUseCase.calculate( - transactions = cryptoTxs, - crypto = crypto, - fiat = page.fiat, - zoomLevel = state.zoomLevel - ) - val byDay = cryptoChartData.associateBy { it.epochDay } - var lastPrice = 0f - var lastAccum = 0f - val priceAligned = mainEpochDays.map { day -> - val v = byDay[day]?.price?.toFloat() - if (v != null) { lastPrice = v; v } else lastPrice - } - val accAligned = mainEpochDays.map { day -> - val v = byDay[day]?.cumulativeCrypto?.toFloat() - if (v != null) { lastAccum = v; v } else lastAccum - } - CryptoGroupLineInfo( - crypto = crypto, - priceSeries = priceAligned, - totalAccumulatedSeries = accAligned - ) + val filteredTxs = if (planId != null) { + completedTransactions.filter { it.planId == planId } + } else { + completedTransactions + } + + val data = if (fiat == null) { + emptyList() + } else { + calculateChartDataUseCase.calculate( + transactions = filteredTxs, + crypto = crypto, + fiat = fiat, + zoomLevel = state.zoomLevel + ) + } + + // Use cached plans (refreshed by loadPortfolio / refreshTransactionsAndPairs) + // instead of hitting the DB on every chart render. + val relevantPlans = if (page is PairPage.Aggregate) { + cachedDbPlans.filter { it.fiat == page.fiat } + } else emptyList() + + // Calculate per-plan lines (only for Aggregate pages - Plan pages show a single plan's main line) + val planLinesList = if (page is PairPage.Aggregate && data.isNotEmpty()) { + try { + if (relevantPlans.size >= 2) { + val mainEpochDays = data.map { it.epochDay } + relevantPlans.mapNotNull { plan -> + val planTxs = completedTransactions.filter { it.planId == plan.id } + if (planTxs.isEmpty()) return@mapNotNull null + val planData = calculateChartDataUseCase.calculate( + transactions = planTxs, + crypto = plan.crypto, + fiat = plan.fiat, + zoomLevel = state.zoomLevel + ) + // Align to main chart's epoch days via forward-fill + val planByDay = planData.associateBy { it.epochDay } + var lastValue = 0f + var lastInvested = 0f + var lastAvg = 0f + var lastAccum = 0f + val valueAligned = mainEpochDays.map { day -> + val v = planByDay[day]?.portfolioValue?.toFloat() + if (v != null) { lastValue = v; v } else lastValue } - } catch (_: Exception) { emptyList() } + val investedAligned = mainEpochDays.map { day -> + val v = planByDay[day]?.totalInvested?.toFloat() + if (v != null) { lastInvested = v; v } else lastInvested + } + val avgBuyAligned = mainEpochDays.map { day -> + val v = planByDay[day]?.avgBuyPrice?.toFloat() + if (v != null) { lastAvg = v; v } else lastAvg + } + val accumulatedAligned = mainEpochDays.map { day -> + val v = planByDay[day]?.cumulativeCrypto?.toFloat() + if (v != null) { lastAccum = v; v } else lastAccum + } + PlanLineInfo( + planId = plan.id, + name = plan.name.ifBlank { "${plan.crypto}/${plan.fiat} #${plan.id}" }, + crypto = plan.crypto, + valueSeries = valueAligned, + investedSeries = investedAligned, + avgBuyPriceSeries = avgBuyAligned, + accumulatedSeries = accumulatedAligned + ) + } } else emptyList() + } catch (_: Exception) { emptyList() } + } else emptyList() - val txCount = filteredTxs.count { tx -> - (crypto == null || tx.crypto == crypto) && - (fiat == null || tx.fiat == fiat) + // Calculate per-crypto-group lines (price + total accumulated per unique crypto within the aggregate) + val cryptoGroupLinesList = if (page is PairPage.Aggregate && data.isNotEmpty()) { + try { + val uniqueCryptos = relevantPlans.map { it.crypto }.distinct() + val mainEpochDays = data.map { it.epochDay } + uniqueCryptos.mapNotNull { cryptoSym -> + val cryptoTxs = completedTransactions.filter { it.crypto == cryptoSym && it.fiat == page.fiat } + if (cryptoTxs.isEmpty()) return@mapNotNull null + val cryptoChartData = calculateChartDataUseCase.calculate( + transactions = cryptoTxs, + crypto = cryptoSym, + fiat = page.fiat, + zoomLevel = state.zoomLevel + ) + val byDay = cryptoChartData.associateBy { it.epochDay } + var lastPrice = 0f + var lastAccum = 0f + val priceAligned = mainEpochDays.map { day -> + val v = byDay[day]?.price?.toFloat() + if (v != null) { lastPrice = v; v } else lastPrice + } + val accAligned = mainEpochDays.map { day -> + val v = byDay[day]?.cumulativeCrypto?.toFloat() + if (v != null) { lastAccum = v; v } else lastAccum + } + CryptoGroupLineInfo( + crypto = cryptoSym, + priceSeries = priceAligned, + totalAccumulatedSeries = accAligned + ) } + } catch (_: Exception) { emptyList() } + } else emptyList() - _uiState.update { it.copy( - chartData = data, - currentPairCrypto = crypto, - currentPairFiat = fiat, - totalTransactions = txCount, - planLines = planLinesList, - cryptoGroupLines = cryptoGroupLinesList, - isChartLoading = false - ) } - } catch (e: OutOfMemoryError) { - Log.e("PortfolioVM", "OOM calculating chart data", e) - _uiState.update { it.copy(isChartLoading = false, error = "Not enough memory for chart") } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e("PortfolioVM", "Error loading chart data", e) - _uiState.update { it.copy(isChartLoading = false) } - } + val txCount = filteredTxs.count { tx -> + (crypto == null || tx.crypto == crypto) && + (fiat == null || tx.fiat == fiat) } + + return ChartComputeResult( + data = data, + crypto = crypto, + fiat = fiat, + txCount = txCount, + planLines = planLinesList, + cryptoGroupLines = cryptoGroupLinesList + ) } fun refreshIfStale() { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt index ec65254..93cb00a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt @@ -114,8 +114,30 @@ class DcaWorker @AssistedInject constructor( val credentials = credentialsStore.getCredentials(plan.connectionId, isSandbox) if (credentials == null) { Log.e(TAG, "No credentials for connection ${plan.connectionId} (${plan.exchange}, sandbox=$isSandbox)") + // Notify the user once per plan per worker lifetime so a missing-credentials + // plan doesn't just silently fail forever (e.g. after a half-completed + // backup restore or a deleted connection). Dedup is in-memory, which + // means we re-notify after the worker is recreated - acceptable, since + // the user needs to act on it anyway. + if (credentialErrorNotifiedPlans.add(plan.id)) { + val connectionName = try { + database.exchangeConnectionDao().getById(plan.connectionId)?.name ?: "" + } catch (_: Exception) { "" } + notificationService.showErrorNotification( + planId = plan.id, + exchange = plan.exchange, + connectionId = plan.connectionId, + templateArgs = NotificationTemplateArgs.MissingCredentials( + crypto = plan.crypto, + exchangeName = plan.exchange.displayName, + connectionName = connectionName + ) + ) + } continue } + // Credentials are back - allow future notifications if they disappear again + credentialErrorNotifiedPlans.remove(plan.id) // Calculate purchase amount based on strategy val strategyResult = calculateStrategyMultiplier( @@ -478,6 +500,14 @@ class DcaWorker @AssistedInject constructor( private const val KEY_REPEAT_COUNT = "repeatCount" const val WORK_NAME = "dca_periodic_work" + /** + * Plan IDs we've already shown a "missing credentials" notification for in + * this process lifetime. Prevents spamming the notification tray on every + * alarm tick (~hourly) for a plan whose credentials will stay missing until + * the user acts. Cleared when credentials reappear for that plan. + */ + private val credentialErrorNotifiedPlans = java.util.Collections.synchronizedSet(mutableSetOf()) + /** * Schedule periodic DCA work as a safety net (backs up AlarmManager). * Minimum interval is 15 minutes (Android restriction). diff --git a/accbot-android/app/src/main/res/values-cs/strings.xml b/accbot-android/app/src/main/res/values-cs/strings.xml index 0c945e8..53d9ff2 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -794,6 +794,8 @@ AccBot: Zmeškané nákupy Zmeškaných %1$d nákupů %2$s na %3$s během offline. Zmeškaných %1$d nákupů %2$s na %3$s během offline. + AccBot: Chybí API klíče + Plán %1$s na %2$s nemůže běžet - chybí API přihlašovací údaje. Otevři Správu burz a zadej klíče znovu. Dokoupit Přeskočit %1$s %2$s na burze – zvažte výběr diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 9686d80..3eb68f2 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -791,6 +791,8 @@ AccBot: Missed Purchases %1$d %2$s purchase(s) on %3$s were missed while offline. %1$d missed %2$s purchase(s) on %3$s while offline. + AccBot: Missing API Keys + %1$s plan on %2$s cannot run - API credentials are missing. Open Exchange Management to re-enter keys. Buy now Skip %1$s %2$s on exchange – consider withdrawal