From cdc3eb180dd7d0394cee7b9cd3c904554a439a02 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 25 May 2026 14:28:05 +0200 Subject: [PATCH 1/5] fix: calculator inputs placeholder --- .../widgets/calculator/CalculatorDisplay.kt | 22 ++++++++++++++++--- .../components/CalculatorCardStateTest.kt | 7 +++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorDisplay.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorDisplay.kt index c56a191fb..c6f4a36db 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorDisplay.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorDisplay.kt @@ -100,7 +100,14 @@ internal fun formatBitcoinPlaceholder( displayUnit: BitcoinDisplayUnit, locale: Locale = Locale.getDefault(), ): String { - if (btcValue.isEmpty() || displayUnit.isModern()) return "" + if (btcValue.isEmpty()) { + return if (displayUnit.isModern()) { + ZERO_PLACEHOLDER + } else { + ZERO_PLACEHOLDER + DECIMAL_SEPARATOR + "0".repeat(CLASSIC_DECIMALS) + } + } + if (displayUnit.isModern()) return "" val normalizedBtcValue = sanitizeDecimalInput( raw = normalizeCalculatorDecimalInput( rawValue = btcValue, @@ -112,6 +119,7 @@ internal fun formatBitcoinPlaceholder( return formatMissingDecimalZeros( value = normalizedBtcValue, maxDecimalPlaces = CLASSIC_DECIMALS, + includeDecimalSeparatorIfMissing = true, ) } @@ -119,7 +127,7 @@ internal fun formatFiatPlaceholder( fiatValue: String, locale: Locale = Locale.getDefault(), ): String { - if (fiatValue.isEmpty()) return "" + if (fiatValue.isEmpty()) return ZERO_PLACEHOLDER val normalizedFiatValue = sanitizeDecimalInput( raw = normalizeCalculatorDecimalInput( rawValue = fiatValue, @@ -268,8 +276,15 @@ private fun formatGroupedIntegerPreservingZeros( private fun formatMissingDecimalZeros( value: String, maxDecimalPlaces: Int, + includeDecimalSeparatorIfMissing: Boolean = false, ): String { - if (!value.contains(PERIOD_SEPARATOR)) return "" + if (!value.contains(PERIOD_SEPARATOR)) { + return if (includeDecimalSeparatorIfMissing) { + DECIMAL_SEPARATOR + "0".repeat(maxDecimalPlaces) + } else { + "" + } + } val decimalLength = value.substringAfter(PERIOD_SEPARATOR).length val remainingDecimals = maxDecimalPlaces - decimalLength @@ -298,5 +313,6 @@ private fun BigDecimal.toSatsLongClamped(): Long { private val MAX_SATS_DECIMAL = BigDecimal.valueOf(Long.MAX_VALUE) private const val GROUP_SIZE = 3 +private const val ZERO_PLACEHOLDER = "0" private const val COMMA_SEPARATOR = ',' private const val PERIOD_SEPARATOR = '.' diff --git a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt index caa0712ba..4a1b3b682 100644 --- a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt @@ -217,7 +217,7 @@ class CalculatorCardStateTest { @Test fun `formatFiatPlaceholder returns missing decimal zeros`() { - assertEquals("", formatFiatPlaceholder("")) + assertEquals("0", formatFiatPlaceholder("")) assertEquals("", formatFiatPlaceholder("1")) assertEquals("00", formatFiatPlaceholder("1.")) assertEquals("0", formatFiatPlaceholder("1.2")) @@ -227,8 +227,9 @@ class CalculatorCardStateTest { @Test fun `formatBitcoinPlaceholder returns missing classic decimal zeros`() { - assertEquals("", formatBitcoinPlaceholder("", BitcoinDisplayUnit.CLASSIC)) - assertEquals("", formatBitcoinPlaceholder("1", BitcoinDisplayUnit.CLASSIC)) + assertEquals("0", formatBitcoinPlaceholder("", BitcoinDisplayUnit.MODERN)) + assertEquals("0.00000000", formatBitcoinPlaceholder("", BitcoinDisplayUnit.CLASSIC)) + assertEquals(".00000000", formatBitcoinPlaceholder("1", BitcoinDisplayUnit.CLASSIC)) assertEquals("", formatBitcoinPlaceholder("1.2", BitcoinDisplayUnit.MODERN)) assertEquals("00000000", formatBitcoinPlaceholder("1.", BitcoinDisplayUnit.CLASSIC)) assertEquals("0000", formatBitcoinPlaceholder("1.2345", BitcoinDisplayUnit.CLASSIC)) From e96e580c8454bcf3f8604b23e3d28828d7d6e95c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 25 May 2026 15:29:08 +0200 Subject: [PATCH 2/5] fix: preserve active calculator input --- .../widgets/calculator/CalculatorViewModel.kt | 109 +++++++++++++++--- .../calculator/components/CalculatorCard.kt | 21 +++- .../calculator/CalculatorViewModelTest.kt | 61 ++++++++++ .../components/CalculatorCardStateTest.kt | 30 +++++ 4 files changed, 201 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt index 051f64b9c..223b0b600 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -15,6 +16,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.FxRate +import to.bitkit.models.MoneyType import to.bitkit.models.WidgetType import to.bitkit.models.widget.CalculatorValues import to.bitkit.models.widget.resolveCalculatorSatsValue @@ -43,6 +46,7 @@ class CalculatorViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private var pendingValues: CalculatorValues? = null private var lastCurrencyKey: CalculatorCurrencyKey? = null + private var activeInput: MoneyType? = null val isCalculatorWidgetEnabled: StateFlow = widgetsRepo.widgetsDataFlow .map { widgetsData -> @@ -70,7 +74,16 @@ class CalculatorViewModel @Inject constructor( } } + fun onInputSelected(input: MoneyType) { + activeInput = input + } + + fun onInputDismissed() { + activeInput = null + } + fun onBtcInputChanged(rawValue: String) { + activeInput = MoneyType.BITCOIN val displayUnit = _uiState.value.displayUnit val btcValue = if (displayUnit.isModern()) { sanitizeIntegerInput(rawValue) @@ -98,6 +111,7 @@ class CalculatorViewModel @Inject constructor( } fun onFiatInputChanged(rawValue: String) { + activeInput = MoneyType.FIAT val displayUnit = _uiState.value.displayUnit val fiatValue = sanitizeDecimalInput(rawValue, maxDecimalPlaces = CALCULATOR_FIAT_DECIMAL_PLACES) val satsValue = if (fiatValue.isEmpty()) 0L else convertFiatToSats(fiatValue) @@ -150,6 +164,7 @@ class CalculatorViewModel @Inject constructor( val currencyKey = CalculatorCurrencyKey( selectedCurrency = currencyState.selectedCurrency, displayUnit = currencyState.displayUnit, + rates = currencyState.rates, ) val previousCurrencyKey = lastCurrencyKey lastCurrencyKey = currencyKey @@ -164,6 +179,15 @@ class CalculatorViewModel @Inject constructor( displayUnitChanged = displayUnitChanged, currencyKey = currencyKey, ) + val refreshSource = nextActiveValues.refreshSource(activeInput) + if (refreshSource == MoneyType.FIAT) { + return refreshBitcoinFromFiat( + activeValues = activeValues, + nextActiveValues = nextActiveValues, + displayUnit = currencyState.displayUnit, + ) + } + val shouldRefreshFiat = isInitialSync || currencyChanged || shouldHydrateFiatFromStoredBtc( storedBtcValue = storedValues.btcValue, storedFiatValue = storedValues.fiatValue, @@ -171,37 +195,76 @@ class CalculatorViewModel @Inject constructor( displayUnit = currencyState.displayUnit, ) - if (!shouldRefreshFiat) { - persistCanonicalValuesIfNeeded( - activeValues = activeValues, - nextActiveValues = nextActiveValues, - ) - return nextActiveValues - } + if (!shouldRefreshFiat) return persistCanonicalValues(activeValues, nextActiveValues) + + return refreshFiatFromBitcoin( + activeValues = activeValues, + nextActiveValues = nextActiveValues, + displayUnit = currencyState.displayUnit, + ) + } + + private fun refreshFiatFromBitcoin( + activeValues: CalculatorValues, + nextActiveValues: CalculatorValues, + displayUnit: BitcoinDisplayUnit, + ): CalculatorValues { if (nextActiveValues.btcValue.isEmpty() || - isZeroBtcValue(nextActiveValues.btcValue, currencyState.displayUnit) + isZeroBtcValue(nextActiveValues.btcValue, displayUnit) ) { - persistCanonicalValuesIfNeeded( - activeValues = activeValues, - nextActiveValues = nextActiveValues, - ) - return nextActiveValues + return persistCanonicalValues(activeValues, nextActiveValues) } val convertedFiat = convertSatsToFiat(nextActiveValues.resolveCalculatorSatsValue()) - if (convertedFiat.isEmpty()) { + if (convertedFiat.isEmpty()) return persistCanonicalValues(activeValues, nextActiveValues) + + val updatedValues = nextActiveValues.copy(fiatValue = convertedFiat) + updateCalculatorValues(updatedValues) + return updatedValues + } + + private fun refreshBitcoinFromFiat( + activeValues: CalculatorValues, + nextActiveValues: CalculatorValues, + displayUnit: BitcoinDisplayUnit, + ): CalculatorValues { + if (nextActiveValues.fiatValue.isEmpty()) { + val updatedValues = nextActiveValues.copy( + btcValue = "", + satsValue = 0L, + displayUnit = displayUnit, + ) persistCanonicalValuesIfNeeded( activeValues = activeValues, - nextActiveValues = nextActiveValues, + nextActiveValues = updatedValues, ) - return nextActiveValues + return updatedValues } - val updatedValues = nextActiveValues.copy(fiatValue = convertedFiat) - updateCalculatorValues(updatedValues) + val satsValue = convertFiatToSats(nextActiveValues.fiatValue) + val updatedValues = nextActiveValues.copy( + btcValue = calculatorSatsToBtcValue(satsValue, displayUnit), + satsValue = satsValue, + displayUnit = displayUnit, + ) + persistCanonicalValuesIfNeeded( + activeValues = activeValues, + nextActiveValues = updatedValues, + ) return updatedValues } + private fun persistCanonicalValues( + activeValues: CalculatorValues, + nextActiveValues: CalculatorValues, + ): CalculatorValues { + persistCanonicalValuesIfNeeded( + activeValues = activeValues, + nextActiveValues = nextActiveValues, + ) + return nextActiveValues + } + private fun deriveActiveValues( activeValues: CalculatorValues, isInitialSync: Boolean, @@ -319,6 +382,7 @@ data class CalculatorUiState( private data class CalculatorCurrencyKey( val selectedCurrency: String, val displayUnit: BitcoinDisplayUnit, + val rates: ImmutableList, ) internal fun shouldHydrateFiatFromStoredBtc( @@ -339,6 +403,15 @@ internal fun shouldHydrateFiatFromStoredBtc( return currentFiatValue.isEmpty() } +internal fun CalculatorValues.refreshSource(activeInput: MoneyType?): MoneyType { + activeInput?.let { return it } + return if (btcValue.isEmpty() && fiatValue.isNotEmpty()) { + MoneyType.FIAT + } else { + MoneyType.BITCOIN + } +} + internal fun isZeroBtcValue( btcValue: String, displayUnit: BitcoinDisplayUnit, diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt index f785412d7..04abff85e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt @@ -89,6 +89,8 @@ fun CalculatorCard( onFiatChange = calculatorViewModel::onFiatInputChanged, dismissNumberPadKey = dismissNumberPadKey, onInputActiveChange = onInputActiveChange, + onInputSelected = calculatorViewModel::onInputSelected, + onInputDismissed = calculatorViewModel::onInputDismissed, onNumberPadBoundsChanged = onNumberPadBoundsChanged, ) } @@ -105,9 +107,15 @@ fun CalculatorCardEditor( onFiatChange: (String) -> Unit, dismissNumberPadKey: Int = 0, onInputActiveChange: (Boolean) -> Unit = {}, + onInputSelected: (MoneyType) -> Unit = {}, + onInputDismissed: () -> Unit = {}, onNumberPadBoundsChanged: (Rect?) -> Unit = {}, ) { val numpadState = rememberNumpadState() + val selectInput = { input: MoneyType -> + onInputSelected(input) + numpadState.selectInput(input) + } Column(modifier = modifier) { Content( @@ -117,7 +125,7 @@ fun CalculatorCardEditor( fiatName = fiatName, fiatValue = fiatValue, activeInput = numpadState.activeInput, - onSelectInput = numpadState::selectInput, + onSelectInput = selectInput, modifier = Modifier.fillMaxWidth(), ) @@ -130,6 +138,7 @@ fun CalculatorCardEditor( fiatValue = fiatValue, onBtcChange = onBtcChange, onFiatChange = onFiatChange, + onInputDismissed = onInputDismissed, onNumberPadBoundsChanged = onNumberPadBoundsChanged, ) } @@ -201,12 +210,14 @@ private fun ColumnScope.NumpadHost( fiatValue: String, onBtcChange: (String) -> Unit, onFiatChange: (String) -> Unit, + onInputDismissed: () -> Unit, onNumberPadBoundsChanged: (Rect?) -> Unit, ) { NumpadEffects( state = state, dismissNumberPadKey = dismissNumberPadKey, onInputActiveChange = onInputActiveChange, + onInputDismissed = onInputDismissed, onNumberPadBoundsChanged = onNumberPadBoundsChanged, ) @@ -226,9 +237,11 @@ private fun NumpadEffects( state: NumpadState, dismissNumberPadKey: Int, onInputActiveChange: (Boolean) -> Unit, + onInputDismissed: () -> Unit, onNumberPadBoundsChanged: (Rect?) -> Unit, ) { val updatedOnInputActiveChange by rememberUpdatedState(onInputActiveChange) + val updatedOnInputDismissed by rememberUpdatedState(onInputDismissed) val updatedOnNumberPadBoundsChanged by rememberUpdatedState(onNumberPadBoundsChanged) val isInputTargetActive = state.visibilityState.targetState @@ -237,6 +250,7 @@ private fun NumpadEffects( LaunchedEffect(isInputTargetActive) { updatedOnInputActiveChange(isInputTargetActive) if (!isInputTargetActive) { + updatedOnInputDismissed() updatedOnNumberPadBoundsChanged(null) } } @@ -248,7 +262,10 @@ private fun NumpadEffects( } DisposableEffect(Unit) { - onDispose { updatedOnInputActiveChange(false) } + onDispose { + updatedOnInputDismissed() + updatedOnInputActiveChange(false) + } } } diff --git a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt index 618ddfd69..7b333531e 100644 --- a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.widgets.calculator +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle @@ -12,6 +13,7 @@ import org.mockito.kotlin.whenever import to.bitkit.data.WidgetsData import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.ConvertedAmount +import to.bitkit.models.FxRate import to.bitkit.models.widget.CalculatorValues import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.CurrencyState @@ -267,6 +269,49 @@ class CalculatorViewModelTest : BaseUnitTest() { assertEquals(BitcoinDisplayUnit.MODERN, widgetsData.value.calculatorValues.displayUnit) } + @Test + fun `rate refresh preserves bitcoin input as source`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onBtcInputChanged("10000") + advanceUntilIdle() + + fiatConversionValue = "7.50" + currencyState.value = currencyState.value.copy( + rates = persistentListOf(fxRate(lastPrice = "75000")), + ) + advanceUntilIdle() + + assertEquals("10000", sut.uiState.value.btcValue) + assertEquals("7.50", sut.uiState.value.fiatValue) + assertEquals(10_000L, widgetsData.value.calculatorValues.satsValue) + assertEquals("7.50", widgetsData.value.calculatorValues.fiatValue) + } + + @Test + fun `currency change preserves fiat input as source`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onFiatInputChanged("12.34") + advanceUntilIdle() + + fiatToSatsValue = 54_321uL + currencyState.value = CurrencyState( + selectedCurrency = "EUR", + currencySymbol = "EUR", + displayUnit = BitcoinDisplayUnit.MODERN, + rates = persistentListOf(fxRate(quote = "EUR", currencySymbol = "EUR")), + ) + advanceUntilIdle() + + assertEquals("54321", sut.uiState.value.btcValue) + assertEquals("12.34", sut.uiState.value.fiatValue) + assertEquals(54_321L, widgetsData.value.calculatorValues.satsValue) + assertEquals("12.34", widgetsData.value.calculatorValues.fiatValue) + } + @Test fun `display unit change preserves btc amount`() = test { widgetsData.value = WidgetsData( @@ -379,4 +424,20 @@ class CalculatorViewModelTest : BaseUnitTest() { "EUR" -> "5.50" else -> "6.25" } + + private fun fxRate( + quote: String = "USD", + currencySymbol: String = "$", + lastPrice: String = "62500", + ) = FxRate( + symbol = "BTC$quote", + lastPrice = lastPrice, + base = "BTC", + baseName = "Bitcoin", + quote = quote, + quoteName = quote, + currencySymbol = currencySymbol, + currencyFlag = "", + lastUpdatedAt = 1L, + ) } diff --git a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt index 4a1b3b682..4cef7df3e 100644 --- a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCardStateTest.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.widgets.calculator.components import org.junit.Before import org.junit.Test import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.MoneyType import to.bitkit.models.widget.CalculatorValues import to.bitkit.models.widget.resolveCalculatorSatsValue import to.bitkit.ui.components.KEY_000 @@ -18,6 +19,7 @@ import to.bitkit.ui.screens.widgets.calculator.formatBitcoinValue import to.bitkit.ui.screens.widgets.calculator.formatFiatPlaceholder import to.bitkit.ui.screens.widgets.calculator.formatFiatValue import to.bitkit.ui.screens.widgets.calculator.isBtcValueInSatsRange +import to.bitkit.ui.screens.widgets.calculator.refreshSource import to.bitkit.ui.screens.widgets.calculator.sanitizeDecimalInput import to.bitkit.ui.screens.widgets.calculator.sanitizeIntegerInput import to.bitkit.ui.screens.widgets.calculator.shouldHydrateFiatFromStoredBtc @@ -105,6 +107,34 @@ class CalculatorCardStateTest { assertFalse(result) } + @Test + fun `refreshSource preserves active fiat input when both values exist`() { + val values = CalculatorValues(btcValue = "10000", fiatValue = "12.34") + + assertEquals(MoneyType.FIAT, values.refreshSource(activeInput = MoneyType.FIAT)) + } + + @Test + fun `refreshSource preserves active bitcoin input when fiat-only would otherwise win`() { + val values = CalculatorValues(btcValue = "", fiatValue = "12.34") + + assertEquals(MoneyType.BITCOIN, values.refreshSource(activeInput = MoneyType.BITCOIN)) + } + + @Test + fun `refreshSource falls back to fiat for fiat-only value`() { + val values = CalculatorValues(btcValue = "", fiatValue = "12.34") + + assertEquals(MoneyType.FIAT, values.refreshSource(activeInput = null)) + } + + @Test + fun `refreshSource falls back to bitcoin when both values exist`() { + val values = CalculatorValues(btcValue = "10000", fiatValue = "12.34") + + assertEquals(MoneyType.BITCOIN, values.refreshSource(activeInput = null)) + } + @Test fun `toCalculatorDisplaySymbol trims and keeps up to two chars`() { assertEquals("$", " $ ".toCalculatorDisplaySymbol()) From 88559ffe2a7f0228f767a634d101aa6e7a3055ed Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 25 May 2026 15:52:32 +0200 Subject: [PATCH 3/5] fix: guard calculator refresh writes --- .../widgets/calculator/CalculatorViewModel.kt | 29 +++++------- .../calculator/CalculatorViewModelTest.kt | 44 +++++++++++++++++++ 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt index 223b0b600..9aef39bcc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt @@ -46,6 +46,7 @@ class CalculatorViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private var pendingValues: CalculatorValues? = null private var lastCurrencyKey: CalculatorCurrencyKey? = null + private var lastRates: ImmutableList? = null private var activeInput: MoneyType? = null val isCalculatorWidgetEnabled: StateFlow = widgetsRepo.widgetsDataFlow @@ -164,14 +165,17 @@ class CalculatorViewModel @Inject constructor( val currencyKey = CalculatorCurrencyKey( selectedCurrency = currencyState.selectedCurrency, displayUnit = currencyState.displayUnit, - rates = currencyState.rates, ) val previousCurrencyKey = lastCurrencyKey + val previousRates = lastRates lastCurrencyKey = currencyKey + lastRates = currencyState.rates - val currencyChanged = previousCurrencyKey != null && previousCurrencyKey != currencyKey + val currencyChanged = previousCurrencyKey != null && + previousCurrencyKey.selectedCurrency != currencyKey.selectedCurrency val displayUnitChanged = previousCurrencyKey != null && previousCurrencyKey.displayUnit != currencyKey.displayUnit + val ratesChanged = previousRates != null && previousRates != currencyState.rates val isInitialSync = previousCurrencyKey == null val nextActiveValues = deriveActiveValues( activeValues = activeValues, @@ -188,7 +192,7 @@ class CalculatorViewModel @Inject constructor( ) } - val shouldRefreshFiat = isInitialSync || currencyChanged || shouldHydrateFiatFromStoredBtc( + val shouldRefreshFiat = isInitialSync || currencyChanged || ratesChanged || shouldHydrateFiatFromStoredBtc( storedBtcValue = storedValues.btcValue, storedFiatValue = storedValues.fiatValue, currentFiatValue = nextActiveValues.fiatValue, @@ -219,7 +223,10 @@ class CalculatorViewModel @Inject constructor( if (convertedFiat.isEmpty()) return persistCanonicalValues(activeValues, nextActiveValues) val updatedValues = nextActiveValues.copy(fiatValue = convertedFiat) - updateCalculatorValues(updatedValues) + persistCanonicalValuesIfNeeded( + activeValues = activeValues, + nextActiveValues = updatedValues, + ) return updatedValues } @@ -228,18 +235,7 @@ class CalculatorViewModel @Inject constructor( nextActiveValues: CalculatorValues, displayUnit: BitcoinDisplayUnit, ): CalculatorValues { - if (nextActiveValues.fiatValue.isEmpty()) { - val updatedValues = nextActiveValues.copy( - btcValue = "", - satsValue = 0L, - displayUnit = displayUnit, - ) - persistCanonicalValuesIfNeeded( - activeValues = activeValues, - nextActiveValues = updatedValues, - ) - return updatedValues - } + if (nextActiveValues.fiatValue.isEmpty()) return persistCanonicalValues(activeValues, nextActiveValues) val satsValue = convertFiatToSats(nextActiveValues.fiatValue) val updatedValues = nextActiveValues.copy( @@ -382,7 +378,6 @@ data class CalculatorUiState( private data class CalculatorCurrencyKey( val selectedCurrency: String, val displayUnit: BitcoinDisplayUnit, - val rates: ImmutableList, ) internal fun shouldHydrateFiatFromStoredBtc( diff --git a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt index 7b333531e..5961f9caf 100644 --- a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt @@ -14,6 +14,7 @@ import to.bitkit.data.WidgetsData import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.ConvertedAmount import to.bitkit.models.FxRate +import to.bitkit.models.MoneyType import to.bitkit.models.widget.CalculatorValues import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.CurrencyState @@ -34,6 +35,7 @@ class CalculatorViewModelTest : BaseUnitTest() { private var fiatConversionValue: String? = null private var fiatConversionFormatted: String? = null private var fiatToSatsValue = 12_345uL + private var updateCalculatorValuesCalls = 0 private lateinit var sut: CalculatorViewModel @@ -46,6 +48,7 @@ class CalculatorViewModelTest : BaseUnitTest() { fiatConversionValue = null fiatConversionFormatted = null fiatToSatsValue = 12_345uL + updateCalculatorValuesCalls = 0 whenever(widgetsRepo.widgetsDataFlow).thenReturn(widgetsData) whenever(currencyRepo.currencyState).thenReturn(currencyState) @@ -64,6 +67,7 @@ class CalculatorViewModelTest : BaseUnitTest() { } whenever(currencyRepo.convertFiatToSats(any(), anyOrNull())).thenAnswer { fiatToSatsValue } whenever { widgetsRepo.updateCalculatorValues(any()) }.thenAnswer { + updateCalculatorValuesCalls++ val calculatorValues = it.getArgument(0) widgetsData.value = widgetsData.value.copy(calculatorValues = calculatorValues) Unit @@ -289,6 +293,25 @@ class CalculatorViewModelTest : BaseUnitTest() { assertEquals("7.50", widgetsData.value.calculatorValues.fiatValue) } + @Test + fun `rate refresh skips persist when fiat display value is unchanged`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onBtcInputChanged("10000") + advanceUntilIdle() + + val updatesBeforeRateRefresh = updateCalculatorValuesCalls + currencyState.value = currencyState.value.copy( + rates = persistentListOf(fxRate(lastPrice = "62501")), + ) + advanceUntilIdle() + + assertEquals("10000", sut.uiState.value.btcValue) + assertEquals("6.25", sut.uiState.value.fiatValue) + assertEquals(updatesBeforeRateRefresh, updateCalculatorValuesCalls) + } + @Test fun `currency change preserves fiat input as source`() = test { sut = createSut() @@ -312,6 +335,27 @@ class CalculatorViewModelTest : BaseUnitTest() { assertEquals("12.34", widgetsData.value.calculatorValues.fiatValue) } + @Test + fun `active empty fiat refresh preserves bitcoin value`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onInputSelected(MoneyType.FIAT) + widgetsData.value = WidgetsData( + calculatorValues = CalculatorValues( + btcValue = "10000", + fiatValue = "", + satsValue = 10_000L, + displayUnit = BitcoinDisplayUnit.MODERN, + ) + ) + advanceUntilIdle() + + assertEquals("10000", sut.uiState.value.btcValue) + assertEquals("", sut.uiState.value.fiatValue) + assertEquals(10_000L, widgetsData.value.calculatorValues.satsValue) + } + @Test fun `display unit change preserves btc amount`() = test { widgetsData.value = WidgetsData( From 90029b1b9561799f186e475786577752bcbf8331 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 25 May 2026 16:04:47 +0200 Subject: [PATCH 4/5] fix: preserve btc on rate failure --- .../widgets/calculator/CalculatorViewModel.kt | 13 +++++++-- .../calculator/CalculatorViewModelTest.kt | 28 +++++++++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt index 9aef39bcc..d48db22f9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt @@ -237,7 +237,8 @@ class CalculatorViewModel @Inject constructor( ): CalculatorValues { if (nextActiveValues.fiatValue.isEmpty()) return persistCanonicalValues(activeValues, nextActiveValues) - val satsValue = convertFiatToSats(nextActiveValues.fiatValue) + val satsValue = convertFiatToSatsOrNull(nextActiveValues.fiatValue) + ?: return persistCanonicalValues(activeValues, nextActiveValues) val updatedValues = nextActiveValues.copy( btcValue = calculatorSatsToBtcValue(satsValue, displayUnit), satsValue = satsValue, @@ -361,8 +362,14 @@ class CalculatorViewModel @Inject constructor( } private fun convertFiatToSats(fiatValue: String): Long { - val fiatDecimal = fiatValue.toBigDecimalOrNull() ?: BigDecimal.ZERO - return currencyRepo.convertFiatToSats(fiatDecimal).getOrNull()?.toLong() ?: 0L + return convertFiatToSatsOrNull(fiatValue) ?: 0L + } + + private fun convertFiatToSatsOrNull(fiatValue: String): Long? { + val fiatDecimal = fiatValue.toBigDecimalOrNull() ?: return null + return runCatching { + currencyRepo.convertFiatToSats(fiatDecimal).getOrNull()?.toLong() + }.getOrNull() } } diff --git a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt index 5961f9caf..5d53c98e8 100644 --- a/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModelTest.kt @@ -20,6 +20,7 @@ import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.CurrencyState import to.bitkit.repositories.WidgetsRepo import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.ServiceError import java.math.BigDecimal import java.util.Locale import kotlin.test.assertEquals @@ -34,7 +35,7 @@ class CalculatorViewModelTest : BaseUnitTest() { private var lastConvertedSats = 0L private var fiatConversionValue: String? = null private var fiatConversionFormatted: String? = null - private var fiatToSatsValue = 12_345uL + private var fiatToSatsValue: ULong? = 12_345uL private var updateCalculatorValuesCalls = 0 private lateinit var sut: CalculatorViewModel @@ -65,7 +66,9 @@ class CalculatorViewModelTest : BaseUnitTest() { sats = sats, ) } - whenever(currencyRepo.convertFiatToSats(any(), anyOrNull())).thenAnswer { fiatToSatsValue } + whenever(currencyRepo.convertFiatToSats(any(), anyOrNull())).thenAnswer { + fiatToSatsValue ?: throw ServiceError.CurrencyRateUnavailable() + } whenever { widgetsRepo.updateCalculatorValues(any()) }.thenAnswer { updateCalculatorValuesCalls++ val calculatorValues = it.getArgument(0) @@ -335,6 +338,27 @@ class CalculatorViewModelTest : BaseUnitTest() { assertEquals("12.34", widgetsData.value.calculatorValues.fiatValue) } + @Test + fun `active fiat refresh preserves bitcoin when conversion is unavailable`() = test { + sut = createSut() + advanceUntilIdle() + + sut.onFiatInputChanged("12.34") + advanceUntilIdle() + + fiatToSatsValue = null + val updatesBeforeRateRefresh = updateCalculatorValuesCalls + currencyState.value = currencyState.value.copy( + rates = persistentListOf(fxRate(lastPrice = "62501")), + ) + advanceUntilIdle() + + assertEquals("12345", sut.uiState.value.btcValue) + assertEquals("12.34", sut.uiState.value.fiatValue) + assertEquals(12_345L, widgetsData.value.calculatorValues.satsValue) + assertEquals(updatesBeforeRateRefresh, updateCalculatorValuesCalls) + } + @Test fun `active empty fiat refresh preserves bitcoin value`() = test { sut = createSut() From abc77a87bc87d0afa12d1928e774c820bda9ffd9 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 25 May 2026 16:20:07 +0200 Subject: [PATCH 5/5] fix: require calculator input callbacks --- .../screens/widgets/calculator/CalculatorPreviewScreen.kt | 7 +++++++ .../widgets/calculator/components/CalculatorCard.kt | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt index 272912315..116a96f0f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt @@ -17,6 +17,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.MoneyType import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -45,6 +46,8 @@ fun CalculatorPreviewScreen( uiState = uiState, onBtcChange = viewModel::onBtcInputChanged, onFiatChange = viewModel::onFiatInputChanged, + onInputSelected = viewModel::onInputSelected, + onInputDismissed = viewModel::onInputDismissed, onClickDelete = { viewModel.removeWidget() onClose() @@ -67,6 +70,8 @@ fun CalculatorPreviewContent( uiState: CalculatorUiState = CalculatorUiState(), onBtcChange: (String) -> Unit = {}, onFiatChange: (String) -> Unit = {}, + onInputSelected: (MoneyType) -> Unit = {}, + onInputDismissed: () -> Unit = {}, ) { ScreenColumn( modifier = modifier.testTag("calculator_preview_screen") @@ -117,6 +122,8 @@ fun CalculatorPreviewContent( fiatName = uiState.selectedCurrency, fiatValue = uiState.fiatValue, onFiatChange = onFiatChange, + onInputSelected = onInputSelected, + onInputDismissed = onInputDismissed, modifier = Modifier .fillMaxWidth() .testTag("calculator_card_wide") diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt index 04abff85e..77ed429de 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt @@ -105,10 +105,10 @@ fun CalculatorCardEditor( fiatName: String, fiatValue: String, onFiatChange: (String) -> Unit, + onInputSelected: (MoneyType) -> Unit, + onInputDismissed: () -> Unit, dismissNumberPadKey: Int = 0, onInputActiveChange: (Boolean) -> Unit = {}, - onInputSelected: (MoneyType) -> Unit = {}, - onInputDismissed: () -> Unit = {}, onNumberPadBoundsChanged: (Rect?) -> Unit = {}, ) { val numpadState = rememberNumpadState() @@ -526,6 +526,8 @@ private fun Preview() { fiatValue = "4.55", fiatName = "USD", onFiatChange = {}, + onInputSelected = {}, + onInputDismissed = {}, btcPrimaryDisplayUnit = BitcoinDisplayUnit.MODERN, modifier = Modifier.fillMaxWidth() ) @@ -537,6 +539,8 @@ private fun Preview() { fiatValue = "4.55", fiatName = "USD", onFiatChange = {}, + onInputSelected = {}, + onInputDismissed = {}, btcPrimaryDisplayUnit = BitcoinDisplayUnit.CLASSIC, modifier = Modifier.fillMaxWidth() )