diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/OnboardingPreferences.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/OnboardingPreferences.kt index 831cef9..e5da5ab 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/OnboardingPreferences.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/OnboardingPreferences.kt @@ -32,15 +32,30 @@ class OnboardingPreferences @Inject constructor( prefs.edit().putBoolean(KEY_ONBOARDING_COMPLETED, completed).apply() } + /** + * Check if a DCA plan was created during onboarding. + */ + fun isPlanCreatedDuringOnboarding(): Boolean { + return prefs.getBoolean(KEY_PLAN_CREATED, false) + } + + fun setPlanCreatedDuringOnboarding(created: Boolean) { + prefs.edit().putBoolean(KEY_PLAN_CREATED, created).apply() + } + /** * Reset onboarding state (for testing or re-onboarding). */ fun resetOnboarding() { - prefs.edit().putBoolean(KEY_ONBOARDING_COMPLETED, false).apply() + prefs.edit() + .putBoolean(KEY_ONBOARDING_COMPLETED, false) + .remove(KEY_PLAN_CREATED) + .apply() } companion object { private const val PREFS_NAME = "accbot_onboarding" private const val KEY_ONBOARDING_COMPLETED = "onboarding_completed" + private const val KEY_PLAN_CREATED = "plan_created_during_onboarding" } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/remote/MarketDataService.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/remote/MarketDataService.kt index 687413b..7cb54fe 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/remote/MarketDataService.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/remote/MarketDataService.kt @@ -69,7 +69,8 @@ class MarketDataService @Inject constructor( "EUR" to "eur", "GBP" to "gbp", "CZK" to "czk", - "USDT" to "usd" // Treat USDT as USD + "USDT" to "usd", // Treat USDT as USD + "USDC" to "usd" // Treat USDC as USD ) } @@ -247,7 +248,7 @@ class MarketDataService @Inject constructor( limit: Int ): List>? = withContext(Dispatchers.IO) { val fsym = crypto.uppercase() - val tsym = if (fiat.uppercase() == "USDT") "USD" else fiat.uppercase() + val tsym = if (fiat.uppercase() in listOf("USDT", "USDC")) "USD" else fiat.uppercase() try { val toUnix = toDate.plusDays(1).atStartOfDay(ZoneOffset.UTC).toEpochSecond() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/ExchangeInstructions.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/ExchangeInstructions.kt index 033d3a7..635779b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/ExchangeInstructions.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/ExchangeInstructions.kt @@ -69,7 +69,6 @@ object ExchangeInstructionsProvider { R.string.exchange_instructions_binance_2, R.string.exchange_instructions_binance_3, R.string.exchange_instructions_binance_4, - R.string.exchange_instructions_binance_5, R.string.exchange_instructions_binance_6 ), url = "https://www.binance.com/en/my/settings/api-management", 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 0086b7c..1f5dd84 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 @@ -38,9 +38,9 @@ enum class Exchange( BINANCE( displayName = "Binance", logoRes = R.drawable.ic_exchange_binance, - supportedFiats = listOf("EUR", "USDT"), - supportedCryptos = listOf("BTC", "ETH", "SOL", "ADA", "DOT"), - minOrderSize = mapOf("EUR" to BigDecimal("10"), "USDT" to BigDecimal("10")), + supportedFiats = listOf("EUR", "USDC"), + supportedCryptos = listOf("BTC", "ETH", "BNB", "SOL", "ADA", "DOT"), + minOrderSize = mapOf("EUR" to BigDecimal("5"), "USDC" to BigDecimal("5")), sandboxSupport = SandboxSupport.FULL ), KRAKEN( 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 new file mode 100644 index 0000000..5ee9750 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CreateDcaPlanUseCase.kt @@ -0,0 +1,62 @@ +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.UserPreferences +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.model.DcaStrategy +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.util.CronUtils +import java.math.BigDecimal +import java.time.Duration +import java.time.Instant +import javax.inject.Inject + +class CreateDcaPlanUseCase @Inject constructor( + private val dcaPlanDao: DcaPlanDao, + private val userPreferences: UserPreferences +) { + suspend fun execute( + exchange: Exchange, + crypto: String, + fiat: String, + amount: BigDecimal, + frequency: DcaFrequency, + cronExpression: String?, + strategy: DcaStrategy, + withdrawalEnabled: Boolean = false, + withdrawalAddress: String? = null, + targetAmount: BigDecimal? = null + ) { + val now = Instant.now() + val nextExecution = if (frequency == DcaFrequency.CUSTOM && cronExpression != null) { + CronUtils.getNextExecution(cronExpression, now) + ?: now.plus(Duration.ofMinutes(1440)) + } else { + now.plus(Duration.ofMinutes(frequency.intervalMinutes)) + } + + val plan = DcaPlanEntity( + exchange = exchange, + crypto = crypto, + fiat = fiat, + amount = amount, + frequency = frequency, + cronExpression = if (frequency == DcaFrequency.CUSTOM) cronExpression else null, + strategy = strategy, + isEnabled = true, + withdrawalEnabled = withdrawalEnabled, + withdrawalAddress = withdrawalAddress, + createdAt = now, + nextExecutionAt = nextExecution, + targetAmount = targetAmount + ) + + dcaPlanDao.insertPlan(plan) + + // Auto-enable Market Pulse when creating a plan with market-aware strategy + if (strategy is DcaStrategy.AthBased || strategy is DcaStrategy.FearAndGreed) { + userPreferences.setMarketPulseEnabled(true) + } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/util/CryptoAddressValidator.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/util/CryptoAddressValidator.kt new file mode 100644 index 0000000..534dac3 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/util/CryptoAddressValidator.kt @@ -0,0 +1,44 @@ +package com.accbot.dca.domain.util + +/** + * Basic validation for cryptocurrency wallet addresses. + * Simplified validation — actual address format depends on the crypto. + */ +object CryptoAddressValidator { + + fun isValid(crypto: String, address: String): Boolean { + if (address.isBlank()) return false + val trimmed = address.trim() + return when (crypto.uppercase()) { + "BTC" -> isValidBtcAddress(trimmed) + "ETH", "SOL", "ADA", "DOT" -> isValidGenericAddress(trimmed, minLength = 26, maxLength = 128) + "LTC" -> isValidLtcAddress(trimmed) + else -> isValidGenericAddress(trimmed, minLength = 20, maxLength = 128) + } + } + + private fun isValidBtcAddress(address: String): Boolean { + return when { + address.startsWith("1") || address.startsWith("3") -> + address.length in 25..34 && address.all { it.isLetterOrDigit() } + address.startsWith("bc1") -> + address.length in 42..62 && address.all { it.isLetterOrDigit() } + else -> false + } + } + + private fun isValidLtcAddress(address: String): Boolean { + return when { + address.startsWith("L") || address.startsWith("M") -> + address.length in 25..34 && address.all { it.isLetterOrDigit() } + address.startsWith("ltc1") -> + address.length in 42..62 && address.all { it.isLetterOrDigit() } + else -> false + } + } + + private fun isValidGenericAddress(address: String, minLength: Int, maxLength: Int): Boolean { + return address.length in minLength..maxLength && + address.all { it.isLetterOrDigit() || it == '_' } + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt index ce8b497..32f16d6 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/OtherExchanges.kt @@ -434,11 +434,27 @@ class KuCoinApi( private val baseUrl = ExchangeConfig.getBaseUrl(Exchange.KUCOIN, isSandbox) + private fun signedRequest(method: String, endpoint: String, body: String? = null): Request.Builder { + val timestamp = System.currentTimeMillis().toString() + val preSign = "$timestamp$method$endpoint${body ?: ""}" + val signature = CryptoUtils.hmacSha256Base64Secret(preSign, credentials.apiSecret) + val passphrase = credentials.passphrase?.let { + CryptoUtils.hmacSha256Base64Secret(it, credentials.apiSecret) + } ?: "" + + return Request.Builder() + .url("$baseUrl$endpoint") + .header("KC-API-KEY", credentials.apiKey) + .header("KC-API-SIGN", signature) + .header("KC-API-TIMESTAMP", timestamp) + .header("KC-API-PASSPHRASE", passphrase) + .header("KC-API-KEY-VERSION", "2") + } + override suspend fun marketBuy(crypto: String, fiat: String, fiatAmount: BigDecimal): DcaResult = withContext(Dispatchers.IO) { try { val symbol = "$crypto-$fiat" - val timestamp = System.currentTimeMillis().toString() val clientOid = java.util.UUID.randomUUID().toString() val body = JSONObject().apply { @@ -449,19 +465,7 @@ class KuCoinApi( put("funds", fiatAmount.setScale(2, RoundingMode.DOWN).toPlainString()) }.toString() - val preSign = "${timestamp}POST/api/v1/orders$body" - val signature = CryptoUtils.hmacSha256Base64Secret(preSign, credentials.apiSecret) - val passphrase = credentials.passphrase?.let { - CryptoUtils.hmacSha256Base64Secret(it, credentials.apiSecret) - } ?: "" - - val request = Request.Builder() - .url("$baseUrl/api/v1/orders") - .header("KC-API-KEY", credentials.apiKey) - .header("KC-API-SIGN", signature) - .header("KC-API-TIMESTAMP", timestamp) - .header("KC-API-PASSPHRASE", passphrase) - .header("KC-API-KEY-VERSION", "2") + val request = signedRequest("POST", "/api/v1/orders", body) .header("Content-Type", "application/json") .post(body.toRequestBody()) .build() @@ -508,20 +512,7 @@ class KuCoinApi( override suspend fun validateCredentials(): Boolean = withContext(Dispatchers.IO) { try { - val timestamp = System.currentTimeMillis().toString() - val preSign = "${timestamp}GET/api/v1/accounts" - val signature = CryptoUtils.hmacSha256Base64Secret(preSign, credentials.apiSecret) - val passphrase = credentials.passphrase?.let { - CryptoUtils.hmacSha256Base64Secret(it, credentials.apiSecret) - } ?: "" - - val request = Request.Builder() - .url("$baseUrl/api/v1/accounts") - .header("KC-API-KEY", credentials.apiKey) - .header("KC-API-SIGN", signature) - .header("KC-API-TIMESTAMP", timestamp) - .header("KC-API-PASSPHRASE", passphrase) - .header("KC-API-KEY-VERSION", "2") + val request = signedRequest("GET", "/api/v1/accounts") .get() .build() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/changelog/ChangelogData.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/changelog/ChangelogData.kt index 544c6ea..6a373e5 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/changelog/ChangelogData.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/changelog/ChangelogData.kt @@ -5,6 +5,36 @@ package com.accbot.dca.presentation.changelog object ChangelogData { val entries: List = listOf( + ChangelogEntry( + versionCode = 26100, + version = "2.6.1", + titles = mapOf( + "cs" to "DRY refaktoring a Binance USDC", + "en" to "DRY Refactor & Binance USDC", + ), + features = mapOf( + "cs" to listOf( + "Sdílený CredentialFormDelegate — méně duplicitního kódu napříč 4 ViewModely", + "Sdílený dialog výsledku API importu na 3 obrazovkách", + "Jednotný AccBotTopAppBar na 11 obrazovkách", + "Binance: přechod z USDT na USDC, minimální objednávka snížena na 5", + "Rychlé částky: 5, 10, 25, 50, 100 (dříve 25–500)", + "Výchozí částka DCA plánu nastavena na minimum burzy", + "Oprava zobrazení minimální částky — bez zbytečných nul", + "Extrakce KuCoin signed-request helperu a ROI výpočtu", + ), + "en" to listOf( + "Shared CredentialFormDelegate — less duplicate code across 4 ViewModels", + "Shared API import result dialog across 3 screens", + "Unified AccBotTopAppBar across 11 screens", + "Binance: switch from USDT to USDC, min order lowered to 5", + "Quick amounts: 5, 10, 25, 50, 100 (was 25–500)", + "Default DCA plan amount set to exchange minimum", + "Fix min order size display — strip trailing zeros", + "Extract KuCoin signed-request helper and ROI calculation", + ), + ) + ), ChangelogEntry( versionCode = 25200, version = "2.5.2", diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ApiImportResultDialog.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ApiImportResultDialog.kt new file mode 100644 index 0000000..fcef7e4 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ApiImportResultDialog.kt @@ -0,0 +1,46 @@ +package com.accbot.dca.presentation.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.accbot.dca.R +import com.accbot.dca.domain.usecase.ApiImportResultState + +@Composable +fun ApiImportResultDialog( + result: ApiImportResultState, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + when (result) { + is ApiImportResultState.Success -> stringResource(R.string.import_api_success_title) + is ApiImportResultState.Error -> stringResource(R.string.import_api_error_title) + } + ) + }, + text = { + Text( + when (result) { + is ApiImportResultState.Success -> { + if (result.imported == 0) { + stringResource(R.string.import_api_no_new) + } else { + stringResource(R.string.import_api_success_message, result.imported, result.skipped) + } + } + is ApiImportResultState.Error -> result.message + } + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_done)) + } + } + ) +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CommonComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CommonComponents.kt index 5652455..442091c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CommonComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CommonComponents.kt @@ -11,7 +11,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.automirrored.filled.* import androidx.compose.material3.* +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -32,6 +38,8 @@ import com.accbot.dca.domain.model.* import com.accbot.dca.presentation.ui.theme.Error import com.accbot.dca.presentation.ui.theme.accentColor import com.accbot.dca.presentation.ui.theme.successColor +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.KeyboardType import com.accbot.dca.presentation.utils.DateFormatters import com.accbot.dca.presentation.utils.NumberFormatters import java.math.BigDecimal @@ -140,6 +148,7 @@ fun PlanCard( fun getCryptoIconRes(crypto: String): Int? = when (crypto.uppercase()) { "BTC" -> R.drawable.ic_crypto_btc "ETH" -> R.drawable.ic_crypto_eth + "BNB" -> R.drawable.ic_crypto_bnb "SOL" -> R.drawable.ic_crypto_sol "LTC" -> R.drawable.ic_crypto_ltc "ADA" -> R.drawable.ic_crypto_ada @@ -154,6 +163,7 @@ fun getFiatIconRes(fiat: String): Int? = when (fiat.uppercase()) { "CZK" -> R.drawable.ic_fiat_czk "GBP" -> R.drawable.ic_fiat_gbp "USDT" -> R.drawable.ic_fiat_usdt + "USDC" -> R.drawable.ic_fiat_usdc else -> null } @@ -579,3 +589,163 @@ fun SectionHeader( } } } + +// ============================================ +// Chip Group (crypto/fiat selection with icons) +// ============================================ + +@Composable +fun ChipGroup( + options: List, + selectedOption: String, + onOptionSelected: (String) -> Unit, + iconResolver: ((String) -> Int?)? = null +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { + options.forEach { option -> + val iconRes = iconResolver?.invoke(option) + SelectableChip( + text = option, + selected = option == selectedOption, + onClick = { onOptionSelected(option) }, + leadingIcon = if (iconRes != null) { + { + Image( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(16.dp), + contentScale = ContentScale.Fit + ) + } + } else null + ) + } + } +} + +// ============================================ +// Frequency Dropdown +// ============================================ + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FrequencyDropdown( + selectedFrequency: DcaFrequency, + onFrequencySelected: (DcaFrequency) -> Unit, + frequencies: List = DcaFrequency.entries.toList() +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = stringResource(selectedFrequency.displayNameRes), + onValueChange = {}, + readOnly = true, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + frequencies.forEach { frequency -> + DropdownMenuItem( + text = { Text(stringResource(frequency.displayNameRes)) }, + onClick = { + onFrequencySelected(frequency) + expanded = false + }, + leadingIcon = if (frequency == selectedFrequency) { + { Icon(Icons.Default.Check, contentDescription = null, tint = successColor()) } + } else null + ) + } + } + } +} + +// ============================================ +// Section Title +// ============================================ + +@Composable +fun SectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) +} + +// ============================================ +// Amount Input with Presets +// ============================================ + +/** + * Amount input field with quick preset chips. + * Used in both AddPlanScreen and onboarding FirstPlanScreen. + */ +@Composable +fun AmountInputWithPresets( + amount: String, + onAmountChange: (String) -> Unit, + fiat: String, + minOrderSize: BigDecimal?, + amountBelowMinimum: Boolean +) { + val quickAmounts = remember(minOrderSize) { + val allAmounts = listOf("5", "10", "25", "50", "100") + if (minOrderSize != null) allAmounts.filter { it.toBigDecimal() >= minOrderSize } + else allAmounts + } + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) { + quickAmounts.forEach { preset -> + SelectableChip( + text = "$preset $fiat", + selected = amount == preset, + onClick = { onAmountChange(preset) } + ) + } + } + + OutlinedTextField( + value = amount, + onValueChange = onAmountChange, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.common_amount)) }, + suffix = { Text(fiat) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + isError = amountBelowMinimum, + supportingText = minOrderSize?.let { min -> + { + Text( + text = stringResource(R.string.min_order_size, min.stripTrailingZeros().toPlainString(), fiat), + color = if (amountBelowMinimum) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } +} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CredentialsInputCard.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CredentialsInputCard.kt index 383ac24..b605221 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CredentialsInputCard.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/CredentialsInputCard.kt @@ -28,6 +28,7 @@ import com.accbot.dca.presentation.ui.theme.accentColor import com.accbot.dca.presentation.ui.theme.successColor import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.ui.platform.LocalClipboardManager +import org.json.JSONObject /** * Reusable credentials input card component. @@ -71,12 +72,14 @@ fun CredentialsInputCard( ) { var showSecret by remember { mutableStateOf(false) } var showMultiScanner by remember { mutableStateOf(false) } + var showBinanceQrScanner by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current val clipboardManager = LocalClipboardManager.current // Determine field requirements based on exchange val needsClientId = exchange == Exchange.COINMATE val needsPassphrase = exchange == Exchange.KUCOIN || exchange == Exchange.COINBASE + val isBinance = exchange == Exchange.BINANCE // The last field gets ImeAction.Done, others get ImeAction.Next val lastFieldImeAction = ImeAction.Done val lastFieldKeyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) @@ -113,6 +116,24 @@ fun CredentialsInputCard( ) } + if (showBinanceQrScanner) { + QrScannerDialog( + onDismiss = { showBinanceQrScanner = false }, + onScanResult = { result -> + try { + val json = JSONObject(result) + json.optString("apiKey").takeIf { it.isNotBlank() }?.let { onApiKeyChange(it) } + json.optString("secretKey").takeIf { it.isNotBlank() }?.let { onApiSecretChange(it) } + } catch (_: Exception) { + // Not JSON — treat as plain API key + onApiKeyChange(result) + } + showBinanceQrScanner = false + }, + showTextMode = false + ) + } + Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface @@ -125,15 +146,16 @@ fun CredentialsInputCard( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // Scan All / Paste All buttons + // Scan / Paste buttons Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - if (!needsClientId) { + if (isBinance) { + // Binance: Scan QR only (parses JSON with apiKey + secretKey) OutlinedButton( - onClick = { showMultiScanner = true }, - modifier = Modifier.weight(1f), + onClick = { showBinanceQrScanner = true }, + modifier = Modifier.fillMaxWidth(), enabled = !isValidating, border = BorderStroke(1.dp, accentColor()), ) { @@ -145,50 +167,71 @@ fun CredentialsInputCard( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = stringResource(R.string.credentials_scan_all), + text = stringResource(R.string.credentials_scan_qr), color = accentColor() ) } - } - OutlinedButton( - onClick = { - val text = clipboardManager.getText()?.text ?: return@OutlinedButton - val lines = text.lines().filter { it.isNotBlank() } - when { - needsClientId && lines.size >= 3 -> { - // Coinmate: Private Key, Public Key, Client ID - onApiSecretChange(lines[0].trim()) - onApiKeyChange(lines[1].trim()) - onClientIdChange(lines[2].trim()) - } - needsPassphrase && lines.size >= 3 -> { - // KuCoin/Coinbase: API Key, API Secret, Passphrase - onApiKeyChange(lines[0].trim()) - onApiSecretChange(lines[1].trim()) - onPassphraseChange(lines[2].trim()) - } - lines.size >= 2 -> { - // Other exchanges: API Key, API Secret - onApiKeyChange(lines[0].trim()) - onApiSecretChange(lines[1].trim()) - } + } else { + if (!needsClientId) { + OutlinedButton( + onClick = { showMultiScanner = true }, + modifier = Modifier.weight(1f), + enabled = !isValidating, + border = BorderStroke(1.dp, accentColor()), + ) { + Icon( + imageVector = Icons.Default.QrCodeScanner, + contentDescription = null, + tint = accentColor(), + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.credentials_scan_all), + color = accentColor() + ) } - }, - modifier = Modifier.weight(1f), - enabled = !isValidating, - border = BorderStroke(1.dp, accentColor()), - ) { - Icon( - imageVector = Icons.Default.ContentPaste, - contentDescription = null, - tint = accentColor(), - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.credentials_paste_all), - color = accentColor() - ) + } + OutlinedButton( + onClick = { + val text = clipboardManager.getText()?.text ?: return@OutlinedButton + val lines = text.lines().filter { it.isNotBlank() } + when { + needsClientId && lines.size >= 3 -> { + // Coinmate: Private Key, Public Key, Client ID + onApiSecretChange(lines[0].trim()) + onApiKeyChange(lines[1].trim()) + onClientIdChange(lines[2].trim()) + } + needsPassphrase && lines.size >= 3 -> { + // KuCoin/Coinbase: API Key, API Secret, Passphrase + onApiKeyChange(lines[0].trim()) + onApiSecretChange(lines[1].trim()) + onPassphraseChange(lines[2].trim()) + } + lines.size >= 2 -> { + // Other exchanges: API Key, API Secret + onApiKeyChange(lines[0].trim()) + onApiSecretChange(lines[1].trim()) + } + } + }, + modifier = Modifier.weight(1f), + enabled = !isValidating, + border = BorderStroke(1.dp, accentColor()), + ) { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = null, + tint = accentColor(), + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.credentials_paste_all), + color = accentColor() + ) + } } } @@ -313,6 +356,91 @@ fun CredentialsInputCard( } } ) + } else if (isBinance) { + // Binance: API Key → API Secret with paste/clear buttons + OutlinedTextField( + value = apiKey, + onValueChange = onApiKeyChange, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.credentials_api_key)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = nextFieldKeyboardActions, + isError = errorMessage != null, + enabled = !isValidating, + trailingIcon = { + if (apiKey.isNotEmpty()) { + IconButton(onClick = { onApiKeyChange("") }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = null + ) + } + } else { + IconButton(onClick = { + clipboardManager.getText()?.text?.trim()?.let { onApiKeyChange(it) } + }) { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = stringResource(R.string.credentials_paste), + tint = accentColor() + ) + } + } + } + ) + + OutlinedTextField( + value = apiSecret, + onValueChange = onApiSecretChange, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.credentials_api_secret)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = lastFieldImeAction), + keyboardActions = lastFieldKeyboardActions, + visualTransformation = if (showSecret) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + isError = errorMessage != null, + enabled = !isValidating, + trailingIcon = { + Row { + if (apiSecret.isNotEmpty()) { + IconButton(onClick = { onApiSecretChange("") }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = null + ) + } + } else { + IconButton(onClick = { + clipboardManager.getText()?.text?.trim()?.let { onApiSecretChange(it) } + }) { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = stringResource(R.string.credentials_paste), + tint = accentColor() + ) + } + } + IconButton(onClick = { showSecret = !showSecret }) { + Icon( + imageVector = if (showSecret) { + Icons.Default.VisibilityOff + } else { + Icons.Default.Visibility + }, + contentDescription = stringResource( + if (showSecret) R.string.credentials_hide_password + else R.string.credentials_show_password + ) + ) + } + } + } + ) } else { // Other exchanges: API Key → API Secret → Passphrase (if needed) OutlinedTextField( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/QrScanner.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/QrScanner.kt index 6d6aabe..be76674 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/QrScanner.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/QrScanner.kt @@ -112,7 +112,8 @@ fun QrScannerButton( @Composable fun QrScannerDialog( onDismiss: () -> Unit, - onScanResult: (String) -> Unit + onScanResult: (String) -> Unit, + showTextMode: Boolean = true ) { val context = LocalContext.current var hasCameraPermission by remember { @@ -218,8 +219,8 @@ fun QrScannerDialog( // Spacer for symmetry Spacer(modifier = Modifier.size(48.dp)) - // Mode toggle - if (hasCameraPermission) { + // Mode toggle (hide when text mode is disabled) + if (hasCameraPermission && showTextMode) { Row( modifier = Modifier .background( @@ -244,6 +245,8 @@ fun QrScannerDialog( onClick = { scanMode = ScanMode.TEXT } ) } + } else { + Spacer(modifier = Modifier.size(48.dp)) } // Close button diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ReusableComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ReusableComponents.kt index bc40f47..50f2a8d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ReusableComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ReusableComponents.kt @@ -7,9 +7,18 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.* import androidx.compose.material3.* +import android.content.Intent +import android.net.Uri import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import com.accbot.dca.domain.model.ExchangeInstructions import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -296,7 +305,11 @@ fun NextStepItem( icon: ImageVector, title: String, description: String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + size: Dp = 40.dp, + iconSize: Dp = 20.dp, + cornerRadius: Dp = 10.dp, + spacing: Dp = 16.dp ) { val successCol = successColor() Row( @@ -304,19 +317,19 @@ fun NextStepItem( verticalAlignment = Alignment.CenterVertically ) { IconBadge( - size = 40.dp, + size = size, backgroundColor = successCol.copy(alpha = 0.1f), - cornerRadius = 10.dp + cornerRadius = cornerRadius ) { Icon( imageVector = icon, contentDescription = null, tint = successCol, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(iconSize) ) } - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(spacing)) Column { Text( @@ -678,3 +691,124 @@ fun ExchangeSelectionGrid( } } } + +/** + * Exchange setup instructions card with numbered steps and link to API page. + * Used in both onboarding ExchangeSetupScreen and AddPlanScreen. + */ +@Composable +fun ExchangeInstructionsCard( + exchange: Exchange, + instructions: ExchangeInstructions, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val resolvedUrl = instructions.urlRes?.let { stringResource(it) } ?: instructions.url + val accentCol = accentColor() + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + modifier = modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + stringResource(R.string.add_exchange_api_setup), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleSmall + ) + + instructions.steps.forEachIndexed { index, stepResId -> + Row { + Text( + "${index + 1}.", + fontWeight = FontWeight.Bold, + modifier = Modifier.width(24.dp) + ) + Text(stringResource(stepResId), style = MaterialTheme.typography.bodySmall) + } + } + + if (resolvedUrl.isNotBlank()) { + OutlinedButton( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolvedUrl)) + context.startActivity(intent) + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = accentCol) + ) { + Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.add_exchange_open_api_page, exchange.displayName)) + } + } + + Row( + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = accentCol, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.add_exchange_security_tip), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +/** + * Strategy selection section with strategy option cards and info bottom sheet. + * Used in both AddPlanScreen and onboarding FirstPlanScreen. + */ +@Composable +fun StrategySelectionSection( + selectedStrategy: DcaStrategy, + onStrategySelected: (DcaStrategy) -> Unit +) { + val strategies = remember { + listOf( + DcaStrategy.Classic, + DcaStrategy.AthBased(), + DcaStrategy.FearAndGreed() + ) + } + var showStrategyInfo by remember { mutableStateOf(null) } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + strategies.forEach { strategy -> + val isSelected = when { + selectedStrategy is DcaStrategy.Classic && strategy is DcaStrategy.Classic -> true + selectedStrategy is DcaStrategy.AthBased && strategy is DcaStrategy.AthBased -> true + selectedStrategy is DcaStrategy.FearAndGreed && strategy is DcaStrategy.FearAndGreed -> true + else -> false + } + StrategyOption( + strategy = strategy, + isSelected = isSelected, + onClick = { onStrategySelected(strategy) }, + onInfoClick = { showStrategyInfo = strategy } + ) + } + } + + showStrategyInfo?.let { strategy -> + StrategyInfoBottomSheet( + strategy = strategy, + onDismiss = { showStrategyInfo = null } + ) + } +} 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 new file mode 100644 index 0000000..6734f85 --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt @@ -0,0 +1,170 @@ +package com.accbot.dca.presentation.credentials + +import androidx.compose.runtime.Immutable +import com.accbot.dca.data.local.CredentialsStore +import com.accbot.dca.data.local.UserPreferences +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.model.ExchangeFilter +import com.accbot.dca.domain.model.ExchangeInstructions +import com.accbot.dca.domain.model.ExchangeInstructionsProvider +import com.accbot.dca.domain.model.isStable +import com.accbot.dca.domain.usecase.CredentialValidationResult +import com.accbot.dca.domain.usecase.ValidateAndSaveCredentialsUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@Immutable +data class CredentialFormState( + val selectedExchange: Exchange? = null, + val selectedExchangeInstructions: ExchangeInstructions? = null, + val hasCredentials: Boolean = false, + val clientId: String = "", + val apiKey: String = "", + val apiSecret: String = "", + val passphrase: String = "", + val isValidatingCredentials: Boolean = false, + val credentialsValid: Boolean = false, + val credentialsError: String? = null, + val isSandboxMode: Boolean = false, + val availableExchanges: List = emptyList(), + val showExperimental: Boolean = false +) + +/** + * Shared delegate for credential form state and logic. + * Used by AddPlanViewModel, OnboardingViewModel, AddExchangeViewModel, and ExchangeDetailViewModel. + * Not a ViewModel — the owning ViewModel passes its coroutineScope. + */ +class CredentialFormDelegate( + private val credentialsStore: CredentialsStore, + private val validateAndSaveCredentialsUseCase: ValidateAndSaveCredentialsUseCase, + private val userPreferences: UserPreferences, + private val coroutineScope: CoroutineScope +) { + private val _state = MutableStateFlow(CredentialFormState()) + val state: StateFlow = _state.asStateFlow() + + fun initialize() { + val isSandbox = userPreferences.isSandboxMode() + val showExperimental = userPreferences.areExperimentalExchangesEnabled() + _state.update { + it.copy( + isSandboxMode = isSandbox, + showExperimental = showExperimental, + availableExchanges = ExchangeFilter.getAvailableExchanges(isSandbox) + .filter { exchange -> showExperimental || exchange.isStable } + ) + } + } + + /** 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) + _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 ?: "", + credentialsError = null + ) + } + } + + fun selectExchange(exchange: Exchange) { + val isSandbox = _state.value.isSandboxMode + val hasCredentials = credentialsStore.hasCredentials(exchange, isSandbox) + val instructions = ExchangeInstructionsProvider.getInstructions(exchange, isSandbox) + _state.update { state -> + state.copy( + selectedExchange = exchange, + selectedExchangeInstructions = instructions, + hasCredentials = hasCredentials, + credentialsValid = hasCredentials, + clientId = "", + apiKey = "", + apiSecret = "", + passphrase = "", + credentialsError = null + ) + } + } + + fun setClientId(value: String) { + _state.update { it.copy(clientId = value, credentialsError = null) } + } + + fun setApiKey(value: String) { + _state.update { it.copy(apiKey = value, credentialsError = null) } + } + + fun setApiSecret(value: String) { + _state.update { it.copy(apiSecret = value, credentialsError = null) } + } + + fun setPassphrase(value: String) { + _state.update { it.copy(passphrase = value, credentialsError = null) } + } + + fun validateAndSaveCredentials(onSuccess: () -> Unit) { + val state = _state.value + if (state.isValidatingCredentials) return + + val exchange = state.selectedExchange ?: return + + coroutineScope.launch { + _state.update { it.copy(isValidatingCredentials = true, credentialsError = null) } + + val result = validateAndSaveCredentialsUseCase.execute( + exchange = exchange, + apiKey = state.apiKey, + apiSecret = state.apiSecret, + passphrase = state.passphrase.takeIf { it.isNotBlank() }, + clientId = state.clientId.takeIf { it.isNotBlank() } + ) + + when (result) { + is CredentialValidationResult.Success -> { + _state.update { + it.copy( + isValidatingCredentials = false, + credentialsValid = true, + hasCredentials = true + ) + } + onSuccess() + } + is CredentialValidationResult.Error -> { + _state.update { + it.copy( + isValidatingCredentials = false, + credentialsError = result.message + ) + } + } + } + } + } + + fun setExperimentalExchangesEnabled(enabled: Boolean) { + userPreferences.setExperimentalExchangesEnabled(enabled) + val isSandbox = _state.value.isSandboxMode + _state.update { + it.copy( + showExperimental = enabled, + availableExchanges = ExchangeFilter.getAvailableExchanges(isSandbox) + .filter { exchange -> enabled || exchange.isStable } + ) + } + } +} 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 new file mode 100644 index 0000000..2984cde --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt @@ -0,0 +1,211 @@ +package com.accbot.dca.presentation.plan + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.accbot.dca.R +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.model.DcaStrategy +import com.accbot.dca.presentation.components.AmountInputWithPresets +import com.accbot.dca.presentation.components.ChipGroup +import com.accbot.dca.presentation.components.FrequencyDropdown +import com.accbot.dca.presentation.components.MonthlyCostEstimateCard +import com.accbot.dca.presentation.components.QrScannerButton +import com.accbot.dca.presentation.components.ScheduleBuilder +import com.accbot.dca.presentation.components.SectionTitle +import com.accbot.dca.presentation.components.StrategySelectionSection +import com.accbot.dca.presentation.components.getCryptoIconRes +import com.accbot.dca.presentation.components.getFiatIconRes + +/** + * Shared plan configuration form used by AddPlanScreen, FirstPlanScreen, and EditPlanScreen. + * Renders: crypto/fiat selection, amount, frequency, strategy, monthly estimate, + * auto-withdrawal, target amount, and error message. + * + * Does NOT include exchange selection, credentials, or the submit button — + * those are screen-specific. + */ +@Composable +fun PlanFormContent( + state: PlanFormState, + availableCryptos: List, + availableFiats: List, + onCryptoSelected: (String) -> Unit, + onFiatSelected: (String) -> Unit, + onAmountChanged: (String) -> Unit, + onFrequencySelected: (DcaFrequency) -> Unit, + onCronExpressionChanged: (String) -> Unit, + onStrategySelected: (DcaStrategy) -> Unit, + onWithdrawalEnabledChanged: (Boolean) -> Unit, + onWithdrawalAddressChanged: (String) -> Unit, + onTargetAmountChanged: (String) -> Unit, + modifier: Modifier = Modifier, + showCryptoFiatSelection: Boolean = true, + errorMessage: String? = null +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Crypto selection + if (showCryptoFiatSelection) { + Column { + SectionTitle(stringResource(R.string.add_plan_cryptocurrency)) + ChipGroup( + options = availableCryptos, + selectedOption = state.selectedCrypto, + onOptionSelected = onCryptoSelected, + iconResolver = { getCryptoIconRes(it) } + ) + } + + // Fiat selection + Column { + SectionTitle(stringResource(R.string.add_plan_fiat_currency)) + ChipGroup( + options = availableFiats, + selectedOption = state.selectedFiat, + onOptionSelected = onFiatSelected, + iconResolver = { getFiatIconRes(it) } + ) + } + } + + // Amount input + Column { + SectionTitle(stringResource(R.string.add_plan_amount_per_purchase)) + AmountInputWithPresets( + amount = state.amount, + onAmountChange = onAmountChanged, + fiat = state.selectedFiat, + minOrderSize = state.minOrderSize, + amountBelowMinimum = state.amountBelowMinimum + ) + } + + // Frequency selection + Column { + SectionTitle(stringResource(R.string.add_plan_purchase_frequency)) + FrequencyDropdown( + selectedFrequency = state.selectedFrequency, + onFrequencySelected = onFrequencySelected + ) + + if (state.selectedFrequency == DcaFrequency.CUSTOM) { + Spacer(modifier = Modifier.height(12.dp)) + ScheduleBuilder( + cronExpression = state.cronExpression, + cronDescription = state.cronDescription, + cronError = state.cronError, + onCronExpressionChange = onCronExpressionChanged + ) + } + } + + // Strategy selection + Column { + SectionTitle(stringResource(R.string.add_plan_dca_strategy)) + StrategySelectionSection( + selectedStrategy = state.selectedStrategy, + onStrategySelected = onStrategySelected + ) + } + + // Monthly cost estimate + if (state.monthlyCostEstimate != null && state.amount.toBigDecimalOrNull() != null) { + MonthlyCostEstimateCard( + estimate = state.monthlyCostEstimate, + fiat = state.selectedFiat, + isClassic = state.selectedStrategy is DcaStrategy.Classic + ) + } + + // Auto-withdrawal toggle + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Switch) { onWithdrawalEnabledChanged(!state.withdrawalEnabled) } + .semantics(mergeDescendants = true) { role = Role.Switch }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.add_plan_auto_withdrawal), fontWeight = FontWeight.SemiBold) + Text( + stringResource(R.string.add_plan_auto_withdrawal_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = state.withdrawalEnabled, + onCheckedChange = null, + modifier = Modifier.clearAndSetSemantics {} + ) + } + + if (state.withdrawalEnabled) { + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = state.withdrawalAddress, + onValueChange = onWithdrawalAddressChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.add_plan_wallet_address, state.selectedCrypto)) }, + singleLine = true, + isError = state.addressError != null, + supportingText = if (state.addressError != null) { + { Text(state.addressError, color = MaterialTheme.colorScheme.error) } + } else null, + trailingIcon = { + QrScannerButton( + onScanResult = onWithdrawalAddressChanged + ) + } + ) + } + } + + // Target amount + Column { + SectionTitle(stringResource(R.string.plan_target_amount)) + OutlinedTextField( + value = state.targetAmount, + onValueChange = onTargetAmountChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.plan_target_amount)) }, + placeholder = { Text(stringResource(R.string.plan_target_amount_hint)) }, + suffix = { Text(state.selectedCrypto) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + supportingText = { + Text( + text = stringResource(R.string.plan_target_amount_description), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } + + // Error message + if (errorMessage != null) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} 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 new file mode 100644 index 0000000..050c4ea --- /dev/null +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt @@ -0,0 +1,225 @@ +package com.accbot.dca.presentation.plan + +import androidx.compose.runtime.Immutable +import com.accbot.dca.domain.model.DcaFrequency +import com.accbot.dca.domain.model.DcaStrategy +import com.accbot.dca.domain.model.Exchange +import com.accbot.dca.domain.usecase.CalculateMonthlyCostUseCase +import com.accbot.dca.domain.util.CronUtils +import com.accbot.dca.domain.util.CryptoAddressValidator +import com.accbot.dca.exchange.MinOrderSizeRepository +import com.accbot.dca.presentation.model.MonthlyCostEstimate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.math.BigDecimal + +@Immutable +data class PlanFormState( + val selectedCrypto: String = "BTC", + val selectedFiat: String = "EUR", + val amount: String = "100", + val selectedFrequency: DcaFrequency = DcaFrequency.DAILY, + val cronExpression: String = "", + val cronDescription: String? = null, + val cronError: String? = null, + val selectedStrategy: DcaStrategy = DcaStrategy.Classic, + val withdrawalEnabled: Boolean = false, + val withdrawalAddress: String = "", + val addressError: String? = null, + val targetAmount: String = "", + val minOrderSize: BigDecimal? = null, + val monthlyCostEstimate: MonthlyCostEstimate? = null +) { + val amountBelowMinimum: Boolean + get() { + val min = minOrderSize ?: return false + val amt = amount.toBigDecimalOrNull() ?: return false + return amt < min + } + + val isAddressValid: Boolean + get() = !withdrawalEnabled || CryptoAddressValidator.isValid(selectedCrypto, withdrawalAddress) + + val isFormValid: Boolean + get() { + val amountValue = amount.toBigDecimalOrNull() ?: return false + if (amountValue <= BigDecimal.ZERO) return false + if (amountBelowMinimum) return false + if (withdrawalEnabled && !isAddressValid) return false + if (selectedFrequency == DcaFrequency.CUSTOM && !CronUtils.isValidCron(cronExpression)) return false + return true + } +} + +/** + * Shared delegate for plan form state and logic. + * Used by AddPlanViewModel, OnboardingViewModel, and EditPlanViewModel. + * Not a ViewModel — the owning ViewModel passes its coroutineScope. + */ +class PlanFormDelegate( + private val calculateMonthlyCost: CalculateMonthlyCostUseCase, + private val minOrderSizeRepository: MinOrderSizeRepository, + private val coroutineScope: CoroutineScope +) { + private val _state = MutableStateFlow(PlanFormState()) + val state: StateFlow = _state.asStateFlow() + + private var estimateJob: Job? = null + private var currentExchange: Exchange? = null + + fun selectCrypto(crypto: String) { + _state.update { it.copy(selectedCrypto = crypto) } + updateMonthlyCostEstimate() + updateMinOrderSize() + } + + fun selectFiat(fiat: String) { + _state.update { it.copy(selectedFiat = fiat) } + updateMonthlyCostEstimate() + updateMinOrderSize() + } + + fun setAmount(amount: String) { + _state.update { it.copy(amount = amount) } + updateMonthlyCostEstimate() + } + + fun selectFrequency(frequency: DcaFrequency) { + _state.update { + it.copy( + selectedFrequency = frequency, + cronExpression = if (frequency != DcaFrequency.CUSTOM) "" else it.cronExpression, + cronDescription = if (frequency != DcaFrequency.CUSTOM) null else it.cronDescription, + cronError = if (frequency != DcaFrequency.CUSTOM) null else it.cronError + ) + } + updateMonthlyCostEstimate() + } + + fun setCronExpression(cron: String) { + val isValid = CronUtils.isValidCron(cron) + val description = if (isValid) CronUtils.describeCron(cron) else null + val error = if (cron.isNotBlank() && !isValid) "Invalid CRON expression" else null + _state.update { + it.copy( + cronExpression = cron, + cronDescription = description, + cronError = error + ) + } + if (isValid) { + updateMonthlyCostEstimate() + } + } + + fun selectStrategy(strategy: DcaStrategy) { + _state.update { it.copy(selectedStrategy = strategy) } + updateMonthlyCostEstimate() + } + + fun setWithdrawalEnabled(enabled: Boolean) { + _state.update { it.copy(withdrawalEnabled = enabled) } + } + + fun setWithdrawalAddress(address: String) { + val crypto = _state.value.selectedCrypto + val addressError = if (address.isNotBlank() && !CryptoAddressValidator.isValid(crypto, address)) { + "Invalid $crypto address format" + } else { + null + } + _state.update { it.copy(withdrawalAddress = address, addressError = addressError) } + } + + fun setTargetAmount(value: String) { + _state.update { it.copy(targetAmount = value) } + } + + /** Initialize form defaults from an exchange (used when exchange is selected). */ + fun initFromExchange(exchange: Exchange) { + currentExchange = exchange + val fiat = exchange.supportedFiats.firstOrNull() ?: "EUR" + val minAmount = exchange.minOrderSize[fiat] + _state.update { + it.copy( + selectedCrypto = exchange.supportedCryptos.firstOrNull() ?: "BTC", + selectedFiat = fiat, + amount = minAmount?.stripTrailingZeros()?.toPlainString() ?: it.amount, + minOrderSize = null + ) + } + updateMinOrderSize() + } + + /** Populate form from an existing plan entity (used by EditPlan). */ + fun initFromPlan( + exchange: Exchange, + crypto: String, + fiat: String, + amount: String, + frequency: DcaFrequency, + cronExpression: String, + strategy: DcaStrategy, + withdrawalEnabled: Boolean, + withdrawalAddress: String, + targetAmount: String + ) { + currentExchange = exchange + val cronDesc = if (cronExpression.isNotBlank()) CronUtils.describeCron(cronExpression) else null + _state.update { + PlanFormState( + selectedCrypto = crypto, + selectedFiat = fiat, + amount = amount, + selectedFrequency = frequency, + cronExpression = cronExpression, + cronDescription = cronDesc, + cronError = null, + selectedStrategy = strategy, + withdrawalEnabled = withdrawalEnabled, + withdrawalAddress = withdrawalAddress, + targetAmount = targetAmount + ) + } + updateMinOrderSize() + updateMonthlyCostEstimate() + } + + private fun updateMinOrderSize() { + val exchange = currentExchange ?: return + coroutineScope.launch { + val min = minOrderSizeRepository.getMinOrderSize( + exchange, _state.value.selectedCrypto, _state.value.selectedFiat + ) + _state.update { it.copy(minOrderSize = min) } + } + } + + private fun updateMonthlyCostEstimate() { + estimateJob?.cancel() + estimateJob = coroutineScope.launch { + delay(300) + val s = _state.value + val amount = s.amount.toBigDecimalOrNull() + if (amount == null) { + _state.update { it.copy(monthlyCostEstimate = null) } + return@launch + } + val estimate = calculateMonthlyCost.computeEstimate( + amount = amount, + frequency = s.selectedFrequency, + cronExpression = s.cronExpression.takeIf { it.isNotBlank() }, + strategy = s.selectedStrategy, + crypto = s.selectedCrypto, + fiat = s.selectedFiat + ) + _state.update { it.copy(monthlyCostEstimate = estimate) } + } + } +} 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 d90fd50..1414c73 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 @@ -2,56 +2,35 @@ package com.accbot.dca.presentation.screens import android.content.Intent import android.net.Uri -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.automirrored.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.accbot.dca.R -import com.accbot.dca.domain.model.DcaFrequency -import com.accbot.dca.domain.model.DcaStrategy import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.isStable +import com.accbot.dca.presentation.components.AccBotTopAppBar import com.accbot.dca.presentation.components.CredentialsInputCard +import com.accbot.dca.presentation.components.ExchangeInstructionsCard import com.accbot.dca.presentation.components.ExchangeSelectionGrid import com.accbot.dca.presentation.components.ExperimentalExchangeDisclaimer import com.accbot.dca.presentation.components.ExperimentalExchangesToggle import com.accbot.dca.presentation.components.ExperimentalToggleDisclaimer -import com.accbot.dca.presentation.components.MonthlyCostEstimateCard -import com.accbot.dca.presentation.components.QrScannerButton import com.accbot.dca.presentation.components.SandboxCredentialsInfoCard import com.accbot.dca.presentation.components.SandboxModeIndicator -import com.accbot.dca.domain.model.ExchangeInstructions -import com.accbot.dca.presentation.ui.theme.accentColor -import com.accbot.dca.presentation.components.ScheduleBuilder -import com.accbot.dca.presentation.components.SelectableChip -import com.accbot.dca.presentation.components.StrategyInfoBottomSheet -import com.accbot.dca.presentation.components.StrategyOption -import com.accbot.dca.presentation.components.getCryptoIconRes -import com.accbot.dca.presentation.components.getFiatIconRes -import com.accbot.dca.presentation.ui.theme.successColor +import com.accbot.dca.presentation.components.SectionTitle +import com.accbot.dca.presentation.plan.PlanFormContent @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -71,7 +50,7 @@ fun AddPlanScreen( // Import offer dialog after creating plan with new credentials if (uiState.showImportDialog) { - val exchangeName = uiState.selectedExchange?.displayName ?: "" + val exchangeName = uiState.credentialForm.selectedExchange?.displayName ?: "" AlertDialog( onDismissRequest = { viewModel.dismissImportDialog() }, title = { Text(stringResource(R.string.import_api_offer_title)) }, @@ -79,7 +58,7 @@ fun AddPlanScreen( confirmButton = { TextButton(onClick = { viewModel.dismissImportDialog() - uiState.selectedExchange?.let { exchange -> + uiState.credentialForm.selectedExchange?.let { exchange -> onNavigateToExchangeDetail?.invoke(exchange.name) } }) { @@ -96,16 +75,9 @@ fun AddPlanScreen( Scaffold( topBar = { - TopAppBar( - title = { Text(stringResource(R.string.add_plan_title), fontWeight = FontWeight.Bold) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.common_back)) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) + AccBotTopAppBar( + title = stringResource(R.string.add_plan_title), + onNavigateBack = onNavigateBack ) } ) { paddingValues -> @@ -129,7 +101,7 @@ fun AddPlanScreen( SectionTitle(stringResource(R.string.add_plan_select_exchange)) // Sandbox mode indicator - if (uiState.isSandboxMode) { + if (uiState.credentialForm.isSandboxMode) { SandboxModeIndicator() } @@ -151,7 +123,7 @@ fun AddPlanScreen( ExperimentalToggleDisclaimer( onConfirm = { showExperimentalDisclaimer = false - viewModel.setExperimentalExchangesEnabled(true) + viewModel.credentialForm.setExperimentalExchangesEnabled(true) }, onDismiss = { showExperimentalDisclaimer = false } ) @@ -163,7 +135,7 @@ fun AddPlanScreen( verticalArrangement = Arrangement.spacedBy(12.dp) ) { ExchangeSelectionGrid( - exchanges = uiState.availableExchanges, + exchanges = uiState.credentialForm.availableExchanges, onExchangeClick = { exchange -> if (exchange.isStable) { viewModel.selectExchange(exchange) @@ -171,7 +143,7 @@ fun AddPlanScreen( experimentalExchangePending = exchange } }, - selectedExchange = uiState.selectedExchange, + selectedExchange = uiState.credentialForm.selectedExchange, onRequestExchangeClick = { val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Crynners/AccBot/issues")) addPlanContext.startActivity(intent) @@ -180,30 +152,31 @@ fun AddPlanScreen( // Experimental exchanges toggle ExperimentalExchangesToggle( - isEnabled = uiState.showExperimental, + isEnabled = uiState.credentialForm.showExperimental, onToggle = { enabled -> if (enabled) { showExperimentalDisclaimer = true } else { - viewModel.setExperimentalExchangesEnabled(false) + viewModel.credentialForm.setExperimentalExchangesEnabled(false) } } ) } // API Credentials - if (uiState.selectedExchange != null && !uiState.hasCredentials) { + val cred = uiState.credentialForm + if (cred.selectedExchange != null && !cred.hasCredentials) { // Exchange setup instructions card - if (uiState.selectedExchangeInstructions != null) { - if (uiState.isSandboxMode) { + if (cred.selectedExchangeInstructions != null) { + if (cred.isSandboxMode) { SandboxCredentialsInfoCard( - exchange = uiState.selectedExchange!!, - instructions = uiState.selectedExchangeInstructions!! + exchange = cred.selectedExchange!!, + instructions = cred.selectedExchangeInstructions!! ) } else { ExchangeInstructionsCard( - exchange = uiState.selectedExchange!!, - instructions = uiState.selectedExchangeInstructions!! + exchange = cred.selectedExchange!!, + instructions = cred.selectedExchangeInstructions!! ) } } @@ -211,220 +184,38 @@ fun AddPlanScreen( SectionTitle(stringResource(R.string.add_plan_api_credentials)) // Use reusable CredentialsInputCard component CredentialsInputCard( - exchange = uiState.selectedExchange!!, - clientId = uiState.clientId, - apiKey = uiState.apiKey, - apiSecret = uiState.apiSecret, - passphrase = uiState.passphrase, - onClientIdChange = viewModel::setClientId, - onApiKeyChange = viewModel::setApiKey, - onApiSecretChange = viewModel::setApiSecret, - onPassphraseChange = viewModel::setPassphrase, + 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 ) } - // Cryptocurrency Selection - if (uiState.selectedExchange != null) { - SectionTitle(stringResource(R.string.add_plan_cryptocurrency)) - ChipGroup( - options = uiState.selectedExchange!!.supportedCryptos, - selectedOption = uiState.selectedCrypto, - onOptionSelected = viewModel::selectCrypto, - iconResolver = { getCryptoIconRes(it) } - ) - - // Fiat Currency Selection - SectionTitle(stringResource(R.string.add_plan_fiat_currency)) - ChipGroup( - options = uiState.selectedExchange!!.supportedFiats, - selectedOption = uiState.selectedFiat, - onOptionSelected = viewModel::selectFiat, - iconResolver = { getFiatIconRes(it) } - ) - - // Amount Input - SectionTitle(stringResource(R.string.add_plan_amount_per_purchase)) - OutlinedTextField( - value = uiState.amount, - onValueChange = viewModel::setAmount, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.common_amount)) }, - suffix = { Text(uiState.selectedFiat) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - singleLine = true, - isError = uiState.amountBelowMinimum, - supportingText = uiState.minOrderSize?.let { min -> - { - Text( - text = stringResource(R.string.min_order_size, min.toPlainString(), uiState.selectedFiat), - color = if (uiState.amountBelowMinimum) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - ) - - // Preset amount buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - val quickAmounts = remember(uiState.minOrderSize) { - val allAmounts = listOf("25", "50", "100", "250", "500") - val min = uiState.minOrderSize - if (min != null) allAmounts.filter { it.toBigDecimal() >= min } - else allAmounts - } - quickAmounts.forEach { preset -> - OutlinedButton( - onClick = { viewModel.setAmount(preset) }, - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(horizontal = 4.dp, vertical = 8.dp), - colors = if (uiState.amount == preset) { - ButtonDefaults.outlinedButtonColors( - containerColor = successColor().copy(alpha = 0.15f), - contentColor = successColor() - ) - } else { - ButtonDefaults.outlinedButtonColors() - } - ) { - Text(text = preset, style = MaterialTheme.typography.bodySmall) - } - } - } - - // Frequency Selection - Dropdown - SectionTitle(stringResource(R.string.add_plan_purchase_frequency)) - FrequencyDropdown( - selectedFrequency = uiState.selectedFrequency, - onFrequencySelected = viewModel::selectFrequency + // Plan form (crypto, fiat, amount, frequency, strategy, withdrawal, target) + if (cred.selectedExchange != null) { + PlanFormContent( + state = uiState.planForm, + availableCryptos = cred.selectedExchange!!.supportedCryptos, + availableFiats = cred.selectedExchange!!.supportedFiats, + onCryptoSelected = viewModel.planForm::selectCrypto, + onFiatSelected = viewModel.planForm::selectFiat, + onAmountChanged = viewModel.planForm::setAmount, + onFrequencySelected = viewModel.planForm::selectFrequency, + onCronExpressionChanged = viewModel.planForm::setCronExpression, + onStrategySelected = viewModel.planForm::selectStrategy, + onWithdrawalEnabledChanged = viewModel.planForm::setWithdrawalEnabled, + onWithdrawalAddressChanged = viewModel.planForm::setWithdrawalAddress, + onTargetAmountChanged = viewModel.planForm::setTargetAmount, + errorMessage = uiState.errorMessage ) - // Schedule Builder (when Custom frequency is selected) - if (uiState.selectedFrequency == DcaFrequency.CUSTOM) { - ScheduleBuilder( - cronExpression = uiState.cronExpression, - cronDescription = uiState.cronDescription, - cronError = uiState.cronError, - onCronExpressionChange = viewModel::setCronExpression - ) - } - - // Strategy Selection - SectionTitle(stringResource(R.string.add_plan_dca_strategy)) - val strategies = remember { - listOf( - DcaStrategy.Classic, - DcaStrategy.AthBased(), - DcaStrategy.FearAndGreed() - ) - } - var showStrategyInfo by remember { mutableStateOf(null) } - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - strategies.forEach { strategy -> - val isSelected = when { - uiState.selectedStrategy is DcaStrategy.Classic && strategy is DcaStrategy.Classic -> true - uiState.selectedStrategy is DcaStrategy.AthBased && strategy is DcaStrategy.AthBased -> true - uiState.selectedStrategy is DcaStrategy.FearAndGreed && strategy is DcaStrategy.FearAndGreed -> true - else -> false - } - StrategyOption( - strategy = strategy, - isSelected = isSelected, - onClick = { viewModel.selectStrategy(strategy) }, - onInfoClick = { showStrategyInfo = strategy } - ) - } - } - - // Strategy Info Bottom Sheet - showStrategyInfo?.let { strategy -> - StrategyInfoBottomSheet( - strategy = strategy, - onDismiss = { showStrategyInfo = null } - ) - } - - // Monthly Cost Estimate - if (uiState.monthlyCostEstimate != null && uiState.amount.toBigDecimalOrNull() != null) { - MonthlyCostEstimateCard( - estimate = uiState.monthlyCostEstimate!!, - fiat = uiState.selectedFiat, - isClassic = uiState.selectedStrategy is DcaStrategy.Classic - ) - } - - // Auto-withdrawal Toggle - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(role = Role.Switch) { viewModel.setWithdrawalEnabled(!uiState.withdrawalEnabled) } - .semantics(mergeDescendants = true) { role = Role.Switch }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text(stringResource(R.string.add_plan_auto_withdrawal), fontWeight = FontWeight.SemiBold) - Text( - stringResource(R.string.add_plan_auto_withdrawal_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = uiState.withdrawalEnabled, - onCheckedChange = null, - modifier = Modifier.clearAndSetSemantics {} - ) - } - - if (uiState.withdrawalEnabled) { - OutlinedTextField( - value = uiState.withdrawalAddress, - onValueChange = viewModel::setWithdrawalAddress, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.add_plan_wallet_address, uiState.selectedCrypto)) }, - singleLine = true, - trailingIcon = { - QrScannerButton( - onScanResult = viewModel::setWithdrawalAddress - ) - } - ) - } - - // Target Amount (optional goal) - SectionTitle(stringResource(R.string.plan_target_amount)) - OutlinedTextField( - value = uiState.targetAmount, - onValueChange = viewModel::setTargetAmount, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.plan_target_amount)) }, - placeholder = { Text(stringResource(R.string.plan_target_amount_hint)) }, - suffix = { Text(uiState.selectedCrypto) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - singleLine = true, - supportingText = { - Text( - text = stringResource(R.string.plan_target_amount_description), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - ) - - // Error Message - if (uiState.errorMessage != null) { - Text( - text = uiState.errorMessage!!, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium - ) - } - // Create Button Button( onClick = viewModel::createPlan, @@ -449,166 +240,3 @@ fun AddPlanScreen( } // Box } } - -@Composable -private fun SectionTitle(text: String) { - Text( - text = text, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 12.dp) - ) -} - -@Composable -private fun ExchangeInstructionsCard( - exchange: Exchange, - instructions: ExchangeInstructions, - modifier: Modifier = Modifier -) { - val context = LocalContext.current - val resolvedUrl = instructions.urlRes?.let { stringResource(it) } ?: instructions.url - val accentCol = accentColor() - - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - modifier = modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - stringResource(R.string.add_exchange_api_setup), - fontWeight = FontWeight.SemiBold, - style = MaterialTheme.typography.titleSmall - ) - - instructions.steps.forEachIndexed { index, stepResId -> - Row { - Text( - "${index + 1}.", - fontWeight = FontWeight.Bold, - modifier = Modifier.width(24.dp) - ) - Text(stringResource(stepResId), style = MaterialTheme.typography.bodySmall) - } - } - - if (resolvedUrl.isNotBlank()) { - OutlinedButton( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolvedUrl)) - context.startActivity(intent) - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors(contentColor = accentCol) - ) { - Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.add_exchange_open_api_page, exchange.displayName)) - } - } - - Row( - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - tint = accentCol, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.add_exchange_security_tip), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} - -@Composable -private fun ChipGroup( - options: List, - selectedOption: String, - onOptionSelected: (String) -> Unit, - iconResolver: ((String) -> Int?)? = null -) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.horizontalScroll(rememberScrollState()) - ) { - options.forEach { option -> - val iconRes = iconResolver?.invoke(option) - SelectableChip( - text = option, - selected = option == selectedOption, - onClick = { onOptionSelected(option) }, - leadingIcon = if (iconRes != null) { - { - Image( - painter = painterResource(iconRes), - contentDescription = null, - modifier = Modifier.size(16.dp), - contentScale = ContentScale.Fit - ) - } - } else null - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun FrequencyDropdown( - selectedFrequency: DcaFrequency, - onFrequencySelected: (DcaFrequency) -> Unit -) { - var expanded by remember { mutableStateOf(false) } - - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it } - ) { - OutlinedTextField( - value = stringResource(selectedFrequency.displayNameRes), - onValueChange = {}, - readOnly = true, - modifier = Modifier - .fillMaxWidth() - .menuAnchor(), - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) - }, - colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors() - ) - - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - DcaFrequency.entries.forEach { frequency -> - DropdownMenuItem( - text = { Text(stringResource(frequency.displayNameRes)) }, - onClick = { - onFrequencySelected(frequency) - expanded = false - }, - leadingIcon = if (frequency == selectedFrequency) { - { Icon(Icons.Default.Check, contentDescription = null, tint = successColor()) } - } else null - ) - } - } - } -} - - 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 5fd3718..a3e3388 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 @@ -2,288 +2,108 @@ package com.accbot.dca.presentation.screens 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.CredentialsStore import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.domain.model.DcaFrequency -import com.accbot.dca.domain.model.DcaStrategy import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.supportsApiImport -import com.accbot.dca.domain.util.CronUtils -import com.accbot.dca.presentation.model.MonthlyCostEstimate -import com.accbot.dca.domain.model.ExchangeFilter -import com.accbot.dca.domain.model.ExchangeInstructions -import com.accbot.dca.domain.model.isStable -import com.accbot.dca.domain.model.ExchangeInstructionsProvider import com.accbot.dca.domain.usecase.CalculateMonthlyCostUseCase +import com.accbot.dca.domain.usecase.CreateDcaPlanUseCase import com.accbot.dca.domain.usecase.CredentialValidationResult import com.accbot.dca.domain.usecase.ValidateAndSaveCredentialsUseCase import com.accbot.dca.exchange.MinOrderSizeRepository +import com.accbot.dca.presentation.credentials.CredentialFormDelegate +import com.accbot.dca.presentation.credentials.CredentialFormState +import com.accbot.dca.presentation.plan.PlanFormDelegate +import com.accbot.dca.presentation.plan.PlanFormState import androidx.compose.runtime.Immutable import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import java.math.BigDecimal -import java.time.Duration -import java.time.Instant import javax.inject.Inject @Immutable data class AddPlanUiState( - // Sandbox state (immutable after init) - val isSandboxMode: Boolean = false, - val availableExchanges: List = emptyList(), - val showExperimental: Boolean = false, + // Credential form (from delegate) + val credentialForm: CredentialFormState = CredentialFormState(), - // Exchange setup - val selectedExchange: Exchange? = null, - val selectedExchangeInstructions: ExchangeInstructions? = null, - val hasCredentials: Boolean = false, - val clientId: String = "", - val apiKey: String = "", - val apiSecret: String = "", - val passphrase: String = "", - val selectedCrypto: String = "BTC", - val selectedFiat: String = "EUR", - val amount: String = "100", - val selectedFrequency: DcaFrequency = DcaFrequency.DAILY, - val cronExpression: String = "", - val cronDescription: String? = null, - val cronError: String? = null, - val selectedStrategy: DcaStrategy = DcaStrategy.Classic, - val withdrawalEnabled: Boolean = false, - val withdrawalAddress: String = "", + // Plan form (from delegate) + val planForm: PlanFormState = PlanFormState(), + + // Action state val isLoading: Boolean = false, val isSuccess: Boolean = false, val showImportDialog: Boolean = false, - val errorMessage: String? = null, - val monthlyCostEstimate: MonthlyCostEstimate? = null, - val minOrderSize: BigDecimal? = null, - val targetAmount: String = "" + val errorMessage: String? = null ) { - val amountBelowMinimum: Boolean - get() { - val min = minOrderSize ?: return false - val amt = amount.toBigDecimalOrNull() ?: return false - return amt < min - } - val isValid: Boolean get() { - if (selectedExchange == null) return false - if (!hasCredentials) { - if (apiKey.isBlank() || apiSecret.isBlank()) return false - // Coinmate requires clientId - if (selectedExchange == Exchange.COINMATE && clientId.isBlank()) return false + 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 } - val amountValue = amount.toBigDecimalOrNull() ?: return false - if (amountValue <= BigDecimal.ZERO) return false - if (amountBelowMinimum) return false - if (withdrawalEnabled && withdrawalAddress.isBlank()) return false - if (selectedFrequency == DcaFrequency.CUSTOM && !CronUtils.isValidCron(cronExpression)) return false - return true + return planForm.isFormValid } } @HiltViewModel class AddPlanViewModel @Inject constructor( - private val dcaPlanDao: DcaPlanDao, private val credentialsStore: CredentialsStore, private val validateAndSaveCredentialsUseCase: ValidateAndSaveCredentialsUseCase, + private val createDcaPlanUseCase: CreateDcaPlanUseCase, private val userPreferences: UserPreferences, - private val calculateMonthlyCost: CalculateMonthlyCostUseCase, - private val minOrderSizeRepository: MinOrderSizeRepository + calculateMonthlyCost: CalculateMonthlyCostUseCase, + minOrderSizeRepository: MinOrderSizeRepository ) : ViewModel() { - private val _uiState = MutableStateFlow(AddPlanUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + val planForm = PlanFormDelegate(calculateMonthlyCost, minOrderSizeRepository, viewModelScope) + val credentialForm = CredentialFormDelegate(credentialsStore, validateAndSaveCredentialsUseCase, userPreferences, viewModelScope) - // Cache sandbox mode to avoid repeated SharedPreferences reads - private val isSandbox: Boolean = userPreferences.isSandboxMode() - private var estimateJob: Job? = null + private val _localState = MutableStateFlow(AddPlanUiState()) - init { - // Initialize sandbox state once - avoids repeated calls during recomposition - val showExperimental = userPreferences.areExperimentalExchangesEnabled() - _uiState.update { - it.copy( - isSandboxMode = isSandbox, - showExperimental = showExperimental, - availableExchanges = ExchangeFilter.getAvailableExchanges(isSandbox) - .filter { exchange -> showExperimental || exchange.isStable } - ) - } - } + val uiState: StateFlow = combine( + _localState, + planForm.state, + credentialForm.state + ) { local, form, cred -> local.copy(planForm = form, credentialForm = cred) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AddPlanUiState()) - fun setExperimentalExchangesEnabled(enabled: Boolean) { - userPreferences.setExperimentalExchangesEnabled(enabled) - _uiState.update { - it.copy( - showExperimental = enabled, - availableExchanges = ExchangeFilter.getAvailableExchanges(isSandbox) - .filter { exchange -> enabled || exchange.isStable } - ) - } + init { + credentialForm.initialize() } fun selectExchange(exchange: Exchange) { - val hasCredentials = credentialsStore.hasCredentials(exchange, isSandbox) - val instructions = ExchangeInstructionsProvider.getInstructions(exchange, isSandbox) - _uiState.update { state -> - state.copy( - selectedExchange = exchange, - selectedExchangeInstructions = instructions, - hasCredentials = hasCredentials, - selectedCrypto = exchange.supportedCryptos.firstOrNull() ?: "BTC", - selectedFiat = exchange.supportedFiats.firstOrNull() ?: "EUR", - clientId = "", - apiKey = "", - apiSecret = "", - passphrase = "", - errorMessage = null, - minOrderSize = null - ) - } - updateMinOrderSize() - } - - fun setClientId(value: String) { - _uiState.update { it.copy(clientId = value) } - } - - fun setApiKey(value: String) { - _uiState.update { it.copy(apiKey = value) } - } - - fun setApiSecret(value: String) { - _uiState.update { it.copy(apiSecret = value) } - } - - fun setPassphrase(value: String) { - _uiState.update { it.copy(passphrase = value) } - } - - fun selectCrypto(crypto: String) { - _uiState.update { it.copy(selectedCrypto = crypto) } - updateMonthlyCostEstimate() - updateMinOrderSize() - } - - fun selectFiat(fiat: String) { - _uiState.update { it.copy(selectedFiat = fiat) } - updateMonthlyCostEstimate() - updateMinOrderSize() - } - - fun setAmount(amount: String) { - _uiState.update { it.copy(amount = amount) } - updateMonthlyCostEstimate() - } - - fun selectFrequency(frequency: DcaFrequency) { - _uiState.update { - it.copy( - selectedFrequency = frequency, - cronExpression = if (frequency != DcaFrequency.CUSTOM) "" else it.cronExpression, - cronDescription = if (frequency != DcaFrequency.CUSTOM) null else it.cronDescription, - cronError = if (frequency != DcaFrequency.CUSTOM) null else it.cronError - ) - } - updateMonthlyCostEstimate() - } - - fun setCronExpression(cron: String) { - val isValid = CronUtils.isValidCron(cron) - val description = if (isValid) CronUtils.describeCron(cron) else null - val error = if (cron.isNotBlank() && !isValid) "Invalid CRON expression" else null - _uiState.update { - it.copy( - cronExpression = cron, - cronDescription = description, - cronError = error - ) - } - if (isValid) { - updateMonthlyCostEstimate() - } - } - - fun selectStrategy(strategy: DcaStrategy) { - _uiState.update { it.copy(selectedStrategy = strategy) } - updateMonthlyCostEstimate() - } - - fun setWithdrawalEnabled(enabled: Boolean) { - _uiState.update { it.copy(withdrawalEnabled = enabled) } - } - - fun setWithdrawalAddress(address: String) { - _uiState.update { it.copy(withdrawalAddress = address) } - } - - fun setTargetAmount(value: String) { - _uiState.update { it.copy(targetAmount = value) } - } - - private fun updateMinOrderSize() { - viewModelScope.launch { - val exchange = _uiState.value.selectedExchange ?: return@launch - val min = minOrderSizeRepository.getMinOrderSize( - exchange, _uiState.value.selectedCrypto, _uiState.value.selectedFiat - ) - _uiState.update { it.copy(minOrderSize = min) } - } - } - - private fun updateMonthlyCostEstimate() { - estimateJob?.cancel() - estimateJob = viewModelScope.launch { - delay(300) - val state = _uiState.value - val amount = state.amount.toBigDecimalOrNull() - if (amount == null) { - _uiState.update { it.copy(monthlyCostEstimate = null) } - return@launch - } - val estimate = calculateMonthlyCost.computeEstimate( - amount = amount, - frequency = state.selectedFrequency, - cronExpression = state.cronExpression.takeIf { it.isNotBlank() }, - strategy = state.selectedStrategy, - crypto = state.selectedCrypto, - fiat = state.selectedFiat - ) - _uiState.update { it.copy(monthlyCostEstimate = estimate) } - } + credentialForm.selectExchange(exchange) + planForm.initFromExchange(exchange) } fun createPlan() { - val state = _uiState.value - val exchange = state.selectedExchange ?: return + val state = uiState.value + val cred = state.credentialForm + val exchange = cred.selectedExchange ?: return + val form = state.planForm - // Prevent concurrent plan creation if (state.isLoading) return viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, errorMessage = null) } + _localState.update { it.copy(isLoading = true, errorMessage = null) } try { // Validate and save credentials if new - if (!state.hasCredentials) { + if (!cred.hasCredentials) { val result = validateAndSaveCredentialsUseCase.execute( exchange = exchange, - apiKey = state.apiKey, - apiSecret = state.apiSecret, - passphrase = state.passphrase.takeIf { it.isNotBlank() }, - clientId = state.clientId.takeIf { it.isNotBlank() } + apiKey = cred.apiKey, + apiSecret = cred.apiSecret, + passphrase = cred.passphrase.takeIf { it.isNotBlank() }, + clientId = cred.clientId.takeIf { it.isNotBlank() } ) when (result) { is CredentialValidationResult.Error -> { - _uiState.update { + _localState.update { it.copy( isLoading = false, errorMessage = result.message @@ -297,47 +117,27 @@ class AddPlanViewModel @Inject constructor( } } - // Create plan - val amount = state.amount.toBigDecimal() - val now = Instant.now() - val nextExecution = if (state.selectedFrequency == DcaFrequency.CUSTOM) { - CronUtils.getNextExecution(state.cronExpression, now) - ?: now.plus(Duration.ofMinutes(1440)) - } else { - now.plus(Duration.ofMinutes(state.selectedFrequency.intervalMinutes)) - } - - val plan = DcaPlanEntity( + createDcaPlanUseCase.execute( exchange = exchange, - crypto = state.selectedCrypto, - fiat = state.selectedFiat, - amount = amount, - frequency = state.selectedFrequency, - cronExpression = if (state.selectedFrequency == DcaFrequency.CUSTOM) state.cronExpression else null, - strategy = state.selectedStrategy, - isEnabled = true, - withdrawalEnabled = state.withdrawalEnabled, - withdrawalAddress = if (state.withdrawalEnabled) state.withdrawalAddress.trim() else null, - createdAt = now, - nextExecutionAt = nextExecution, - targetAmount = state.targetAmount.toBigDecimalOrNull() + crypto = form.selectedCrypto, + fiat = form.selectedFiat, + amount = form.amount.toBigDecimal(), + frequency = form.selectedFrequency, + cronExpression = if (form.selectedFrequency == DcaFrequency.CUSTOM) form.cronExpression else null, + strategy = form.selectedStrategy, + withdrawalEnabled = form.withdrawalEnabled, + withdrawalAddress = if (form.withdrawalEnabled) form.withdrawalAddress.trim() else null, + targetAmount = form.targetAmount.toBigDecimalOrNull() ) - dcaPlanDao.insertPlan(plan) - - // Auto-enable Market Pulse when creating a plan with market-aware strategy - if (state.selectedStrategy is DcaStrategy.AthBased || state.selectedStrategy is DcaStrategy.FearAndGreed) { - userPreferences.setMarketPulseEnabled(true) - } - - val shouldOfferImport = !state.hasCredentials && exchange.supportsApiImport - _uiState.update { it.copy( + val shouldOfferImport = !cred.hasCredentials && exchange.supportsApiImport + _localState.update { it.copy( isLoading = false, isSuccess = !shouldOfferImport, showImportDialog = shouldOfferImport ) } } catch (e: Exception) { - _uiState.update { + _localState.update { it.copy( isLoading = false, errorMessage = e.message ?: "Failed to create plan" @@ -348,6 +148,6 @@ class AddPlanViewModel @Inject constructor( } fun dismissImportDialog() { - _uiState.update { it.copy(showImportDialog = false, isSuccess = true) } + _localState.update { it.copy(showImportDialog = false, isSuccess = true) } } } 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 9baaf92..3c26772 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 @@ -1011,12 +1011,9 @@ internal fun PortfolioSummaryCard( val totalValue = holdings.fold(BigDecimal.ZERO) { acc, h -> acc + (h.currentValue ?: BigDecimal.ZERO) } val totalInvested = holdings.fold(BigDecimal.ZERO) { acc, h -> acc + h.totalInvested } - val roiAbsolute = totalValue.subtract(totalInvested) - val roiPercent = if (totalInvested > BigDecimal.ZERO) { - roiAbsolute.divide(totalInvested, 4, RoundingMode.HALF_UP) - .multiply(BigDecimal(100)) - .setScale(2, RoundingMode.HALF_UP) - } else null + val roiResult = NumberFormatters.roiValues(totalInvested, totalValue) + val roiAbsolute = roiResult?.first ?: BigDecimal.ZERO + val roiPercent = roiResult?.second val successCol = successColor() val isPositive = roiAbsolute >= BigDecimal.ZERO diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt index 2b7f265..b82bb9d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryScreen.kt @@ -30,6 +30,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.accbot.dca.R import com.accbot.dca.data.local.TransactionEntity import com.accbot.dca.domain.model.TransactionStatus +import com.accbot.dca.presentation.components.AccBotTopAppBar import com.accbot.dca.presentation.components.EmptyState import com.accbot.dca.presentation.components.SelectableChip import com.accbot.dca.presentation.components.WarningOrange @@ -145,13 +146,9 @@ fun HistoryScreen( Scaffold( topBar = { - TopAppBar( - title = { Text(stringResource(R.string.history_title), fontWeight = FontWeight.Bold) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.common_back)) - } - }, + AccBotTopAppBar( + title = stringResource(R.string.history_title), + onNavigateBack = onNavigateBack, actions = { // Search button IconButton(onClick = { @@ -204,10 +201,7 @@ fun HistoryScreen( Icon(Icons.Default.FileDownload, contentDescription = stringResource(R.string.history_export_csv)) } } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) + } ) } ) { paddingValues -> 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 ffd3951..16139e0 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 @@ -45,6 +45,7 @@ import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.WithdrawalThreshold import java.math.BigDecimal import com.accbot.dca.presentation.changelog.ChangelogData +import com.accbot.dca.presentation.components.AccBotTopAppBar import com.accbot.dca.presentation.components.ChangelogSheet import com.accbot.dca.presentation.ui.theme.Error import com.accbot.dca.presentation.ui.theme.Warning @@ -409,12 +410,7 @@ fun SettingsScreen( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp), topBar = { - TopAppBar( - title = { Text(stringResource(R.string.settings_title), fontWeight = FontWeight.Bold) }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) - ) + AccBotTopAppBar(title = stringResource(R.string.settings_title)) } ) { paddingValues -> Box( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/backup/BackupExportScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/backup/BackupExportScreen.kt index 964c6c4..e51aa05 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/backup/BackupExportScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/backup/BackupExportScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -27,6 +26,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.accbot.dca.R import com.accbot.dca.domain.model.EncryptionMode +import com.accbot.dca.presentation.components.AccBotTopAppBar import com.accbot.dca.presentation.ui.theme.Warning import java.io.File import java.io.FileWriter @@ -43,16 +43,9 @@ fun BackupExportScreen( Scaffold( topBar = { - TopAppBar( - title = { Text(stringResource(R.string.backup_export_title), fontWeight = FontWeight.Bold) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.common_back)) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) + AccBotTopAppBar( + title = stringResource(R.string.backup_export_title), + onNavigateBack = onNavigateBack ) } ) { paddingValues -> diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/backup/BackupImportScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/backup/BackupImportScreen.kt index a2a636b..5098cb2 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/backup/BackupImportScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/backup/BackupImportScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -24,6 +23,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.accbot.dca.R import com.accbot.dca.domain.model.EncryptionMode +import com.accbot.dca.presentation.components.AccBotTopAppBar import com.accbot.dca.presentation.components.QrScannerDialog import com.accbot.dca.presentation.components.SeedPhraseGrid import com.accbot.dca.presentation.ui.theme.successColor @@ -63,16 +63,9 @@ fun BackupImportScreen( Scaffold( topBar = { - TopAppBar( - title = { Text(stringResource(R.string.backup_import_title), fontWeight = FontWeight.Bold) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.common_back)) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) + AccBotTopAppBar( + title = stringResource(R.string.backup_import_title), + onNavigateBack = onNavigateBack ) } ) { paddingValues -> 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 6213376..9662159 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 @@ -38,6 +38,7 @@ import com.accbot.dca.domain.model.isStable import com.accbot.dca.domain.model.supportsApiImport import com.accbot.dca.domain.usecase.ApiImportResultState import com.accbot.dca.presentation.components.AccBotTopAppBar +import com.accbot.dca.presentation.components.ApiImportResultDialog import com.accbot.dca.presentation.components.CredentialsInputCard import com.accbot.dca.presentation.components.ExchangeSelectionTile import com.accbot.dca.presentation.components.ExperimentalExchangeDisclaimer @@ -60,7 +61,7 @@ fun AddExchangeScreen( // Import offer dialog after successful exchange connection if (uiState.showImportOffer) { - val exchangeName = uiState.selectedExchange?.displayName ?: "" + val exchangeName = uiState.credentialForm.selectedExchange?.displayName ?: "" AlertDialog( onDismissRequest = { viewModel.dismissImportOffer() }, title = { Text(stringResource(R.string.import_api_offer_title)) }, @@ -68,7 +69,7 @@ fun AddExchangeScreen( confirmButton = { TextButton(onClick = { viewModel.dismissImportOffer() - uiState.selectedExchange?.let { exchange -> + uiState.credentialForm.selectedExchange?.let { exchange -> onNavigateToExchangeDetail?.invoke(exchange.name) } }) { @@ -117,8 +118,8 @@ fun AddExchangeScreen( modifier = Modifier.padding(paddingValues) ) ExchangeSetupStep.INSTRUCTIONS -> InstructionsStep( - exchange = uiState.selectedExchange!!, - instructions = viewModel.getInstructionsForExchange(uiState.selectedExchange!!), + exchange = uiState.credentialForm.selectedExchange!!, + instructions = viewModel.getInstructionsForExchange(uiState.credentialForm.selectedExchange!!), isSandboxMode = uiState.isSandboxMode, onContinue = { viewModel.proceedToCredentials() }, onOpenUrl = { url -> @@ -128,22 +129,22 @@ fun AddExchangeScreen( modifier = Modifier.padding(paddingValues) ) ExchangeSetupStep.CREDENTIALS -> CredentialsStep( - exchange = uiState.selectedExchange!!, - clientId = uiState.clientId, - apiKey = uiState.apiKey, - apiSecret = uiState.apiSecret, - passphrase = uiState.passphrase, - isValidating = uiState.isValidating, - error = uiState.error, - onClientIdChange = viewModel::setClientId, - onApiKeyChange = viewModel::setApiKey, - onApiSecretChange = viewModel::setApiSecret, - onPassphraseChange = viewModel::setPassphrase, + exchange = uiState.credentialForm.selectedExchange!!, + clientId = uiState.credentialForm.clientId, + apiKey = uiState.credentialForm.apiKey, + apiSecret = uiState.credentialForm.apiSecret, + passphrase = uiState.credentialForm.passphrase, + isValidating = uiState.credentialForm.isValidatingCredentials, + error = uiState.credentialForm.credentialsError, + onClientIdChange = viewModel.credentialForm::setClientId, + onApiKeyChange = viewModel.credentialForm::setApiKey, + onApiSecretChange = viewModel.credentialForm::setApiSecret, + onPassphraseChange = viewModel.credentialForm::setPassphrase, onValidate = { viewModel.validateAndSave(onExchangeAdded) }, modifier = Modifier.padding(paddingValues) ) ExchangeSetupStep.SUCCESS -> SuccessStep( - exchange = uiState.selectedExchange!!, + exchange = uiState.credentialForm.selectedExchange!!, onFinish = onExchangeAdded, plansForExchange = uiState.plansForExchange, isApiImporting = uiState.isApiImporting, @@ -519,36 +520,7 @@ private fun SuccessStep( // Import result dialog apiImportResult?.let { result -> - AlertDialog( - onDismissRequest = onDismissImportResult, - title = { - Text( - when (result) { - is ApiImportResultState.Success -> stringResource(R.string.import_api_success_title) - is ApiImportResultState.Error -> stringResource(R.string.import_api_error_title) - } - ) - }, - text = { - Text( - when (result) { - is ApiImportResultState.Success -> { - if (result.imported == 0) { - stringResource(R.string.import_api_no_new) - } else { - stringResource(R.string.import_api_success_message, result.imported, result.skipped) - } - } - is ApiImportResultState.Error -> result.message - } - ) - }, - confirmButton = { - TextButton(onClick = onDismissImportResult) { - Text(stringResource(R.string.common_done)) - } - } - ) + ApiImportResultDialog(result = result, onDismiss = onDismissImportResult) } Column( 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 97178f3..59b1af3 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 @@ -19,11 +19,12 @@ import com.accbot.dca.domain.model.supportsApiImport import com.accbot.dca.domain.model.supportsSandbox import com.accbot.dca.domain.usecase.ApiImportProgress import com.accbot.dca.domain.usecase.ApiImportResultState -import com.accbot.dca.domain.usecase.CredentialValidationResult -import com.accbot.dca.domain.usecase.ImportTradeHistoryUseCase import com.accbot.dca.domain.usecase.ValidateAndSaveCredentialsUseCase +import com.accbot.dca.domain.usecase.ImportTradeHistoryUseCase import com.accbot.dca.exchange.ExchangeApiFactory import com.accbot.dca.exchange.ExchangeConfig +import com.accbot.dca.presentation.credentials.CredentialFormDelegate +import com.accbot.dca.presentation.credentials.CredentialFormState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Job @@ -46,21 +47,16 @@ enum class ExchangeSetupStep(@StringRes val titleRes: Int) { @Immutable data class AddExchangeUiState( val currentStep: ExchangeSetupStep = ExchangeSetupStep.SELECTION, - val selectedExchange: Exchange? = null, val preSelectedExchange: Boolean = false, - val clientId: String = "", - val apiKey: String = "", - val apiSecret: String = "", - val passphrase: String = "", - val isValidating: Boolean = false, val isSuccess: Boolean = false, - val error: String? = null, val isSandboxMode: Boolean = false, val plansForExchange: List = emptyList(), val showImportOffer: Boolean = false, val isApiImporting: Boolean = false, val apiImportProgress: String = "", - val apiImportResult: ApiImportResultState? = null + val apiImportResult: ApiImportResultState? = null, + // Credential form (from delegate) + val credentialForm: CredentialFormState = CredentialFormState() ) @HiltViewModel @@ -75,12 +71,20 @@ class AddExchangeViewModel @Inject constructor( savedStateHandle: SavedStateHandle ) : ViewModel() { - private val _uiState = MutableStateFlow(AddExchangeUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + val credentialForm = CredentialFormDelegate(credentialsStore, validateAndSaveCredentialsUseCase, userPreferences, viewModelScope) + + private val _localState = MutableStateFlow(AddExchangeUiState()) + val uiState: StateFlow = combine( + _localState, + credentialForm.state + ) { local, cred -> local.copy(credentialForm = cred) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AddExchangeUiState()) + private var plansCollectionJob: Job? = null init { - _uiState.update { it.copy(isSandboxMode = userPreferences.isSandboxMode()) } + _localState.update { it.copy(isSandboxMode = userPreferences.isSandboxMode()) } + credentialForm.initialize() // If exchange was passed via navigation, auto-select it val exchangeName = savedStateHandle.get("exchange") @@ -88,17 +92,16 @@ class AddExchangeViewModel @Inject constructor( val exchange = Exchange.entries.find { it.name == exchangeName } if (exchange != null) { selectExchange(exchange) - _uiState.update { it.copy(preSelectedExchange = true) } + _localState.update { it.copy(preSelectedExchange = true) } } } } fun selectExchange(exchange: Exchange) { - _uiState.update { + credentialForm.selectExchange(exchange) + _localState.update { it.copy( - selectedExchange = exchange, currentStep = ExchangeSetupStep.INSTRUCTIONS, - error = null ) } @@ -106,87 +109,43 @@ class AddExchangeViewModel @Inject constructor( plansCollectionJob?.cancel() plansCollectionJob = viewModelScope.launch { dcaPlanDao.getPlansByExchange(exchange).collect { plans -> - _uiState.update { it.copy(plansForExchange = plans) } + _localState.update { it.copy(plansForExchange = plans) } } } } fun proceedToCredentials() { - _uiState.update { it.copy(currentStep = ExchangeSetupStep.CREDENTIALS) } - } - - fun setClientId(value: String) { - _uiState.update { it.copy(clientId = value, error = null) } - } - - fun setApiKey(value: String) { - _uiState.update { it.copy(apiKey = value, error = null) } - } - - fun setApiSecret(value: String) { - _uiState.update { it.copy(apiSecret = value, error = null) } - } - - fun setPassphrase(value: String) { - _uiState.update { it.copy(passphrase = value, error = null) } + _localState.update { it.copy(currentStep = ExchangeSetupStep.CREDENTIALS) } } fun validateAndSave(onSuccess: () -> Unit) { - val state = _uiState.value - val exchange = state.selectedExchange ?: return - - // Prevent concurrent validation - if (state.isValidating) return - - viewModelScope.launch { - _uiState.update { it.copy(isValidating = true, error = null) } - - val result = validateAndSaveCredentialsUseCase.execute( - exchange = exchange, - apiKey = state.apiKey, - apiSecret = state.apiSecret, - passphrase = state.passphrase.takeIf { it.isNotBlank() }, - clientId = state.clientId.takeIf { it.isNotBlank() } - ) - - when (result) { - is CredentialValidationResult.Success -> { - _uiState.update { - it.copy( - isValidating = false, - isSuccess = true, - showImportOffer = exchange.supportsApiImport, - currentStep = ExchangeSetupStep.SUCCESS - ) - } - onSuccess() - } - is CredentialValidationResult.Error -> { - _uiState.update { - it.copy( - isValidating = false, - error = result.message - ) - } - } + credentialForm.validateAndSaveCredentials { + val exchange = credentialForm.state.value.selectedExchange + _localState.update { + it.copy( + isSuccess = true, + showImportOffer = exchange?.supportsApiImport == true, + currentStep = ExchangeSetupStep.SUCCESS + ) } + onSuccess() } } fun importViaApi() { - val state = _uiState.value - val exchange = state.selectedExchange ?: return + val state = uiState.value + val exchange = state.credentialForm.selectedExchange ?: return if (state.isApiImporting) return if (state.plansForExchange.isEmpty()) return viewModelScope.launch { - _uiState.update { it.copy(isApiImporting = true, apiImportProgress = "", apiImportResult = null) } + _localState.update { it.copy(isApiImporting = true, apiImportProgress = "", apiImportResult = null) } try { val isSandbox = userPreferences.isSandboxMode() val credentials = credentialsStore.getCredentials(exchange, isSandbox) if (credentials == null) { - _uiState.update { it.copy( + _localState.update { it.copy( isApiImporting = false, apiImportResult = ApiImportResultState.Error("No credentials found for ${exchange.displayName}") ) } @@ -209,19 +168,19 @@ class AddExchangeViewModel @Inject constructor( ).collect { progress -> when (progress) { is ApiImportProgress.Fetching -> { - _uiState.update { it.copy( + _localState.update { it.copy( apiImportProgress = context.getString( R.string.import_api_fetching, progress.page ) ) } } is ApiImportProgress.Deduplicating -> { - _uiState.update { it.copy( + _localState.update { it.copy( apiImportProgress = context.getString(R.string.import_api_deduplicating) ) } } is ApiImportProgress.Importing -> { - _uiState.update { it.copy( + _localState.update { it.copy( apiImportProgress = context.getString( R.string.import_api_importing, progress.newCount ) @@ -240,14 +199,14 @@ class AddExchangeViewModel @Inject constructor( val result = errorMessage?.let { ApiImportResultState.Error(it) } ?: ApiImportResultState.Success(totalImported, totalSkipped) - _uiState.update { it.copy( + _localState.update { it.copy( isApiImporting = false, apiImportProgress = "", apiImportResult = result ) } } catch (e: Exception) { Log.e(TAG, "API import failed", e) - _uiState.update { it.copy( + _localState.update { it.copy( isApiImporting = false, apiImportProgress = "", apiImportResult = ApiImportResultState.Error(e.message ?: "Import failed") @@ -257,18 +216,18 @@ class AddExchangeViewModel @Inject constructor( } fun dismissImportResult() { - _uiState.update { it.copy(apiImportResult = null) } + _localState.update { it.copy(apiImportResult = null) } } fun dismissImportOffer() { - _uiState.update { it.copy(showImportOffer = false) } + _localState.update { it.copy(showImportOffer = false) } } /** * Returns true if the caller should pop back (navigate away from this screen). */ fun goBack(): Boolean { - val state = _uiState.value + val state = _localState.value // If pre-selected and on INSTRUCTIONS, go back to previous screen entirely if (state.preSelectedExchange && state.currentStep == ExchangeSetupStep.INSTRUCTIONS) { return true @@ -279,7 +238,7 @@ class AddExchangeViewModel @Inject constructor( ExchangeSetupStep.SUCCESS -> ExchangeSetupStep.CREDENTIALS ExchangeSetupStep.SELECTION -> return true } - _uiState.update { it.copy(currentStep = previousStep, error = null) } + _localState.update { it.copy(currentStep = previousStep) } return false } 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 cc347d7..3847994 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 @@ -26,8 +26,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.accbot.dca.R import com.accbot.dca.domain.model.supportsApiImport -import com.accbot.dca.domain.usecase.ApiImportResultState import com.accbot.dca.presentation.components.AccBotTopAppBar +import com.accbot.dca.presentation.components.ApiImportResultDialog import com.accbot.dca.presentation.components.CredentialsInputCard import com.accbot.dca.presentation.components.ExchangeAvatar import com.accbot.dca.presentation.components.ImportConfigDialog @@ -70,36 +70,7 @@ fun ExchangeDetailScreen( // Import result dialog uiState.apiImportResult?.let { result -> - AlertDialog( - onDismissRequest = { viewModel.dismissImportResult() }, - title = { - Text( - when (result) { - is ApiImportResultState.Success -> stringResource(R.string.import_api_success_title) - is ApiImportResultState.Error -> stringResource(R.string.import_api_error_title) - } - ) - }, - text = { - Text( - when (result) { - is ApiImportResultState.Success -> { - if (result.imported == 0) { - stringResource(R.string.import_api_no_new) - } else { - stringResource(R.string.import_api_success_message, result.imported, result.skipped) - } - } - is ApiImportResultState.Error -> result.message - } - ) - }, - confirmButton = { - TextButton(onClick = { viewModel.dismissImportResult() }) { - Text(stringResource(R.string.common_done)) - } - } - ) + ApiImportResultDialog(result = result, onDismiss = { viewModel.dismissImportResult() }) } // Remove confirmation dialog @@ -272,16 +243,16 @@ fun ExchangeDetailScreen( Column(modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp)) { CredentialsInputCard( exchange = exchange, - clientId = uiState.clientId, - apiKey = uiState.apiKey, - apiSecret = uiState.apiSecret, - passphrase = uiState.passphrase, - onClientIdChange = viewModel::setClientId, - onApiKeyChange = viewModel::setApiKey, - onApiSecretChange = viewModel::setApiSecret, - onPassphraseChange = viewModel::setPassphrase, - errorMessage = uiState.error, - isValidating = uiState.isValidating + clientId = uiState.credentialForm.clientId, + apiKey = uiState.credentialForm.apiKey, + apiSecret = uiState.credentialForm.apiSecret, + passphrase = uiState.credentialForm.passphrase, + onClientIdChange = viewModel.credentialForm::setClientId, + onApiKeyChange = viewModel.credentialForm::setApiKey, + onApiSecretChange = viewModel.credentialForm::setApiSecret, + onPassphraseChange = viewModel.credentialForm::setPassphrase, + errorMessage = uiState.credentialForm.credentialsError, + isValidating = uiState.credentialForm.isValidatingCredentials ) Spacer(modifier = Modifier.height(16.dp)) @@ -296,9 +267,9 @@ fun ExchangeDetailScreen( } }, modifier = Modifier.fillMaxWidth(), - enabled = !uiState.isValidating + enabled = !uiState.credentialForm.isValidatingCredentials ) { - if (uiState.isValidating) { + if (uiState.credentialForm.isValidatingCredentials) { CircularProgressIndicator( modifier = Modifier.size(20.dp), strokeWidth = 2.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 81c1045..873b1ae 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 @@ -13,10 +13,11 @@ import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.usecase.ApiImportProgress import com.accbot.dca.domain.usecase.ApiImportResultState -import com.accbot.dca.domain.usecase.CredentialValidationResult -import com.accbot.dca.domain.usecase.ImportTradeHistoryUseCase import com.accbot.dca.domain.usecase.ValidateAndSaveCredentialsUseCase +import com.accbot.dca.domain.usecase.ImportTradeHistoryUseCase import com.accbot.dca.exchange.ExchangeApiFactory +import com.accbot.dca.presentation.credentials.CredentialFormDelegate +import com.accbot.dca.presentation.credentials.CredentialFormState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.* @@ -28,20 +29,15 @@ import javax.inject.Inject @Immutable data class ExchangeDetailUiState( val exchange: Exchange? = null, - val clientId: String = "", - val apiKey: String = "", - val apiSecret: String = "", - val passphrase: String = "", - val isValidating: Boolean = false, - val error: String? = null, - val isSandboxMode: Boolean = false, val credentialsExpanded: Boolean = false, val plans: List = emptyList(), val isApiImporting: Boolean = false, val apiImportProgress: String = "", val apiImportResult: ApiImportResultState? = null, val showImportDialog: Boolean = false, - val importSinceMillis: Long? = null + val importSinceMillis: Long? = null, + // Credential form (from delegate) + val credentialForm: CredentialFormState = CredentialFormState() ) @HiltViewModel @@ -57,11 +53,17 @@ class ExchangeDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle ) : ViewModel() { - private val _uiState = MutableStateFlow(ExchangeDetailUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + val credentialForm = CredentialFormDelegate(credentialsStore, validateAndSaveCredentialsUseCase, userPreferences, viewModelScope) + + private val _localState = MutableStateFlow(ExchangeDetailUiState()) + val uiState: StateFlow = combine( + _localState, + credentialForm.state + ) { local, cred -> local.copy(credentialForm = cred) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ExchangeDetailUiState()) init { - val isSandbox = userPreferences.isSandboxMode() + credentialForm.initialize() val exchangeName = savedStateHandle.get("exchange") val autoImport = savedStateHandle.get("autoImport") ?: false val exchange = exchangeName?.let { name -> @@ -69,23 +71,14 @@ class ExchangeDetailViewModel @Inject constructor( } if (exchange != null) { - val credentials = credentialsStore.getCredentials(exchange, isSandbox) - _uiState.update { - it.copy( - exchange = exchange, - isSandboxMode = isSandbox, - apiKey = credentials?.apiKey ?: "", - apiSecret = credentials?.apiSecret ?: "", - passphrase = credentials?.passphrase ?: "", - clientId = credentials?.clientId ?: "" - ) - } + _localState.update { it.copy(exchange = exchange) } + credentialForm.initWithExchange(exchange) // Load plans for this exchange var autoImportTriggered = false viewModelScope.launch { dcaPlanDao.getPlansByExchange(exchange).collect { plans -> - _uiState.update { it.copy(plans = plans) } + _localState.update { it.copy(plans = plans) } // Auto-trigger import when navigated from import offer dialog if (autoImport && !autoImportTriggered && plans.isNotEmpty()) { autoImportTriggered = true @@ -97,87 +90,45 @@ class ExchangeDetailViewModel @Inject constructor( } fun toggleCredentials() { - _uiState.update { it.copy(credentialsExpanded = !it.credentialsExpanded) } - } - - fun setClientId(value: String) { - _uiState.update { it.copy(clientId = value, error = null) } - } - - fun setApiKey(value: String) { - _uiState.update { it.copy(apiKey = value, error = null) } - } - - fun setApiSecret(value: String) { - _uiState.update { it.copy(apiSecret = value, error = null) } - } - - fun setPassphrase(value: String) { - _uiState.update { it.copy(passphrase = value, error = null) } + _localState.update { it.copy(credentialsExpanded = !it.credentialsExpanded) } } fun saveCredentials(onSuccess: () -> Unit) { - val state = _uiState.value - val exchange = state.exchange ?: return - if (state.isValidating) return - - viewModelScope.launch { - _uiState.update { it.copy(isValidating = true, error = null) } - - val result = validateAndSaveCredentialsUseCase.execute( - exchange = exchange, - apiKey = state.apiKey, - apiSecret = state.apiSecret, - passphrase = state.passphrase.takeIf { it.isNotBlank() }, - clientId = state.clientId.takeIf { it.isNotBlank() } - ) - - when (result) { - is CredentialValidationResult.Success -> { - _uiState.update { it.copy(isValidating = false) } - onSuccess() - } - is CredentialValidationResult.Error -> { - _uiState.update { - it.copy(isValidating = false, error = result.message) - } - } - } - } + credentialForm.validateAndSaveCredentials(onSuccess) } fun showImportDialog() { - _uiState.update { it.copy(showImportDialog = true, importSinceMillis = null) } + _localState.update { it.copy(showImportDialog = true, importSinceMillis = null) } } fun dismissImportDialog() { - _uiState.update { it.copy(showImportDialog = false) } + _localState.update { it.copy(showImportDialog = false) } } fun setImportSinceDate(millis: Long?) { - _uiState.update { it.copy(importSinceMillis = millis) } + _localState.update { it.copy(importSinceMillis = millis) } } fun confirmImport() { - val sinceMillis = _uiState.value.importSinceMillis - _uiState.update { it.copy(showImportDialog = false) } + val sinceMillis = _localState.value.importSinceMillis + _localState.update { it.copy(showImportDialog = false) } importViaApi(sinceMillis?.let { Instant.ofEpochMilli(it) }) } fun importViaApi(sinceDate: Instant? = null) { - val state = _uiState.value + val state = _localState.value val exchange = state.exchange ?: return if (state.isApiImporting) return if (state.plans.isEmpty()) return viewModelScope.launch { - _uiState.update { it.copy(isApiImporting = true, apiImportProgress = "", apiImportResult = null) } + _localState.update { it.copy(isApiImporting = true, apiImportProgress = "", apiImportResult = null) } try { val isSandbox = userPreferences.isSandboxMode() val credentials = credentialsStore.getCredentials(exchange, isSandbox) if (credentials == null) { - _uiState.update { it.copy( + _localState.update { it.copy( isApiImporting = false, apiImportResult = ApiImportResultState.Error("No credentials found for ${exchange.displayName}") ) } @@ -201,19 +152,19 @@ class ExchangeDetailViewModel @Inject constructor( ).collect { progress -> when (progress) { is ApiImportProgress.Fetching -> { - _uiState.update { it.copy( + _localState.update { it.copy( apiImportProgress = context.getString( com.accbot.dca.R.string.import_api_fetching, progress.page ) ) } } is ApiImportProgress.Deduplicating -> { - _uiState.update { it.copy( + _localState.update { it.copy( apiImportProgress = context.getString(com.accbot.dca.R.string.import_api_deduplicating) ) } } is ApiImportProgress.Importing -> { - _uiState.update { it.copy( + _localState.update { it.copy( apiImportProgress = context.getString( com.accbot.dca.R.string.import_api_importing, progress.newCount ) @@ -232,14 +183,14 @@ class ExchangeDetailViewModel @Inject constructor( val result = errorMessage?.let { ApiImportResultState.Error(it) } ?: ApiImportResultState.Success(totalImported, totalSkipped) - _uiState.update { it.copy( + _localState.update { it.copy( isApiImporting = false, apiImportProgress = "", apiImportResult = result ) } } catch (e: Exception) { Log.e(TAG, "API import failed", e) - _uiState.update { it.copy( + _localState.update { it.copy( isApiImporting = false, apiImportProgress = "", apiImportResult = ApiImportResultState.Error(e.message ?: "Import failed") @@ -249,17 +200,17 @@ class ExchangeDetailViewModel @Inject constructor( } fun dismissImportResult() { - _uiState.update { it.copy(apiImportResult = null) } + _localState.update { it.copy(apiImportResult = null) } } fun removeExchange(onRemoved: () -> Unit) { - val state = _uiState.value + val state = _localState.value val exchange = state.exchange ?: return viewModelScope.launch { // Delete transactions, plans and credentials for this exchange transactionDao.deleteTransactionsByExchange(exchange) dcaPlanDao.deletePlansByExchange(exchange) - credentialsStore.deleteCredentials(exchange, state.isSandboxMode) + credentialsStore.deleteCredentials(exchange, credentialForm.state.value.isSandboxMode) onRemoved() } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationsScreen.kt index efca08b..d165a81 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationsScreen.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.accbot.dca.R import com.accbot.dca.data.local.NotificationType import com.accbot.dca.domain.model.AppNotification +import com.accbot.dca.presentation.components.AccBotTopAppBar import com.accbot.dca.presentation.components.EmptyState import com.accbot.dca.presentation.ui.theme.Error import com.accbot.dca.presentation.ui.theme.LocalSandboxMode @@ -72,13 +73,8 @@ fun NotificationsScreen( Scaffold( contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp), topBar = { - TopAppBar( - title = { - Text( - stringResource(R.string.notifications_title), - fontWeight = FontWeight.Bold - ) - }, + AccBotTopAppBar( + title = stringResource(R.string.notifications_title), actions = { if (LocalSandboxMode.current) { IconButton(onClick = { viewModel.createTestNotifications() }) { @@ -97,10 +93,7 @@ fun NotificationsScreen( Text(stringResource(R.string.notifications_delete_all)) } } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) + } ) }, ) { paddingValues -> diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/CompletionScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/CompletionScreen.kt index 0b4bc0f..315e709 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/CompletionScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/CompletionScreen.kt @@ -11,18 +11,20 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.accbot.dca.R +import com.accbot.dca.presentation.components.NextStepItem import com.accbot.dca.presentation.ui.theme.accentColor import com.accbot.dca.presentation.ui.theme.successColor import kotlinx.coroutines.delay @@ -32,6 +34,7 @@ fun CompletionScreen( onFinish: () -> Unit, viewModel: OnboardingViewModel = hiltViewModel() ) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() // Success animation val scale = remember { Animatable(0f) } val checkScale = remember { Animatable(0f) } @@ -122,19 +125,51 @@ fun CompletionScreen( Spacer(modifier = Modifier.height(16.dp)) - NextStepItem( - icon = Icons.Default.PlayArrow, - title = stringResource(R.string.completion_start_service_title), - description = stringResource(R.string.completion_start_service_desc) - ) - - Spacer(modifier = Modifier.height(12.dp)) - - NextStepItem( - icon = Icons.Default.Tune, - title = stringResource(R.string.completion_fine_tune_title), - description = stringResource(R.string.completion_fine_tune_desc) - ) + if (uiState.planCreated) { + NextStepItem( + icon = Icons.Default.CheckCircle, + title = stringResource(R.string.completion_plan_active_title), + description = stringResource(R.string.completion_plan_active_desc), + size = 36.dp, + iconSize = 18.dp, + cornerRadius = 8.dp, + spacing = 12.dp + ) + + Spacer(modifier = Modifier.height(12.dp)) + + NextStepItem( + icon = Icons.Default.Add, + title = stringResource(R.string.completion_add_more_title), + description = stringResource(R.string.completion_add_more_desc), + size = 36.dp, + iconSize = 18.dp, + cornerRadius = 8.dp, + spacing = 12.dp + ) + } else { + NextStepItem( + icon = Icons.Default.PlayArrow, + title = stringResource(R.string.completion_start_service_title), + description = stringResource(R.string.completion_start_service_desc), + size = 36.dp, + iconSize = 18.dp, + cornerRadius = 8.dp, + spacing = 12.dp + ) + + Spacer(modifier = Modifier.height(12.dp)) + + NextStepItem( + icon = Icons.Default.Tune, + title = stringResource(R.string.completion_fine_tune_title), + description = stringResource(R.string.completion_fine_tune_desc), + size = 36.dp, + iconSize = 18.dp, + cornerRadius = 8.dp, + spacing = 12.dp + ) + } } } @@ -164,41 +199,3 @@ fun CompletionScreen( } } -@Composable -internal fun NextStepItem( - icon: ImageVector, - title: String, - description: String -) { - Row( - verticalAlignment = Alignment.Top - ) { - Box( - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(8.dp)) - .background(successColor().copy(alpha = 0.1f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = successColor(), - modifier = Modifier.size(18.dp) - ) - } - Spacer(modifier = Modifier.width(12.dp)) - Column { - Text( - text = title, - fontWeight = FontWeight.Medium, - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/ExchangeSetupScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/ExchangeSetupScreen.kt index 12bfd8a..de54572 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/ExchangeSetupScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/ExchangeSetupScreen.kt @@ -23,10 +23,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.accbot.dca.domain.model.Exchange -import com.accbot.dca.domain.model.ExchangeInstructions import com.accbot.dca.domain.model.isStable +import com.accbot.dca.presentation.components.AccBotTopAppBar import com.accbot.dca.presentation.components.CredentialsInputCard import com.accbot.dca.presentation.components.ExchangeSelectionGrid +import com.accbot.dca.presentation.components.ExchangeInstructionsCard import com.accbot.dca.presentation.components.ExperimentalExchangeDisclaimer import com.accbot.dca.presentation.components.ExperimentalExchangesToggle import com.accbot.dca.presentation.components.ExperimentalToggleDisclaimer @@ -47,21 +48,14 @@ fun ExchangeSetupScreen( Scaffold( topBar = { - TopAppBar( - title = { Text(stringResource(R.string.exchange_setup_title), fontWeight = FontWeight.Bold) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.common_back)) - } - }, + AccBotTopAppBar( + title = stringResource(R.string.exchange_setup_title), + onNavigateBack = onBack, actions = { TextButton(onClick = onSkip) { Text(stringResource(R.string.common_skip), color = MaterialTheme.colorScheme.onSurfaceVariant) } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) + } ) } ) { paddingValues -> @@ -85,7 +79,7 @@ fun ExchangeSetupScreen( ExperimentalToggleDisclaimer( onConfirm = { showExperimentalDisclaimer = false - viewModel.setExperimentalExchangesEnabled(true) + viewModel.credentialForm.setExperimentalExchangesEnabled(true) }, onDismiss = { showExperimentalDisclaimer = false } ) @@ -130,14 +124,14 @@ fun ExchangeSetupScreen( Spacer(modifier = Modifier.height(24.dp)) // Sandbox mode indicator - if (uiState.isSandboxMode) { + if (uiState.credentialForm.isSandboxMode) { SandboxModeIndicator() Spacer(modifier = Modifier.height(16.dp)) } // Exchange selection grid with request card ExchangeSelectionGrid( - exchanges = uiState.availableExchanges, + exchanges = uiState.credentialForm.availableExchanges, onExchangeClick = { exchange -> if (exchange.isStable) { viewModel.selectExchange(exchange) @@ -145,7 +139,7 @@ fun ExchangeSetupScreen( experimentalExchangePending = exchange } }, - selectedExchange = uiState.selectedExchange, + selectedExchange = uiState.credentialForm.selectedExchange, onRequestExchangeClick = { val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Crynners/AccBot/issues")) context.startActivity(intent) @@ -156,48 +150,49 @@ fun ExchangeSetupScreen( // Experimental exchanges toggle ExperimentalExchangesToggle( - isEnabled = uiState.showExperimental, + isEnabled = uiState.credentialForm.showExperimental, onToggle = { enabled -> if (enabled) { showExperimentalDisclaimer = true } else { - viewModel.setExperimentalExchangesEnabled(false) + viewModel.credentialForm.setExperimentalExchangesEnabled(false) } } ) // Instructions + credentials (only show when exchange is selected) - if (uiState.selectedExchange != null) { + val cred = uiState.credentialForm + if (cred.selectedExchange != null) { Spacer(modifier = Modifier.height(24.dp)) // Exchange setup instructions card - if (uiState.selectedExchangeInstructions != null) { - if (uiState.isSandboxMode) { + if (cred.selectedExchangeInstructions != null) { + if (cred.isSandboxMode) { SandboxCredentialsInfoCard( - exchange = uiState.selectedExchange!!, - instructions = uiState.selectedExchangeInstructions!! + exchange = cred.selectedExchange!!, + instructions = cred.selectedExchangeInstructions!! ) } else { ExchangeInstructionsCard( - exchange = uiState.selectedExchange!!, - instructions = uiState.selectedExchangeInstructions!! + exchange = cred.selectedExchange!!, + instructions = cred.selectedExchangeInstructions!! ) } Spacer(modifier = Modifier.height(16.dp)) } CredentialsInputCard( - exchange = uiState.selectedExchange!!, - clientId = uiState.clientId, - apiKey = uiState.apiKey, - apiSecret = uiState.apiSecret, - passphrase = uiState.passphrase, - onClientIdChange = viewModel::setClientId, - onApiKeyChange = viewModel::setApiKey, - onApiSecretChange = viewModel::setApiSecret, - onPassphraseChange = viewModel::setPassphrase, - errorMessage = uiState.credentialsError, - isValidating = uiState.isValidatingCredentials + 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 = cred.credentialsError, + isValidating = cred.isValidatingCredentials ) } @@ -206,23 +201,23 @@ fun ExchangeSetupScreen( // Continue button Button( onClick = { - if (uiState.selectedExchange != null) { - viewModel.validateAndSaveCredentials(onSuccess = onContinue) + if (cred.selectedExchange != null) { + viewModel.credentialForm.validateAndSaveCredentials(onSuccess = onContinue) } }, modifier = Modifier .fillMaxWidth() .height(56.dp), - enabled = uiState.selectedExchange != null && - uiState.apiKey.isNotBlank() && - uiState.apiSecret.isNotBlank() && - (uiState.selectedExchange != Exchange.COINMATE || uiState.clientId.isNotBlank()) && - !uiState.isValidatingCredentials, + enabled = cred.selectedExchange != null && + cred.apiKey.isNotBlank() && + cred.apiSecret.isNotBlank() && + (cred.selectedExchange != Exchange.COINMATE || cred.clientId.isNotBlank()) && + !cred.isValidatingCredentials, colors = ButtonDefaults.buttonColors( containerColor = accentColor() ) ) { - if (uiState.isValidatingCredentials) { + if (cred.isValidatingCredentials) { CircularProgressIndicator( modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary @@ -239,76 +234,3 @@ fun ExchangeSetupScreen( } } -@Composable -private fun ExchangeInstructionsCard( - exchange: Exchange, - instructions: ExchangeInstructions, - modifier: Modifier = Modifier -) { - val context = LocalContext.current - val resolvedUrl = instructions.urlRes?.let { stringResource(it) } ?: instructions.url - val accentCol = accentColor() - - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - modifier = modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - stringResource(R.string.add_exchange_api_setup), - fontWeight = FontWeight.SemiBold, - style = MaterialTheme.typography.titleSmall - ) - - instructions.steps.forEachIndexed { index, stepResId -> - Row { - Text( - "${index + 1}.", - fontWeight = FontWeight.Bold, - modifier = Modifier.width(24.dp) - ) - Text(stringResource(stepResId), style = MaterialTheme.typography.bodySmall) - } - } - - if (resolvedUrl.isNotBlank()) { - OutlinedButton( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolvedUrl)) - context.startActivity(intent) - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors(contentColor = accentCol) - ) { - Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.add_exchange_open_api_page, exchange.displayName)) - } - } - - Row( - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - tint = accentCol, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.add_exchange_security_tip), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} 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 80823ff..ad8f95d 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 @@ -1,12 +1,9 @@ package com.accbot.dca.presentation.screens.onboarding -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.automirrored.filled.* @@ -17,17 +14,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.accbot.dca.domain.model.DcaFrequency -import com.accbot.dca.presentation.components.SelectableChip import com.accbot.dca.R +import com.accbot.dca.presentation.components.AccBotTopAppBar +import com.accbot.dca.presentation.plan.PlanFormContent import com.accbot.dca.presentation.ui.theme.accentColor -import com.accbot.dca.presentation.ui.theme.successColor import java.math.BigDecimal @OptIn(ExperimentalMaterial3Api::class) @@ -40,37 +36,16 @@ fun FirstPlanScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - // Quick amount presets — filter out values below minimum order size - val quickAmounts = remember(uiState.minOrderSize) { - val allAmounts = listOf("25", "50", "100", "250", "500") - val min = uiState.minOrderSize - if (min != null) allAmounts.filter { it.toBigDecimal() >= min } - else allAmounts - } - - // Simplified frequency options for onboarding - val frequencyOptions = listOf( - DcaFrequency.DAILY, - DcaFrequency.WEEKLY - ) - Scaffold( topBar = { - TopAppBar( - title = { Text(stringResource(R.string.first_plan_title), fontWeight = FontWeight.Bold) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.common_back)) - } - }, + AccBotTopAppBar( + title = stringResource(R.string.first_plan_title), + onNavigateBack = onBack, actions = { TextButton(onClick = onSkip) { Text(stringResource(R.string.common_skip), color = MaterialTheme.colorScheme.onSurfaceVariant) } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) + } ) } ) { paddingValues -> @@ -112,187 +87,26 @@ fun FirstPlanScreen( Spacer(modifier = Modifier.height(32.dp)) - // Crypto selection - if (uiState.selectedExchange != null) { - Text( - text = stringResource(R.string.first_plan_what_to_buy), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.align(Alignment.Start) - ) - - Spacer(modifier = Modifier.height(12.dp)) - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.horizontalScroll(rememberScrollState()) - ) { - uiState.selectedExchange!!.supportedCryptos.forEach { crypto -> - SelectableChip( - text = crypto, - selected = crypto == uiState.selectedCrypto, - onClick = { viewModel.selectCrypto(crypto) } - ) - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Fiat selection - Text( - text = stringResource(R.string.first_plan_currency), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.align(Alignment.Start) + if (uiState.credentialForm.selectedExchange != null) { + // Plan form (crypto, fiat, amount, frequency, strategy, withdrawal, target) + PlanFormContent( + state = uiState.planForm, + availableCryptos = uiState.credentialForm.selectedExchange!!.supportedCryptos, + availableFiats = uiState.credentialForm.selectedExchange!!.supportedFiats, + onCryptoSelected = viewModel.planForm::selectCrypto, + onFiatSelected = viewModel.planForm::selectFiat, + onAmountChanged = viewModel.planForm::setAmount, + onFrequencySelected = viewModel.planForm::selectFrequency, + onCronExpressionChanged = viewModel.planForm::setCronExpression, + onStrategySelected = viewModel.planForm::selectStrategy, + onWithdrawalEnabledChanged = viewModel.planForm::setWithdrawalEnabled, + onWithdrawalAddressChanged = viewModel.planForm::setWithdrawalAddress, + onTargetAmountChanged = viewModel.planForm::setTargetAmount, + errorMessage = uiState.error ) - Spacer(modifier = Modifier.height(12.dp)) - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.horizontalScroll(rememberScrollState()) - ) { - uiState.selectedExchange!!.supportedFiats.forEach { fiat -> - SelectableChip( - text = fiat, - selected = fiat == uiState.selectedFiat, - onClick = { viewModel.selectFiat(fiat) } - ) - } - } - Spacer(modifier = Modifier.height(24.dp)) - // Amount input with presets - Text( - text = stringResource(R.string.add_plan_amount_per_purchase), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.align(Alignment.Start) - ) - - Spacer(modifier = Modifier.height(12.dp)) - - // Quick amount buttons - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.horizontalScroll(rememberScrollState()) - ) { - quickAmounts.forEach { amount -> - SelectableChip( - text = "$amount ${uiState.selectedFiat}", - selected = uiState.amount == amount, - onClick = { viewModel.setAmount(amount) } - ) - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = uiState.amount, - onValueChange = viewModel::setAmount, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.first_plan_custom_amount)) }, - suffix = { Text(uiState.selectedFiat) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - singleLine = true, - isError = uiState.amountBelowMinimum, - supportingText = uiState.minOrderSize?.let { min -> - { - Text( - text = stringResource(R.string.min_order_size, min.toPlainString(), uiState.selectedFiat), - color = if (uiState.amountBelowMinimum) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - } - } - ) - - Spacer(modifier = Modifier.height(24.dp)) - - // Frequency selection - Text( - text = stringResource(R.string.first_plan_how_often), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.align(Alignment.Start) - ) - - Spacer(modifier = Modifier.height(12.dp)) - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - frequencyOptions.forEach { frequency -> - Card( - modifier = Modifier - .fillMaxWidth() - .clickable { viewModel.selectFrequency(frequency) }, - colors = CardDefaults.cardColors( - containerColor = if (uiState.selectedFrequency == frequency) { - successColor().copy(alpha = 0.15f) - } else { - MaterialTheme.colorScheme.surface - } - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - text = stringResource(frequency.displayNameRes), - fontWeight = if (uiState.selectedFrequency == frequency) { - FontWeight.SemiBold - } else { - FontWeight.Normal - }, - color = if (uiState.selectedFrequency == frequency) { - successColor() - } else { - MaterialTheme.colorScheme.onSurface - } - ) - Text( - text = stringResource(if (frequency == DcaFrequency.DAILY) { - R.string.first_plan_recommended - } else { - R.string.first_plan_lower_frequency - }), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - RadioButton( - selected = uiState.selectedFrequency == frequency, - onClick = { viewModel.selectFrequency(frequency) }, - colors = RadioButtonDefaults.colors( - selectedColor = successColor() - ) - ) - } - } - } - } - - // Error message - if (uiState.error != null) { - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = uiState.error!!, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium - ) - } - - Spacer(modifier = Modifier.height(32.dp)) - // Summary card Card( colors = CardDefaults.cardColors( @@ -311,7 +125,13 @@ fun FirstPlanScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.first_plan_summary, uiState.amount, uiState.selectedFiat, uiState.selectedCrypto, stringResource(uiState.selectedFrequency.displayNameRes).lowercase()), + text = stringResource( + R.string.first_plan_summary, + uiState.planForm.amount, + uiState.planForm.selectedFiat, + uiState.planForm.selectedCrypto, + stringResource(uiState.planForm.selectedFrequency.displayNameRes).lowercase() + ), style = MaterialTheme.typography.bodyMedium ) } @@ -357,9 +177,8 @@ fun FirstPlanScreen( modifier = Modifier .fillMaxWidth() .height(56.dp), - enabled = uiState.selectedExchange != null && - uiState.amount.toBigDecimalOrNull()?.let { it > BigDecimal.ZERO } == true && - !uiState.amountBelowMinimum && + enabled = uiState.credentialForm.selectedExchange != null && + uiState.planForm.isFormValid && !uiState.isLoading, colors = ButtonDefaults.buttonColors( containerColor = accentColor() 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 6a69b80..b934914 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 @@ -2,271 +2,126 @@ package com.accbot.dca.presentation.screens.onboarding 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.CredentialsStore import com.accbot.dca.data.local.OnboardingPreferences import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.domain.model.DcaFrequency -import com.accbot.dca.domain.model.Exchange -import com.accbot.dca.domain.model.ExchangeFilter -import com.accbot.dca.domain.model.ExchangeInstructions -import com.accbot.dca.domain.model.ExchangeInstructionsProvider -import com.accbot.dca.domain.model.isStable -import com.accbot.dca.domain.usecase.CredentialValidationResult +import com.accbot.dca.domain.usecase.CalculateMonthlyCostUseCase +import com.accbot.dca.domain.usecase.CreateDcaPlanUseCase import com.accbot.dca.domain.usecase.ValidateAndSaveCredentialsUseCase import com.accbot.dca.exchange.MinOrderSizeRepository +import com.accbot.dca.presentation.credentials.CredentialFormDelegate +import com.accbot.dca.presentation.credentials.CredentialFormState +import com.accbot.dca.presentation.plan.PlanFormDelegate +import com.accbot.dca.presentation.plan.PlanFormState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.math.BigDecimal -import java.time.Duration -import java.time.Instant import androidx.compose.runtime.Immutable import javax.inject.Inject @Immutable data class OnboardingUiState( - // Sandbox state (immutable after init) - val isSandboxMode: Boolean = false, - val availableExchanges: List = emptyList(), - val showExperimental: Boolean = false, + // Credential form (from delegate) + val credentialForm: CredentialFormState = CredentialFormState(), - // Exchange setup - val selectedExchange: Exchange? = null, - val selectedExchangeInstructions: ExchangeInstructions? = null, - val clientId: String = "", - val apiKey: String = "", - val apiSecret: String = "", - val passphrase: String = "", - val isValidatingCredentials: Boolean = false, - val credentialsValid: Boolean = false, - val credentialsError: String? = null, - - // First plan setup - val selectedCrypto: String = "BTC", - val selectedFiat: String = "EUR", - val amount: String = "100", - val selectedFrequency: DcaFrequency = DcaFrequency.DAILY, - - // Min order size (fetched from API) - val minOrderSize: BigDecimal? = null, + // Plan form (from delegate) + val planForm: PlanFormState = PlanFormState(), // General state val isLoading: Boolean = false, - val error: String? = null -) { - val amountBelowMinimum: Boolean - get() { - val min = minOrderSize ?: return false - val amt = amount.toBigDecimalOrNull() ?: return false - return amt < min - } -} + val error: String? = null, + val planCreated: Boolean = false // persisted via OnboardingPreferences +) @HiltViewModel class OnboardingViewModel @Inject constructor( private val credentialsStore: CredentialsStore, private val onboardingPreferences: OnboardingPreferences, private val validateAndSaveCredentialsUseCase: ValidateAndSaveCredentialsUseCase, - private val dcaPlanDao: DcaPlanDao, + private val createDcaPlanUseCase: CreateDcaPlanUseCase, private val userPreferences: UserPreferences, - private val minOrderSizeRepository: MinOrderSizeRepository + calculateMonthlyCost: CalculateMonthlyCostUseCase, + minOrderSizeRepository: MinOrderSizeRepository ) : ViewModel() { - private val _uiState = MutableStateFlow(OnboardingUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + val planForm = PlanFormDelegate(calculateMonthlyCost, minOrderSizeRepository, viewModelScope) + val credentialForm = CredentialFormDelegate(credentialsStore, validateAndSaveCredentialsUseCase, userPreferences, viewModelScope) + + private val _localState = MutableStateFlow(OnboardingUiState()) + + val uiState: StateFlow = combine( + _localState, + planForm.state, + credentialForm.state + ) { local, form, cred -> local.copy(planForm = form, credentialForm = cred) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), OnboardingUiState()) init { - // Initialize sandbox state once - avoids repeated calls during recomposition - val isSandbox = userPreferences.isSandboxMode() - val showExperimental = userPreferences.areExperimentalExchangesEnabled() + credentialForm.initialize() // Detect already-configured exchange (e.g. credentials saved on ExchangeSetupScreen). - // hiltViewModel() creates a separate instance per NavBackStackEntry, so FirstPlanScreen - // needs to restore the selected exchange from persisted credentials. + val isSandbox = userPreferences.isSandboxMode() val configured = credentialsStore.getConfiguredExchanges(isSandbox).firstOrNull() - _uiState.update { + _localState.update { it.copy( - isSandboxMode = isSandbox, - showExperimental = showExperimental, - availableExchanges = ExchangeFilter.getAvailableExchanges(isSandbox) - .filter { exchange -> showExperimental || exchange.isStable }, - selectedExchange = configured, - selectedCrypto = configured?.supportedCryptos?.firstOrNull() ?: "BTC", - selectedFiat = configured?.supportedFiats?.firstOrNull() ?: "EUR", - credentialsValid = configured != null + planCreated = onboardingPreferences.isPlanCreatedDuringOnboarding() ) } if (configured != null) { - updateMinOrderSize() - } - } - - fun setExperimentalExchangesEnabled(enabled: Boolean) { - userPreferences.setExperimentalExchangesEnabled(enabled) - val isSandbox = _uiState.value.isSandboxMode - _uiState.update { - it.copy( - showExperimental = enabled, - availableExchanges = ExchangeFilter.getAvailableExchanges(isSandbox) - .filter { exchange -> enabled || exchange.isStable } - ) + credentialForm.initWithExchange(configured) + planForm.initFromExchange(configured) } } // Exchange setup functions - fun selectExchange(exchange: Exchange) { - val isSandbox = _uiState.value.isSandboxMode - val instructions = ExchangeInstructionsProvider.getInstructions(exchange, isSandbox) - _uiState.update { state -> - state.copy( - selectedExchange = exchange, - selectedExchangeInstructions = instructions, - selectedCrypto = exchange.supportedCryptos.firstOrNull() ?: "BTC", - selectedFiat = exchange.supportedFiats.firstOrNull() ?: "EUR", - clientId = "", - apiKey = "", - apiSecret = "", - passphrase = "", - credentialsValid = false, - credentialsError = null, - minOrderSize = null - ) - } - updateMinOrderSize() - } - - fun setClientId(value: String) { - _uiState.update { it.copy(clientId = value, credentialsError = null) } - } - - fun setApiKey(value: String) { - _uiState.update { it.copy(apiKey = value, credentialsError = null) } - } - - fun setApiSecret(value: String) { - _uiState.update { it.copy(apiSecret = value, credentialsError = null) } - } - - fun setPassphrase(value: String) { - _uiState.update { it.copy(passphrase = value, credentialsError = null) } - } - - fun validateAndSaveCredentials(onSuccess: () -> Unit) { - val state = _uiState.value - - // Guard against concurrent validation calls (race condition prevention) - if (state.isValidatingCredentials) return - - val exchange = state.selectedExchange ?: return - - viewModelScope.launch { - _uiState.update { it.copy(isValidatingCredentials = true, credentialsError = null) } - - val result = validateAndSaveCredentialsUseCase.execute( - exchange = exchange, - apiKey = state.apiKey, - apiSecret = state.apiSecret, - passphrase = state.passphrase.takeIf { it.isNotBlank() }, - clientId = state.clientId.takeIf { it.isNotBlank() } - ) - - when (result) { - is CredentialValidationResult.Success -> { - _uiState.update { - it.copy( - isValidatingCredentials = false, - credentialsValid = true - ) - } - onSuccess() - } - is CredentialValidationResult.Error -> { - _uiState.update { - it.copy( - isValidatingCredentials = false, - credentialsError = result.message - ) - } - } - } - } - } - - private fun updateMinOrderSize() { - viewModelScope.launch { - val exchange = _uiState.value.selectedExchange ?: return@launch - val min = minOrderSizeRepository.getMinOrderSize( - exchange, _uiState.value.selectedCrypto, _uiState.value.selectedFiat - ) - _uiState.update { it.copy(minOrderSize = min) } - } - } - - // First plan functions - fun selectCrypto(crypto: String) { - _uiState.update { it.copy(selectedCrypto = crypto) } - updateMinOrderSize() - } - - fun selectFiat(fiat: String) { - _uiState.update { it.copy(selectedFiat = fiat) } - updateMinOrderSize() - } - - fun setAmount(amount: String) { - _uiState.update { it.copy(amount = amount) } - } - - fun selectFrequency(frequency: DcaFrequency) { - _uiState.update { it.copy(selectedFrequency = frequency) } + fun selectExchange(exchange: com.accbot.dca.domain.model.Exchange) { + credentialForm.selectExchange(exchange) + planForm.initFromExchange(exchange) } fun createFirstPlan(onSuccess: () -> Unit) { - val state = _uiState.value + val state = uiState.value + val form = state.planForm // Guard against concurrent plan creation calls (race condition prevention) if (state.isLoading) return - val exchange = state.selectedExchange + val exchange = state.credentialForm.selectedExchange if (exchange == null) { - _uiState.update { it.copy(error = "No exchange configured") } + _localState.update { it.copy(error = "No exchange configured") } return } - val amount = state.amount.toBigDecimalOrNull() + val amount = form.amount.toBigDecimalOrNull() if (amount == null || amount <= BigDecimal.ZERO) { - _uiState.update { it.copy(error = "Please enter a valid amount") } + _localState.update { it.copy(error = "Please enter a valid amount") } return } viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } + _localState.update { it.copy(isLoading = true, error = null) } try { - val now = Instant.now() - val nextExecution = now.plus(Duration.ofMinutes(state.selectedFrequency.intervalMinutes)) - - val plan = DcaPlanEntity( + createDcaPlanUseCase.execute( exchange = exchange, - crypto = state.selectedCrypto, - fiat = state.selectedFiat, + crypto = form.selectedCrypto, + fiat = form.selectedFiat, amount = amount, - frequency = state.selectedFrequency, - isEnabled = true, - withdrawalEnabled = false, - withdrawalAddress = null, - createdAt = now, - nextExecutionAt = nextExecution + frequency = form.selectedFrequency, + cronExpression = if (form.selectedFrequency == DcaFrequency.CUSTOM) form.cronExpression else null, + strategy = form.selectedStrategy, + withdrawalEnabled = form.withdrawalEnabled, + withdrawalAddress = if (form.withdrawalEnabled) form.withdrawalAddress.trim() else null, + targetAmount = form.targetAmount.toBigDecimalOrNull() ) - dcaPlanDao.insertPlan(plan) - - _uiState.update { it.copy(isLoading = false) } + onboardingPreferences.setPlanCreatedDuringOnboarding(true) + _localState.update { it.copy(isLoading = false, planCreated = true) } onSuccess() } catch (e: Exception) { - _uiState.update { + _localState.update { it.copy( isLoading = false, error = e.message ?: "Failed to create plan" @@ -279,6 +134,7 @@ class OnboardingViewModel @Inject constructor( // Completion fun completeOnboarding() { onboardingPreferences.setOnboardingCompleted(true) + onboardingPreferences.setPlanCreatedDuringOnboarding(false) // cleanup temp flag } fun hasConfiguredExchange(): Boolean { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/PermissionsScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/PermissionsScreen.kt index 838324e..0700631 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/PermissionsScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/PermissionsScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -34,6 +33,7 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import com.accbot.dca.R +import com.accbot.dca.presentation.components.AccBotTopAppBar import com.accbot.dca.presentation.ui.theme.accentColor import com.accbot.dca.presentation.ui.theme.successColor @@ -91,20 +91,7 @@ fun PermissionsScreen( Scaffold( topBar = { - TopAppBar( - title = { }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.common_back) - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) - ) + AccBotTopAppBar(title = "", onNavigateBack = onBack) } ) { paddingValues -> Column( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/SecurityScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/SecurityScreen.kt index 139e65f..e748de7 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/SecurityScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/SecurityScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.sp import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.accbot.dca.R +import com.accbot.dca.presentation.components.AccBotTopAppBar import com.accbot.dca.presentation.screens.SettingsViewModel import com.accbot.dca.presentation.ui.theme.accentColor import com.accbot.dca.presentation.ui.theme.successColor @@ -48,17 +49,7 @@ fun SecurityScreen( var biometricEnabled by remember { mutableStateOf(false) } Scaffold( topBar = { - TopAppBar( - title = { }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.common_back)) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background - ) - ) + AccBotTopAppBar(title = "", onNavigateBack = onBack) } ) { paddingValues -> Column( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/WelcomeScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/WelcomeScreen.kt index 5980052..9bccc7d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/WelcomeScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/onboarding/WelcomeScreen.kt @@ -13,14 +13,13 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.accbot.dca.R +import com.accbot.dca.presentation.components.FeatureCard import com.accbot.dca.presentation.ui.theme.accentColor import com.accbot.dca.presentation.ui.theme.successColor @@ -122,54 +121,3 @@ fun WelcomeScreen( } } -@Composable -internal fun FeatureCard( - icon: ImageVector, - title: String, - description: String, - modifier: Modifier = Modifier -) { - Card( - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background(successColor().copy(alpha = 0.15f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = successColor(), - modifier = Modifier.size(24.dp) - ) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Column { - Text( - text = title, - fontWeight = FontWeight.SemiBold, - style = MaterialTheme.typography.titleSmall - ) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} 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 fc394da..99af2d9 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 @@ -1,38 +1,22 @@ package com.accbot.dca.presentation.screens.plans -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.accbot.dca.R -import com.accbot.dca.domain.model.DcaFrequency -import com.accbot.dca.domain.model.DcaStrategy -import com.accbot.dca.presentation.components.ScheduleBuilder import com.accbot.dca.presentation.components.AccBotTopAppBar import com.accbot.dca.presentation.components.LoadingState import com.accbot.dca.presentation.components.ErrorState -import com.accbot.dca.presentation.components.MonthlyCostEstimateCard -import com.accbot.dca.presentation.components.QrScannerButton -import com.accbot.dca.presentation.components.StrategyInfoBottomSheet -import com.accbot.dca.presentation.components.StrategyOption +import com.accbot.dca.presentation.plan.PlanFormContent import com.accbot.dca.presentation.ui.theme.accentColor -import com.accbot.dca.presentation.ui.theme.successColor @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -127,213 +111,26 @@ fun EditPlanScreen( } } - // Amount input + // Plan form (amount, frequency, strategy, withdrawal, target) item { - Text( - text = stringResource(R.string.add_plan_amount_per_purchase), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( - value = uiState.amount, - onValueChange = viewModel::setAmount, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.common_amount)) }, - suffix = { Text(uiState.fiat) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - singleLine = true, - isError = uiState.amountBelowMinimum || (uiState.error != null && uiState.isSaving), - supportingText = uiState.minOrderSize?.let { min -> - { - Text( - text = stringResource(R.string.min_order_size, min.toPlainString(), uiState.fiat), - color = if (uiState.amountBelowMinimum) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - ) - } - - // Frequency selection - item { - Text( - text = stringResource(R.string.add_plan_purchase_frequency), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(12.dp)) - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - DcaFrequency.entries.forEach { frequency -> - FrequencyOption( - frequency = frequency, - isSelected = uiState.selectedFrequency == frequency, - onClick = { viewModel.selectFrequency(frequency) } - ) - } - } - - // Schedule Builder (when Custom frequency is selected) - if (uiState.selectedFrequency == DcaFrequency.CUSTOM) { - Spacer(modifier = Modifier.height(12.dp)) - ScheduleBuilder( - cronExpression = uiState.cronExpression, - cronDescription = uiState.cronDescription, - cronError = uiState.cronError, - onCronExpressionChange = viewModel::setCronExpression - ) - } - } - - // Strategy selection - item { - var showStrategyInfo by remember { mutableStateOf(null) } - - Text( - text = stringResource(R.string.add_plan_dca_strategy), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(12.dp)) - - val strategies = remember { - listOf( - DcaStrategy.Classic, - DcaStrategy.AthBased(), - DcaStrategy.FearAndGreed() - ) - } - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - strategies.forEach { strategy -> - val isSelected = when { - uiState.selectedStrategy is DcaStrategy.Classic && strategy is DcaStrategy.Classic -> true - uiState.selectedStrategy is DcaStrategy.AthBased && strategy is DcaStrategy.AthBased -> true - uiState.selectedStrategy is DcaStrategy.FearAndGreed && strategy is DcaStrategy.FearAndGreed -> true - else -> false - } - StrategyOption( - strategy = strategy, - isSelected = isSelected, - onClick = { viewModel.selectStrategy(strategy) }, - onInfoClick = { showStrategyInfo = strategy } - ) - } - } - - // Strategy Info Bottom Sheet - showStrategyInfo?.let { strategy -> - StrategyInfoBottomSheet( - strategy = strategy, - onDismiss = { showStrategyInfo = null } - ) - } - } - - // Monthly Cost Estimate - if (uiState.monthlyCostEstimate != null && uiState.amount.toBigDecimalOrNull() != null) { - item { - MonthlyCostEstimateCard( - estimate = uiState.monthlyCostEstimate!!, - fiat = uiState.fiat, - isClassic = uiState.selectedStrategy is DcaStrategy.Classic - ) - } - } - - // Target Amount (optional goal) - item { - Text( - text = stringResource(R.string.plan_target_amount), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( - value = uiState.targetAmount, - onValueChange = viewModel::setTargetAmount, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.plan_target_amount)) }, - placeholder = { Text(stringResource(R.string.plan_target_amount_hint)) }, - suffix = { Text(uiState.crypto) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - singleLine = true, - supportingText = { - Text( - text = stringResource(R.string.plan_target_amount_description), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + PlanFormContent( + state = uiState.planForm, + availableCryptos = listOf(uiState.crypto), + availableFiats = listOf(uiState.fiat), + showCryptoFiatSelection = false, + onCryptoSelected = viewModel.planForm::selectCrypto, + onFiatSelected = viewModel.planForm::selectFiat, + onAmountChanged = viewModel.planForm::setAmount, + onFrequencySelected = viewModel.planForm::selectFrequency, + onCronExpressionChanged = viewModel.planForm::setCronExpression, + onStrategySelected = viewModel.planForm::selectStrategy, + onWithdrawalEnabledChanged = viewModel.planForm::setWithdrawalEnabled, + onWithdrawalAddressChanged = viewModel.planForm::setWithdrawalAddress, + onTargetAmountChanged = viewModel.planForm::setTargetAmount, + errorMessage = if (uiState.isSaving) uiState.error else null ) } - // Auto-withdrawal toggle - item { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(role = Role.Switch) { viewModel.setWithdrawalEnabled(!uiState.withdrawalEnabled) } - .semantics(mergeDescendants = true) { role = Role.Switch }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - text = stringResource(R.string.add_plan_auto_withdrawal), - fontWeight = FontWeight.SemiBold - ) - Text( - text = stringResource(R.string.add_plan_auto_withdrawal_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = uiState.withdrawalEnabled, - onCheckedChange = null, - modifier = Modifier.clearAndSetSemantics {}, - colors = SwitchDefaults.colors( - checkedThumbColor = successColor(), - checkedTrackColor = successColor().copy(alpha = 0.5f) - ) - ) - } - - if (uiState.withdrawalEnabled) { - Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( - value = uiState.withdrawalAddress, - onValueChange = viewModel::setWithdrawalAddress, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.add_plan_wallet_address, uiState.crypto)) }, - singleLine = true, - isError = uiState.addressError != null, - supportingText = if (uiState.addressError != null) { - { Text(uiState.addressError!!, color = MaterialTheme.colorScheme.error) } - } else if (uiState.withdrawalAddress.isBlank()) { - { Text(stringResource(R.string.edit_plan_enter_wallet, uiState.crypto)) } - } else null, - trailingIcon = { - QrScannerButton( - onScanResult = viewModel::setWithdrawalAddress - ) - } - ) - } - } - - // Error message - if (uiState.error != null && uiState.isSaving) { - item { - Text( - text = uiState.error!!, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium - ) - } - } - // Save button item { Button( @@ -365,46 +162,3 @@ fun EditPlanScreen( } } } - -@Composable -internal fun FrequencyOption( - frequency: DcaFrequency, - isSelected: Boolean, - onClick: () -> Unit -) { - val successCol = successColor() - Card( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - colors = CardDefaults.cardColors( - containerColor = if (isSelected) { - successCol.copy(alpha = 0.15f) - } else { - MaterialTheme.colorScheme.surface - } - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(frequency.displayNameRes), - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - color = if (isSelected) successCol else MaterialTheme.colorScheme.onSurface - ) - RadioButton( - selected = isSelected, - onClick = onClick, - colors = RadioButtonDefaults.colors( - selectedColor = successCol - ) - ) - } - } -} - diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanViewModel.kt index 03bc7cb..b9813d0 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/plans/EditPlanViewModel.kt @@ -10,10 +10,9 @@ import com.accbot.dca.domain.usecase.CalculateMonthlyCostUseCase import com.accbot.dca.domain.util.CronUtils import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.exchange.MinOrderSizeRepository -import com.accbot.dca.presentation.model.MonthlyCostEstimate +import com.accbot.dca.presentation.plan.PlanFormDelegate +import com.accbot.dca.presentation.plan.PlanFormState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.math.BigDecimal @@ -28,138 +27,77 @@ data class EditPlanUiState( val crypto: String = "", val fiat: String = "", val exchangeName: String = "", - val amount: String = "", - val selectedFrequency: DcaFrequency = DcaFrequency.DAILY, - val cronExpression: String = "", - val cronDescription: String? = null, - val cronError: String? = null, - val selectedStrategy: DcaStrategy = DcaStrategy.Classic, - val withdrawalEnabled: Boolean = false, - val withdrawalAddress: String = "", + + // Plan form (from delegate) + val planForm: PlanFormState = PlanFormState(), + + // Action state val isLoading: Boolean = true, val isSaving: Boolean = false, val isSuccess: Boolean = false, - val error: String? = null, - val addressError: String? = null, - val monthlyCostEstimate: MonthlyCostEstimate? = null, - val minOrderSize: BigDecimal? = null, - val targetAmount: String = "" + val error: String? = null ) { - val amountBelowMinimum: Boolean - get() { - val min = minOrderSize ?: return false - val amt = amount.toBigDecimalOrNull() ?: return false - return amt < min - } - val isValid: Boolean - get() = amount.toBigDecimalOrNull()?.let { it > BigDecimal.ZERO } == true && - !amountBelowMinimum && - (!withdrawalEnabled || isValidCryptoAddress(crypto, withdrawalAddress)) && - (selectedFrequency != DcaFrequency.CUSTOM || CronUtils.isValidCron(cronExpression)) - - val isAddressValid: Boolean - get() = !withdrawalEnabled || isValidCryptoAddress(crypto, withdrawalAddress) -} - -/** - * Basic validation for cryptocurrency wallet addresses. - * This is a simplified validation - actual address format depends on the crypto. - */ -private fun isValidCryptoAddress(crypto: String, address: String): Boolean { - if (address.isBlank()) return false - - val trimmed = address.trim() - - return when (crypto.uppercase()) { - "BTC" -> isValidBtcAddress(trimmed) - "ETH", "SOL", "ADA", "DOT" -> isValidGenericAddress(trimmed, minLength = 26, maxLength = 128) - "LTC" -> isValidLtcAddress(trimmed) - else -> isValidGenericAddress(trimmed, minLength = 20, maxLength = 128) - } -} - -private fun isValidBtcAddress(address: String): Boolean { - // Legacy addresses start with 1 (P2PKH) or 3 (P2SH), 25-34 chars - // Native SegWit (Bech32) start with bc1, 42-62 chars - return when { - address.startsWith("1") || address.startsWith("3") -> - address.length in 25..34 && address.all { it.isLetterOrDigit() } - address.startsWith("bc1") -> - address.length in 42..62 && address.all { it.isLetterOrDigit() } - else -> false - } -} - -private fun isValidLtcAddress(address: String): Boolean { - // Litecoin: starts with L, M, or ltc1 - return when { - address.startsWith("L") || address.startsWith("M") -> - address.length in 25..34 && address.all { it.isLetterOrDigit() } - address.startsWith("ltc1") -> - address.length in 42..62 && address.all { it.isLetterOrDigit() } - else -> false - } -} - -private fun isValidGenericAddress(address: String, minLength: Int, maxLength: Int): Boolean { - return address.length in minLength..maxLength && - address.all { it.isLetterOrDigit() || it == '_' } + get() = planForm.isFormValid } @HiltViewModel class EditPlanViewModel @Inject constructor( private val dcaPlanDao: DcaPlanDao, - private val calculateMonthlyCost: CalculateMonthlyCostUseCase, - private val minOrderSizeRepository: MinOrderSizeRepository, - private val userPreferences: UserPreferences + private val userPreferences: UserPreferences, + calculateMonthlyCost: CalculateMonthlyCostUseCase, + minOrderSizeRepository: MinOrderSizeRepository ) : ViewModel() { - private val _uiState = MutableStateFlow(EditPlanUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + val planForm = PlanFormDelegate(calculateMonthlyCost, minOrderSizeRepository, viewModelScope) + + private val _localState = MutableStateFlow(EditPlanUiState()) + + val uiState: StateFlow = combine( + _localState, + planForm.state + ) { local, form -> local.copy(planForm = form) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), EditPlanUiState()) private var originalPlan: DcaPlanEntity? = null - private var estimateJob: Job? = null fun loadPlan(planId: Long) { viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } + _localState.update { it.copy(isLoading = true, error = null) } try { val plan = dcaPlanDao.getPlanById(planId) if (plan == null) { - _uiState.update { it.copy(isLoading = false, error = "Plan not found") } + _localState.update { it.copy(isLoading = false, error = "Plan not found") } return@launch } originalPlan = plan - val cronExpr = plan.cronExpression ?: "" - _uiState.update { + _localState.update { it.copy( planId = plan.id, crypto = plan.crypto, fiat = plan.fiat, exchangeName = plan.exchange.displayName, - amount = plan.amount.toPlainString(), - selectedFrequency = plan.frequency, - cronExpression = cronExpr, - cronDescription = if (cronExpr.isNotBlank()) CronUtils.describeCron(cronExpr) else null, - cronError = null, - selectedStrategy = plan.strategy, - withdrawalEnabled = plan.withdrawalEnabled, - withdrawalAddress = plan.withdrawalAddress ?: "", - targetAmount = plan.targetAmount?.toPlainString() ?: "", isLoading = false ) } - updateMonthlyCostEstimate() - // Fetch min order size for this plan's exchange/pair - val min = minOrderSizeRepository.getMinOrderSize(plan.exchange, plan.crypto, plan.fiat) - _uiState.update { it.copy(minOrderSize = min) } + planForm.initFromPlan( + exchange = plan.exchange, + crypto = plan.crypto, + fiat = plan.fiat, + amount = plan.amount.toPlainString(), + frequency = plan.frequency, + cronExpression = plan.cronExpression ?: "", + strategy = plan.strategy, + withdrawalEnabled = plan.withdrawalEnabled, + withdrawalAddress = plan.withdrawalAddress ?: "", + targetAmount = plan.targetAmount?.toPlainString() ?: "" + ) } catch (e: Exception) { - _uiState.update { + _localState.update { it.copy( isLoading = false, error = e.message ?: "Failed to load plan" @@ -169,121 +107,43 @@ class EditPlanViewModel @Inject constructor( } } - fun setAmount(amount: String) { - _uiState.update { it.copy(amount = amount, error = null) } - updateMonthlyCostEstimate() - } - - fun selectFrequency(frequency: DcaFrequency) { - _uiState.update { - it.copy( - selectedFrequency = frequency, - cronExpression = if (frequency != DcaFrequency.CUSTOM) "" else it.cronExpression, - cronDescription = if (frequency != DcaFrequency.CUSTOM) null else it.cronDescription, - cronError = if (frequency != DcaFrequency.CUSTOM) null else it.cronError - ) - } - updateMonthlyCostEstimate() - } - - fun setCronExpression(cron: String) { - val isValid = CronUtils.isValidCron(cron) - val description = if (isValid) CronUtils.describeCron(cron) else null - val error = if (cron.isNotBlank() && !isValid) "Invalid CRON expression" else null - _uiState.update { - it.copy( - cronExpression = cron, - cronDescription = description, - cronError = error - ) - } - if (isValid) { - updateMonthlyCostEstimate() - } - } - - fun selectStrategy(strategy: DcaStrategy) { - _uiState.update { it.copy(selectedStrategy = strategy) } - updateMonthlyCostEstimate() - } - - fun setTargetAmount(value: String) { - _uiState.update { it.copy(targetAmount = value) } - } - - fun setWithdrawalEnabled(enabled: Boolean) { - _uiState.update { it.copy(withdrawalEnabled = enabled) } - } - - fun setWithdrawalAddress(address: String) { - val state = _uiState.value - val addressError = if (address.isNotBlank() && !isValidCryptoAddress(state.crypto, address)) { - "Invalid ${state.crypto} address format" - } else { - null - } - _uiState.update { it.copy(withdrawalAddress = address, addressError = addressError) } - } - - private fun updateMonthlyCostEstimate() { - estimateJob?.cancel() - estimateJob = viewModelScope.launch { - delay(300) - val state = _uiState.value - val amount = state.amount.toBigDecimalOrNull() - if (amount == null) { - _uiState.update { it.copy(monthlyCostEstimate = null) } - return@launch - } - val estimate = calculateMonthlyCost.computeEstimate( - amount = amount, - frequency = state.selectedFrequency, - cronExpression = state.cronExpression.takeIf { it.isNotBlank() }, - strategy = state.selectedStrategy, - crypto = state.crypto, - fiat = state.fiat - ) - _uiState.update { it.copy(monthlyCostEstimate = estimate) } - } - } - fun savePlan(onSuccess: () -> Unit) { - val state = _uiState.value + val state = uiState.value + val form = state.planForm val plan = originalPlan ?: return // Guard against concurrent saves if (state.isSaving) return - val amount = state.amount.toBigDecimalOrNull() + val amount = form.amount.toBigDecimalOrNull() if (amount == null || amount <= BigDecimal.ZERO) { - _uiState.update { it.copy(error = "Please enter a valid amount") } + _localState.update { it.copy(error = "Please enter a valid amount") } return } // Validate withdrawal address if enabled - if (state.withdrawalEnabled && !isValidCryptoAddress(state.crypto, state.withdrawalAddress)) { - _uiState.update { it.copy(error = "Please enter a valid ${state.crypto} wallet address") } + if (form.withdrawalEnabled && !form.isAddressValid) { + _localState.update { it.copy(error = "Please enter a valid ${form.selectedCrypto} wallet address") } return } viewModelScope.launch { - _uiState.update { it.copy(isSaving = true, error = null) } + _localState.update { it.copy(isSaving = true, error = null) } try { // Calculate new next execution if frequency changed - val frequencyChanged = state.selectedFrequency != plan.frequency || - (state.selectedFrequency == DcaFrequency.CUSTOM && state.cronExpression != plan.cronExpression) + val frequencyChanged = form.selectedFrequency != plan.frequency || + (form.selectedFrequency == DcaFrequency.CUSTOM && form.cronExpression != plan.cronExpression) val nextExecution = if (frequencyChanged) { - if (state.selectedFrequency == DcaFrequency.CUSTOM) { - CronUtils.getNextExecution(state.cronExpression, Instant.now()) + if (form.selectedFrequency == DcaFrequency.CUSTOM) { + CronUtils.getNextExecution(form.cronExpression, Instant.now()) ?: Instant.now().plus(Duration.ofMinutes(1440)) } else { val base = plan.lastExecutedAt ?: Instant.now() - val next = base.plus(Duration.ofMinutes(state.selectedFrequency.intervalMinutes)) + val next = base.plus(Duration.ofMinutes(form.selectedFrequency.intervalMinutes)) if (next.isAfter(Instant.now())) { next } else if (plan.nextExecutionAt != null && plan.nextExecutionAt.isAfter(Instant.now())) { - // Switching to shorter interval but existing timer is still valid — keep it plan.nextExecutionAt } else { Instant.now() @@ -295,26 +155,26 @@ class EditPlanViewModel @Inject constructor( val updatedPlan = plan.copy( amount = amount, - frequency = state.selectedFrequency, - cronExpression = if (state.selectedFrequency == DcaFrequency.CUSTOM) state.cronExpression else null, - strategy = state.selectedStrategy, - withdrawalEnabled = state.withdrawalEnabled, - withdrawalAddress = if (state.withdrawalEnabled) state.withdrawalAddress.trim() else null, + frequency = form.selectedFrequency, + cronExpression = if (form.selectedFrequency == DcaFrequency.CUSTOM) form.cronExpression else null, + strategy = form.selectedStrategy, + withdrawalEnabled = form.withdrawalEnabled, + withdrawalAddress = if (form.withdrawalEnabled) form.withdrawalAddress.trim() else null, nextExecutionAt = nextExecution, - targetAmount = state.targetAmount.toBigDecimalOrNull() + targetAmount = form.targetAmount.toBigDecimalOrNull() ) dcaPlanDao.updatePlan(updatedPlan) // Auto-enable Market Pulse when saving a plan with market-aware strategy - if (state.selectedStrategy is DcaStrategy.AthBased || state.selectedStrategy is DcaStrategy.FearAndGreed) { + if (form.selectedStrategy is DcaStrategy.AthBased || form.selectedStrategy is DcaStrategy.FearAndGreed) { userPreferences.setMarketPulseEnabled(true) } - _uiState.update { it.copy(isSaving = false, isSuccess = true) } + _localState.update { it.copy(isSaving = false, isSuccess = true) } onSuccess() } catch (e: Exception) { - _uiState.update { + _localState.update { it.copy( isSaving = false, error = e.message ?: "Failed to save plan" 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 6cbde68..4b2429f 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 @@ -28,7 +28,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.accbot.dca.R import com.accbot.dca.domain.model.DcaStrategy import com.accbot.dca.domain.model.supportsApiImport -import com.accbot.dca.domain.usecase.ApiImportResultState import com.accbot.dca.presentation.components.* import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.platform.LocalContext @@ -181,36 +180,7 @@ fun PlanDetailsScreen( // API import result dialog uiState.apiImportResult?.let { result -> - AlertDialog( - onDismissRequest = { viewModel.dismissImportResult() }, - title = { - Text( - when (result) { - is ApiImportResultState.Success -> stringResource(R.string.import_api_success_title) - is ApiImportResultState.Error -> stringResource(R.string.import_api_error_title) - } - ) - }, - text = { - Text( - when (result) { - is ApiImportResultState.Success -> { - if (result.imported == 0) { - stringResource(R.string.import_api_no_new) - } else { - stringResource(R.string.import_api_success_message, result.imported, result.skipped) - } - } - is ApiImportResultState.Error -> result.message - } - ) - }, - confirmButton = { - TextButton(onClick = { viewModel.dismissImportResult() }) { - Text(stringResource(R.string.common_done)) - } - } - ) + ApiImportResultDialog(result = result, onDismiss = { viewModel.dismissImportResult() }) } Scaffold( 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 98e737d..21f03e8 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 @@ -14,6 +14,7 @@ import com.accbot.dca.domain.usecase.ApiImportProgress 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.presentation.utils.TimeUtils import androidx.compose.runtime.Immutable import dagger.hilt.android.lifecycle.HiltViewModel @@ -147,12 +148,8 @@ class PlanDetailsViewModel @Inject constructor( val price = marketDataService.getCachedPrice(plan.crypto, plan.fiat) if (price != null && totalCrypto > BigDecimal.ZERO) { val currentValue = totalCrypto.multiply(price).setScale(2, RoundingMode.HALF_UP) - val roiAbsolute = currentValue.subtract(totalInvested) - val roiPercent = if (totalInvested > BigDecimal.ZERO) { - roiAbsolute.divide(totalInvested, 4, RoundingMode.HALF_UP) - .multiply(BigDecimal(100)) - .setScale(2, RoundingMode.HALF_UP) - } else null + val (roiAbsolute, roiPercent) = NumberFormatters.roiValues(totalInvested, currentValue) + ?: (BigDecimal.ZERO to BigDecimal.ZERO) _uiState.update { it.copy( currentPrice = price, currentValue = currentValue, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/utils/NumberFormatters.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/utils/NumberFormatters.kt index fc333c5..f13502c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/utils/NumberFormatters.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/utils/NumberFormatters.kt @@ -89,4 +89,14 @@ object NumberFormatters { /** Returns true if value is >= 0 */ fun isPositiveRoi(value: BigDecimal): Boolean = value >= BigDecimal.ZERO + + /** Compute ROI absolute and percentage from invested and current value. Returns null if totalInvested is zero. */ + fun roiValues(totalInvested: BigDecimal, currentValue: BigDecimal): Pair? { + if (totalInvested <= BigDecimal.ZERO) return null + val roiAbsolute = currentValue.subtract(totalInvested) + val roiPercent = roiAbsolute.divide(totalInvested, 4, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + .setScale(2, RoundingMode.HALF_UP) + return roiAbsolute to roiPercent + } } diff --git a/accbot-android/app/src/main/res/drawable/ic_crypto_bnb.xml b/accbot-android/app/src/main/res/drawable/ic_crypto_bnb.xml new file mode 100644 index 0000000..236fa36 --- /dev/null +++ b/accbot-android/app/src/main/res/drawable/ic_crypto_bnb.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/accbot-android/app/src/main/res/drawable/ic_fiat_usdc.xml b/accbot-android/app/src/main/res/drawable/ic_fiat_usdc.xml new file mode 100644 index 0000000..619a8c3 --- /dev/null +++ b/accbot-android/app/src/main/res/drawable/ic_fiat_usdc.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + 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 b5485b6..09e0428 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -513,6 +513,10 @@ Zapněte službu z přehledu Doladit plán Upravte částky, přidejte další kryptoměny nebo změňte frekvenci + Váš plán je aktivní + DCA nákupy se začnou provádět automaticky + Přidat další plány + Diverzifikujte napříč burzami, kryptoměnami nebo strategiemi Povolte spolehlivé nákupy AccBot potřebuje tato oprávnění, aby mohl spolehlivě nakupovat na pozadí @@ -583,6 +587,7 @@ Text (OCR) Žádný text nedetekován Skenovat všechny údaje + Skenovat QR Vložit vše Vložit Skenovat více údajů najednou @@ -670,7 +675,7 @@ Vytvořte nový API klíč Povolte pouze \'Spot & Margin obchodování\' Pokud možno, omezte na vaši IP adresu - Zkopírujte API klíč a tajný klíč + Naskenujte QR kód nebo zkopírujte API klíč a tajný klíč Otevřete testnet.binance.vision Přihlaste se pomocí GitHub účtu Klikněte na \'Generate HMAC_SHA256 Key\' diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index c6626f5..8184e05 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -512,6 +512,10 @@ Toggle the service on from the dashboard Fine-tune your plan Adjust amounts, add more cryptos, or change frequency + Your plan is active + DCA purchases will start running automatically + Add more plans + Diversify across exchanges, cryptos, or strategies Enable Reliable Purchases AccBot needs these permissions to reliably execute purchases in the background @@ -581,6 +585,7 @@ Text (OCR) No text detected Scan All Credentials + Scan QR Paste All Paste Scan multiple credentials at once @@ -667,7 +672,7 @@ Create a new API key Enable \'Spot & Margin Trading\' only Restrict to your IP if possible - Copy the API Key and Secret + Scan the QR code or copy the API Key and Secret Open testnet.binance.vision Log in with your GitHub account Click \'Generate HMAC_SHA256 Key\' diff --git a/accbot-android/gradle.properties b/accbot-android/gradle.properties index 2c6dc99..a2dbe26 100644 --- a/accbot-android/gradle.properties +++ b/accbot-android/gradle.properties @@ -9,7 +9,7 @@ android.nonTransitiveRClass=true # App versioning (semver) VERSION_MAJOR=2 VERSION_MINOR=6 -VERSION_PATCH=0 +VERSION_PATCH=1 # Screenshot testing android.experimental.enableScreenshotTest=true