From a952badabf7d4fa2709e37125a41bb30bc680a42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sat, 14 Mar 2026 15:45:04 +0100 Subject: [PATCH 1/4] Binance UX improvements: fiat precision, lot size info & network error handling - Fix fiat amount display: show 2 decimal places (4.36 instead of 4) - Add Binance lot size info note per crypto in plan form and plan details - Add localized network error message for credential validation (CS/EN) - Add fiatWhole() formatter for chart axis labels (no decimals) Co-Authored-By: Claude Opus 4.6 --- .../com/accbot/dca/domain/model/Models.kt | 14 +++- .../ValidateAndSaveCredentialsUseCase.kt | 6 ++ .../credentials/CredentialFormDelegate.kt | 48 ++++++++++--- .../dca/presentation/plan/PlanFormContent.kt | 26 +++++++ .../dca/presentation/screens/AddPlanScreen.kt | 1 + .../presentation/screens/AddPlanViewModel.kt | 5 ++ .../screens/exchanges/AddExchangeScreen.kt | 3 +- .../screens/exchanges/ExchangeDetailScreen.kt | 3 +- .../screens/onboarding/ExchangeSetupScreen.kt | 3 +- .../screens/onboarding/FirstPlanScreen.kt | 1 + .../screens/plans/EditPlanScreen.kt | 1 + .../screens/plans/EditPlanViewModel.kt | 3 + .../screens/plans/PlanDetailsScreen.kt | 71 +++++++++++++++++-- .../presentation/utils/NumberFormatters.kt | 19 +++-- .../app/src/main/res/values-cs/strings.xml | 2 + .../app/src/main/res/values/strings.xml | 2 + 16 files changed, 186 insertions(+), 22 deletions(-) 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 1f5dd84..1838ae8 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 @@ -82,7 +82,19 @@ enum class Exchange( supportedCryptos = listOf("BTC", "ETH", "SOL", "ADA"), minOrderSize = mapOf("EUR" to BigDecimal("1"), "USD" to BigDecimal("1")), sandboxSupport = SandboxSupport.FULL - ) + ); + + companion object { + /** Binance LOT_SIZE step sizes per crypto (from /api/v3/exchangeInfo). */ + val binanceLotStepSize = mapOf( + "BTC" to "0.00001", + "ETH" to "0.0001", + "BNB" to "0.001", + "SOL" to "0.001", + "ADA" to "0.1", + "DOT" to "0.01" + ) + } } /** diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateAndSaveCredentialsUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateAndSaveCredentialsUseCase.kt index 5ead9ab..29866cc 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateAndSaveCredentialsUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ValidateAndSaveCredentialsUseCase.kt @@ -5,6 +5,7 @@ import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.domain.model.Exchange import com.accbot.dca.domain.model.ExchangeCredentials import com.accbot.dca.exchange.ExchangeApiFactory +import java.net.UnknownHostException import javax.inject.Inject /** @@ -13,6 +14,7 @@ import javax.inject.Inject sealed class CredentialValidationResult { data object Success : CredentialValidationResult() data class Error(val message: String) : CredentialValidationResult() + data object NetworkError : CredentialValidationResult() } /** @@ -82,6 +84,10 @@ class ValidateAndSaveCredentialsUseCase @Inject constructor( } else "" CredentialValidationResult.Error("Invalid API credentials.$hint") } + } catch (e: UnknownHostException) { + CredentialValidationResult.NetworkError + } catch (e: java.io.IOException) { + CredentialValidationResult.NetworkError } catch (e: Exception) { val isSandbox = userPreferences.isSandboxMode() val hint = if (isSandbox) { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt index 6734f85..b750701 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt @@ -1,6 +1,10 @@ package com.accbot.dca.presentation.credentials +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.ui.res.stringResource +import com.accbot.dca.R import com.accbot.dca.data.local.CredentialsStore import com.accbot.dca.data.local.UserPreferences import com.accbot.dca.domain.model.Exchange @@ -29,10 +33,21 @@ data class CredentialFormState( val isValidatingCredentials: Boolean = false, val credentialsValid: Boolean = false, val credentialsError: String? = null, + @StringRes val credentialsErrorRes: Int = 0, val isSandboxMode: Boolean = false, val availableExchanges: List = emptyList(), val showExperimental: Boolean = false -) +) { + val hasCredentialsError: Boolean + get() = credentialsError != null || credentialsErrorRes != 0 +} + +/** Resolve the credentials error to a localized string. */ +val CredentialFormState.resolvedCredentialsError: String? + @Composable get() = when { + credentialsErrorRes != 0 -> stringResource(credentialsErrorRes) + else -> credentialsError + } /** * Shared delegate for credential form state and logic. @@ -76,7 +91,7 @@ class CredentialFormDelegate( apiSecret = credentials?.apiSecret ?: "", passphrase = credentials?.passphrase ?: "", clientId = credentials?.clientId ?: "", - credentialsError = null + credentialsError = null, credentialsErrorRes = 0 ) } } @@ -95,25 +110,25 @@ class CredentialFormDelegate( apiKey = "", apiSecret = "", passphrase = "", - credentialsError = null + credentialsError = null, credentialsErrorRes = 0 ) } } fun setClientId(value: String) { - _state.update { it.copy(clientId = value, credentialsError = null) } + _state.update { it.copy(clientId = value, credentialsError = null, credentialsErrorRes = 0) } } fun setApiKey(value: String) { - _state.update { it.copy(apiKey = value, credentialsError = null) } + _state.update { it.copy(apiKey = value, credentialsError = null, credentialsErrorRes = 0) } } fun setApiSecret(value: String) { - _state.update { it.copy(apiSecret = value, credentialsError = null) } + _state.update { it.copy(apiSecret = value, credentialsError = null, credentialsErrorRes = 0) } } fun setPassphrase(value: String) { - _state.update { it.copy(passphrase = value, credentialsError = null) } + _state.update { it.copy(passphrase = value, credentialsError = null, credentialsErrorRes = 0) } } fun validateAndSaveCredentials(onSuccess: () -> Unit) { @@ -123,7 +138,7 @@ class CredentialFormDelegate( val exchange = state.selectedExchange ?: return coroutineScope.launch { - _state.update { it.copy(isValidatingCredentials = true, credentialsError = null) } + _state.update { it.copy(isValidatingCredentials = true, credentialsError = null, credentialsErrorRes = 0) } val result = validateAndSaveCredentialsUseCase.execute( exchange = exchange, @@ -152,10 +167,27 @@ class CredentialFormDelegate( ) } } + is CredentialValidationResult.NetworkError -> { + _state.update { + it.copy( + isValidatingCredentials = false, + credentialsErrorRes = R.string.error_no_internet + ) + } + } } } } + fun notifyNetworkError() { + _state.update { + it.copy( + isValidatingCredentials = false, + credentialsErrorRes = R.string.error_no_internet + ) + } + } + fun setExperimentalExchangesEnabled(enabled: Boolean) { userPreferences.setExperimentalExchangesEnabled(enabled) val isSandbox = _state.value.isSandboxMode diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt index 2984cde..7b25cf9 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt @@ -3,6 +3,8 @@ 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.material.icons.Icons +import androidx.compose.material.icons.filled.Info import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -18,6 +20,7 @@ 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.domain.model.Exchange import com.accbot.dca.presentation.components.AmountInputWithPresets import com.accbot.dca.presentation.components.ChipGroup import com.accbot.dca.presentation.components.FrequencyDropdown @@ -52,6 +55,7 @@ fun PlanFormContent( onWithdrawalAddressChanged: (String) -> Unit, onTargetAmountChanged: (String) -> Unit, modifier: Modifier = Modifier, + exchange: Exchange? = null, showCryptoFiatSelection: Boolean = true, errorMessage: String? = null ) { @@ -93,6 +97,28 @@ fun PlanFormContent( minOrderSize = state.minOrderSize, amountBelowMinimum = state.amountBelowMinimum ) + if (exchange == Exchange.BINANCE) { + val stepSize = Exchange.binanceLotStepSize[state.selectedCrypto] + if (stepSize != null) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + Text( + text = stringResource(R.string.binance_lot_size_note, state.selectedCrypto, stepSize), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } } // Frequency selection 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 1414c73..847b977 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 @@ -213,6 +213,7 @@ fun AddPlanScreen( onWithdrawalEnabledChanged = viewModel.planForm::setWithdrawalEnabled, onWithdrawalAddressChanged = viewModel.planForm::setWithdrawalAddress, onTargetAmountChanged = viewModel.planForm::setTargetAmount, + exchange = cred.selectedExchange, errorMessage = uiState.errorMessage ) 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 a3e3388..06e89aa 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 @@ -111,6 +111,11 @@ class AddPlanViewModel @Inject constructor( } return@launch } + is CredentialValidationResult.NetworkError -> { + credentialForm.notifyNetworkError() + _localState.update { it.copy(isLoading = false) } + return@launch + } is CredentialValidationResult.Success -> { // Credentials validated and saved, continue with plan creation } 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 9662159..c12c369 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 @@ -40,6 +40,7 @@ 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.credentials.resolvedCredentialsError import com.accbot.dca.presentation.components.ExchangeSelectionTile import com.accbot.dca.presentation.components.ExperimentalExchangeDisclaimer import com.accbot.dca.presentation.components.RequestExchangeTile @@ -135,7 +136,7 @@ fun AddExchangeScreen( apiSecret = uiState.credentialForm.apiSecret, passphrase = uiState.credentialForm.passphrase, isValidating = uiState.credentialForm.isValidatingCredentials, - error = uiState.credentialForm.credentialsError, + error = uiState.credentialForm.resolvedCredentialsError, onClientIdChange = viewModel.credentialForm::setClientId, onApiKeyChange = viewModel.credentialForm::setApiKey, onApiSecretChange = viewModel.credentialForm::setApiSecret, 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 3847994..d51a323 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 @@ -29,6 +29,7 @@ import com.accbot.dca.domain.model.supportsApiImport 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.credentials.resolvedCredentialsError import com.accbot.dca.presentation.components.ExchangeAvatar import com.accbot.dca.presentation.components.ImportConfigDialog import com.accbot.dca.presentation.ui.theme.Error @@ -251,7 +252,7 @@ fun ExchangeDetailScreen( onApiKeyChange = viewModel.credentialForm::setApiKey, onApiSecretChange = viewModel.credentialForm::setApiSecret, onPassphraseChange = viewModel.credentialForm::setPassphrase, - errorMessage = uiState.credentialForm.credentialsError, + errorMessage = uiState.credentialForm.resolvedCredentialsError, isValidating = uiState.credentialForm.isValidatingCredentials ) 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 de54572..6d12f87 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 @@ -26,6 +26,7 @@ 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.credentials.resolvedCredentialsError import com.accbot.dca.presentation.components.ExchangeSelectionGrid import com.accbot.dca.presentation.components.ExchangeInstructionsCard import com.accbot.dca.presentation.components.ExperimentalExchangeDisclaimer @@ -191,7 +192,7 @@ fun ExchangeSetupScreen( onApiKeyChange = viewModel.credentialForm::setApiKey, onApiSecretChange = viewModel.credentialForm::setApiSecret, onPassphraseChange = viewModel.credentialForm::setPassphrase, - errorMessage = cred.credentialsError, + errorMessage = cred.resolvedCredentialsError, isValidating = cred.isValidatingCredentials ) } 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 ad8f95d..7c6a4d7 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 @@ -102,6 +102,7 @@ fun FirstPlanScreen( onWithdrawalEnabledChanged = viewModel.planForm::setWithdrawalEnabled, onWithdrawalAddressChanged = viewModel.planForm::setWithdrawalAddress, onTargetAmountChanged = viewModel.planForm::setTargetAmount, + exchange = uiState.credentialForm.selectedExchange, errorMessage = uiState.error ) 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 99af2d9..5ca28cd 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 @@ -127,6 +127,7 @@ fun EditPlanScreen( onWithdrawalEnabledChanged = viewModel.planForm::setWithdrawalEnabled, onWithdrawalAddressChanged = viewModel.planForm::setWithdrawalAddress, onTargetAmountChanged = viewModel.planForm::setTargetAmount, + exchange = uiState.exchange, errorMessage = if (uiState.isSaving) uiState.error else null ) } 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 b9813d0..58b41a2 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 @@ -6,6 +6,7 @@ import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.DcaPlanEntity 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.data.local.UserPreferences @@ -26,6 +27,7 @@ data class EditPlanUiState( val planId: Long = 0, val crypto: String = "", val fiat: String = "", + val exchange: Exchange? = null, val exchangeName: String = "", // Plan form (from delegate) @@ -79,6 +81,7 @@ class EditPlanViewModel @Inject constructor( planId = plan.id, crypto = plan.crypto, fiat = plan.fiat, + exchange = plan.exchange, exchangeName = plan.exchange.displayName, isLoading = false ) 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 4b2429f..f51122f 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 @@ -27,6 +27,7 @@ import androidx.compose.ui.res.stringResource 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.Exchange import com.accbot.dca.domain.model.supportsApiImport import com.accbot.dca.presentation.components.* import androidx.compose.foundation.text.KeyboardOptions @@ -53,6 +54,7 @@ fun PlanDetailsScreen( var showDeleteDialog by rememberSaveable { mutableStateOf(false) } var deletePlanConfirmText by rememberSaveable { mutableStateOf("") } var showStrategyInfo by rememberSaveable { mutableStateOf(false) } + var showLotSizeInfo by rememberSaveable { mutableStateOf(false) } var showDeleteTransactionsDialog by rememberSaveable { mutableStateOf(false) } var deleteTransactionsConfirmText by rememberSaveable { mutableStateOf("") } var dangerZoneExpanded by rememberSaveable { mutableStateOf(false) } @@ -433,11 +435,47 @@ fun PlanDetailsScreen( modifier = Modifier.semantics { heading() } ) - PlanConfigRow( - icon = Icons.Default.AttachMoney, - label = stringResource(R.string.plan_details_amount), - value = "${plan.amount} ${plan.fiat}" - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.AttachMoney, + contentDescription = null, + tint = accentColor(), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = stringResource(R.string.plan_details_amount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${plan.amount} ${plan.fiat}", + fontWeight = FontWeight.Medium + ) + } + } + if (plan.exchange == Exchange.BINANCE) { + IconButton( + onClick = { showLotSizeInfo = true } + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = accentColor(), + modifier = Modifier.size(20.dp) + ) + } + } + } val frequencyDisplayText = if (plan.frequency == com.accbot.dca.domain.model.DcaFrequency.CUSTOM && plan.cronExpression != null) { com.accbot.dca.domain.util.CronUtils.describeCron(plan.cronExpression) ?: stringResource(plan.frequency.displayNameRes) @@ -521,6 +559,29 @@ fun PlanDetailsScreen( onDismiss = { showStrategyInfo = false } ) } + + // Binance lot size info dialog + if (showLotSizeInfo) { + AlertDialog( + onDismissRequest = { showLotSizeInfo = false }, + confirmButton = { + TextButton(onClick = { showLotSizeInfo = false }) { + Text(stringResource(R.string.common_done)) + } + }, + icon = { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = accentColor() + ) + }, + text = { + val stepSize = Exchange.binanceLotStepSize[plan.crypto] ?: "0.00001" + Text(stringResource(R.string.binance_lot_size_note, plan.crypto, stepSize)) + } + ) + } } // 4. Exchange Balance card (with heading) 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 f13502c..8620c28 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 @@ -12,13 +12,13 @@ import java.util.Locale */ object NumberFormatters { - /** Format fiat amounts: whole numbers, with grouping (e.g. 1,235 or 1 235) */ + /** Format fiat amounts: 2 decimal places, with grouping (e.g. 1,235.00 or 1 235,00) */ fun fiat(value: BigDecimal): String { val nf = NumberFormat.getInstance(Locale.getDefault()) - nf.minimumFractionDigits = 0 - nf.maximumFractionDigits = 0 + nf.minimumFractionDigits = 2 + nf.maximumFractionDigits = 2 nf.isGroupingUsed = true - return nf.format(value.setScale(0, RoundingMode.HALF_UP)) + return nf.format(value.setScale(2, RoundingMode.HALF_UP)) } /** Format fee amounts: 2 decimal places, with grouping (e.g. 1.50 or 0.45) */ @@ -30,6 +30,15 @@ object NumberFormatters { return nf.format(value.setScale(2, RoundingMode.HALF_UP)) } + /** Format fiat as whole number: no decimal places, with grouping (e.g. 1,235 or 1 235). Used for chart axis labels. */ + fun fiatWhole(value: BigDecimal): String { + val nf = NumberFormat.getInstance(Locale.getDefault()) + nf.minimumFractionDigits = 0 + nf.maximumFractionDigits = 0 + nf.isGroupingUsed = true + return nf.format(value.setScale(0, RoundingMode.HALF_UP)) + } + /** Compact fiat for Y-axis labels: 1800000 → "1.8M", 50000 → "50K", 800 → "800" */ fun compactFiat(value: BigDecimal): String { val abs = value.abs() @@ -42,7 +51,7 @@ object NumberFormatters { val k = value.divide(BigDecimal(1_000), 1, RoundingMode.HALF_UP) "${stripTrailingDecimalZero(k)}K" } - else -> fiat(value) + else -> fiatWhole(value) } } 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 09e0428..cc24392 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -410,6 +410,7 @@ Skrýt heslo Vyžadováno pro %1$s Přihlašovací údaje uloženy lokálně se šifrováním + Žádné připojení k internetu. Zkontrolujte síť a zkuste to znovu. Šifrováno lokálně pomocí AES-256 @@ -496,6 +497,7 @@ Měna Vlastní částka Minimum: %1$s %2$s + Binance zaokrouhluje nákup %1$s na %2$s %1$s. Skutečná částka nákupu může být o něco nižší, než je nastaveno. Jak často Doporučeno pro začátečníky Nižší frekvence, větší nákupy diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 8184e05..3423123 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -409,6 +409,7 @@ Hide password Required for %1$s Credentials stored locally with encryption + No internet connection. Please check your network and try again. Encrypted locally with AES-256 @@ -495,6 +496,7 @@ Currency Custom amount Minimum: %1$s %2$s + Binance rounds %1$s purchases to %2$s %1$s. The actual purchase amount may be slightly less than configured. How often Recommended for beginners Lower frequency, larger purchases From f4ada9b6d2599f13fd4d9806332a2de6c54ba07d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Sun, 5 Apr 2026 09:56:06 +0200 Subject: [PATCH 2/4] Android: offline retry notifications, purchase delay info & auto-read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show push notification + dashboard banner when purchase fails due to no internet (instead of silent retry). Banner shows retry count, next retry time, and "Run Now" button. - Track retry state on DcaPlanEntity (networkRetryCount, nextNetworkRetryAt, originalScheduledAt) so banner survives notification deletion. - Purchase notification shows scheduled vs actual time when delay >5 min. - Worker runs without NetworkType.CONNECTED constraint on alarm trigger so offline state is detected immediately. - Auto-mark all notifications as read after 2s on notifications tab. - DB migrations v15→v16→v17. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/accbot/dca/data/local/Daos.kt | 11 ++ .../com/accbot/dca/data/local/DcaDatabase.kt | 19 ++- .../com/accbot/dca/data/local/Entities.kt | 7 +- .../data/local/NotificationTemplateArgs.kt | 49 +++++++- .../presentation/screens/DashboardScreen.kt | 111 ++++++++++++++++++ .../screens/DashboardViewModel.kt | 61 +++++++++- .../notifications/NotificationRenderer.kt | 35 +++++- .../notifications/NotificationsScreen.kt | 9 ++ .../accbot/dca/service/NotificationService.kt | 64 ++++++++-- .../java/com/accbot/dca/worker/DcaWorker.kt | 38 ++++-- .../app/src/main/res/values-cs/strings.xml | 6 + .../app/src/main/res/values/strings.xml | 6 + 12 files changed, 385 insertions(+), 31 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt index 9a56cb3..b808d08 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt @@ -66,6 +66,16 @@ interface DcaPlanDao { @Query("SELECT * FROM dca_plans ORDER BY createdAt DESC") suspend fun getAllPlansOnce(): List + + @Query("UPDATE dca_plans SET networkRetryCount = networkRetryCount + 1, nextNetworkRetryAt = :nextRetryAt, originalScheduledAt = CASE WHEN originalScheduledAt IS NULL THEN :originalScheduledAt ELSE originalScheduledAt END WHERE id = :planId") + suspend fun incrementNetworkRetry(planId: Long, nextRetryAt: Instant, originalScheduledAt: Instant) + + @Query("UPDATE dca_plans SET networkRetryCount = networkRetryCount + 1, nextNetworkRetryAt = :nextRetryAt, originalScheduledAt = CASE WHEN originalScheduledAt IS NULL THEN :originalScheduledAt ELSE originalScheduledAt END WHERE id = :planId") + fun incrementNetworkRetrySync(planId: Long, nextRetryAt: Instant, originalScheduledAt: Instant) + + @Query("UPDATE dca_plans SET networkRetryCount = 0, nextNetworkRetryAt = NULL, originalScheduledAt = NULL WHERE id = :planId") + suspend fun resetNetworkRetry(planId: Long) + } @Dao @@ -418,6 +428,7 @@ interface NotificationDao { @Query("SELECT * FROM notifications ORDER BY createdAt DESC") suspend fun getAllNotificationsOnce(): List + } @Dao diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt index ecb4b71..c7fb6aa 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt @@ -19,7 +19,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase NotificationEntity::class, WithdrawalThresholdEntity::class ], - version = 15, + version = 17, exportSchema = true ) @TypeConverters(Converters::class) @@ -193,6 +193,21 @@ abstract class DcaDatabase : RoomDatabase() { } } + // Migration from version 15 to 16: Add network retry tracking columns to dca_plans + private val MIGRATION_15_16 = object : Migration(15, 16) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE dca_plans ADD COLUMN networkRetryCount INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE dca_plans ADD COLUMN nextNetworkRetryAt INTEGER DEFAULT NULL") + } + } + + // Migration from version 16 to 17: Add originalScheduledAt for delay tracking across retries + private val MIGRATION_16_17 = object : Migration(16, 17) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE dca_plans ADD COLUMN originalScheduledAt INTEGER DEFAULT NULL") + } + } + // Migration from version 9 to 10: Add notifications and withdrawal_thresholds tables private val MIGRATION_9_10 = object : Migration(9, 10) { override fun migrate(database: SupportSQLiteDatabase) { @@ -295,7 +310,7 @@ abstract class DcaDatabase : RoomDatabase() { DcaDatabase::class.java, databaseName ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17) // Only allow destructive migration on app downgrade, never on failed upgrade // This protects user's transaction history from accidental deletion .fallbackToDestructiveMigrationOnDowngrade() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt index a944396..46c092c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt @@ -17,7 +17,7 @@ import java.time.Instant /** * Notification type for in-app notification history */ -enum class NotificationType { PURCHASE, ERROR, LOW_BALANCE, WITHDRAWAL_THRESHOLD } +enum class NotificationType { PURCHASE, ERROR, LOW_BALANCE, WITHDRAWAL_THRESHOLD, NETWORK_RETRY } /** * Room type converters @@ -130,7 +130,10 @@ data class DcaPlanEntity( val createdAt: Instant = Instant.now(), val lastExecutedAt: Instant? = null, val nextExecutionAt: Instant? = null, - val targetAmount: BigDecimal? = null + val targetAmount: BigDecimal? = null, + val networkRetryCount: Int = 0, + val nextNetworkRetryAt: Instant? = null, + val originalScheduledAt: Instant? = null ) /** diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt index 7181728..114bba2 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt @@ -20,7 +20,9 @@ sealed class NotificationTemplateArgs { val crypto: String, val fiatAmount: String, val fiat: String, - val price: String + val price: String, + val scheduledAtEpochMs: Long? = null, + val executedAtEpochMs: Long? = null ) : NotificationTemplateArgs() { override fun toJson(): String = JSONObject().apply { put(KEY_TYPE, TYPE_PURCHASE) @@ -29,6 +31,8 @@ sealed class NotificationTemplateArgs { put("fiatAmount", fiatAmount) put("fiat", fiat) put("price", price) + if (scheduledAtEpochMs != null) put("scheduledAtEpochMs", scheduledAtEpochMs) + if (executedAtEpochMs != null) put("executedAtEpochMs", executedAtEpochMs) }.toString() } @@ -37,7 +41,9 @@ sealed class NotificationTemplateArgs { val fiatAmount: String, val fiat: String, val crypto: String, - val price: String + val price: String, + val scheduledAtEpochMs: Long? = null, + val executedAtEpochMs: Long? = null ) : NotificationTemplateArgs() { override fun toJson(): String = JSONObject().apply { put(KEY_TYPE, TYPE_PURCHASE_PENDING) @@ -45,6 +51,8 @@ sealed class NotificationTemplateArgs { put("fiat", fiat) put("crypto", crypto) put("price", price) + if (scheduledAtEpochMs != null) put("scheduledAtEpochMs", scheduledAtEpochMs) + if (executedAtEpochMs != null) put("executedAtEpochMs", executedAtEpochMs) }.toString() } @@ -116,6 +124,26 @@ sealed class NotificationTemplateArgs { }.toString() } + /** Network error — purchase will be retried. */ + data class NetworkRetry( + val crypto: String, + val exchangeName: String, + val errorMessage: String, + val nextRetryAtEpochMs: Long, + val attemptCount: Int, + val planId: Long + ) : NotificationTemplateArgs() { + override fun toJson(): String = JSONObject().apply { + put(KEY_TYPE, TYPE_NETWORK_RETRY) + put("crypto", crypto) + put("exchangeName", exchangeName) + put("errorMessage", errorMessage) + put("nextRetryAtEpochMs", nextRetryAtEpochMs) + put("attemptCount", attemptCount) + put("planId", planId) + }.toString() + } + companion object { private const val KEY_TYPE = "type" private const val TYPE_PURCHASE = "purchase" @@ -125,6 +153,7 @@ sealed class NotificationTemplateArgs { private const val TYPE_WITHDRAWAL_THRESHOLD = "withdrawal_threshold" private const val TYPE_TARGET_REACHED = "target_reached" private const val TYPE_BELOW_MINIMUM = "below_minimum" + private const val TYPE_NETWORK_RETRY = "network_retry" fun fromJson(json: String): NotificationTemplateArgs? = try { val obj = JSONObject(json) @@ -134,13 +163,17 @@ sealed class NotificationTemplateArgs { crypto = obj.getString("crypto"), fiatAmount = obj.getString("fiatAmount"), fiat = obj.getString("fiat"), - price = obj.getString("price") + price = obj.getString("price"), + scheduledAtEpochMs = obj.optLong("scheduledAtEpochMs", 0L).takeIf { it > 0 }, + executedAtEpochMs = obj.optLong("executedAtEpochMs", 0L).takeIf { it > 0 } ) TYPE_PURCHASE_PENDING -> PurchasePending( fiatAmount = obj.getString("fiatAmount"), fiat = obj.getString("fiat"), crypto = obj.getString("crypto"), - price = obj.getString("price") + price = obj.getString("price"), + scheduledAtEpochMs = obj.optLong("scheduledAtEpochMs", 0L).takeIf { it > 0 }, + executedAtEpochMs = obj.optLong("executedAtEpochMs", 0L).takeIf { it > 0 } ) TYPE_ERROR -> Error( crypto = obj.getString("crypto"), @@ -166,6 +199,14 @@ sealed class NotificationTemplateArgs { fiat = obj.getString("fiat"), minOrderSize = obj.getString("minOrderSize") ) + TYPE_NETWORK_RETRY -> NetworkRetry( + crypto = obj.getString("crypto"), + exchangeName = obj.getString("exchangeName"), + errorMessage = obj.getString("errorMessage"), + nextRetryAtEpochMs = obj.getLong("nextRetryAtEpochMs"), + attemptCount = obj.optInt("attemptCount", 1), + planId = obj.optLong("planId", 0) + ) else -> null } } catch (_: Exception) { 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 3c26772..93c67d8 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 @@ -166,6 +166,14 @@ fun DashboardScreen( SandboxBanner() } + if (uiState.networkRetryInfo.plans.isNotEmpty() && !uiState.networkRetryInfo.dismissed) { + NetworkRetryBanner( + retryPlans = uiState.networkRetryInfo.plans, + onRunNow = { viewModel.runRetryPlans() }, + onDismiss = { viewModel.dismissRetryBanner() } + ) + } + if (uiState.holdings.size >= 2) { PortfolioSummaryCard(holdings = uiState.holdings) } @@ -258,6 +266,17 @@ fun DashboardScreen( } } + // Network Retry Banner + if (uiState.networkRetryInfo.plans.isNotEmpty() && !uiState.networkRetryInfo.dismissed) { + item { + NetworkRetryBanner( + retryPlans = uiState.networkRetryInfo.plans, + onRunNow = { viewModel.runRetryPlans() }, + onDismiss = { viewModel.dismissRetryBanner() } + ) + } + } + // Portfolio Summary (when 2+ holdings) if (uiState.holdings.size >= 2) { item { @@ -442,6 +461,98 @@ internal fun SandboxBanner() { } } +@Composable +internal fun NetworkRetryBanner( + retryPlans: List, + onRunNow: () -> Unit, + onDismiss: () -> Unit +) { + if (retryPlans.isEmpty()) return + val context = LocalContext.current + val bannerText = retryPlans.joinToString("\n") { plan -> + context.getString(R.string.notification_network_retry_banner, plan.crypto, plan.exchangeName) + } + val attemptCount = retryPlans.sumOf { it.retryCount } + val earliestRetry = retryPlans.mapNotNull { it.nextRetryAt }.minOrNull() + val nextRetryText = earliestRetry?.let { + val time = it.atZone(java.time.ZoneId.systemDefault()) + .format(java.time.format.DateTimeFormatter.ofPattern("H:mm")) + context.getString(R.string.notification_network_retry_banner_next, time) + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = Error.copy(alpha = 0.15f) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.WifiOff, + contentDescription = null, + tint = Error, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = bannerText, + fontWeight = FontWeight.SemiBold, + color = Error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Dismiss", + tint = Error, + modifier = Modifier.size(18.dp) + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) { + Column { + Text( + text = context.getString(R.string.notification_network_retry_banner_attempts, attemptCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (nextRetryText != null) { + Text( + text = nextRetryText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + FilledTonalButton( + onClick = onRunNow, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = Error.copy(alpha = 0.2f), + contentColor = Error + ) + ) { + Text(stringResource(R.string.dashboard_run_now)) + } + } + } + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable internal fun HoldingsPager( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt index 7072a4b..4857809 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt @@ -65,6 +65,21 @@ data class DcaPlanWithBalance( val strategyMultiplier: StrategyMultiplierResult? = null ) +@Immutable +data class NetworkRetryPlan( + val planId: Long, + val crypto: String, + val exchangeName: String, + val retryCount: Int, + val nextRetryAt: Instant? +) + +@Immutable +data class NetworkRetryInfo( + val plans: List = emptyList(), + val dismissed: Boolean = false +) + @Immutable data class DashboardUiState( val holdings: List = emptyList(), @@ -79,7 +94,8 @@ data class DashboardUiState( val athDataByCrypto: Map = emptyMap(), val isMarketDataLoading: Boolean = false, val showMarketPulse: Boolean = true, - val isMarketPulseExpanded: Boolean = true + val isMarketPulseExpanded: Boolean = true, + val networkRetryInfo: NetworkRetryInfo = NetworkRetryInfo() ) @HiltViewModel @@ -170,11 +186,29 @@ class DashboardViewModel @Inject constructor( } else h } + // Check network retry state from plans + val retryPlans = planEntities + .filter { it.networkRetryCount > 0 } + .map { + NetworkRetryPlan( + planId = it.id, + crypto = it.crypto, + exchangeName = it.exchange.displayName, + retryCount = it.networkRetryCount, + nextRetryAt = it.nextNetworkRetryAt + ) + } + _uiState.update { state -> state.copy( activePlans = plansWithBalance, holdings = mergedHoldings, - isLoading = false + isLoading = false, + networkRetryInfo = if (retryPlans.isNotEmpty()) { + NetworkRetryInfo(plans = retryPlans, dismissed = false) + } else { + NetworkRetryInfo() + } ) } @@ -527,4 +561,27 @@ class DashboardViewModel @Inject constructor( fun clearRunNowTriggered() { _uiState.update { it.copy(runNowTriggered = false) } } + + fun runRetryPlans() { + val plans = _uiState.value.networkRetryInfo.plans + if (plans.isEmpty()) return + _uiState.update { + it.copy( + networkRetryInfo = NetworkRetryInfo(dismissed = true), + runNowTriggered = true + ) + } + viewModelScope.launch { + plans.forEach { dcaPlanDao.resetNetworkRetry(it.planId) } + plans.forEach { DcaWorker.runPlan(application, it.planId) } + } + } + + fun dismissRetryBanner() { + val plans = _uiState.value.networkRetryInfo.plans + _uiState.update { it.copy(networkRetryInfo = NetworkRetryInfo(dismissed = true)) } + viewModelScope.launch { + plans.forEach { dcaPlanDao.resetNetworkRetry(it.planId) } + } + } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt index ba5e4e3..5ad6aa6 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt @@ -6,6 +6,9 @@ import com.accbot.dca.data.local.NotificationEntity import com.accbot.dca.data.local.NotificationTemplateArgs import com.accbot.dca.presentation.utils.NumberFormatters import java.math.BigDecimal +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter /** * Renders notification title/message from structured [NotificationTemplateArgs] @@ -23,11 +26,13 @@ object NotificationRenderer { return render(context, args) } + private val timeFormatter = DateTimeFormatter.ofPattern("H:mm") + fun render(context: Context, args: NotificationTemplateArgs): Pair { return when (args) { is NotificationTemplateArgs.Purchase -> { val title = context.getString(R.string.notification_purchase_title) - val message = context.getString( + var message = context.getString( R.string.notification_purchase_text, NumberFormatters.crypto(BigDecimal(args.cryptoAmount)), args.crypto, @@ -35,18 +40,20 @@ object NotificationRenderer { args.fiat, NumberFormatters.fiat(BigDecimal(args.price)) ) + message += formatDelaySuffix(context, args.scheduledAtEpochMs, args.executedAtEpochMs) title to message } is NotificationTemplateArgs.PurchasePending -> { val title = context.getString(R.string.notification_purchase_title) - val message = context.getString( + var message = context.getString( R.string.notification_purchase_pending_text, NumberFormatters.fiat(BigDecimal(args.fiatAmount)), args.fiat, args.crypto, NumberFormatters.fiat(BigDecimal(args.price)) ) + message += formatDelaySuffix(context, args.scheduledAtEpochMs, args.executedAtEpochMs) title to message } @@ -106,6 +113,30 @@ object NotificationRenderer { ) title to message } + + is NotificationTemplateArgs.NetworkRetry -> { + val title = context.getString(R.string.notification_network_retry_title) + val retryTime = Instant.ofEpochMilli(args.nextRetryAtEpochMs) + .atZone(ZoneId.systemDefault()) + .format(timeFormatter) + val message = context.getString( + R.string.notification_network_retry_text, + args.crypto, + args.exchangeName, + retryTime + ) + title to message + } } } + + private fun formatDelaySuffix(context: Context, scheduledAtEpochMs: Long?, executedAtEpochMs: Long?): String { + if (scheduledAtEpochMs == null || executedAtEpochMs == null) return "" + val delayMinutes = (executedAtEpochMs - scheduledAtEpochMs) / 60_000 + if (delayMinutes < 5) return "" + val zone = ZoneId.systemDefault() + val scheduledTime = Instant.ofEpochMilli(scheduledAtEpochMs).atZone(zone).format(timeFormatter) + val executedTime = Instant.ofEpochMilli(executedAtEpochMs).atZone(zone).format(timeFormatter) + return "\n" + context.getString(R.string.notification_purchase_delayed, scheduledTime, executedTime, delayMinutes) + } } 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 d165a81..4e096c0 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 @@ -45,6 +45,14 @@ fun NotificationsScreen( val notifications by viewModel.notifications.collectAsStateWithLifecycle() val unreadCount by viewModel.unreadCount.collectAsStateWithLifecycle() + + // Auto-mark all as read after 2s on this screen + LaunchedEffect(unreadCount) { + if (unreadCount > 0) { + kotlinx.coroutines.delay(2_000L) + viewModel.markAllAsRead() + } + } var showDeleteAllDialog by remember { mutableStateOf(false) } if (showDeleteAllDialog) { @@ -271,6 +279,7 @@ private fun NotificationTypeIcon(type: NotificationType) { NotificationType.ERROR -> Icons.Default.Error to Error NotificationType.LOW_BALANCE -> Icons.Default.Warning to Warning NotificationType.WITHDRAWAL_THRESHOLD -> Icons.AutoMirrored.Filled.CallMade to Warning + NotificationType.NETWORK_RETRY -> Icons.Default.WifiOff to Error } Box( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt b/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt index c520055..3f9d32e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt @@ -115,22 +115,21 @@ class NotificationService @Inject constructor( price: BigDecimal, planId: Long = 0, pending: Boolean = false, - exchange: Exchange? = null + exchange: Exchange? = null, + scheduledAt: java.time.Instant? = null, + executedAt: java.time.Instant? = null ) { - val title = context.getString(R.string.notification_purchase_title) - val priceFormatted = NumberFormatters.fiat(price) - val text = if (pending) { - context.getString(R.string.notification_purchase_pending_text, NumberFormatters.fiat(fiatAmount), fiat, crypto, priceFormatted) - } else { - context.getString(R.string.notification_purchase_text, NumberFormatters.crypto(cryptoAmount), crypto, NumberFormatters.fiat(fiatAmount), fiat, priceFormatted) - } + val scheduledMs = scheduledAt?.toEpochMilli() + val executedMs = executedAt?.toEpochMilli() val args = if (pending) { NotificationTemplateArgs.PurchasePending( fiatAmount = fiatAmount.toPlainString(), fiat = fiat, crypto = crypto, - price = price.toPlainString() + price = price.toPlainString(), + scheduledAtEpochMs = scheduledMs, + executedAtEpochMs = executedMs ) } else { NotificationTemplateArgs.Purchase( @@ -138,10 +137,14 @@ class NotificationService @Inject constructor( crypto = crypto, fiatAmount = fiatAmount.toPlainString(), fiat = fiat, - price = price.toPlainString() + price = price.toPlainString(), + scheduledAtEpochMs = scheduledMs, + executedAtEpochMs = executedMs ) } + val (title, text) = NotificationRenderer.render(context, args) + val sysNotifId = notificationIdForPlan(NOTIFICATION_ID_PURCHASE, planId) persistAndShow( sysNotifId = sysNotifId, @@ -262,6 +265,46 @@ class NotificationService @Inject constructor( ) } + /** + * Show notification for network retry (offline purchase failure). + */ + fun showNetworkRetryNotification( + crypto: String, + exchangeName: String, + errorMessage: String, + nextRetryAt: java.time.Instant, + attemptCount: Int, + planId: Long, + exchange: Exchange? = null + ) { + val args = NotificationTemplateArgs.NetworkRetry( + crypto = crypto, + exchangeName = exchangeName, + errorMessage = errorMessage, + nextRetryAtEpochMs = nextRetryAt.toEpochMilli(), + attemptCount = attemptCount, + planId = planId + ) + val (title, text) = NotificationRenderer.render(context, args) + val sysNotifId = notificationIdForPlan(NOTIFICATION_ID_NETWORK_RETRY, planId) + persistAndShow( + sysNotifId = sysNotifId, + channel = CHANNEL_ERROR, + title = title, + text = text, + entity = NotificationEntity( + type = NotificationType.NETWORK_RETRY, + title = title, + message = text, + planId = planId.takeIf { it > 0 }, + crypto = crypto, + exchange = exchange, + systemNotificationId = sysNotifId, + templateArgs = args.toJson() + ) + ) + } + /** * Cancel a specific system notification by its ID. */ @@ -327,6 +370,7 @@ class NotificationService @Inject constructor( private const val NOTIFICATION_ID_ERROR = 20_000 private const val NOTIFICATION_ID_LOW_BALANCE = 30_000 private const val NOTIFICATION_ID_WITHDRAWAL_THRESHOLD = 40_000 + private const val NOTIFICATION_ID_NETWORK_RETRY = 50_000 const val EXTRA_NOTIFICATION_ID = "extra_notification_id" diff --git a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt index 96621cf..5a87204 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt @@ -208,6 +208,11 @@ class DcaWorker @AssistedInject constructor( when (finalResult) { is DcaResult.Success -> { + // Reset network retry state on success + try { + database.dcaPlanDao().resetNetworkRetry(plan.id) + } catch (_: Exception) {} + // Save transaction atomically with plan update try { val transaction = TransactionEntity( @@ -237,8 +242,11 @@ class DcaWorker @AssistedInject constructor( Log.e(TAG, "Failed to save transaction for plan ${plan.id}", e) } - // Show notification (pending-aware) + // Show notification (pending-aware, with delay info) + // Use originalScheduledAt if plan went through retries, otherwise nextExecution val isPending = finalResult.transaction.status == TransactionStatus.PENDING + val executedNow = Instant.now() + val scheduledTime = if (!forceRun) (plan.originalScheduledAt ?: nextExecution) else null notificationService.showPurchaseNotification( plan.crypto, finalResult.transaction.cryptoAmount, @@ -247,7 +255,9 @@ class DcaWorker @AssistedInject constructor( finalResult.transaction.price, plan.id, pending = isPending, - exchange = plan.exchange + exchange = plan.exchange, + scheduledAt = scheduledTime, + executedAt = if (scheduledTime != null) executedNow else null ) // Check withdrawal threshold @@ -268,12 +278,25 @@ class DcaWorker @AssistedInject constructor( is DcaResult.Error -> { if (finalResult.retryable) { - // Network error — silent retry in 5 min, no transaction saved. + // Network error — retry in 5 min and notify user. // Override the claimed nextExecutionAt with an earlier retry time. try { val retryTime = now.plus(Duration.ofMinutes(5)) - database.dcaPlanDao().updateExecutionTime(plan.id, now, retryTime) + database.runInTransaction { + database.dcaPlanDao().updateExecutionTimeSync(plan.id, now, retryTime) + database.dcaPlanDao().incrementNetworkRetrySync(plan.id, retryTime, nextExecution ?: now) + } Log.w(TAG, "Network error for plan ${plan.id}, will retry at $retryTime: ${finalResult.message}") + + notificationService.showNetworkRetryNotification( + crypto = plan.crypto, + exchangeName = plan.exchange.displayName, + errorMessage = finalResult.message, + nextRetryAt = retryTime, + attemptCount = plan.networkRetryCount + 1, + planId = plan.id, + exchange = plan.exchange + ) } catch (e: Exception) { Log.e(TAG, "Failed to update retry time for plan ${plan.id}", e) } @@ -481,12 +504,9 @@ class DcaWorker @AssistedInject constructor( private const val ALARM_WORK_NAME = "dca_alarm_execution" fun runFromAlarm(context: Context) { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - + // No network constraint — worker must run even when offline so it can + // show a network-retry notification instead of silently waiting. val oneTimeWorkRequest = OneTimeWorkRequestBuilder() - .setConstraints(constraints) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) .build() 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 cc24392..dac988b 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -751,6 +751,12 @@ Nakumulovali jste %1$s %2$s na %3$s — zvažte výběr do cold wallet Cíl dosažen: %1$s %2$s — plán automaticky deaktivován Částka %1$s %2$s pod minimem burzy %3$s %2$s + Plán %1$s → provedeno %2$s (zpoždění %3$d min) + Chyba sítě + Nákup %1$s na %2$s selhal — bez internetu. Další pokus v %3$s. + Nákup %1$s na %2$s selhal kvůli nedostupnému internetu. + Pokusů: %1$d + Další pokus v %1$s %1$s %2$s na burze — zvažte výběr diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 3423123..70c9b75 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -748,6 +748,12 @@ You have accumulated %1$s %2$s on %3$s — consider withdrawing to cold wallet Target reached: %1$s %2$s — plan auto-disabled Amount %1$s %2$s below exchange minimum %3$s %2$s + Scheduled %1$s → executed %2$s (delay %3$d min) + Network Error + %1$s purchase on %2$s failed — no internet. Next retry at %3$s. + %1$s purchase on %2$s failed due to no internet. + %1$d attempt(s) + Next retry at %1$s %1$s %2$s on exchange — consider withdrawal From b19c30362c2cf0ce1da0a38ddf51a13cb5a12eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Mon, 6 Apr 2026 12:37:40 +0200 Subject: [PATCH 3/4] Android: missed purchases recovery, offline KPI placeholders, alarm reschedule fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect missed purchases after retry recovery and device boot/long-off, offer user choice to buy missed or skip via dashboard banner. - Worker repeat count support for executing multiple missed purchases. - Fix alarm not rescheduling after plan mutations (edit, toggle, delete). - Show "unavailable" placeholder for price/ROI when offline instead of hiding KPI values. - Clickable transactions in plan detail screen. - Replace em dash with en dash across entire codebase. - Remove retry time from network error notification (only first failure notified). - Fix race conditions in missed purchases banner dismiss/execute. - Fix missed count calculation: subtract 1 for the just-executed purchase, and only calculate after successful purchase (not before). - DB migrations v17→v18 (missedPurchaseCount). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/com/accbot/dca/MainActivity.kt | 5 +- .../dca/data/local/BackupDataRestorer.kt | 2 +- .../java/com/accbot/dca/data/local/Daos.kt | 6 + .../com/accbot/dca/data/local/DcaDatabase.kt | 11 +- .../com/accbot/dca/data/local/Entities.kt | 5 +- .../data/local/NotificationTemplateArgs.kt | 29 +- .../accbot/dca/data/local/UserPreferences.kt | 2 +- .../dca/data/remote/MarketDataService.kt | 2 +- .../accbot/dca/domain/model/BackupModels.kt | 4 +- .../usecase/CalculateChartDataUseCase.kt | 6 +- .../usecase/ImportTradeHistoryUseCase.kt | 2 +- .../domain/usecase/SyncDailyPricesUseCase.kt | 8 +- .../dca/domain/util/CryptoAddressValidator.kt | 2 +- .../dca/domain/util/ScheduleBuilderState.kt | 8 +- .../presentation/changelog/ChangelogData.kt | 84 +++--- .../components/ChartComponents.kt | 14 +- .../components/CredentialsInputCard.kt | 4 +- .../components/ReusableComponents.kt | 2 +- .../components/ScheduleBuilder.kt | 2 +- .../credentials/CredentialFormDelegate.kt | 2 +- .../dca/presentation/plan/PlanFormContent.kt | 2 +- .../dca/presentation/plan/PlanFormDelegate.kt | 2 +- .../presentation/screens/DashboardScreen.kt | 266 +++++++++++++----- .../screens/DashboardViewModel.kt | 51 +++- .../notifications/NotificationRenderer.kt | 17 +- .../notifications/NotificationsScreen.kt | 1 + .../screens/onboarding/PermissionsScreen.kt | 2 +- .../screens/plans/EditPlanViewModel.kt | 8 +- .../screens/plans/PlanDetailsScreen.kt | 8 +- .../screens/plans/PlanDetailsViewModel.kt | 2 + .../screens/portfolio/PortfolioScreen.kt | 6 +- .../dca/service/DcaForegroundService.kt | 2 +- .../accbot/dca/service/NotificationService.kt | 39 ++- .../java/com/accbot/dca/worker/DcaWorker.kt | 103 +++++-- .../src/main/res/drawable/ic_notification.xml | 8 +- .../app/src/main/res/values-cs/strings.xml | 36 ++- .../app/src/main/res/values/strings.xml | 36 ++- 37 files changed, 576 insertions(+), 213 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt b/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt index 39614a0..b2eeef6 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/MainActivity.kt @@ -337,7 +337,7 @@ fun AccBotApp( ) } - // Main screens — HorizontalPager with bottom nav / nav rail + // Main screens – HorizontalPager with bottom nav / nav rail composable("main") { var isChartTouching by remember { mutableStateOf(false) } @@ -429,6 +429,9 @@ fun AccBotApp( }, onNavigateToHistory = { crypto, fiat -> navController.navigate(Screen.History.createRoute(crypto, fiat)) + }, + onNavigateToTransactionDetails = { transactionId -> + navController.navigate(Screen.TransactionDetails.createRoute(transactionId)) } ) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt index 8730fe3..f99422d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/BackupDataRestorer.kt @@ -155,7 +155,7 @@ class BackupDataRestorer @Inject constructor( val restoredNext = nextExecutionAt?.let { Instant.ofEpochMilli(it) } val effectiveNext = if (restoredNext != null && restoredNext.isAfter(now)) { - restoredNext // still in the future — keep it + restoredNext // still in the future – keep it } else if (cronExpression != null) { CronUtils.getNextExecution(cronExpression, now) ?: now.plus(Duration.ofMinutes(freq.intervalMinutes.takeIf { it > 0 } ?: 1440)) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt index b808d08..3c0c870 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Daos.kt @@ -76,6 +76,12 @@ interface DcaPlanDao { @Query("UPDATE dca_plans SET networkRetryCount = 0, nextNetworkRetryAt = NULL, originalScheduledAt = NULL WHERE id = :planId") suspend fun resetNetworkRetry(planId: Long) + @Query("UPDATE dca_plans SET missedPurchaseCount = :count WHERE id = :planId") + suspend fun setMissedPurchaseCount(planId: Long, count: Int) + + @Query("UPDATE dca_plans SET missedPurchaseCount = 0 WHERE id = :planId") + suspend fun resetMissedPurchaseCount(planId: Long) + } @Dao diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt index c7fb6aa..895cd2b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/DcaDatabase.kt @@ -19,7 +19,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase NotificationEntity::class, WithdrawalThresholdEntity::class ], - version = 17, + version = 18, exportSchema = true ) @TypeConverters(Converters::class) @@ -208,6 +208,13 @@ abstract class DcaDatabase : RoomDatabase() { } } + // Migration from version 17 to 18: Add missedPurchaseCount for offline recovery + private val MIGRATION_17_18 = object : Migration(17, 18) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE dca_plans ADD COLUMN missedPurchaseCount INTEGER NOT NULL DEFAULT 0") + } + } + // Migration from version 9 to 10: Add notifications and withdrawal_thresholds tables private val MIGRATION_9_10 = object : Migration(9, 10) { override fun migrate(database: SupportSQLiteDatabase) { @@ -310,7 +317,7 @@ abstract class DcaDatabase : RoomDatabase() { DcaDatabase::class.java, databaseName ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18) // Only allow destructive migration on app downgrade, never on failed upgrade // This protects user's transaction history from accidental deletion .fallbackToDestructiveMigrationOnDowngrade() diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt index 46c092c..b2f0947 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/Entities.kt @@ -17,7 +17,7 @@ import java.time.Instant /** * Notification type for in-app notification history */ -enum class NotificationType { PURCHASE, ERROR, LOW_BALANCE, WITHDRAWAL_THRESHOLD, NETWORK_RETRY } +enum class NotificationType { PURCHASE, ERROR, LOW_BALANCE, WITHDRAWAL_THRESHOLD, NETWORK_RETRY, MISSED_PURCHASES } /** * Room type converters @@ -133,7 +133,8 @@ data class DcaPlanEntity( val targetAmount: BigDecimal? = null, val networkRetryCount: Int = 0, val nextNetworkRetryAt: Instant? = null, - val originalScheduledAt: Instant? = null + val originalScheduledAt: Instant? = null, + val missedPurchaseCount: Int = 0 ) /** diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt index 114bba2..556f963 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/NotificationTemplateArgs.kt @@ -4,7 +4,7 @@ import org.json.JSONObject /** * Structured arguments for notification templates. - * Stored as JSON in the `templateArgs` column — text is rendered at display time + * Stored as JSON in the `templateArgs` column – text is rendered at display time * using the current locale, so language switches re-render old notifications. * * Numbers are stored as raw strings (BigDecimal.toPlainString()) and formatted @@ -96,7 +96,7 @@ sealed class NotificationTemplateArgs { }.toString() } - /** Target accumulation reached — plan auto-disabled. */ + /** Target accumulation reached – plan auto-disabled. */ data class TargetReached( val targetAmount: String, val crypto: String @@ -124,7 +124,7 @@ sealed class NotificationTemplateArgs { }.toString() } - /** Network error — purchase will be retried. */ + /** Network error – purchase will be retried. */ data class NetworkRetry( val crypto: String, val exchangeName: String, @@ -144,6 +144,22 @@ sealed class NotificationTemplateArgs { }.toString() } + /** Missed purchases due to prolonged offline period. */ + data class MissedPurchases( + val crypto: String, + val exchangeName: String, + val missedCount: Int, + val planId: Long + ) : NotificationTemplateArgs() { + override fun toJson(): String = JSONObject().apply { + put(KEY_TYPE, TYPE_MISSED_PURCHASES) + put("crypto", crypto) + put("exchangeName", exchangeName) + put("missedCount", missedCount) + put("planId", planId) + }.toString() + } + companion object { private const val KEY_TYPE = "type" private const val TYPE_PURCHASE = "purchase" @@ -154,6 +170,7 @@ sealed class NotificationTemplateArgs { private const val TYPE_TARGET_REACHED = "target_reached" private const val TYPE_BELOW_MINIMUM = "below_minimum" private const val TYPE_NETWORK_RETRY = "network_retry" + private const val TYPE_MISSED_PURCHASES = "missed_purchases" fun fromJson(json: String): NotificationTemplateArgs? = try { val obj = JSONObject(json) @@ -199,6 +216,12 @@ sealed class NotificationTemplateArgs { fiat = obj.getString("fiat"), minOrderSize = obj.getString("minOrderSize") ) + TYPE_MISSED_PURCHASES -> MissedPurchases( + crypto = obj.getString("crypto"), + exchangeName = obj.getString("exchangeName"), + missedCount = obj.getInt("missedCount"), + planId = obj.optLong("planId", 0) + ) TYPE_NETWORK_RETRY -> NetworkRetry( crypto = obj.getString("crypto"), exchangeName = obj.getString("exchangeName"), diff --git a/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt b/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt index c28261c..5c55b2e 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/data/local/UserPreferences.kt @@ -34,7 +34,7 @@ class UserPreferences @Inject constructor( private val _appThemeFlow = MutableStateFlow(readAppTheme()) - /** Observable theme state — emits immediately when theme changes. */ + /** Observable theme state – emits immediately when theme changes. */ val appThemeFlow: StateFlow = _appThemeFlow.asStateFlow() private fun readAppTheme(): AppTheme { 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 7cb54fe..110f8d8 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 @@ -238,7 +238,7 @@ class MarketDataService @Inject constructor( /** * Get daily price history for a date range using CryptoCompare histoday endpoint. * Free tier supports full historical data (up to 2000 data points per call). - * Used for historical backfill — fetches data ending at [toDate] going back [limit] days. + * Used for historical backfill – fetches data ending at [toDate] going back [limit] days. * Returns list of (LocalDate, BigDecimal) pairs ordered by date ascending. */ suspend fun getDailyPriceHistoryRange( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt index d2db32f..07e2351 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/model/BackupModels.kt @@ -3,7 +3,7 @@ package com.accbot.dca.domain.model import java.time.Instant /** - * Backup envelope — the top-level structure of a backup file (always plaintext JSON). + * Backup envelope – the top-level structure of a backup file (always plaintext JSON). */ data class BackupEnvelope( val format: String = FORMAT_IDENTIFIER, @@ -24,7 +24,7 @@ data class BackupEnvelope( } /** - * Backup payload — the actual data after decryption/decompression. + * Backup payload – the actual data after decryption/decompression. */ data class BackupPayload( val plans: List = emptyList(), diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculateChartDataUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculateChartDataUseCase.kt index 5e6a38d..ae8e17a 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculateChartDataUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/CalculateChartDataUseCase.kt @@ -209,7 +209,7 @@ class CalculateChartDataUseCase @Inject constructor( YearMonth.from(currentDate).let { it.year * 100 + it.monthValue } } if (pendingBucketKey != null && bucketKey != pendingBucketKey) { - // Bucket boundary crossed — emit the pending snapshot + // Bucket boundary crossed – emit the pending snapshot pendingPrice?.let { pp -> result.add(buildChartDataPoint( pendingEpochDay, pendingCrypto, pendingInvested, pp @@ -223,7 +223,7 @@ class CalculateChartDataUseCase @Inject constructor( pendingPrice = price pendingBucketKey = bucketKey } else { - // Daily emission — build point directly + // Daily emission – build point directly result.add(buildChartDataPoint( epochDay, cumulativeCrypto, cumulativeInvested, price )) @@ -356,7 +356,7 @@ class CalculateChartDataUseCase @Inject constructor( YearMonth.from(currentDate).let { it.year * 100 + it.monthValue } } if (pendingBucketKey != null && bucketKey != pendingBucketKey && hasPendingData) { - // Bucket boundary crossed — emit previous snapshot + // Bucket boundary crossed – emit previous snapshot result.add(buildAggregatePoint(pendingEpochDay, pendingValue, pendingInvested)) } pendingEpochDay = epochDay diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt index 01fa7fa..4262a8b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/ImportTradeHistoryUseCase.kt @@ -36,7 +36,7 @@ class ImportTradeHistoryUseCase @Inject constructor( sinceDate: Instant? = null ): Flow = flow { try { - // Fetch all pages — from sinceDate if provided, otherwise from the beginning. + // Fetch all pages – from sinceDate if provided, otherwise from the beginning. // Deduplication by exchangeOrderId prevents duplicates, so there's no need // for a timestamp cursor which can skip historical trades when the only // existing transactions are from auto-buy (not from a prior API import). diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/SyncDailyPricesUseCase.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/SyncDailyPricesUseCase.kt index 903874f..271f50c 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/SyncDailyPricesUseCase.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/usecase/SyncDailyPricesUseCase.kt @@ -12,10 +12,10 @@ import javax.inject.Inject /** * Orchestrates two-phase daily price sync: - * 1. Forward sync (every sync): CoinGecko — fills the small gap between latest cached date and today - * 2. Historical backfill (one-time per pair): CryptoCompare — fetches older data in ≤2000-day chunks backwards + * 1. Forward sync (every sync): CoinGecko – fills the small gap between latest cached date and today + * 2. Historical backfill (one-time per pair): CryptoCompare – fetches older data in ≤2000-day chunks backwards * - * Historical prices are immutable — once fetched, they never need re-fetching. + * Historical prices are immutable – once fetched, they never need re-fetching. * After the one-time backfill, subsequent syncs only run phase 1 (0-2 API calls total). */ class SyncDailyPricesUseCase @Inject constructor( @@ -62,7 +62,7 @@ class SyncDailyPricesUseCase @Inject constructor( val latestDate = latestDay?.let { LocalDate.ofEpochDay(it) } if (latestDate == null) { - // Brand new pair — bootstrap with last 365 days via CoinGecko + // Brand new pair – bootstrap with last 365 days via CoinGecko Log.d(TAG, "[$crypto/$fiat] Bootstrap: fetching last 365 days") val prices = marketDataService.getDailyPriceHistory(crypto, fiat, 365) if (prices != null && prices.isNotEmpty()) { 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 index 534dac3..3892819 100644 --- 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 @@ -2,7 +2,7 @@ package com.accbot.dca.domain.util /** * Basic validation for cryptocurrency wallet addresses. - * Simplified validation — actual address format depends on the crypto. + * Simplified validation – actual address format depends on the crypto. */ object CryptoAddressValidator { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/domain/util/ScheduleBuilderState.kt b/accbot-android/app/src/main/java/com/accbot/dca/domain/util/ScheduleBuilderState.kt index 65f5498..948686b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/domain/util/ScheduleBuilderState.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/domain/util/ScheduleBuilderState.kt @@ -25,7 +25,7 @@ data class TimeOfDay(val hour: Int, val minute: Int) : Comparable { * Pure state model for the visual schedule builder. * Maps bidirectionally to/from 5-field UNIX CRON expressions. * - * Design: single-minute model — minute selector is shared across all selected hours. + * Design: single-minute model – minute selector is shared across all selected hours. * Hours are multi-selectable. This maps cleanly to CRON fields. */ data class ScheduleBuilderState( @@ -101,13 +101,13 @@ data class ScheduleBuilderState( return ScheduleBuilderState(useAdvancedMode = true, rawCronExpression = cron) } - // Parse minute — must be a single number (no lists, ranges, steps) + // Parse minute – must be a single number (no lists, ranges, steps) val minute = minutePart.toIntOrNull() if (minute == null || minute !in 0..59) { return ScheduleBuilderState(useAdvancedMode = true, rawCronExpression = cron) } - // Parse hours — must be a comma-separated list of numbers + // Parse hours – must be a comma-separated list of numbers val hours = parseNumberList(hourPart, 0..23) ?: return ScheduleBuilderState(useAdvancedMode = true, rawCronExpression = cron) @@ -146,7 +146,7 @@ data class ScheduleBuilderState( selectedDaysOfMonth = doms ) } - // Both DOM and DOW specified — too complex + // Both DOM and DOW specified – too complex else -> ScheduleBuilderState(useAdvancedMode = true, rawCronExpression = cron) } } 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 90971d5..8a49077 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 @@ -1,7 +1,7 @@ package com.accbot.dca.presentation.changelog // Generated from changelog.json by scripts/generate-changelog.sh -// Do not edit manually — run the generator script after updating changelog.json +// Do not edit manually – run the generator script after updating changelog.json object ChangelogData { val entries: List = listOf( @@ -14,23 +14,23 @@ object ChangelogData { ), features = mapOf( "cs" to listOf( - "Sdílený CredentialFormDelegate — méně duplicitního kódu napříč 4 ViewModely", + "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", + "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 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", + "Fix min order size display – strip trailing zeros", "Extract KuCoin signed-request helper and ROI calculation", ), ) @@ -44,24 +44,24 @@ object ChangelogData { ), features = mapOf( "cs" to listOf( - "Chytré obnovování — Dashboard a Portfolio načítají data jen když jsou zastaralá (5 min)", - "SQL filtrování v historii transakcí — rychlejší s velkým množstvím dat", - "Debounce vyhledávání (300ms) — plynulejší psaní v historii", - "Cachování Fear & Greed indexu (1h TTL) — méně API volání", - "Real-time cena v grafu portfolia — dnešní bod se aktualizuje okamžitě", - "Optimalizované pořadí načítání tržních dat — warm-up cache před dotazy na ceny", + "Chytré obnovování – Dashboard a Portfolio načítají data jen když jsou zastaralá (5 min)", + "SQL filtrování v historii transakcí – rychlejší s velkým množstvím dat", + "Debounce vyhledávání (300ms) – plynulejší psaní v historii", + "Cachování Fear & Greed indexu (1h TTL) – méně API volání", + "Real-time cena v grafu portfolia – dnešní bod se aktualizuje okamžitě", + "Optimalizované pořadí načítání tržních dat – warm-up cache před dotazy na ceny", "Nový DB index na transakcích pro rychlejší filtrované dotazy", - "Úklid repositáře — archivace neaktivních .NET, Docker a legacy projektů", + "Úklid repositáře – archivace neaktivních .NET, Docker a legacy projektů", ), "en" to listOf( - "Smart refresh — Dashboard and Portfolio only reload when data is stale (5 min)", - "SQL-level filtering in transaction history — faster with large datasets", - "Search debounce (300ms) — smoother typing in history search", - "Fear & Greed index caching (1h TTL) — fewer API calls", - "Real-time price in portfolio chart — today's data point updates immediately", - "Market data fetch order optimized — cache warm-up before price lookups", + "Smart refresh – Dashboard and Portfolio only reload when data is stale (5 min)", + "SQL-level filtering in transaction history – faster with large datasets", + "Search debounce (300ms) – smoother typing in history search", + "Fear & Greed index caching (1h TTL) – fewer API calls", + "Real-time price in portfolio chart – today's data point updates immediately", + "Market data fetch order optimized – cache warm-up before price lookups", "New DB index on transactions for faster filtered queries", - "Repository cleanup — archived inactive .NET, Docker and legacy projects", + "Repository cleanup – archived inactive .NET, Docker and legacy projects", ), ) ), @@ -89,8 +89,8 @@ object ChangelogData { versionCode = 25100, version = "2.5.1", titles = mapOf( - "cs" to "Own your DCA — Branding a opravy", - "en" to "Own your DCA — Branding & Bugfixes", + "cs" to "Own your DCA – Branding a opravy", + "en" to "Own your DCA – Branding & Bugfixes", ), features = mapOf( "cs" to listOf( @@ -99,7 +99,7 @@ object ChangelogData { "Drobné opravy: reset časovače, minimum Coinmate EUR, auto-aktivace Market Pulse", ), "en" to listOf( - "New tagline: Own your DCA — your keys, your data, your rules", + "New tagline: Own your DCA – your keys, your data, your rules", "Updated landing page and welcome screen messaging", "Minor fixes: timer reset, Coinmate EUR minimum, Market Pulse auto-activation", ), @@ -114,9 +114,9 @@ object ChangelogData { ), features = mapOf( "cs" to listOf( - "Experimentální burzy — vyzkoušejte nové burzy a požádejte o chybějící", - "Informační list Market Pulse — vysvětlení Fear & Greed a vzdálenosti od ATH", - "Onboarding obrazovka oprávnění — přehledné nastavení notifikací a baterie", + "Experimentální burzy – vyzkoušejte nové burzy a požádejte o chybějící", + "Informační list Market Pulse – vysvětlení Fear & Greed a vzdálenosti od ATH", + "Onboarding obrazovka oprávnění – přehledné nastavení notifikací a baterie", "Sjednocené UI výběru burzy na všech obrazovkách", "Coinmate paste-only zadávání credentials s API URL dle jazyka", "Plná přesnost satoshi pro částky pod 1 jednotku kryptoměny", @@ -124,9 +124,9 @@ object ChangelogData { "Vylepšené instrukce při vytváření plánu a scrollovatelné kroky", ), "en" to listOf( - "Experimental exchanges — try new exchanges and request missing ones", - "Market Pulse info sheet — learn what Fear & Greed and ATH distance mean", - "Onboarding Permissions screen — guided notification and battery setup", + "Experimental exchanges – try new exchanges and request missing ones", + "Market Pulse info sheet – learn what Fear & Greed and ATH distance mean", + "Onboarding Permissions screen – guided notification and battery setup", "Unified exchange selection UI across all screens", "Coinmate paste-only credential flow with locale-aware API URL", "Full satoshi precision for sub-1 crypto amounts", @@ -165,13 +165,13 @@ object ChangelogData { features = mapOf( "cs" to listOf( "Oprava R8 obfuskace narušující parsování API odpovědí v release buildech", - "Chytré cachování ATH dat — méně zbytečných síťových volání", - "Oprava CI pipeline — odstranění závislosti na PAT pro release workflow", + "Chytré cachování ATH dat – méně zbytečných síťových volání", + "Oprava CI pipeline – odstranění závislosti na PAT pro release workflow", ), "en" to listOf( "Fix R8 obfuscation breaking API JSON parsing in release builds", - "Smart ATH caching — reduces redundant network calls", - "CI pipeline fix — remove PAT dependency for release workflow", + "Smart ATH caching – reduces redundant network calls", + "CI pipeline fix – remove PAT dependency for release workflow", ), ) ), @@ -184,11 +184,11 @@ object ChangelogData { ), features = mapOf( "cs" to listOf( - "Market Pulse karta na Dashboard — Fear & Greed ukazatel + vzdálenost od ATH", + "Market Pulse karta na Dashboard – Fear & Greed ukazatel + vzdálenost od ATH", "Sbalitelný Market Pulse s přepínačem v Nastavení", - "Adaptivní granularita grafů — denní, týdenní nebo měsíční dle rozsahu dat", + "Adaptivní granularita grafů – denní, týdenní nebo měsíční dle rozsahu dat", "Průměrná nákupní cena v grafech portfolia", - "Filtr data importu — výběr počátečního data pro API importy", + "Filtr data importu – výběr počátečního data pro API importy", "Přehlednější obrazovka detailu plánu", "Přečtené/nepřečtené notifikace se smazáním swipem", "Přeorganizované Nastavení (méně sekcí, WCAG přístupnost)", @@ -197,11 +197,11 @@ object ChangelogData { "Odstraněn CSV import (nahrazen API importem)", ), "en" to listOf( - "Market Pulse dashboard card — Fear & Greed gauge + ATH distance", + "Market Pulse dashboard card – Fear & Greed gauge + ATH distance", "Collapsible Market Pulse with settings toggle", - "Adaptive chart aggregation — daily, weekly, or monthly based on data span", + "Adaptive chart aggregation – daily, weekly, or monthly based on data span", "Avg buy price line in portfolio charts", - "API import date filter — choose start date for imports", + "API import date filter – choose start date for imports", "Consolidated plan details screen (cleaner layout)", "Read/unread notifications with swipe-to-delete", "Reorganized Settings (fewer sections, WCAG accessibility)", @@ -240,16 +240,16 @@ object ChangelogData { ), features = mapOf( "cs" to listOf( - "Sledování cílů — nastavení cílové částky kryptoměny pro DCA plán", - "Výběr motivu — Tmavý, Světlý nebo Podle systému", + "Sledování cílů – nastavení cílové částky kryptoměny pro DCA plán", + "Výběr motivu – Tmavý, Světlý nebo Podle systému", "Celkové shrnutí portfolia na Dashboard", "Vyhledávání v historii transakcí", "Informace o kanálu oznámení v Nastavení", "Obrazovka Co je nového po aktualizacích", ), "en" to listOf( - "Goal tracking — set a target crypto amount for your DCA plan", - "Theme selection — choose Dark, Light, or System", + "Goal tracking – set a target crypto amount for your DCA plan", + "Theme selection – choose Dark, Light, or System", "Total portfolio summary on Dashboard", "Transaction history search", "Notification channel info in Settings", diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt index bca23bc..d31f462 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ChartComponents.kt @@ -69,7 +69,7 @@ data class LegendEntry( ) /** - * Interactive chart legend — tap a label to show/hide its series. + * Interactive chart legend – tap a label to show/hide its series. * Renders entries in rows of 2. */ @Composable @@ -152,7 +152,7 @@ fun PortfolioLineChart( LaunchedEffect(chartData, denominationMode, visibleSeries) { try { modelProducer.runTransaction { - // Layer 1: left axis (portfolio value, cost basis, crypto price — all fiat) + // Layer 1: left axis (portfolio value, cost basis, crypto price – all fiat) lineSeries { val (series0, series1) = when (denominationMode) { DenominationMode.FIAT -> @@ -170,7 +170,7 @@ fun PortfolioLineChart( series(List(chartData.size) { 0f }) } } - // Layer 2: right axis (accumulated crypto — BTC units) + // Layer 2: right axis (accumulated crypto – BTC units) lineSeries { if (3 in visibleSeries) series(chartData.map { it.cumulativeCrypto.toFloat() }) else series(List(chartData.size) { 0f }) @@ -237,7 +237,7 @@ fun PortfolioLineChart( if (isEmpty()) add(hiddenLine) } - // Tap-to-inspect marker — scrub fires onScrub to update KPI cards, no tooltip text + // Tap-to-inspect marker – scrub fires onScrub to update KPI cards, no tooltip text val indicatorComponent = rememberShapeComponent( fill = fill(chartAccentColor), shape = CorneredShape.Pill @@ -258,18 +258,18 @@ fun PortfolioLineChart( guideline = rememberAxisGuidelineComponent() ) - // Axis title styling — unit label shown once above axis instead of on every tick + // Axis title styling – unit label shown once above axis instead of on every tick val axisTitleComponent = rememberTextComponent( color = MaterialTheme.colorScheme.onSurfaceVariant, textSize = 10.sp ) - // Axis tick label styling — must be explicit for light theme support + // Axis tick label styling – must be explicit for light theme support val axisLabelComponent = rememberTextComponent( color = MaterialTheme.colorScheme.onSurfaceVariant, textSize = 10.sp ) - // End (right) axis — accumulated crypto in BTC units + // End (right) axis – accumulated crypto in BTC units val endAxisComponent = VerticalAxis.rememberEnd( label = axisLabelComponent, title = cryptoSymbol, 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 b605221..bf42fb0 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 @@ -125,7 +125,7 @@ fun CredentialsInputCard( 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 + // Not JSON – treat as plain API key onApiKeyChange(result) } showBinanceQrScanner = false @@ -237,7 +237,7 @@ fun CredentialsInputCard( if (needsClientId) { // Coinmate field order: Private Key → Public Key → Client ID - // No QR/OCR scanner — paste only + // No QR/OCR scanner – paste only OutlinedTextField( value = apiSecret, onValueChange = onApiSecretChange, 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 50f2a8d..b900ac5 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 @@ -489,7 +489,7 @@ fun ExchangeSelectionTile( } /** - * "Request Exchange" card — OutlinedCard with Add icon, used in all exchange grids. + * "Request Exchange" card – OutlinedCard with Add icon, used in all exchange grids. */ @Composable fun RequestExchangeTile( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ScheduleBuilder.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ScheduleBuilder.kt index 708ca95..c1db44b 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ScheduleBuilder.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/components/ScheduleBuilder.kt @@ -25,7 +25,7 @@ import java.time.format.TextStyle import java.util.Locale /** - * Visual schedule builder — drop-in replacement for CronExpressionInput. + * Visual schedule builder – drop-in replacement for CronExpressionInput. * Same signature: takes a CRON string in, emits a CRON string out. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt index b750701..0100a09 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/credentials/CredentialFormDelegate.kt @@ -52,7 +52,7 @@ val CredentialFormState.resolvedCredentialsError: String? /** * Shared delegate for credential form state and logic. * Used by AddPlanViewModel, OnboardingViewModel, AddExchangeViewModel, and ExchangeDetailViewModel. - * Not a ViewModel — the owning ViewModel passes its coroutineScope. + * Not a ViewModel – the owning ViewModel passes its coroutineScope. */ class CredentialFormDelegate( private val credentialsStore: CredentialsStore, diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt index 7b25cf9..61abb81 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormContent.kt @@ -37,7 +37,7 @@ import com.accbot.dca.presentation.components.getFiatIconRes * 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 — + * Does NOT include exchange selection, credentials, or the submit button – * those are screen-specific. */ @Composable diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt index 050c4ea..5b58d3d 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/plan/PlanFormDelegate.kt @@ -60,7 +60,7 @@ data class PlanFormState( /** * Shared delegate for plan form state and logic. * Used by AddPlanViewModel, OnboardingViewModel, and EditPlanViewModel. - * Not a ViewModel — the owning ViewModel passes its coroutineScope. + * Not a ViewModel – the owning ViewModel passes its coroutineScope. */ class PlanFormDelegate( private val calculateMonthlyCost: CalculateMonthlyCostUseCase, 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 93c67d8..935ad11 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 @@ -174,6 +174,14 @@ fun DashboardScreen( ) } + if (uiState.missedPurchases.isNotEmpty()) { + MissedPurchasesBanner( + missed = uiState.missedPurchases, + onExecute = { planId, count -> viewModel.executeMissedPurchases(planId, count) }, + onDismiss = { planId -> viewModel.dismissMissedPurchases(planId) } + ) + } + if (uiState.holdings.size >= 2) { PortfolioSummaryCard(holdings = uiState.holdings) } @@ -277,6 +285,17 @@ fun DashboardScreen( } } + // Missed Purchases Banner + if (uiState.missedPurchases.isNotEmpty()) { + item { + MissedPurchasesBanner( + missed = uiState.missedPurchases, + onExecute = { planId, count -> viewModel.executeMissedPurchases(planId, count) }, + onDismiss = { planId -> viewModel.dismissMissedPurchases(planId) } + ) + } + } + // Portfolio Summary (when 2+ holdings) if (uiState.holdings.size >= 2) { item { @@ -461,6 +480,75 @@ internal fun SandboxBanner() { } } +@Composable +internal fun MissedPurchasesBanner( + missed: List, + onExecute: (planId: Long, count: Int) -> Unit, + onDismiss: (planId: Long) -> Unit +) { + if (missed.isEmpty()) return + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + for (info in missed) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = Warning.copy(alpha = 0.15f) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.EventBusy, + contentDescription = null, + tint = Warning, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource( + R.string.notification_missed_purchases_banner, + info.missedCount, + info.crypto, + info.exchangeName + ), + fontWeight = FontWeight.SemiBold, + color = Warning, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + OutlinedButton(onClick = { onDismiss(info.planId) }) { + Text(stringResource(R.string.missed_purchases_skip)) + } + FilledTonalButton( + onClick = { onExecute(info.planId, info.missedCount) }, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = Warning.copy(alpha = 0.2f), + contentColor = Warning + ) + ) { + Text(stringResource(R.string.missed_purchases_buy)) + } + } + } + } + } + } +} + @Composable internal fun NetworkRetryBanner( retryPlans: List, @@ -720,12 +808,17 @@ internal fun HoldingPage( label = stringResource(R.string.dashboard_avg_price), value = "${NumberFormatters.fiat(holding.averageBuyPrice)} ${holding.fiat}/${holding.crypto}" ) + Spacer(Modifier.height(4.dp)) if (holding.currentPrice != null) { - Spacer(Modifier.height(4.dp)) StatItemInline( label = stringResource(R.string.dashboard_current_price), value = "${NumberFormatters.fiat(holding.currentPrice)} ${holding.fiat}/${holding.crypto}" ) + } else { + StatItemInline( + label = stringResource(R.string.dashboard_current_price), + value = stringResource(R.string.dashboard_price_unavailable) + ) } } if (holding.roiAbsolute != null && holding.roiPercent != null) { @@ -745,6 +838,12 @@ internal fun HoldingPage( color = roiColor ) } + } else if (holding.currentPrice == null) { + Text( + text = "ROI –", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } else { @@ -763,34 +862,44 @@ internal fun HoldingPage( ) } - if (holding.currentPrice != null) { - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + if (holding.currentPrice != null) { StatItem( label = stringResource(R.string.dashboard_current_price), value = "${NumberFormatters.fiat(holding.currentPrice)} ${holding.fiat}/${holding.crypto}" ) - if (holding.roiAbsolute != null && holding.roiPercent != null) { - val isPositive = NumberFormatters.isPositiveRoi(holding.roiAbsolute) - val roiColor = if (isPositive) successCol else Error - val sign = NumberFormatters.roiSign(holding.roiAbsolute) - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = "$sign${NumberFormatters.fiat(holding.roiAbsolute)} ${holding.fiat}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = roiColor - ) - Text( - text = stringResource(R.string.dashboard_roi, "${sign}${NumberFormatters.percent(holding.roiPercent)}%"), - style = MaterialTheme.typography.bodySmall, - color = roiColor - ) - } + } else { + StatItem( + label = stringResource(R.string.dashboard_current_price), + value = stringResource(R.string.dashboard_price_unavailable) + ) + } + if (holding.roiAbsolute != null && holding.roiPercent != null) { + val isPositive = NumberFormatters.isPositiveRoi(holding.roiAbsolute) + val roiColor = if (isPositive) successCol else Error + val sign = NumberFormatters.roiSign(holding.roiAbsolute) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$sign${NumberFormatters.fiat(holding.roiAbsolute)} ${holding.fiat}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = roiColor + ) + Text( + text = stringResource(R.string.dashboard_roi, "${sign}${NumberFormatters.percent(holding.roiPercent)}%"), + style = MaterialTheme.typography.bodySmall, + color = roiColor + ) } + } else if (holding.currentPrice == null) { + StatItem( + label = "ROI", + value = "–" + ) } } } @@ -1116,26 +1225,17 @@ internal fun QuickActionsRow( internal fun PortfolioSummaryCard( holdings: List ) { - // Only show when all holdings have prices loaded val allPricesLoaded = holdings.all { it.currentValue != null } - if (!allPricesLoaded) return - 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 roiResult = NumberFormatters.roiValues(totalInvested, totalValue) - val roiAbsolute = roiResult?.first ?: BigDecimal.ZERO - val roiPercent = roiResult?.second - - val successCol = successColor() - val isPositive = roiAbsolute >= BigDecimal.ZERO - val roiColor = if (isPositive) successCol else Error - val sign = if (isPositive) "+" else "" - // Determine common fiat — only show summary when all plans use the same currency + // Determine common fiat – only show summary when all plans use the same currency val distinctFiats = holdings.map { it.fiat }.distinct() if (distinctFiats.size != 1) return val fiat = distinctFiats.first() + val unavailableText = stringResource(R.string.dashboard_price_unavailable) + Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( @@ -1153,37 +1253,73 @@ internal fun PortfolioSummaryCard( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - text = "${NumberFormatters.fiat(totalValue)} $fiat", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Text( - text = "${stringResource(R.string.dashboard_portfolio_total_invested)}: ${NumberFormatters.fiat(totalInvested)} $fiat", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Column(horizontalAlignment = Alignment.End) { - Text( - text = "$sign${NumberFormatters.fiat(roiAbsolute)} $fiat", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = roiColor - ) - if (roiPercent != null) { + if (allPricesLoaded) { + val totalValue = holdings.fold(BigDecimal.ZERO) { acc, h -> acc + (h.currentValue ?: BigDecimal.ZERO) } + val roiResult = NumberFormatters.roiValues(totalInvested, totalValue) + val roiAbsolute = roiResult?.first ?: BigDecimal.ZERO + val roiPercent = roiResult?.second + val successCol = successColor() + val isPositive = roiAbsolute >= BigDecimal.ZERO + val roiColor = if (isPositive) successCol else Error + val sign = if (isPositive) "+" else "" + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { Text( - text = stringResource(R.string.dashboard_roi, "$sign${NumberFormatters.percent(roiPercent)}%"), + text = "${NumberFormatters.fiat(totalValue)} $fiat", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = "${stringResource(R.string.dashboard_portfolio_total_invested)}: ${NumberFormatters.fiat(totalInvested)} $fiat", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = "$sign${NumberFormatters.fiat(roiAbsolute)} $fiat", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, color = roiColor ) + if (roiPercent != null) { + Text( + text = stringResource(R.string.dashboard_roi, "$sign${NumberFormatters.percent(roiPercent)}%"), + style = MaterialTheme.typography.bodySmall, + color = roiColor + ) + } + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = unavailableText, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${stringResource(R.string.dashboard_portfolio_total_invested)}: ${NumberFormatters.fiat(totalInvested)} $fiat", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } + Text( + text = "ROI –", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } @@ -1402,7 +1538,7 @@ internal fun MarketPulseCard( textAlign = TextAlign.Center ) - // Row: "Fear" (left) — "14 — Extreme Fear" (center) — "Greed" (right) + // Row: "Fear" (left) – "14 – Extreme Fear" (center) – "Greed" (right) Box(modifier = Modifier.fillMaxWidth()) { Text( text = stringResource(R.string.dashboard_fear_label), @@ -1411,7 +1547,7 @@ internal fun MarketPulseCard( modifier = Modifier.align(Alignment.CenterStart) ) Text( - text = "${fearGreedData.value} — $localizedClassification", + text = "${fearGreedData.value} – $localizedClassification", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, color = fearGreedColor(fearGreedData.value), @@ -1491,7 +1627,7 @@ internal fun MarketPulseCard( ) { Column(modifier = Modifier.fillMaxWidth()) { if (athDataByCrypto.isNotEmpty()) { - // Row: "0" (left) — "BTC -35 %" (center) — "ATH" (right) + // Row: "0" (left) – "BTC -35 %" (center) – "ATH" (right) val singleFmt = stringResource(R.string.ath_distance_format) val cryptoFmt = stringResource(R.string.ath_distance_crypto_format) val athCenterText = if (athDataByCrypto.size == 1) { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt index 4857809..cb392af 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/DashboardViewModel.kt @@ -23,6 +23,7 @@ import com.accbot.dca.domain.usecase.CalculateStrategyMultiplierUseCase import com.accbot.dca.exchange.ExchangeApiFactory import com.accbot.dca.scheduler.DcaAlarmScheduler import com.accbot.dca.service.DcaForegroundService + import com.accbot.dca.worker.DcaWorker import androidx.compose.runtime.Immutable import dagger.hilt.android.lifecycle.HiltViewModel @@ -65,6 +66,14 @@ data class DcaPlanWithBalance( val strategyMultiplier: StrategyMultiplierResult? = null ) +@Immutable +data class MissedPurchaseInfo( + val planId: Long, + val crypto: String, + val exchangeName: String, + val missedCount: Int +) + @Immutable data class NetworkRetryPlan( val planId: Long, @@ -95,7 +104,8 @@ data class DashboardUiState( val isMarketDataLoading: Boolean = false, val showMarketPulse: Boolean = true, val isMarketPulseExpanded: Boolean = true, - val networkRetryInfo: NetworkRetryInfo = NetworkRetryInfo() + val networkRetryInfo: NetworkRetryInfo = NetworkRetryInfo(), + val missedPurchases: List = emptyList() ) @HiltViewModel @@ -158,6 +168,9 @@ class DashboardViewModel @Inject constructor( val hasEnabledPlans = plans.any { it.isEnabled } ensureServiceState(hasEnabledPlans) + if (hasEnabledPlans) { + launch { DcaAlarmScheduler.scheduleNextAlarm(application) } + } val plansWithBalance = plans.map { plan -> val accumulated = if (plan.targetAmount != null) { @@ -186,6 +199,18 @@ class DashboardViewModel @Inject constructor( } else h } + // Check missed purchases from plans + val missedPurchases = planEntities + .filter { it.missedPurchaseCount > 0 } + .map { + MissedPurchaseInfo( + planId = it.id, + crypto = it.crypto, + exchangeName = it.exchange.displayName, + missedCount = it.missedPurchaseCount + ) + } + // Check network retry state from plans val retryPlans = planEntities .filter { it.networkRetryCount > 0 } @@ -204,6 +229,7 @@ class DashboardViewModel @Inject constructor( activePlans = plansWithBalance, holdings = mergedHoldings, isLoading = false, + missedPurchases = missedPurchases, networkRetryInfo = if (retryPlans.isNotEmpty()) { NetworkRetryInfo(plans = retryPlans, dismissed = false) } else { @@ -528,6 +554,7 @@ class DashboardViewModel @Inject constructor( viewModelScope.launch { val plan = dcaPlanDao.getPlanById(planId) ?: return@launch dcaPlanDao.setEnabled(planId, !plan.isEnabled) + DcaAlarmScheduler.scheduleNextAlarm(application) } } @@ -577,6 +604,28 @@ class DashboardViewModel @Inject constructor( } } + fun executeMissedPurchases(planId: Long, count: Int) { + _uiState.update { + it.copy( + runNowTriggered = true, + missedPurchases = it.missedPurchases.filter { m -> m.planId != planId } + ) + } + viewModelScope.launch { + dcaPlanDao.resetMissedPurchaseCount(planId) + DcaWorker.runMissedPurchases(application, planId, count) + } + } + + fun dismissMissedPurchases(planId: Long) { + _uiState.update { + it.copy(missedPurchases = it.missedPurchases.filter { m -> m.planId != planId }) + } + viewModelScope.launch { + dcaPlanDao.resetMissedPurchaseCount(planId) + } + } + fun dismissRetryBanner() { val plans = _uiState.value.networkRetryInfo.plans _uiState.update { it.copy(networkRetryInfo = NetworkRetryInfo(dismissed = true)) } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt index 5ad6aa6..e50e978 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/notifications/NotificationRenderer.kt @@ -114,16 +114,23 @@ object NotificationRenderer { title to message } + is NotificationTemplateArgs.MissedPurchases -> { + val title = context.getString(R.string.notification_missed_purchases_title) + val message = context.getString( + R.string.notification_missed_purchases_text, + args.missedCount, + args.crypto, + args.exchangeName + ) + title to message + } + is NotificationTemplateArgs.NetworkRetry -> { val title = context.getString(R.string.notification_network_retry_title) - val retryTime = Instant.ofEpochMilli(args.nextRetryAtEpochMs) - .atZone(ZoneId.systemDefault()) - .format(timeFormatter) val message = context.getString( R.string.notification_network_retry_text, args.crypto, - args.exchangeName, - retryTime + args.exchangeName ) title to message } 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 4e096c0..1621dc6 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 @@ -280,6 +280,7 @@ private fun NotificationTypeIcon(type: NotificationType) { NotificationType.LOW_BALANCE -> Icons.Default.Warning to Warning NotificationType.WITHDRAWAL_THRESHOLD -> Icons.AutoMirrored.Filled.CallMade to Warning NotificationType.NETWORK_RETRY -> Icons.Default.WifiOff to Error + NotificationType.MISSED_PURCHASES -> Icons.Default.EventBusy to Warning } Box( 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 0700631..3472333 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 @@ -222,7 +222,7 @@ fun PermissionsScreen( Spacer(modifier = Modifier.height(24.dp)) - // Continue button — always enabled + // Continue button – always enabled Button( onClick = onContinue, modifier = Modifier 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 58b41a2..18f8ae7 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 @@ -1,6 +1,7 @@ package com.accbot.dca.presentation.screens.plans -import androidx.lifecycle.ViewModel +import android.app.Application +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.accbot.dca.data.local.DcaPlanDao import com.accbot.dca.data.local.DcaPlanEntity @@ -12,6 +13,7 @@ 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.plan.PlanFormDelegate +import com.accbot.dca.scheduler.DcaAlarmScheduler import com.accbot.dca.presentation.plan.PlanFormState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* @@ -45,11 +47,12 @@ data class EditPlanUiState( @HiltViewModel class EditPlanViewModel @Inject constructor( + private val application: Application, private val dcaPlanDao: DcaPlanDao, private val userPreferences: UserPreferences, calculateMonthlyCost: CalculateMonthlyCostUseCase, minOrderSizeRepository: MinOrderSizeRepository -) : ViewModel() { +) : AndroidViewModel(application) { val planForm = PlanFormDelegate(calculateMonthlyCost, minOrderSizeRepository, viewModelScope) @@ -168,6 +171,7 @@ class EditPlanViewModel @Inject constructor( ) dcaPlanDao.updatePlan(updatedPlan) + DcaAlarmScheduler.scheduleNextAlarm(application) // Auto-enable Market Pulse when saving a plan with market-aware strategy if (form.selectedStrategy is DcaStrategy.AthBased || form.selectedStrategy is DcaStrategy.FearAndGreed) { 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 f51122f..d459bcb 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 @@ -46,6 +46,7 @@ fun PlanDetailsScreen( onNavigateBack: () -> Unit, onNavigateToEdit: () -> Unit, onNavigateToHistory: ((crypto: String, fiat: String) -> Unit)? = null, + onNavigateToTransactionDetails: ((Long) -> Unit)? = null, viewModel: PlanDetailsViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -235,7 +236,7 @@ fun PlanDetailsScreen( ) { item { Spacer(modifier = Modifier.height(8.dp)) } - // 1. Header card (simplified — no strategy name) + // 1. Header card (simplified – no strategy name) item { Card( colors = CardDefaults.cardColors( @@ -692,7 +693,10 @@ fun PlanDetailsScreen( if (uiState.transactions.isNotEmpty()) { items(uiState.transactions.take(10), key = { it.id }) { transaction -> - TransactionCard(transaction = transaction) + TransactionCard( + transaction = transaction, + onClick = onNavigateToTransactionDetails?.let { nav -> { nav(transaction.id) } } + ) } if (uiState.transactions.size > 10) { 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 21f03e8..e002cb7 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 @@ -9,6 +9,7 @@ import com.accbot.dca.data.local.* import com.accbot.dca.data.remote.MarketDataService import com.accbot.dca.domain.model.DcaPlan import com.accbot.dca.domain.model.Transaction +import com.accbot.dca.scheduler.DcaAlarmScheduler import com.accbot.dca.domain.model.TransactionStatus import com.accbot.dca.domain.usecase.ApiImportProgress import com.accbot.dca.domain.usecase.ApiImportResultState @@ -237,6 +238,7 @@ class PlanDetailsViewModel @Inject constructor( transactionDao.deleteTransactionsByPlanId(planId) dcaPlanDao.deletePlanById(planId) } + DcaAlarmScheduler.scheduleNextAlarm(context) onDeleted() } catch (e: Exception) { _uiState.update { it.copy(error = "Failed to delete plan") } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt index 3b17ecd..6448faf 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/portfolio/PortfolioScreen.kt @@ -81,7 +81,7 @@ fun PortfolioScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - // Landscape: two-pane layout — chart left, controls right + // Landscape: two-pane layout – chart left, controls right if (isLandscape) { val chartData = uiState.chartData val hasAnyData = chartData.isNotEmpty() @@ -462,7 +462,7 @@ internal fun PortfolioContent( } } } else if (pageCount == 1) { - // Single page (no pager needed) — still show label + KPI + // Single page (no pager needed) – still show label + KPI item { Card( modifier = Modifier.animateContentSize(), @@ -640,7 +640,7 @@ internal fun ChartZoomHeader( ) { when (zoomLevel) { is ChartZoomLevel.Overview -> { - // No header needed — drill-down chips show "Explore history" label + // No header needed – drill-down chips show "Explore history" label } is ChartZoomLevel.Year -> { Row( diff --git a/accbot-android/app/src/main/java/com/accbot/dca/service/DcaForegroundService.kt b/accbot-android/app/src/main/java/com/accbot/dca/service/DcaForegroundService.kt index 1ab5a40..d2e1d98 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/service/DcaForegroundService.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/service/DcaForegroundService.kt @@ -41,7 +41,7 @@ class DcaForegroundService : Service() { START_NOT_STICKY } else -> { - // Null intent (e.g. system restart after crash) — do not auto-restart + // Null intent (e.g. system restart after crash) – do not auto-restart START_NOT_STICKY } } diff --git a/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt b/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt index 3f9d32e..7089d72 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/service/NotificationService.kt @@ -265,6 +265,42 @@ class NotificationService @Inject constructor( ) } + /** + * Show notification for missed purchases after prolonged offline period. + */ + fun showMissedPurchasesNotification( + crypto: String, + exchangeName: String, + missedCount: Int, + planId: Long, + exchange: Exchange? = null + ) { + val args = NotificationTemplateArgs.MissedPurchases( + crypto = crypto, + exchangeName = exchangeName, + missedCount = missedCount, + planId = planId + ) + val (title, text) = NotificationRenderer.render(context, args) + val sysNotifId = notificationIdForPlan(NOTIFICATION_ID_MISSED_PURCHASES, planId) + persistAndShow( + sysNotifId = sysNotifId, + channel = CHANNEL_ERROR, + title = title, + text = text, + entity = NotificationEntity( + type = NotificationType.MISSED_PURCHASES, + title = title, + message = text, + planId = planId.takeIf { it > 0 }, + crypto = crypto, + exchange = exchange, + systemNotificationId = sysNotifId, + templateArgs = args.toJson() + ) + ) + } + /** * Show notification for network retry (offline purchase failure). */ @@ -354,7 +390,7 @@ class NotificationService @Inject constructor( notificationManager.notify(sysNotifId, notification) } catch (_: Exception) { - // Best-effort — don't crash if DB write fails + // Best-effort – don't crash if DB write fails } } } @@ -371,6 +407,7 @@ class NotificationService @Inject constructor( private const val NOTIFICATION_ID_LOW_BALANCE = 30_000 private const val NOTIFICATION_ID_WITHDRAWAL_THRESHOLD = 40_000 private const val NOTIFICATION_ID_NETWORK_RETRY = 50_000 + private const val NOTIFICATION_ID_MISSED_PURCHASES = 60_000 const val EXTRA_NOTIFICATION_ID = "extra_notification_id" diff --git a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt index 5a87204..3b6ebd6 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/worker/DcaWorker.kt @@ -52,7 +52,8 @@ class DcaWorker @AssistedInject constructor( override suspend fun doWork(): Result { val forceRun = inputData.getBoolean(KEY_FORCE_RUN, false) val forcePlanId = inputData.getLong(KEY_PLAN_ID, -1L) - Log.d(TAG, "DcaWorker started (forceRun=$forceRun, forcePlanId=$forcePlanId)") + val repeatCount = inputData.getInt(KEY_REPEAT_COUNT, 1) + Log.d(TAG, "DcaWorker started (forceRun=$forceRun, forcePlanId=$forcePlanId, repeatCount=$repeatCount)") // Resolve any PENDING transactions from previous runs before processing new purchases try { @@ -62,6 +63,12 @@ class DcaWorker @AssistedInject constructor( } try { + for (iteration in 1..repeatCount) { + if (iteration > 1) { + Log.d(TAG, "Repeat iteration $iteration/$repeatCount") + kotlinx.coroutines.delay(3_000L) // brief pause between missed purchases + } + val enabledPlans = if (forcePlanId > 0) { listOfNotNull(database.dcaPlanDao().getPlanById(forcePlanId)) } else { @@ -70,7 +77,7 @@ class DcaWorker @AssistedInject constructor( if (enabledPlans.isEmpty()) { Log.d(TAG, "No enabled DCA plans") - return Result.success() + if (iteration == 1) return Result.success() else break } for (plan in enabledPlans) { @@ -208,9 +215,25 @@ class DcaWorker @AssistedInject constructor( when (finalResult) { is DcaResult.Success -> { - // Reset network retry state on success + // Reset network retry state on success and calculate missed purchases try { + // Detect missed purchases: from retry recovery OR device boot/long-off + val missedOrigin = plan.originalScheduledAt ?: nextExecution + val missed = if (missedOrigin != null) { + // Subtract 1: this purchase just executed, covering one of the missed slots + (calculateMissedPurchaseCount(plan, missedOrigin, Instant.now()) - 1).coerceAtLeast(0) + } else 0 database.dcaPlanDao().resetNetworkRetry(plan.id) + if (missed > 0) { + database.dcaPlanDao().setMissedPurchaseCount(plan.id, missed) + notificationService.showMissedPurchasesNotification( + crypto = plan.crypto, + exchangeName = plan.exchange.displayName, + missedCount = missed, + planId = plan.id, + exchange = plan.exchange + ) + } } catch (_: Exception) {} // Save transaction atomically with plan update @@ -278,7 +301,7 @@ class DcaWorker @AssistedInject constructor( is DcaResult.Error -> { if (finalResult.retryable) { - // Network error — retry in 5 min and notify user. + // Network error – retry in 5 min and notify user. // Override the claimed nextExecutionAt with an earlier retry time. try { val retryTime = now.plus(Duration.ofMinutes(5)) @@ -288,20 +311,23 @@ class DcaWorker @AssistedInject constructor( } Log.w(TAG, "Network error for plan ${plan.id}, will retry at $retryTime: ${finalResult.message}") - notificationService.showNetworkRetryNotification( - crypto = plan.crypto, - exchangeName = plan.exchange.displayName, - errorMessage = finalResult.message, - nextRetryAt = retryTime, - attemptCount = plan.networkRetryCount + 1, - planId = plan.id, - exchange = plan.exchange - ) + // Only notify on first failure, not on subsequent retries + if (plan.networkRetryCount == 0) { + notificationService.showNetworkRetryNotification( + crypto = plan.crypto, + exchangeName = plan.exchange.displayName, + errorMessage = finalResult.message, + nextRetryAt = retryTime, + attemptCount = 1, + planId = plan.id, + exchange = plan.exchange + ) + } } catch (e: Exception) { Log.e(TAG, "Failed to update retry time for plan ${plan.id}", e) } } else { - // Business error — save failed transaction, notify, advance to next interval + // Business error – save failed transaction, notify, advance to next interval val failedWarning = if (failedAttemptMessages.size > 1) { failedAttemptMessages.dropLast(1).joinToString("; ") } else null @@ -348,6 +374,7 @@ class DcaWorker @AssistedInject constructor( } } } + } // repeat loop // Re-arm alarm for next execution (self-perpetuating chain) DcaAlarmScheduler.scheduleNextAlarm(context) @@ -362,6 +389,24 @@ class DcaWorker @AssistedInject constructor( } } + private fun calculateMissedPurchaseCount(plan: DcaPlanEntity, originalScheduledAt: Instant, now: Instant): Int { + if (plan.cronExpression != null) { + var count = 0 + var next = CronUtils.getNextExecution(plan.cronExpression, originalScheduledAt) + while (next != null && !next.isAfter(now)) { + count++ + next = CronUtils.getNextExecution(plan.cronExpression, next) + if (count > 1000) break // safety limit + } + return count + } else { + val intervalMinutes = plan.frequency.intervalMinutes + if (intervalMinutes <= 0) return 0 + val elapsedMinutes = Duration.between(originalScheduledAt, now).toMinutes() + return (elapsedMinutes / intervalMinutes).coerceAtLeast(0).toInt() + } + } + private fun calculateNextExecution(plan: DcaPlanEntity, now: Instant): Instant { return if (plan.cronExpression != null) { CronUtils.getNextExecution(plan.cronExpression, now) @@ -415,6 +460,7 @@ class DcaWorker @AssistedInject constructor( private const val TAG = "DcaWorker" private const val KEY_FORCE_RUN = "forceRun" private const val KEY_PLAN_ID = "planId" + private const val KEY_REPEAT_COUNT = "repeatCount" const val WORK_NAME = "dca_periodic_work" /** @@ -496,6 +542,31 @@ class DcaWorker @AssistedInject constructor( Log.d(TAG, "DCA one-time work enqueued for plan $planId (forceRun=true)") } + /** + * Run missed purchases for a plan (user chose to catch up). + */ + fun runMissedPurchases(context: Context, planId: Long, count: Int) { + val inputData = Data.Builder() + .putBoolean(KEY_FORCE_RUN, true) + .putLong(KEY_PLAN_ID, planId) + .putInt(KEY_REPEAT_COUNT, count) + .build() + + val oneTimeWorkRequest = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setInputData(inputData) + .build() + + WorkManager.getInstance(context) + .enqueue(oneTimeWorkRequest) + + Log.d(TAG, "Missed purchases enqueued for plan $planId (count=$count)") + } + /** * Run DCA from an alarm trigger. * Creates an expedited OneTimeWorkRequest that respects nextExecutionAt checks @@ -504,7 +575,7 @@ class DcaWorker @AssistedInject constructor( private const val ALARM_WORK_NAME = "dca_alarm_execution" fun runFromAlarm(context: Context) { - // No network constraint — worker must run even when offline so it can + // No network constraint – worker must run even when offline so it can // show a network-retry notification instead of silently waiting. val oneTimeWorkRequest = OneTimeWorkRequestBuilder() .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) @@ -512,7 +583,7 @@ class DcaWorker @AssistedInject constructor( .build() WorkManager.getInstance(context) - .enqueueUniqueWork(ALARM_WORK_NAME, ExistingWorkPolicy.KEEP, oneTimeWorkRequest) + .enqueueUniqueWork(ALARM_WORK_NAME, ExistingWorkPolicy.REPLACE, oneTimeWorkRequest) Log.d(TAG, "DCA alarm-triggered work enqueued (expedited, unique=$ALARM_WORK_NAME)") } diff --git a/accbot-android/app/src/main/res/drawable/ic_notification.xml b/accbot-android/app/src/main/res/drawable/ic_notification.xml index dcbfd10..f65984b 100644 --- a/accbot-android/app/src/main/res/drawable/ic_notification.xml +++ b/accbot-android/app/src/main/res/drawable/ic_notification.xml @@ -5,20 +5,20 @@ android:viewportWidth="24" android:viewportHeight="24"> - + - + - + - + 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 dac988b..8d19729 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -75,6 +75,7 @@ Celkové portfolio Investováno ROI %1$s + nedostupné -%1$d %% %1$s -%2$d %% < 1 hodina @@ -598,7 +599,7 @@ Hotovo (%1$d/%2$d) Nyní přiřaďte: %1$s Klepněte ● pro zachycení textu - Text zachycen — vyberte níže + Text zachycen – vyberte níže Všechna pole přiřazena! Klepněte na Hotovo. @@ -748,16 +749,21 @@ Částka (např. 0,01) Žádné aktivní DCA plány Doporučen výběr - Nakumulovali jste %1$s %2$s na %3$s — zvažte výběr do cold wallet - Cíl dosažen: %1$s %2$s — plán automaticky deaktivován + Nakumulovali jste %1$s %2$s na %3$s – zvažte výběr do cold wallet + Cíl dosažen: %1$s %2$s – plán automaticky deaktivován Částka %1$s %2$s pod minimem burzy %3$s %2$s Plán %1$s → provedeno %2$s (zpoždění %3$d min) - Chyba sítě - Nákup %1$s na %2$s selhal — bez internetu. Další pokus v %3$s. + AccBot: Chyba sítě + Nákup %1$s na %2$s selhal – bez internetu. Nákup bude automaticky zopakován. Nákup %1$s na %2$s selhal kvůli nedostupnému internetu. Pokusů: %1$d Další pokus v %1$s - %1$s %2$s na burze — zvažte výběr + AccBot: Zmeškané nákupy + Zmeškaných %1$d nákupů %2$s na %3$s během offline. + Zmeškaných %1$d nákupů %2$s na %3$s během offline. + Dokoupit + Přeskočit + %1$s %2$s na burze – zvažte výběr DCA UPOZORNĚNÍ @@ -812,7 +818,7 @@ Nastavení notifikací - AccBot používá 4 notifikační kanály. V nastavení Androidu si můžete každý kanál upravit individuálně:\n\n• DCA služba — stav služby na pozadí\n• Notifikace nákupů — úspěšné obchody\n• Chybové notifikace — neúspěšné obchody\n• Upozornění na nízký zůstatek — výstrahy zůstatku\n\nPro každý kanál si můžete nastavit vlastní zvuk, vibrace a výjimky z režimu Nerušit. + AccBot používá 4 notifikační kanály. V nastavení Androidu si můžete každý kanál upravit individuálně:\n\n• DCA služba – stav služby na pozadí\n• Notifikace nákupů – úspěšné obchody\n• Chybové notifikace – neúspěšné obchody\n• Upozornění na nízký zůstatek – výstrahy zůstatku\n\nPro každý kanál si můžete nastavit vlastní zvuk, vibrace a výjimky z režimu Nerušit. Otevřít nastavení @@ -820,8 +826,8 @@ Rozumím Poznámky k verzím a nové funkce Verze 2.2.1 - Sledování cíle — nastavte cílové množství kryptoměny pro DCA plán - Výběr vzhledu — tmavý, světlý nebo podle systému + Sledování cíle – nastavte cílové množství kryptoměny pro DCA plán + Výběr vzhledu – tmavý, světlý nebo podle systému Celkový přehled portfolia na Dashboardu Vyhledávání v historii transakcí Informace o notifikačních kanálech v Nastavení @@ -854,18 +860,18 @@ Index strachu a chamtivosti Index strachu a chamtivosti měří celkovou náladu kryptotrhu na stupnici od 0 (extrémní strach) do 100 (extrémní chamtivost). Kombinuje signály jako volatilita, objem, sociální média a trendy. Extrémní strach (0–19) - Investoři mají velký strach — často příležitost k nákupu + Investoři mají velký strach – často příležitost k nákupu Strach (20–39) - Trh je opatrný — ceny mohou být podhodnocené + Trh je opatrný – ceny mohou být podhodnocené Neutrální (40–59) - Vyvážená nálada — žádný silný signál + Vyvážená nálada – žádný silný signál Chamtivost (60–79) - Optimismus je vysoký — ceny mohou být nadsazené + Optimismus je vysoký – ceny mohou být nadsazené Extrémní chamtivost (80–100) - Euforie — trh může být přehřátý + Euforie – trh může být přehřátý Vzdálenost od ATH Vzdálenost od ATH ukazuje, jak daleko je aktuální cena od svého historického maxima. Vzdálenost 0 % znamená, že cena je na ATH; 50 % znamená, že cena klesla na polovinu z maxima. - Strategie DCA dle strachu a chamtivosti a dle ATH používají tyto indikátory k automatické úpravě nákupních částek — nakupují více, když je trh ve strachu nebo daleko od ATH, a méně, když je chamtivý nebo blízko vrcholu. + Strategie DCA dle strachu a chamtivosti a dle ATH používají tyto indikátory k automatické úpravě nákupních částek – nakupují více, když je trh ve strachu nebo daleko od ATH, a méně, když je chamtivý nebo blízko vrcholu. Tržní nálada diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index 70c9b75..fb7873a 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -76,6 +76,7 @@ Total Portfolio Invested ROI %1$s + unavailable -%1$d %% %1$s -%2$d %% < 1 hour @@ -596,7 +597,7 @@ Done (%1$d/%2$d) Now assign: %1$s Tap ● to capture text - Text captured — select below + Text captured – select below All fields assigned! Tap Done to confirm. @@ -745,16 +746,21 @@ Amount (e.g. 0.01) No active DCA plans found Withdrawal Recommended - You have accumulated %1$s %2$s on %3$s — consider withdrawing to cold wallet - Target reached: %1$s %2$s — plan auto-disabled + You have accumulated %1$s %2$s on %3$s – consider withdrawing to cold wallet + Target reached: %1$s %2$s – plan auto-disabled Amount %1$s %2$s below exchange minimum %3$s %2$s Scheduled %1$s → executed %2$s (delay %3$d min) - Network Error - %1$s purchase on %2$s failed — no internet. Next retry at %3$s. + AccBot: Network Error + %1$s purchase on %2$s failed – no internet. Will retry automatically. %1$s purchase on %2$s failed due to no internet. %1$d attempt(s) Next retry at %1$s - %1$s %2$s on exchange — consider withdrawal + AccBot: Missed Purchases + %1$d %2$s purchase(s) on %3$s were missed while offline. + %1$d missed %2$s purchase(s) on %3$s while offline. + Buy now + Skip + %1$s %2$s on exchange – consider withdrawal DCA ALERTS @@ -806,7 +812,7 @@ Notification Settings - AccBot uses 4 notification channels. In Android settings, you can customize each channel individually:\n\n• DCA Service — background service status\n• Purchase Notifications — successful trades\n• Error Notifications — failed trades\n• Low Balance Warnings — balance alerts\n\nFor each channel, you can set custom sound, vibration, and Do Not Disturb exceptions. + AccBot uses 4 notification channels. In Android settings, you can customize each channel individually:\n\n• DCA Service – background service status\n• Purchase Notifications – successful trades\n• Error Notifications – failed trades\n• Low Balance Warnings – balance alerts\n\nFor each channel, you can set custom sound, vibration, and Do Not Disturb exceptions. Open Settings @@ -814,8 +820,8 @@ Got it See release notes and new features Version 2.2.1 - Goal tracking — set a target crypto amount for your DCA plan - Theme selection — choose Dark, Light, or System + Goal tracking – set a target crypto amount for your DCA plan + Theme selection – choose Dark, Light, or System Total portfolio summary on Dashboard Transaction history search Notification channel info in Settings @@ -848,18 +854,18 @@ Fear & Greed Index The Fear & Greed Index measures overall crypto market sentiment on a scale from 0 (extreme fear) to 100 (extreme greed). It combines signals like volatility, volume, social media, and trends. Extreme Fear (0–19) - Investors are very worried — often a buying opportunity + Investors are very worried – often a buying opportunity Fear (20–39) - Market is cautious — prices may be undervalued + Market is cautious – prices may be undervalued Neutral (40–59) - Balanced sentiment — no strong signal + Balanced sentiment – no strong signal Greed (60–79) - Optimism is high — prices may be elevated + Optimism is high – prices may be elevated Extreme Greed (80–100) - Euphoria — market may be overheated + Euphoria – market may be overheated ATH Distance ATH Distance shows how far the current price is from its All-Time High. A distance of 0% means the price is at ATH; 50% means the price has dropped halfway from the peak. - The Fear & Greed and ATH-based DCA strategies use these indicators to automatically adjust your purchase amounts — buying more when the market is fearful or far from ATH, and less when it\'s greedy or near the peak. + The Fear & Greed and ATH-based DCA strategies use these indicators to automatically adjust your purchase amounts – buying more when the market is fearful or far from ATH, and less when it\'s greedy or near the peak. Market Pulse From a9e8e5bcbc2644649ba9191e3c27474f8afb1e7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20Nehasil?= Date: Tue, 7 Apr 2026 11:02:57 +0200 Subject: [PATCH 4/4] Android: add unsaved changes warning, fix bugs, add backup Done button - Add BackHandler + discard confirmation dialog to EditPlanScreen and AddPlanScreen so users are warned before losing unsaved changes - Fix potential NPE in MinOrderSizeRepository (null-safe response.body) - Fix NoSuchElementException in Kraken API when result has no keys - Fix Kraken trade history timestamp precision loss causing duplicate imports - Fix HistoryViewModel search query updating two state sources (extra recomposition) - Add Done button to backup export result screen Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dca/exchange/MinOrderSizeRepository.kt | 6 +-- .../com/accbot/dca/exchange/OtherExchanges.kt | 3 +- .../dca/presentation/screens/AddPlanScreen.kt | 37 ++++++++++++++++++- .../presentation/screens/AddPlanViewModel.kt | 8 +++- .../presentation/screens/HistoryViewModel.kt | 1 - .../screens/backup/BackupExportScreen.kt | 14 ++++++- .../screens/plans/EditPlanScreen.kt | 37 ++++++++++++++++++- .../screens/plans/EditPlanViewModel.kt | 24 +++++++++++- .../app/src/main/res/values-cs/strings.xml | 4 ++ .../app/src/main/res/values/strings.xml | 4 ++ 10 files changed, 126 insertions(+), 12 deletions(-) diff --git a/accbot-android/app/src/main/java/com/accbot/dca/exchange/MinOrderSizeRepository.kt b/accbot-android/app/src/main/java/com/accbot/dca/exchange/MinOrderSizeRepository.kt index e496a4c..e01a8c7 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/exchange/MinOrderSizeRepository.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/exchange/MinOrderSizeRepository.kt @@ -78,7 +78,7 @@ class MinOrderSizeRepository @Inject constructor( val minAmount = okHttpClient.newCall(pairsRequest).execute().use { pairsResponse -> if (!pairsResponse.isSuccessful) return@withContext null - val pairsBody = pairsResponse.body.string() + val pairsBody = pairsResponse.body?.string() ?: return@withContext null val pairsJson = JSONObject(pairsBody) val pairsData = pairsJson.optJSONArray("data") ?: return@withContext null @@ -103,7 +103,7 @@ class MinOrderSizeRepository @Inject constructor( val lastPrice = okHttpClient.newCall(tickerRequest).execute().use { tickerResponse -> if (!tickerResponse.isSuccessful) return@withContext null - val tickerBody = tickerResponse.body.string() + val tickerBody = tickerResponse.body?.string() ?: return@withContext null val tickerJson = JSONObject(tickerBody) val tickerData = tickerJson.optJSONObject("data") ?: return@withContext null BigDecimal(tickerData.getString("last")) @@ -134,7 +134,7 @@ class MinOrderSizeRepository @Inject constructor( okHttpClient.newCall(request).execute().use { response -> if (!response.isSuccessful) return@withContext null - val body = response.body.string() + val body = response.body?.string() ?: return@withContext null val json = JSONObject(body) val symbols = json.optJSONArray("symbols") if (symbols == null || symbols.length() == 0) return@withContext null 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 32f16d6..d3b4649 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 @@ -296,6 +296,7 @@ class KrakenApi( val result = json.getJSONObject("result") // Kraken returns the pair key which may differ from input + if (!result.keys().hasNext()) return@withContext null val pairKey = result.keys().next() val ticker = result.getJSONObject(pairKey) // c = last trade closed [price, lot-volume] @@ -317,7 +318,7 @@ class KrakenApi( val params = buildString { append("pair=$pair") if (sinceTimestamp != null) { - append("&start=${sinceTimestamp.epochSecond + 1}") + append("&start=${sinceTimestamp.plusSeconds(1).epochSecond}") } } 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 847b977..785611b 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,6 +2,7 @@ package com.accbot.dca.presentation.screens import android.content.Intent import android.net.Uri +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.* import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.rememberScrollState @@ -41,6 +42,19 @@ fun AddPlanScreen( viewModel: AddPlanViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showDiscardAlert by remember { mutableStateOf(false) } + + val handleBack: () -> Unit = { + if (uiState.hasChanges) { + showDiscardAlert = true + } else { + onNavigateBack() + } + } + + BackHandler(enabled = uiState.hasChanges) { + showDiscardAlert = true + } LaunchedEffect(uiState.isSuccess) { if (uiState.isSuccess) { @@ -48,6 +62,27 @@ fun AddPlanScreen( } } + if (showDiscardAlert) { + AlertDialog( + onDismissRequest = { showDiscardAlert = false }, + title = { Text(stringResource(R.string.common_discard_changes_title)) }, + text = { Text(stringResource(R.string.common_discard_changes_message)) }, + confirmButton = { + TextButton(onClick = { + showDiscardAlert = false + onNavigateBack() + }) { + Text(stringResource(R.string.common_discard)) + } + }, + dismissButton = { + TextButton(onClick = { showDiscardAlert = false }) { + Text(stringResource(R.string.common_keep_editing)) + } + } + ) + } + // Import offer dialog after creating plan with new credentials if (uiState.showImportDialog) { val exchangeName = uiState.credentialForm.selectedExchange?.displayName ?: "" @@ -77,7 +112,7 @@ fun AddPlanScreen( topBar = { AccBotTopAppBar( title = stringResource(R.string.add_plan_title), - onNavigateBack = onNavigateBack + onNavigateBack = handleBack ) } ) { paddingValues -> 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 06e89aa..53968a0 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 @@ -30,6 +30,9 @@ data class AddPlanUiState( // Plan form (from delegate) val planForm: PlanFormState = PlanFormState(), + // Change tracking + val hasChanges: Boolean = false, + // Action state val isLoading: Boolean = false, val isSuccess: Boolean = false, @@ -67,7 +70,10 @@ class AddPlanViewModel @Inject constructor( _localState, planForm.state, credentialForm.state - ) { local, form, cred -> local.copy(planForm = form, credentialForm = cred) } + ) { local, form, cred -> + val hasChanges = cred.selectedExchange != null + local.copy(planForm = form, credentialForm = cred, hasChanges = hasChanges) + } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AddPlanUiState()) init { diff --git a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt index 27c935e..6673a54 100644 --- a/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt +++ b/accbot-android/app/src/main/java/com/accbot/dca/presentation/screens/HistoryViewModel.kt @@ -158,7 +158,6 @@ class HistoryViewModel @Inject constructor( fun setSearchQuery(query: String) { _searchQuery.value = query - _filterState.update { it.copy(searchQuery = query) } } fun setFilter(filter: HistoryFilter) { 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 e51aa05..99a6f9b 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 @@ -72,7 +72,7 @@ fun BackupExportScreen( when (uiState.wizardStep) { ExportWizardStep.SELECT_DATA -> SelectDataStep(uiState, viewModel) ExportWizardStep.ENCRYPTION -> EncryptionStep(uiState, viewModel) - ExportWizardStep.RESULT -> ResultStep(uiState, qrBitmap, context) + ExportWizardStep.RESULT -> ResultStep(uiState, qrBitmap, context, onNavigateBack) } // Error display @@ -375,7 +375,8 @@ private fun EncryptionStep( private fun ResultStep( uiState: BackupExportUiState, qrBitmap: Bitmap?, - context: android.content.Context + context: android.content.Context, + onDone: () -> Unit ) { Text( text = stringResource(R.string.backup_export_success), @@ -447,5 +448,14 @@ private fun ResultStep( color = MaterialTheme.colorScheme.onSurfaceVariant ) } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = onDone, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.common_done)) + } } 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 5ca28cd..9164e03 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,5 +1,6 @@ package com.accbot.dca.presentation.screens.plans +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.* @@ -27,16 +28,50 @@ fun EditPlanScreen( viewModel: EditPlanViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showDiscardAlert by remember { mutableStateOf(false) } + + val handleBack: () -> Unit = { + if (uiState.hasChanges) { + showDiscardAlert = true + } else { + onNavigateBack() + } + } + + BackHandler(enabled = uiState.hasChanges) { + showDiscardAlert = true + } LaunchedEffect(planId) { viewModel.loadPlan(planId) } + if (showDiscardAlert) { + AlertDialog( + onDismissRequest = { showDiscardAlert = false }, + title = { Text(stringResource(R.string.common_discard_changes_title)) }, + text = { Text(stringResource(R.string.common_discard_changes_message)) }, + confirmButton = { + TextButton(onClick = { + showDiscardAlert = false + onNavigateBack() + }) { + Text(stringResource(R.string.common_discard)) + } + }, + dismissButton = { + TextButton(onClick = { showDiscardAlert = false }) { + Text(stringResource(R.string.common_keep_editing)) + } + } + ) + } + Scaffold( topBar = { AccBotTopAppBar( title = stringResource(R.string.edit_plan_title), - onNavigateBack = onNavigateBack + onNavigateBack = handleBack ) } ) { paddingValues -> 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 18f8ae7..3091bd4 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 @@ -35,6 +35,9 @@ data class EditPlanUiState( // Plan form (from delegate) val planForm: PlanFormState = PlanFormState(), + // Change tracking + val hasChanges: Boolean = false, + // Action state val isLoading: Boolean = true, val isSaving: Boolean = false, @@ -58,10 +61,24 @@ class EditPlanViewModel @Inject constructor( private val _localState = MutableStateFlow(EditPlanUiState()) + private val _originalFormState = MutableStateFlow(null) + val uiState: StateFlow = combine( _localState, - planForm.state - ) { local, form -> local.copy(planForm = form) } + planForm.state, + _originalFormState + ) { local, form, original -> + val hasChanges = original != null && ( + form.amount != original.amount + || form.selectedFrequency != original.selectedFrequency + || form.cronExpression != original.cronExpression + || form.selectedStrategy != original.selectedStrategy + || form.withdrawalEnabled != original.withdrawalEnabled + || form.withdrawalAddress != original.withdrawalAddress + || form.targetAmount != original.targetAmount + ) + local.copy(planForm = form, hasChanges = hasChanges) + } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), EditPlanUiState()) private var originalPlan: DcaPlanEntity? = null @@ -102,6 +119,9 @@ class EditPlanViewModel @Inject constructor( withdrawalAddress = plan.withdrawalAddress ?: "", targetAmount = plan.targetAmount?.toPlainString() ?: "" ) + + // Snapshot the original form state for change tracking + _originalFormState.value = planForm.state.value } catch (e: Exception) { _localState.update { it.copy( 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 8d19729..e113a78 100644 --- a/accbot-android/app/src/main/res/values-cs/strings.xml +++ b/accbot-android/app/src/main/res/values-cs/strings.xml @@ -10,6 +10,10 @@ Vymazat Použít Hotovo + Zahodit změny? + Máte neuložené změny. Opravdu se chcete vrátit zpět? + Zahodit + Pokračovat v úpravách Odebrat Načítání… Něco se pokazilo diff --git a/accbot-android/app/src/main/res/values/strings.xml b/accbot-android/app/src/main/res/values/strings.xml index fb7873a..04d5e7a 100644 --- a/accbot-android/app/src/main/res/values/strings.xml +++ b/accbot-android/app/src/main/res/values/strings.xml @@ -12,6 +12,10 @@ Clear Apply Done + Discard Changes? + You have unsaved changes. Are you sure you want to go back? + Discard + Keep Editing Remove Loading… Something went wrong