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..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 @@ -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..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 @@ -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 @@ -55,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: * ``` @@ -161,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) @@ -210,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) @@ -232,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() @@ -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..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 @@ -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)), @@ -103,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 -> @@ -126,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 @@ -146,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() @@ -166,16 +180,16 @@ 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 } 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..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 @@ -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..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 @@ -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,37 +162,57 @@ 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 } } - // 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 + val txExchange = Exchange.valueOf(tx.exchange) + val connectionId = resolveConnectionForRestore(tx.connectionId, txExchange) if (restoreMode == RestoreMode.Merge && !tx.exchangeOrderId.isNullOrEmpty()) { - val existing = transactionDao.getByExchangeOrderId(tx.exchangeOrderId) - if (existing != null) continue // Already imported, skip + val existing = transactionDao.getByExchangeOrderIdAndConnection( + tx.exchangeOrderId, + connectionId + ) + if (existing != null) continue // Already imported on this connection, skip } - transactionDao.insertTransaction(tx.toEntity(remappedPlanId)) + 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 +230,90 @@ 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++ + } + } + + // 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") } } - BackupResult.Success() + if (warnings.isNotEmpty()) { + BackupResult.Success("Restored, but " + warnings.joinToString("; ")) + } else { + BackupResult.Success() + } } catch (e: Exception) { BackupResult.Error(e.message ?: "Unknown error during restore") } } + /** + * 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. + */ + 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 +330,7 @@ class BackupDataRestorer @Inject constructor( return DcaPlanEntity( id = 0, exchange = Exchange.valueOf(exchange), + connectionId = connectionId, crypto = crypto, fiat = fiat, amount = BigDecimal(amount), @@ -182,10 +347,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 +366,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 +381,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 +389,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..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 @@ -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,117 @@ 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 { + 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) + } + } + + /** + * @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}" + 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) + allOk = false + 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") + } + return allOk + } + + // ─────────────────────────────────────────────────────────────────────── + // 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 +209,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)) - } - - /** - * 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() + fun hasCredentials(connectionId: Long, isSandbox: Boolean): Boolean { + return encryptedPrefs.contains(v3Key(connectionId, isSandbox)) } - /** - * 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..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 @@ -6,9 +6,39 @@ 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") + @Query("SELECT * FROM dca_plans ORDER BY displayOrder ASC, createdAt DESC") fun getAllPlans(): Flow> @Query("SELECT * FROM dca_plans WHERE isEnabled = 1") @@ -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 @@ -82,6 +121,25 @@ 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 + + @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 @@ -280,9 +338,21 @@ 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? + /** + * 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 } @@ -346,8 +416,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 +431,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 +518,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..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 @@ -17,9 +17,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase MonthlySummaryEntity::class, DailyPriceEntity::class, NotificationEntity::class, - WithdrawalThresholdEntity::class + WithdrawalThresholdEntity::class, + ExchangeConnectionEntity::class ], - version = 18, + version = 20, 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,163 @@ 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 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) { @@ -317,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) + .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 b2f0947..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 @@ -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,13 @@ 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, + /** 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, @@ -134,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 ) /** @@ -145,6 +192,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 +207,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 +245,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 +258,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 +339,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 +349,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..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 @@ -8,6 +8,8 @@ import com.accbot.dca.domain.model.WithdrawalThreshold fun DcaPlanEntity.toDomain() = DcaPlan( id = id, exchange = exchange, + connectionId = connectionId, + name = name, crypto = crypto, fiat = fiat, amount = amount, @@ -20,13 +22,15 @@ fun DcaPlanEntity.toDomain() = DcaPlan( createdAt = createdAt, lastExecutedAt = lastExecutedAt, nextExecutionAt = nextExecutionAt, - targetAmount = targetAmount + targetAmount = targetAmount, + displayOrder = displayOrder ) fun TransactionEntity.toDomain() = Transaction( id = id, planId = planId, exchange = exchange, + connectionId = connectionId, crypto = crypto, fiat = fiat, fiatAmount = fiatAmount, @@ -49,13 +53,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/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/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/data/repository/ExchangeConnectionRepository.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/repository/ExchangeConnectionRepository.kt new file mode 100644 index 0000000..9848552 --- /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..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 @@ -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..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,149 +1,201 @@ package com.accbot.dca.domain.model +import com.google.gson.annotations.SerializedName 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. + * + * 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" - 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(), - 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. */ + @SerializedName("connections") 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. */ + @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. */ + @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 ) /** * 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 + @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. */ + @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. */ + @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. */ + @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. */ + @SerializedName("connectionId") 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 + @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. */ + @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 1838ae8..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 @@ -152,6 +153,10 @@ 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, + /** 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) @@ -164,18 +169,24 @@ 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 ) /** - * 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 ) /** @@ -185,6 +196,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 +279,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..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 @@ -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,9 @@ class CreateDcaPlanUseCase @Inject constructor( strategy: DcaStrategy, withdrawalEnabled: Boolean = false, withdrawalAddress: String? = null, - targetAmount: BigDecimal? = null + targetAmount: BigDecimal? = null, + connectionId: Long? = null, + name: String = "" ) { val now = Instant.now() val nextExecution = if (frequency == DcaFrequency.CUSTOM && cronExpression != null) { @@ -36,8 +54,18 @@ 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 nextDisplayOrder = dcaPlanDao.getMaxDisplayOrder() + 1 + val plan = DcaPlanEntity( exchange = exchange, + connectionId = resolvedConnectionId, + name = name.trim(), crypto = crypto, fiat = fiat, amount = amount, @@ -49,7 +77,8 @@ class CreateDcaPlanUseCase @Inject constructor( withdrawalAddress = withdrawalAddress, createdAt = now, nextExecutionAt = nextExecution, - targetAmount = targetAmount + targetAmount = targetAmount, + displayOrder = nextDisplayOrder ) dcaPlanDao.insertPlan(plan) 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..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 @@ -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/components/ChartComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt index d31f462..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 @@ -23,7 +23,11 @@ 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 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 +66,53 @@ internal val btcPriceColor = Color(0xFFF7931A) internal val accumulatedCryptoColor = Color(0xFF4CAF50) internal val avgBuyPriceColor = Color(0xFF9C27B0) +/** + * 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), + "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, @@ -139,6 +190,10 @@ fun PortfolioLineChart( fiatSymbol: String = "", cryptoSymbol: String = "", 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 @@ -146,10 +201,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) { + LaunchedEffect( + chartData, + denominationMode, + visibleSeries, + planLines, + visiblePlanLines, + cryptoGroupLines, + visibleCryptoGroupLines + ) { try { modelProducer.runTransaction { // Layer 1: left axis (portfolio value, cost basis, crypto price – all fiat) @@ -166,14 +237,66 @@ 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 lines (value + invested per plan, only when visible) + 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) + anyLeftSeriesAdded = true + } + val investedKey = planLine.planId to PlanLineType.INVESTED + if (investedKey in visiblePlanLines && planLine.investedSeries.size == chartData.size) { + series(planLine.investedSeries) + 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 } && !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) { @@ -224,16 +347,121 @@ fun PortfolioLineChart( fill = LineCartesianLayer.LineFill.single(fill(Color.Transparent)) ) + // 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)))) + 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) if (1 in visibleSeries) add(costBasisLine) if (2 in visibleSeries) add(priceLine) if (4 in visibleSeries) add(avgBuyPriceLine) + // 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 + if (valueKey in visiblePlanLines && planLine.valueSeries.size == chartData.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(distinctLineStyles[distinctColorIdx(planIdx, PlanLineType.INVESTED.ordinal)]) + } + 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)]) + } + } + // 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) { + add(cryptoPriceStylesMap[cgLine.crypto] ?: defaultCryptoPriceStyle) + } + } if (isEmpty()) add(hiddenLine) } val rightLines = buildList { if (3 in visibleSeries) add(accumulatedLine) + // 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(distinctLineStyles[distinctColorIdx(planIdx, PlanLineType.ACCUMULATED.ordinal)]) + } + } + // 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/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 0100a09..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 @@ -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,36 @@ 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, + /** 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, + * 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 +90,9 @@ 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 dcaPlanDao: com.accbot.dca.data.local.DcaPlanDao? = null ) { private val _state = MutableStateFlow(CredentialFormState()) val state: StateFlow = _state.asStateFlow() @@ -79,40 +113,128 @@ 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. + * Also loads the number of existing plans on that connection for a multi-plan warning. + */ + fun selectExistingConnection(connectionId: Long) { + _state.update { + it.copy( + 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) } + } + } + + /** + * 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 +256,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 +281,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/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 5b58d3d..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() @@ -80,7 +85,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 785611b..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 @@ -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,107 @@ 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!! + 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() } ) - } else { - ExchangeInstructionsCard( - exchange = cred.selectedExchange!!, - instructions = cred.selectedExchangeInstructions!! + } + } + + // 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 + ) } + } - 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 - ) + // 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) @@ -239,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, @@ -276,3 +347,48 @@ fun AddPlanScreen( } // Box } } + +/** + * Single row in the connection picker (radio-like): a row with a leading RadioButton, + * 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( + label: String, + 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) accent.copy(alpha = 0.15f) + 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, + colors = RadioButtonDefaults.colors( + selectedColor = accent, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = if (selected) accent 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..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 @@ -3,7 +3,9 @@ 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 import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.supportsApiImport @@ -43,10 +45,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 +62,21 @@ class AddPlanViewModel @Inject constructor( private val validateAndSaveCredentialsUseCase: ValidateAndSaveCredentialsUseCase, private val createDcaPlanUseCase: CreateDcaPlanUseCase, private val userPreferences: UserPreferences, + private val connectionRepository: ExchangeConnectionRepository, + private val dcaPlanDao: DcaPlanDao, 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, + dcaPlanDao = dcaPlanDao + ) private val _localState = MutableStateFlow(AddPlanUiState()) @@ -97,23 +111,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 +141,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(), @@ -138,10 +156,13 @@ 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() ) - 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..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 @@ -6,12 +6,14 @@ 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 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 @@ -36,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 @@ -50,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 @@ -75,6 +80,75 @@ import java.math.BigDecimal import java.math.RoundingMode import kotlinx.coroutines.delay +/** + * 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. + */ +/** + * 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 draggedId by mutableLongStateOf(NO_DRAG) + var dragOffset by mutableFloatStateOf(0f) + private var accumulatedOffset = 0f + private var itemHeight = 0 + + fun startDrag(planId: Long, heightPx: Int) { + draggedId = planId + dragOffset = 0f + accumulatedOffset = 0f + itemHeight = heightPx + } + + /** + * 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 && currentIndex < totalItems - 1) { + onReorder(currentIndex, currentIndex + 1) + accumulatedOffset -= itemHeight + dragOffset = accumulatedOffset + } else if (accumulatedOffset < -threshold && currentIndex > 0) { + onReorder(currentIndex, currentIndex - 1) + accumulatedOffset += itemHeight + dragOffset = accumulatedOffset + } + } + + fun endDrag() { + draggedId = NO_DRAG + dragOffset = 0f + accumulatedOffset = 0f + } + + fun isDragging(planId: Long): Boolean = draggedId == planId + + companion object { + const val NO_DRAG = -1L + } +} + +@Composable +private fun rememberPlanDragState( + onReorder: (from: Int, to: Int) -> Unit +): PlanDragState { + return remember { PlanDragState(onReorder) } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun DashboardScreen( @@ -83,7 +157,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 +263,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 ) @@ -216,6 +287,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) @@ -244,12 +318,22 @@ fun DashboardScreen( EmptyPlansCard(onAddPlan = onNavigateToPlans) } } else { - items(uiState.activePlans, key = { it.plan.id }) { 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) }, - currentTime = currentTime + onToggle = { viewModel.togglePlan(planId) }, + onClick = { onNavigateToPlanDetails?.invoke(planId) }, + currentTime = currentTime, + 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() } ) } } @@ -261,6 +345,9 @@ fun DashboardScreen( } } else { // Portrait: single column + val portraitDragState = rememberPlanDragState { from, to -> + viewModel.reorderPlans(from, to) + } LazyColumn( modifier = Modifier .fillMaxSize() @@ -308,12 +395,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() } } ) } @@ -354,12 +437,22 @@ fun DashboardScreen( EmptyPlansCard(onAddPlan = onNavigateToPlans) } } else { - items(uiState.activePlans, key = { it.plan.id }) { 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) }, - currentTime = currentTime + onToggle = { viewModel.togglePlan(planId) }, + onClick = { onNavigateToPlanDetails?.invoke(planId) }, + currentTime = currentTime, + 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() } ) } } @@ -943,16 +1036,75 @@ internal fun DcaPlanCard( planWithBalance: DcaPlanWithBalance, onToggle: () -> Unit, onClick: (() -> Unit)? = null, - currentTime: Long = System.currentTimeMillis() + currentTime: Long = System.currentTimeMillis(), + isDragging: Boolean = false, + dragOffset: Float = 0f, + onDragStart: ((heightPx: Int) -> Unit)? = null, + onDrag: ((Float) -> Unit)? = null, + onDragEnd: (() -> Unit)? = null ) { val plan = planWithBalance.plan val successCol = successColor() 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) + val currentOnDragEnd by rememberUpdatedState(onDragEnd) 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 -> + 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 ) @@ -960,7 +1112,7 @@ internal fun DcaPlanCard( Row( modifier = Modifier .fillMaxWidth() - .padding(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 ) { @@ -968,9 +1120,29 @@ internal fun DcaPlanCard( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f) ) { + // Drag handle - only visible while dragging + if (isDragging) { + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = "Reorder", + modifier = Modifier + .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 { + // 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) @@ -1017,11 +1189,22 @@ 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 ) - 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) @@ -1136,14 +1319,26 @@ internal fun DcaPlanCard( } } } - Switch( - checked = plan.isEnabled, - onCheckedChange = { onToggle() }, - colors = SwitchDefaults.colors( - checkedThumbColor = successCol, - checkedTrackColor = successCol.copy(alpha = 0.5f) + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Switch( + checked = plan.isEnabled, + 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) + ) ) - ) + } } } } @@ -1402,6 +1597,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/DashboardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt index cb392af..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 @@ -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 @@ -32,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 @@ -63,7 +66,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 +124,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) { @@ -136,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() } @@ -156,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() } @@ -172,13 +202,19 @@ class DashboardViewModel @Inject constructor( launch { DcaAlarmScheduler.scheduleNextAlarm(application) } } + val connectionNames: Map = connections.associate { it.id to it.name } + 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 +360,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 +390,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) } @@ -558,6 +595,32 @@ 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() + if (fromIndex !in plans.indices || toIndex !in plans.indices) return + 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 (serialized) + val planOrders = plans.mapIndexed { index, pwb -> pwb.plan.id to index } + viewModelScope.launch { + reorderMutex.withLock { + dcaPlanDao.updateAllDisplayOrders(planOrders) + } + } + } + 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/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..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 @@ -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..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 @@ -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..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 @@ -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/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 873b1ae..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 @@ -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() @@ -89,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) } } @@ -126,6 +147,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 +225,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..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 @@ -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,21 @@ 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 -> ExchangeSelectionTile( - exchange = exchange, + exchange = connection.exchange, isConnected = true, - subtitle = stringResource(R.string.common_connected), - onClick = { onNavigateToExchangeDetail(exchange.name) } + subtitle = if (connection.name.isNotBlank()) connection.name + else stringResource(R.string.exchanges_default_connection_label), + onClick = { onNavigateToExchangeDetail(connection.id) } ) } @@ -121,15 +127,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..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 @@ -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/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/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 b934914..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 @@ -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) + } } } @@ -114,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) @@ -137,7 +150,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/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, 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..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 ) } @@ -253,6 +254,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 e002cb7..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 @@ -54,7 +55,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 +98,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() } @@ -109,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( @@ -121,7 +132,8 @@ class PlanDetailsViewModel @Inject constructor( averagePrice = averagePrice, transactionCount = completedTransactions.size, timeUntilNextExecution = timeUntilNext, - isLoading = false + isLoading = false, + otherPlansOnSameConnection = otherPlans ) } @@ -176,7 +188,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) } @@ -212,6 +224,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 @@ -273,7 +293,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/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index 6448faf..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 @@ -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 @@ -33,6 +34,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 @@ -87,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) } @@ -103,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 -> "" } @@ -128,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, @@ -137,6 +140,10 @@ fun PortfolioScreen( fiatSymbol = uiState.currentPairFiat ?: "EUR", cryptoSymbol = uiState.currentPairCrypto ?: "", 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 @@ -151,6 +158,19 @@ fun PortfolioScreen( onToggleSeries = { viewModel.toggleSeriesVisibility(it) }, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) ) + // Per-plan legend (landscape) + if (uiState.planLines.isNotEmpty() || uiState.cryptoGroupLines.isNotEmpty()) { + PlanLinesLegend( + planLines = uiState.planLines, + visiblePlanLines = uiState.visiblePlanLines, + 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() } + ) + } // Zoom header + drill-down chips Column( @@ -237,12 +257,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) } ) } } @@ -298,9 +318,11 @@ fun PortfolioScreen( onZoomOut = { viewModel.zoomOut() }, onNavigatePrev = { viewModel.navigatePrev() }, onNavigateNext = { viewModel.navigateNext() }, - onExchangeFilterSelected = { viewModel.selectExchangeFilter(it) }, 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) @@ -319,9 +341,11 @@ internal fun PortfolioContent( onZoomOut: () -> Unit, onNavigatePrev: () -> Unit, onNavigateNext: () -> Unit, - onExchangeFilterSelected: (String?) -> Unit, onPairPageSelected: (Int) -> Unit, onToggleSeriesVisibility: (Int) -> Unit, + onTogglePlanLineVisibility: (Long, PlanLineType) -> Unit, + onToggleCryptoGroupLineVisibility: (String, CryptoGroupLineType) -> Unit, + onToggleAdvancedLegend: () -> Unit, onRefresh: () -> Unit, onChartTouching: (Boolean) -> Unit = {}, modifier: Modifier = Modifier @@ -336,15 +360,44 @@ internal fun PortfolioContent( pageCount = { pageCount } ) - // Sync pager with ViewModel - LaunchedEffect(pagerState.currentPage) { + // 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) { + if (pagerState.settledPage != uiState.selectedPageIndex) { + onPairPageSelected(pagerState.settledPage) + } + } + // Sync ViewModel -> pager (chip tap changes page) + LaunchedEffect(uiState.selectedPageIndex) { if (pagerState.currentPage != uiState.selectedPageIndex) { - onPairPageSelected(pagerState.currentPage) + 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.SinglePair + val isSinglePair = currentPage is PairPage.Plan // Scrub-to-inspect state (ephemeral, local to composable) var scrubbedIndex by remember { mutableIntStateOf(-1) } @@ -410,7 +463,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 +483,7 @@ internal fun PortfolioContent( Spacer(Modifier.height(8.dp)) KpiCardContent( uiState = uiState, - isSinglePair = pageItem is PairPage.SinglePair, + isSinglePair = pageItem is PairPage.Plan, scrubbedDataPoint = scrubbedDataPoint ) } @@ -479,7 +532,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 +545,7 @@ internal fun PortfolioContent( Spacer(Modifier.height(8.dp)) KpiCardContent( uiState = uiState, - isSinglePair = pageItem is PairPage.SinglePair, + isSinglePair = pageItem is PairPage.Plan, scrubbedDataPoint = scrubbedDataPoint ) } @@ -524,6 +577,10 @@ internal fun PortfolioContent( fiatSymbol = uiState.currentPairFiat ?: "EUR", cryptoSymbol = uiState.currentPairCrypto ?: "", 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() @@ -549,13 +606,28 @@ 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, visibleSeries = uiState.visibleSeries, onToggleSeries = onToggleSeriesVisibility ) + // Per-plan legend entries + if (uiState.planLines.isNotEmpty() || uiState.cryptoGroupLines.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + PlanLinesLegend( + planLines = uiState.planLines, + visiblePlanLines = uiState.visiblePlanLines, + onToggle = onTogglePlanLineVisibility, + cryptoGroupLines = uiState.cryptoGroupLines, + visibleCryptoGroupLines = uiState.visibleCryptoGroupLines, + onToggleCryptoGroup = onToggleCryptoGroupLineVisibility, + isAdvancedExpanded = uiState.isAdvancedLegendExpanded, + onToggleAdvanced = onToggleAdvancedLegend + ) + } } } @@ -584,13 +656,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 ) } } @@ -606,17 +678,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))) @@ -1183,27 +1262,201 @@ 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 ExchangeFilterRow( - exchanges: List, - selectedExchange: String?, - onExchangeSelected: (String?) -> Unit +private fun PlanLinesLegend( + planLines: List, + visiblePlanLines: Set>, + onToggle: (Long, PlanLineType) -> Unit, + cryptoGroupLines: List = emptyList(), + visibleCryptoGroupLines: Set> = emptySet(), + onToggleCryptoGroup: (String, CryptoGroupLineType) -> Unit = { _, _ -> }, + isAdvancedExpanded: Boolean = false, + onToggleAdvanced: () -> Unit = {} +) { + 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, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + // Primary section: value + invested per plan + planLines.forEachIndexed { index, planLine -> + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + val valueEnabled = (planLine.planId to PlanLineType.VALUE) in visiblePlanLines + LegendTextEntry( + color = planMetricColor(index, PlanLineType.VALUE), + 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 = planMetricColor(index, PlanLineType.INVESTED), + 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( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + 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(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) } + ) + } + } + + // Per-plan advanced rows: Prům. nák. cena + Akumulováno + planLines.forEachIndexed { index, planLine -> + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + val avgEnabled = (planLine.planId to PlanLineType.AVG_BUY_PRICE) in visiblePlanLines + LegendTextEntry( + 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) } + ) + + Spacer(Modifier.width(16.dp)) + + val accEnabled = (planLine.planId to PlanLineType.ACCUMULATED) in visiblePlanLines + LegendTextEntry( + color = planMetricColor(index, PlanLineType.ACCUMULATED), + label = stringResource(R.string.chart_plan_accumulated, planLine.name), + enabled = accEnabled, + onClick = { onToggle(planLine.planId, PlanLineType.ACCUMULATED) } + ) + } + } + } + } +} + +@Composable +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 bda616e..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 @@ -4,8 +4,11 @@ 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.DcaPlanEntity 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 @@ -14,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 @@ -25,9 +30,31 @@ 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() } +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 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 data class PortfolioUiState( val chartData: List = emptyList(), @@ -36,8 +63,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, @@ -46,6 +71,11 @@ data class PortfolioUiState( val totalTransactions: Int = 0, val visibleSeries: Set = setOf(0, 1), 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, @@ -56,17 +86,37 @@ 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 + private val calculateChartDataUseCase: CalculateChartDataUseCase, + 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 @@ -77,6 +127,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 { @@ -91,28 +150,52 @@ 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() + cachedDbPlans = allDbPlans + + // 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 } - if (idx >= 0) idx else 0 - } else 0 + 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 { + deepLinkCrypto != null && deepLinkFiat != null -> { + // Explicit deep-link from dashboard takes priority + val idx = pages.indexOfFirst { it is PairPage.Plan && it.crypto == deepLinkCrypto && it.fiat == deepLinkFiat } + 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( - availableExchanges = exchanges, pages = pages, selectedPageIndex = pageIndex, isLoading = false @@ -141,25 +224,29 @@ 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() + cachedDbPlans = allDbPlans + 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 ) @@ -281,17 +368,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 +375,24 @@ class PortfolioViewModel @Inject constructor( selectedPageIndex = index, denominationMode = newMode, visibleSeries = setOf(0, 1), - zoomLevel = ChartZoomLevel.Overview + zoomLevel = ChartZoomLevel.Overview, + planLines = emptyList(), + visiblePlanLines = emptySet(), + cryptoGroupLines = emptyList(), + visibleCryptoGroupLines = emptySet(), + isAdvancedLegendExpanded = false ) } + // Persist selection so the same chip is restored on next app launch + if (page != null) { + userPreferences.setPortfolioSelectedPageId(pageIdOf(page)) + } updateNavigationState() 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, @@ -323,11 +408,32 @@ 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) } } + fun togglePlanLineVisibility(planId: Long, type: PlanLineType) { + _uiState.update { state -> + val key = planId to type + val current = state.visiblePlanLines + val toggled = if (key in current) current - key else current + key + state.copy(visiblePlanLines = toggled) + } + } + + 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() @@ -348,10 +454,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() { @@ -404,51 +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) = when (page) { - is PairPage.Aggregate -> null to page.fiat - is PairPage.SinglePair -> page.crypto to page.fiat - null -> null to null - } - - val data = if (crypto == null && fiat == null) { - emptyList() - } else { - val filteredTxs = getFilteredTransactions() - calculateChartDataUseCase.calculate( - transactions = filteredTxs, - crypto = crypto, - fiat = fiat, - zoomLevel = state.zoomLevel - ) - } - - val txCount = completedTransactions.count { tx -> - (crypto == null || tx.crypto == crypto) && - (fiat == null || tx.fiat == fiat) && - (state.selectedExchangeFilter == null || tx.exchange.name == state.selectedExchangeFilter) + // 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() } - - _uiState.update { it.copy( - chartData = data, - currentPairCrypto = crypto, - currentPairFiat = fiat, - totalTransactions = txCount, - isChartLoading = false - ) } + } 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") } - } catch (e: CancellationException) { - throw e + return@launch } catch (e: Exception) { Log.e("PortfolioVM", "Error loading chart data", e) _uiState.update { it.copy(isChartLoading = false) } + return@launch } + + _uiState.update { it.copy( + chartData = chartResult.data, + currentPairCrypto = chartResult.crypto, + currentPairFiat = chartResult.fiat, + totalTransactions = chartResult.txCount, + planLines = chartResult.planLines, + cryptoGroupLines = chartResult.cryptoGroupLines, + isChartLoading = false + ) } + } + } + + private data class ChartComputeResult( + val data: List, + val crypto: String?, + val fiat: String?, + val txCount: Int, + val planLines: List, + val cryptoGroupLines: List + ) + + private suspend fun computeChartData(): ChartComputeResult { + 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 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 + } + 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() + + // 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() + + 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/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..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 @@ -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..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 @@ -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,13 +109,35 @@ 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)") + // 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( @@ -138,6 +162,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 +183,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 +258,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 +269,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 +308,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 +326,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 +350,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 +366,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 +392,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 +452,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 +461,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 +476,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 +485,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) { @@ -463,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). @@ -569,8 +614,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 +631,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..53d9ff2 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,13 @@ Zrušit Smazat Uložit + Přejmenovat + Posunout nahoru + Posunout dolů + např. Dlouhodobé spoření, Měsíční BTC… + Přidat název plánu + 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 @@ -65,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 @@ -98,7 +109,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í @@ -330,6 +341,16 @@ Investováno Hodnota portfolia Investováno + Celkem hodnota + 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 + Další metriky + Skrýt metriky Držené krypto Ekvivalent investice Prům. cena @@ -345,6 +366,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í připojení + 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. @@ -765,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 04d5e7a..3eb68f2 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -7,6 +7,13 @@ Cancel Delete Save + Rename + Move up + 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 @@ -66,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 @@ -97,7 +108,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 @@ -329,6 +340,16 @@ Invested Portfolio Value Invested + Total value + Total invested + Value %1$s + Invested %1$s + Avg buy %1$s + Accumulated %1$s + Price %1$s + Total accumulated %1$s + More metrics + Hide metrics Crypto Held Invested Equiv. Avg. Price @@ -344,6 +365,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 connection + 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. @@ -762,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 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 @@ 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 )