From 068fb917d7c09f0d62ed014721183954671698b3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 14:18:33 +0200 Subject: [PATCH 01/14] fix: require payment pin for quickpay --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 12 ++++++++++- .../viewmodels/AppViewModelSendFlowTest.kt | 21 ++++++++++++++++--- .../next/require-payment-pin.security.md | 1 + 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 changelog.d/next/require-payment-pin.security.md diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 849726cc2..842bf4fc8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -74,6 +74,7 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin @@ -2603,7 +2604,7 @@ class AppViewModel @Inject constructor( if (hasActiveContactPaymentContext()) return false val settings = settingsStore.data.first() - if (!settings.isQuickPayEnabled || amountSats == 0uL) return false + if (!canApplyQuickPay(settings, amountSats)) return false val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() ?: return false @@ -2636,6 +2637,15 @@ class AppViewModel @Inject constructor( return false } + private fun canApplyQuickPay(settings: SettingsData, amountSats: ULong): Boolean { + if (!settings.isQuickPayEnabled || amountSats == 0uL) return false + if (settings.isPinEnabled && settings.isPinForPaymentsEnabled) { + Logger.debug("Skipping QuickPay because PIN is required for payments", context = TAG) + return false + } + return true + } + private fun resetAmountInput() { _sendUiState.update { state -> state.copy( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 6822141c6..1fbd60d6a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2155,7 +2155,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `lightning scan uses QuickPay when PIN is required for payments`() = test { + fun `lightning scan skips QuickPay when PIN is required for payments`() = test { val bolt11 = "lnbcrt1quickpaypin" enableQuickPay(thresholdSats = 1000u) settingsData.value = settingsData.value.copy( @@ -2168,6 +2168,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onScanResult(bolt11) advanceUntilIdle() + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + + @Test + fun `lightning scan uses QuickPay when PIN is on without PIN for payments`() = test { + val bolt11 = "lnbcrt1quickpayunlocked" + enableQuickPay(thresholdSats = 1000u) + settingsData.value = settingsData.value.copy(isPinEnabled = true) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.setIsAuthenticated(true) + + sut.onScanResult(bolt11) + advanceUntilIdle() + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } @@ -2192,8 +2207,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) advanceUntilIdle() - assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) - assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) verify(coreService).decode(bolt11) } diff --git a/changelog.d/next/require-payment-pin.security.md b/changelog.d/next/require-payment-pin.security.md new file mode 100644 index 000000000..dbb88e6bc --- /dev/null +++ b/changelog.d/next/require-payment-pin.security.md @@ -0,0 +1 @@ +QuickPay now asks for the payment PIN when that setting is enabled. From 76cb9ef2fc367e481baea0bc5448473c800f5589 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 14:26:10 +0200 Subject: [PATCH 02/14] chore: rename changelog fragment --- .../next/{require-payment-pin.security.md => 1159.security.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{require-payment-pin.security.md => 1159.security.md} (100%) diff --git a/changelog.d/next/require-payment-pin.security.md b/changelog.d/next/1159.security.md similarity index 100% rename from changelog.d/next/require-payment-pin.security.md rename to changelog.d/next/1159.security.md From 01cc20880fe28d478b322cda8dc2e3e762812959 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 15:43:38 +0200 Subject: [PATCH 03/14] fix: add quickpay daily spend limit --- .../main/java/to/bitkit/data/CacheStore.kt | 17 +++++ .../main/java/to/bitkit/data/SettingsStore.kt | 1 + app/src/main/java/to/bitkit/ext/DateTime.kt | 6 ++ .../quickPay/QuickPaySettingsScreen.kt | 35 +++++++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 66 ++++++++++--------- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 13 ++++ .../to/bitkit/viewmodels/SettingsViewModel.kt | 9 +++ app/src/main/res/values/strings.xml | 2 + .../java/to/bitkit/data/CacheStoreTest.kt | 25 +++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 48 ++++++++++++-- changelog.d/next/1159.security.md | 2 +- 11 files changed, 185 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index dc337a825..0a8fe8e5d 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -139,6 +139,21 @@ class CacheStore @Inject constructor( store.updateData { it.copy(backgroundReceive = null) } } + suspend fun quickPaySpentUsdForDay(dayKey: String): Double { + val data = store.data.first() + return if (data.quickPaySpendDayKey == dayKey) data.quickPaySpentUsdToday else 0.0 + } + + suspend fun recordQuickPaySpendUsd(amountUsd: Double, dayKey: String) { + store.updateData { + if (it.quickPaySpendDayKey != dayKey) { + it.copy(quickPaySpendDayKey = dayKey, quickPaySpentUsdToday = amountUsd) + } else { + it.copy(quickPaySpentUsdToday = it.quickPaySpentUsdToday + amountUsd) + } + } + } + suspend fun reset() { store.updateData { AppCacheData() } Logger.info("Deleted all app cached data.") @@ -164,6 +179,8 @@ data class AppCacheData( val backgroundReceive: NewTransactionSheetDetails? = null, val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), + val quickPaySpendDayKey: String = "", + val quickPaySpentUsdToday: Double = 0.0, ) { fun isActivityDeleted(activityId: String, walletId: String): Boolean = scopedActivityId(walletId, activityId) in deletedActivities || diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 5904e4881..eddec111d 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -128,6 +128,7 @@ data class SettingsData( val bgPaymentsIntroSeen: Boolean = false, val isQuickPayEnabled: Boolean = false, val quickPayAmount: Int = 5, + val quickPayDailyLimitMultiplier: Int = 5, val lightningSetupStep: Int = 0, val isPinEnabled: Boolean = false, val isBiometricEnabled: Boolean = false, diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 8358e3871..145b2908d 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -39,6 +39,12 @@ fun nowMillis(clock: Clock = Clock.System): Long = clock.now().toEpochMillisecon @OptIn(ExperimentalTime::class) fun Clock.nowMs(): Long = now().toEpochMilliseconds() +@OptIn(ExperimentalTime::class) +fun quickPaySpendDayKey( + clock: Clock = Clock.System, + timeZone: TimeZone = TimeZone.currentSystemDefault(), +): String = clock.now().toLocalDateTime(timeZone).date.toString() + fun nowTimestamp(): Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) fun dateTimeFormatterOf( diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index 145bea29e..0ada40b55 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -38,12 +38,15 @@ fun QuickPaySettingsScreen( ) { val isQuickPayEnabled by settingsViewModel.isQuickpayEnabled.collectAsStateWithLifecycle() val quickPayAmount by settingsViewModel.quickPayAmount.collectAsStateWithLifecycle() + val quickPayDailyLimitMultiplier by settingsViewModel.quickPayDailyLimitMultiplier.collectAsStateWithLifecycle() QuickPaySettingsScreenContent( isQuickPayEnabled = isQuickPayEnabled, quickPayAmount = quickPayAmount, + quickPayDailyLimitMultiplier = quickPayDailyLimitMultiplier, onToggleQuickPay = settingsViewModel::setIsQuickPayEnabled, onQuickPayAmountChange = settingsViewModel::setQuickPayAmount, + onQuickPayDailyLimitMultiplierChange = settingsViewModel::setQuickPayDailyLimitMultiplier, onBack = onBack, ) } @@ -52,11 +55,15 @@ fun QuickPaySettingsScreen( fun QuickPaySettingsScreenContent( isQuickPayEnabled: Boolean, quickPayAmount: Int, + quickPayDailyLimitMultiplier: Int, onToggleQuickPay: (Boolean) -> Unit = {}, onQuickPayAmountChange: (Int) -> Unit = {}, + onQuickPayDailyLimitMultiplierChange: (Int) -> Unit = {}, onBack: () -> Unit = {}, ) { val sliderSteps = remember { persistentListOf(1, 5, 10, 20, 50) } + val dailyLimitSteps = remember { persistentListOf(1, 3, 5, 10, 50) } + val dailyLimitUsd = quickPayAmount * quickPayDailyLimitMultiplier ScreenColumn { AppTopBar( @@ -98,7 +105,32 @@ fun QuickPaySettingsScreenContent( value = quickPayAmount, steps = sliderSteps, onValueChange = onQuickPayAmountChange, - modifier = Modifier.testTag("quickpay_amount_slider") + modifier = Modifier.testTag("QuickpayAmountSlider") + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Caption13Up( + text = stringResource(R.string.settings__quickpay__settings__daily_label), + color = Colors.White64, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + BodyM( + text = stringResource(R.string.settings__quickpay__settings__daily_text) + .replace("{limit}", dailyLimitUsd.toString()) + .replace("{multiplier}", quickPayDailyLimitMultiplier.toString()), + color = Colors.White64, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + StepSlider( + value = quickPayDailyLimitMultiplier, + steps = dailyLimitSteps, + onValueChange = onQuickPayDailyLimitMultiplierChange, + modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) Spacer(modifier = Modifier.weight(1f)) @@ -128,6 +160,7 @@ private fun Preview() { QuickPaySettingsScreenContent( isQuickPayEnabled = true, quickPayAmount = 5, + quickPayDailyLimitMultiplier = 5, ) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 842bf4fc8..fb54acf8b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -74,7 +74,6 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin @@ -98,6 +97,7 @@ import to.bitkit.ext.maxSendableSat import to.bitkit.ext.maxWithdrawableSat import to.bitkit.ext.minSendableSat import to.bitkit.ext.minWithdrawableSat +import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.rawId import to.bitkit.ext.removeSpaces import to.bitkit.ext.runSuspendCatching @@ -2601,48 +2601,52 @@ class AppViewModel @Inject constructor( lnurlPay: LnurlPayData? = null, invoice: LightningInvoice? = null, ): Boolean { - if (hasActiveContactPaymentContext()) return false - - val settings = settingsStore.data.first() - if (!canApplyQuickPay(settings, amountSats)) return false - - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() - ?: return false - - if (amountSats <= quickPayAmountSats) { - Logger.info("Using QuickPay: $amountSats sats <= $quickPayAmountSats sats threshold", context = TAG) + if (!canApplyQuickPay(amountSats)) return false - val quickPayData: QuickPayData = when { - lnurlPay != null -> { - QuickPayData.LnurlPay( - sats = amountSats, - data = lnurlPay, - ) - } + Logger.info("Using QuickPay for '$amountSats' sats", context = TAG) - else -> { - val decodedInvoice = requireNotNull(invoice) - QuickPayData.Bolt11(sats = amountSats, bolt11 = decodedInvoice.bolt11) - } + val quickPayData: QuickPayData = when { + lnurlPay != null -> { + QuickPayData.LnurlPay( + sats = amountSats, + data = lnurlPay, + ) } - _quickPayData.update { quickPayData } + else -> { + val decodedInvoice = requireNotNull(invoice) + QuickPayData.Bolt11(sats = amountSats, bolt11 = decodedInvoice.bolt11) + } + } - Logger.debug("QuickPayData: $quickPayData", context = TAG) + _quickPayData.update { quickPayData } - navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) - return true - } + Logger.debug("QuickPayData: $quickPayData", context = TAG) - return false + navigateToSendRoute(fromMainScanner, SendRoute.QuickPay, SendEffect.NavigateToQuickPay) + return true } - private fun canApplyQuickPay(settings: SettingsData, amountSats: ULong): Boolean { + private suspend fun canApplyQuickPay(amountSats: ULong): Boolean { + if (hasActiveContactPaymentContext()) return false + + val settings = settingsStore.data.first() if (!settings.isQuickPayEnabled || amountSats == 0uL) return false - if (settings.isPinEnabled && settings.isPinForPaymentsEnabled) { - Logger.debug("Skipping QuickPay because PIN is required for payments", context = TAG) + + val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() + if (quickPayAmountSats == null || amountUsd == null || amountSats > quickPayAmountSats) return false + + val dailyCapUsd = settings.quickPayAmount.toDouble() * settings.quickPayDailyLimitMultiplier + val spentUsdToday = cacheStore.quickPaySpentUsdForDay(quickPaySpendDayKey()) + if (spentUsdToday + amountUsd > dailyCapUsd) { + Logger.info( + "Skipping QuickPay: daily spend '$spentUsdToday' + '$amountUsd' exceeds cap '$dailyCapUsd'", + context = TAG, + ) return false } + return true } diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 0599d4ae4..e4fb98fa0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -11,10 +11,13 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentId +import to.bitkit.data.CacheStore import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats +import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.toUserMessage import to.bitkit.ext.watchUntil +import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException import to.bitkit.repositories.PendingPaymentRepo @@ -27,6 +30,8 @@ class QuickPayViewModel @Inject constructor( @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, private val pendingPaymentRepo: PendingPaymentRepo, + private val currencyRepo: CurrencyRepo, + private val cacheStore: CacheStore, ) : ViewModel() { companion object { @@ -65,6 +70,7 @@ class QuickPayViewModel @Inject constructor( sendLightning(bolt11, amount) .onSuccess { paymentHash -> Logger.info("QuickPay lightning payment successful") + recordQuickPaySpend(displaySats) _uiState.update { it.copy( result = QuickPayResult.Success( @@ -77,6 +83,7 @@ class QuickPayViewModel @Inject constructor( if (error is PaymentPendingException) { Logger.info("QuickPay lightning payment pending", context = TAG) pendingPaymentRepo.track(error.paymentHash) + recordQuickPaySpend(displaySats) _uiState.update { it.copy( result = QuickPayResult.Pending( @@ -96,6 +103,12 @@ class QuickPayViewModel @Inject constructor( } } + private suspend fun recordQuickPaySpend(amountSats: ULong) { + val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() + ?: return + cacheStore.recordQuickPaySpendUsd(amountUsd, quickPaySpendDayKey()) + } + private suspend fun sendLightning( bolt11: String, amount: ULong? = null, diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 9410f5c5f..e406efd3e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -348,6 +348,15 @@ class SettingsViewModel @Inject constructor( } } + val quickPayDailyLimitMultiplier = settingsStore.data.map { it.quickPayDailyLimitMultiplier } + .asStateFlow(initialValue = 5) + + fun setQuickPayDailyLimitMultiplier(value: Int) { + viewModelScope.launch { + settingsStore.update { it.copy(quickPayDailyLimitMultiplier = value) } + } + } + val enableSwipeToHideBalance = settingsStore.data.map { it.enableSwipeToHideBalance } .asStateFlow(initialValue = true) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f0682904c..3cf21d8f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -933,6 +933,8 @@ Bitkit QuickPay makes checking out faster by automatically paying QR codes when scanned. <accent>Frictionless</accent>\npayments QuickPay + Daily QuickPay limit + Auto-pay up to ${limit} per day without confirmation or PIN ({multiplier}× your threshold). After that, payments open Confirm. Quickpay threshold * Bitkit QuickPay exclusively supports payments from your Spending Balance. If enabled, scanned invoices below ${amount} will be paid automatically without requiring your confirmation or PIN*. diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index 915f35d3b..90855eb81 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -91,4 +91,29 @@ class CacheStoreTest : BaseUnitTest() { sut.data.first().deletedActivities, ) } + + @Test + fun `quickPaySpentUsdForDay returns spend for matching day key`() = test { + sut.recordQuickPaySpendUsd(amountUsd = 3.5, dayKey = "2026-08-15") + + assertEquals(3.5, sut.quickPaySpentUsdForDay("2026-08-15")) + } + + @Test + fun `quickPaySpentUsdForDay returns zero for a different day key`() = test { + sut.recordQuickPaySpendUsd(amountUsd = 12.0, dayKey = "2026-08-14") + + assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + } + + @Test + fun `recordQuickPaySpendUsd accumulates on the same day and resets on a new day`() = test { + sut.recordQuickPaySpendUsd(amountUsd = 2.0, dayKey = "2026-08-15") + sut.recordQuickPaySpendUsd(amountUsd = 1.5, dayKey = "2026-08-15") + assertEquals(3.5, sut.quickPaySpentUsdForDay("2026-08-15")) + + sut.recordQuickPaySpendUsd(amountUsd = 4.0, dayKey = "2026-08-16") + assertEquals(4.0, sut.quickPaySpentUsdForDay("2026-08-16")) + assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 1fbd60d6a..1f7dc0d13 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -60,6 +60,7 @@ import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler import to.bitkit.models.BalanceState +import to.bitkit.models.ConvertedAmount import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection @@ -117,8 +118,10 @@ import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError import to.bitkit.utils.timedsheets.TimedSheetManager +import java.math.BigDecimal import java.net.URLEncoder import java.nio.charset.StandardCharsets +import java.util.Locale import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -220,6 +223,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(backupRepo.isRestoring).thenReturn(MutableStateFlow(false)) stubSettingsStore() whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) + whenever { cacheStore.quickPaySpentUsdForDay(any()) }.thenReturn(0.0) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) whenever { blocktankRepo.refreshInfo() }.thenReturn(Result.success(Unit)) @@ -2155,7 +2159,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `lightning scan skips QuickPay when PIN is required for payments`() = test { + fun `lightning scan uses QuickPay when PIN is required for payments under daily cap`() = test { val bolt11 = "lnbcrt1quickpaypin" enableQuickPay(thresholdSats = 1000u) settingsData.value = settingsData.value.copy( @@ -2168,8 +2172,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onScanResult(bolt11) advanceUntilIdle() - assertNull(sut.quickPayData.value) - assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } @Test @@ -2187,6 +2191,21 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) } + @Test + fun `lightning scan skips QuickPay when daily spend cap is exceeded`() = test { + val bolt11 = "lnbcrt1quickpaycap" + enableQuickPay(thresholdSats = 1000u, spentUsdToday = 24.0) + settingsData.value = settingsData.value.copy(quickPayDailyLimitMultiplier = 5) + stubLightningScan(bolt11 = bolt11, amountSats = 500u) + sut.setIsAuthenticated(true) + + sut.onScanResult(bolt11) + advanceUntilIdle() + + assertNull(sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + } + @Test fun `QuickPay eligible scan remains deferred until authenticated`() = test { val bolt11 = "lnbcrt1lockedscan" @@ -2207,8 +2226,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setIsAuthenticated(true) advanceUntilIdle() - assertNull(sut.quickPayData.value) - assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(QuickPayData.Bolt11(sats = 500u, bolt11 = bolt11), sut.quickPayData.value) + assertEquals(Sheet.Send(SendRoute.QuickPay), sut.currentSheet.value) verify(coreService).decode(bolt11) } @@ -3194,9 +3213,26 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() } - private fun enableQuickPay(thresholdSats: ULong) { + private fun enableQuickPay( + thresholdSats: ULong, + spentUsdToday: Double = 0.0, + ) { settingsData.value = SettingsData(isQuickPayEnabled = true, quickPayAmount = 5) whenever(currencyRepo.convertFiatToSats(5.0, "USD")).thenReturn(Result.success(thresholdSats)) + whenever(currencyRepo.convertSatsToFiat(any(), anyOrNull())).thenAnswer { invocation -> + val sats = invocation.getArgument(0) + val usd = 5.0 * sats.toDouble() / thresholdSats.toDouble() + ConvertedAmount( + value = BigDecimal.valueOf(usd), + formatted = usd.toString(), + symbol = "$", + currency = "USD", + flag = "", + sats = sats, + locale = Locale.US, + ) + } + whenever { cacheStore.quickPaySpentUsdForDay(any()) }.thenReturn(spentUsdToday) } private suspend fun stubLightningScan(bolt11: String, amountSats: ULong) { diff --git a/changelog.d/next/1159.security.md b/changelog.d/next/1159.security.md index dbb88e6bc..3a5339f9f 100644 --- a/changelog.d/next/1159.security.md +++ b/changelog.d/next/1159.security.md @@ -1 +1 @@ -QuickPay now asks for the payment PIN when that setting is enabled. +QuickPay stays PIN-free under a configurable daily spend limit; once that limit is reached, payments open Confirm instead. From 99b47d87d0d5378afb2580d5ead8a5bcad5c0163 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 15:52:28 +0200 Subject: [PATCH 04/14] fix: show multiplier steps as times --- app/src/main/java/to/bitkit/ui/components/Slider.kt | 3 ++- .../to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 8c6d12405..fd7f07b6d 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -60,6 +60,7 @@ fun StepSlider( steps: ImmutableList, onValueChange: (Int) -> Unit, modifier: Modifier = Modifier, + formatLabel: (Int) -> String = { "$$it" }, ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() @@ -228,7 +229,7 @@ fun StepSlider( steps.forEachIndexed { index, step -> if (stepPositions.isNotEmpty() && index < stepPositions.size) { Caption13Up( - text = "$$step", + text = formatLabel(step), color = Colors.White64, textAlign = TextAlign.Center, modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index 0ada40b55..23992e2fe 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -130,6 +130,7 @@ fun QuickPaySettingsScreenContent( value = quickPayDailyLimitMultiplier, steps = dailyLimitSteps, onValueChange = onQuickPayDailyLimitMultiplierChange, + formatLabel = { "${it}×" }, modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) From fbe9321e3649bfe51311c4eb0f30bf2189ba1bb6 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 16:46:32 +0200 Subject: [PATCH 05/14] fix: include steplider labels in layout bounds --- .../java/to/bitkit/ui/components/Slider.kt | 239 ++++++++++-------- 1 file changed, 128 insertions(+), 111 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index fd7f07b6d..6b353b306 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -117,130 +117,147 @@ fun StepSlider( sliderWidth = coordinates.size.width } ) { - // Track and step markers - Canvas( - modifier = Modifier - .fillMaxWidth() - .height(KNOB_SIZE_DP.dp) - .pointerInput(Unit) { - detectTapGestures { offset -> - val (closestStep, closestIndex) = findClosestStep(offset.x) - coroutineScope.launch { - knobPosition.animateTo( - targetValue = closestStep, - animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), - ) + Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(KNOB_SIZE_DP.dp) + ) { + // Track and step markers + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(KNOB_SIZE_DP.dp) + .pointerInput(Unit) { + detectTapGestures { offset -> + val (closestStep, closestIndex) = findClosestStep(offset.x) + coroutineScope.launch { + knobPosition.animateTo( + targetValue = closestStep, + animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), + ) + } + onValueChange(steps[closestIndex]) + } } - onValueChange(steps[closestIndex]) - } - } - ) { - val trackY = center.y - val trackHeight = density.run { TRACK_HEIGHT_DP.dp.toPx() } - val cornerRadius = density.run { 3.dp.toPx() } + ) { + val trackY = center.y + val trackHeight = density.run { TRACK_HEIGHT_DP.dp.toPx() } + val cornerRadius = density.run { 3.dp.toPx() } - // Draw inactive track - drawRoundRect( - color = Colors.Green32, - topLeft = Offset(0f, trackY - trackHeight / 2), - size = Size(size.width, trackHeight), - cornerRadius = CornerRadius(cornerRadius), - ) - - // Draw active track - val activeWidth = knobPosition.value - if (activeWidth > 0) { - drawRoundRect( - color = Colors.Green, - topLeft = Offset(0f, trackY - trackHeight / 2), - size = Size(activeWidth, trackHeight), - cornerRadius = CornerRadius(cornerRadius), - ) - } + // Draw inactive track + drawRoundRect( + color = Colors.Green32, + topLeft = Offset(0f, trackY - trackHeight / 2), + size = Size(size.width, trackHeight), + cornerRadius = CornerRadius(cornerRadius), + ) - // Draw step markers - val markerWidth = density.run { STEP_MARKER_WIDTH_DP.dp.toPx() } - val markerHeight = density.run { STEP_MARKER_HEIGHT_DP.dp.toPx() } - val markerRadius = density.run { 2.5.dp.toPx() } + // Draw active track + val activeWidth = knobPosition.value + if (activeWidth > 0) { + drawRoundRect( + color = Colors.Green, + topLeft = Offset(0f, trackY - trackHeight / 2), + size = Size(activeWidth, trackHeight), + cornerRadius = CornerRadius(cornerRadius), + ) + } - stepPositions.forEach { position -> - drawRoundRect( - color = Colors.White, - topLeft = Offset(position - markerWidth / 2, trackY - markerHeight / 2), - size = Size(markerWidth, markerHeight), - cornerRadius = CornerRadius(markerRadius), - ) - } - } + // Draw step markers + val markerWidth = density.run { STEP_MARKER_WIDTH_DP.dp.toPx() } + val markerHeight = density.run { STEP_MARKER_HEIGHT_DP.dp.toPx() } + val markerRadius = density.run { 2.5.dp.toPx() } - // Knob - Box( - modifier = Modifier - .offset { - IntOffset( - x = (knobPosition.value - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), - y = 0, - ) - } - .size(KNOB_SIZE_DP.dp) - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { _ -> - // No action needed on drag start - }, - onDragEnd = { - val (closestStep, closestIndex) = findClosestStep(knobPosition.value) - coroutineScope.launch { - knobPosition.animateTo( - targetValue = closestStep, - animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), - ) - } - onValueChange(steps[closestIndex]) - }, - ) { _, dragAmount -> - coroutineScope.launch { - val newPosition = (knobPosition.value + dragAmount.x) - .coerceIn(0f, sliderWidth.toFloat()) - knobPosition.snapTo(newPosition) - } + stepPositions.forEach { position -> + drawRoundRect( + color = Colors.White, + topLeft = Offset(position - markerWidth / 2, trackY - markerHeight / 2), + size = Size(markerWidth, markerHeight), + cornerRadius = CornerRadius(markerRadius), + ) } } - ) { - // Outer green circle - Box( - modifier = Modifier - .size(KNOB_SIZE_DP.dp) - .clip(CircleShape) - .background(Colors.Green) - ) { - // Inner white circle - Box( - modifier = Modifier - .size(16.dp) - .clip(CircleShape) - .background(Colors.White) - .align(Alignment.Center) - ) - } - } - // Step labels - steps.forEachIndexed { index, step -> - if (stepPositions.isNotEmpty() && index < stepPositions.size) { - Caption13Up( - text = formatLabel(step), - color = Colors.White64, - textAlign = TextAlign.Center, + // Knob + Box( modifier = Modifier - .width(KNOB_SIZE_DP.dp) .offset { IntOffset( - x = (stepPositions[index] - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), - y = with(density) { (KNOB_SIZE_DP.dp + 4.dp).toPx() }.roundToInt(), + x = (knobPosition.value - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + y = 0, ) } - ) + .size(KNOB_SIZE_DP.dp) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { _ -> + // No action needed on drag start + }, + onDragEnd = { + val (closestStep, closestIndex) = findClosestStep(knobPosition.value) + coroutineScope.launch { + knobPosition.animateTo( + targetValue = closestStep, + animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), + ) + } + onValueChange(steps[closestIndex]) + }, + ) { _, dragAmount -> + coroutineScope.launch { + val newPosition = (knobPosition.value + dragAmount.x) + .coerceIn(0f, sliderWidth.toFloat()) + knobPosition.snapTo(newPosition) + } + } + } + ) { + // Outer green circle + Box( + modifier = Modifier + .size(KNOB_SIZE_DP.dp) + .clip(CircleShape) + .background(Colors.Green) + ) { + // Inner white circle + Box( + modifier = Modifier + .size(16.dp) + .clip(CircleShape) + .background(Colors.White) + .align(Alignment.Center) + ) + } + } + } + + // Labels participate in layout height (horizontal offset only) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) { + steps.forEachIndexed { index, step -> + Caption13Up( + text = formatLabel(step), + color = Colors.White64, + textAlign = TextAlign.Center, + modifier = Modifier + .width(KNOB_SIZE_DP.dp) + .offset { + val x = if (index < stepPositions.size) { + ( + stepPositions[index] - + with(density) { KNOB_SIZE_DP.dp.toPx() / 2 } + ).roundToInt() + } else { + 0 + } + IntOffset(x = x, y = 0) + } + ) + } } } } From d90dfb1c53dcd94584d600af91c415eb0e593153 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 17:34:33 +0200 Subject: [PATCH 06/14] fix: layout steplider labels from constraints --- .../java/to/bitkit/ui/components/Slider.kt | 170 +++++++++--------- 1 file changed, 89 insertions(+), 81 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 6b353b306..f1d40fc85 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -21,6 +22,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -31,10 +33,12 @@ import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList @@ -52,6 +56,7 @@ private const val SLIDER_EDGE_INSET_DP = 16 private const val TRACK_HEIGHT_DP = 8 private const val STEP_MARKER_WIDTH_DP = 4 private const val STEP_MARKER_HEIGHT_DP = 16 +private const val LABEL_TOP_PADDING_DP = 4 @Suppress("CyclomaticComplexMethod") @Composable @@ -64,78 +69,69 @@ fun StepSlider( ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() - - var sliderWidth by remember { mutableIntStateOf(0) } val knobPosition = remember { Animatable(0f) } + var isDragging by remember { mutableStateOf(false) } - // Calculate step positions (evenly spaced) - val stepPositions = remember(steps, sliderWidth) { - if (sliderWidth == 0) { - emptyList() - } else { - steps.indices.map { index -> + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val sliderWidth = constraints.maxWidth.toFloat() + val stepPositions = remember(steps, sliderWidth) { + if (sliderWidth <= 0f) { + emptyList() + } else { val numSteps = (steps.size - 1).coerceAtLeast(1) - (index.toFloat() / numSteps) * sliderWidth - } - } - } - - // Initialize knob position when value changes - LaunchedEffect(value, stepPositions) { - if (stepPositions.isNotEmpty()) { - val valueIndex = steps.indexOf(value) - if (valueIndex >= 0) { - knobPosition.snapTo(stepPositions[valueIndex]) + steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } } } - } + val valueIndex = steps.indexOf(value).takeIf { it >= 0 } ?: 0 + val settledX = stepPositions.getOrElse(valueIndex) { 0f } + val knobX = if (isDragging) knobPosition.value else settledX - // Find closest step position - fun findClosestStep(currentPosition: Float): Pair { - if (stepPositions.isEmpty()) return 0f to 0 + fun findClosestStep(currentPosition: Float): Pair { + if (stepPositions.isEmpty()) return 0f to 0 - var closestPosition = stepPositions[0] - var closestIndex = 0 - var minDistance = abs(currentPosition - stepPositions[0]) + var closestPosition = stepPositions[0] + var closestIndex = 0 + var minDistance = abs(currentPosition - stepPositions[0]) - stepPositions.forEachIndexed { index, position -> - val distance = abs(currentPosition - position) - if (distance < minDistance) { - minDistance = distance - closestPosition = position - closestIndex = index + stepPositions.forEachIndexed { index, position -> + val distance = abs(currentPosition - position) + if (distance < minDistance) { + minDistance = distance + closestPosition = position + closestIndex = index + } } - } - return closestPosition to closestIndex - } + return closestPosition to closestIndex + } - Box( - modifier = modifier - .fillMaxWidth() - .onGloballyPositioned { coordinates -> - sliderWidth = coordinates.size.width + LaunchedEffect(settledX, isDragging) { + if (!isDragging) { + knobPosition.snapTo(settledX) } - ) { + } + Column(modifier = Modifier.fillMaxWidth()) { Box( modifier = Modifier .fillMaxWidth() .height(KNOB_SIZE_DP.dp) ) { - // Track and step markers Canvas( modifier = Modifier .fillMaxWidth() .height(KNOB_SIZE_DP.dp) - .pointerInput(Unit) { + .pointerInput(stepPositions, steps) { detectTapGestures { offset -> val (closestStep, closestIndex) = findClosestStep(offset.x) coroutineScope.launch { + isDragging = true + knobPosition.snapTo(knobX) knobPosition.animateTo( targetValue = closestStep, animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), ) + isDragging = false } onValueChange(steps[closestIndex]) } @@ -145,7 +141,6 @@ fun StepSlider( val trackHeight = density.run { TRACK_HEIGHT_DP.dp.toPx() } val cornerRadius = density.run { 3.dp.toPx() } - // Draw inactive track drawRoundRect( color = Colors.Green32, topLeft = Offset(0f, trackY - trackHeight / 2), @@ -153,18 +148,15 @@ fun StepSlider( cornerRadius = CornerRadius(cornerRadius), ) - // Draw active track - val activeWidth = knobPosition.value - if (activeWidth > 0) { + if (knobX > 0f) { drawRoundRect( color = Colors.Green, topLeft = Offset(0f, trackY - trackHeight / 2), - size = Size(activeWidth, trackHeight), + size = Size(knobX, trackHeight), cornerRadius = CornerRadius(cornerRadius), ) } - // Draw step markers val markerWidth = density.run { STEP_MARKER_WIDTH_DP.dp.toPx() } val markerHeight = density.run { STEP_MARKER_HEIGHT_DP.dp.toPx() } val markerRadius = density.run { 2.5.dp.toPx() } @@ -179,20 +171,20 @@ fun StepSlider( } } - // Knob Box( modifier = Modifier .offset { IntOffset( - x = (knobPosition.value - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), + x = (knobX - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 }).roundToInt(), y = 0, ) } .size(KNOB_SIZE_DP.dp) - .pointerInput(Unit) { + .pointerInput(stepPositions, steps, sliderWidth) { detectDragGestures( - onDragStart = { _ -> - // No action needed on drag start + onDragStart = { + isDragging = true + coroutineScope.launch { knobPosition.snapTo(settledX) } }, onDragEnd = { val (closestStep, closestIndex) = findClosestStep(knobPosition.value) @@ -201,26 +193,25 @@ fun StepSlider( targetValue = closestStep, animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), ) + isDragging = false } onValueChange(steps[closestIndex]) }, ) { _, dragAmount -> coroutineScope.launch { val newPosition = (knobPosition.value + dragAmount.x) - .coerceIn(0f, sliderWidth.toFloat()) + .coerceIn(0f, sliderWidth) knobPosition.snapTo(newPosition) } } } ) { - // Outer green circle Box( modifier = Modifier .size(KNOB_SIZE_DP.dp) .clip(CircleShape) .background(Colors.Green) ) { - // Inner white circle Box( modifier = Modifier .size(16.dp) @@ -232,32 +223,49 @@ fun StepSlider( } } - // Labels participate in layout height (horizontal offset only) - Box( + StepSliderLabels( + steps = steps, + formatLabel = formatLabel, modifier = Modifier .fillMaxWidth() - .padding(top = 4.dp) - ) { - steps.forEachIndexed { index, step -> - Caption13Up( - text = formatLabel(step), - color = Colors.White64, - textAlign = TextAlign.Center, - modifier = Modifier - .width(KNOB_SIZE_DP.dp) - .offset { - val x = if (index < stepPositions.size) { - ( - stepPositions[index] - - with(density) { KNOB_SIZE_DP.dp.toPx() / 2 } - ).roundToInt() - } else { - 0 - } - IntOffset(x = x, y = 0) - } - ) - } + .padding(top = LABEL_TOP_PADDING_DP.dp) + ) + } + } +} + +@Composable +private fun StepSliderLabels( + steps: ImmutableList, + formatLabel: (Int) -> String, + modifier: Modifier = Modifier, +) { + Layout( + modifier = modifier, + content = { + steps.forEach { step -> + Caption13Up( + text = formatLabel(step), + color = Colors.White64, + textAlign = TextAlign.Center, + modifier = Modifier.width(KNOB_SIZE_DP.dp) + ) + } + }, + ) { measurables, constraints -> + val placeables = measurables.map { measurable -> + measurable.measure(Constraints()) + } + val height = placeables.maxOfOrNull { it.height } ?: 0 + val width = constraints.maxWidth + val numSteps = (placeables.size - 1).coerceAtLeast(1) + + layout(width, height) { + placeables.forEachIndexed { index, placeable -> + val centerX = (index.toFloat() / numSteps) * width + val x = (centerX - placeable.width / 2f).roundToInt() + .coerceIn(0, (width - placeable.width).coerceAtLeast(0)) + placeable.placeRelative(x, 0) } } } From ef76b51b78ccbeac135dbedcba111df59230d0d3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 18:33:14 +0200 Subject: [PATCH 07/14] refactor: measure slider as one unit --- .../java/to/bitkit/ui/components/Slider.kt | 97 +++++++++++++------ 1 file changed, 65 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index f1d40fc85..24fb60d48 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -34,7 +33,9 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -60,7 +61,7 @@ private const val LABEL_TOP_PADDING_DP = 4 @Suppress("CyclomaticComplexMethod") @Composable -fun StepSlider( +fun Slider( value: Int, steps: ImmutableList, onValueChange: (Int) -> Unit, @@ -71,20 +72,42 @@ fun StepSlider( val coroutineScope = rememberCoroutineScope() val knobPosition = remember { Animatable(0f) } var isDragging by remember { mutableStateOf(false) } + var layoutWidthPx by remember { mutableIntStateOf(0) } + val knobHeightPx = with(density) { KNOB_SIZE_DP.dp.roundToPx() } + val labelTopPadPx = with(density) { LABEL_TOP_PADDING_DP.dp.roundToPx() } - BoxWithConstraints(modifier = modifier.fillMaxWidth()) { - val sliderWidth = constraints.maxWidth.toFloat() - val stepPositions = remember(steps, sliderWidth) { - if (sliderWidth <= 0f) { - emptyList() - } else { - val numSteps = (steps.size - 1).coerceAtLeast(1) - steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } - } + val compositionStepPositions = remember(steps, layoutWidthPx) { + val sliderWidth = layoutWidthPx.toFloat() + if (sliderWidth <= 0f) { + emptyList() + } else { + val numSteps = (steps.size - 1).coerceAtLeast(1) + steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } + } + } + val valueIndex = steps.indexOf(value).takeIf { it >= 0 } ?: 0 + val settledX = compositionStepPositions.getOrElse(valueIndex) { 0f } + + LaunchedEffect(settledX, isDragging) { + if (!isDragging) { + knobPosition.snapTo(settledX) + } + } + + SubcomposeLayout( + modifier = modifier + .fillMaxWidth() + .onSizeChanged { layoutWidthPx = it.width } + ) { constraints -> + val width = constraints.maxWidth + val sliderWidth = width.toFloat() + val stepPositions = if (sliderWidth <= 0f) { + emptyList() + } else { + val numSteps = (steps.size - 1).coerceAtLeast(1) + steps.indices.map { index -> (index.toFloat() / numSteps) * sliderWidth } } - val valueIndex = steps.indexOf(value).takeIf { it >= 0 } ?: 0 - val settledX = stepPositions.getOrElse(valueIndex) { 0f } - val knobX = if (isDragging) knobPosition.value else settledX + val knobX = if (isDragging) knobPosition.value else stepPositions.getOrElse(valueIndex) { 0f } fun findClosestStep(currentPosition: Float): Pair { if (stepPositions.isEmpty()) return 0f to 0 @@ -105,13 +128,7 @@ fun StepSlider( return closestPosition to closestIndex } - LaunchedEffect(settledX, isDragging) { - if (!isDragging) { - knobPosition.snapTo(settledX) - } - } - - Column(modifier = Modifier.fillMaxWidth()) { + val trackPlaceable = subcompose(StepSliderSlot.Track) { Box( modifier = Modifier .fillMaxWidth() @@ -222,18 +239,25 @@ fun StepSlider( } } } + }.first().measure(Constraints.fixed(width, knobHeightPx)) + val labelsPlaceable = subcompose(StepSliderSlot.Labels) { StepSliderLabels( steps = steps, formatLabel = formatLabel, - modifier = Modifier - .fillMaxWidth() - .padding(top = LABEL_TOP_PADDING_DP.dp) ) + }.first().measure(Constraints.fixedWidth(width)) + + val height = trackPlaceable.height + labelTopPadPx + labelsPlaceable.height + layout(width, height) { + trackPlaceable.placeRelative(0, 0) + labelsPlaceable.placeRelative(0, trackPlaceable.height + labelTopPadPx) } } } +private enum class StepSliderSlot { Track, Labels } + @Composable private fun StepSliderLabels( steps: ImmutableList, @@ -272,7 +296,7 @@ private fun StepSliderLabels( } /** - * Continuous slider over a [min]..[max] range, styled to match [StepSlider] (same track and + * Continuous slider over a [min]..[max] range, styled to match [Slider] (same track and * knob) but without discrete steps. Used to pick a transfer amount within its allowed limits. */ @Composable @@ -393,7 +417,7 @@ private fun Preview() { AppThemeSurface { var value by remember { mutableIntStateOf(10) } Column(modifier = Modifier.padding(32.dp)) { - StepSlider( + Slider( value = value, steps = persistentListOf(1, 5, 10, 20, 50), onValueChange = { value = it }, @@ -404,7 +428,7 @@ private fun Preview() { @Preview @Composable -private fun AmountSliderPreview() { +private fun PreviewUnitStops() { AppThemeSurface { var value by remember { mutableLongStateOf(72_000L) } Column(modifier = Modifier.padding(32.dp)) { @@ -420,13 +444,22 @@ private fun AmountSliderPreview() { @Preview @Composable -private fun Preview2() { +private fun PreviewVerticalStack() { AppThemeSurface { + var dollars by remember { mutableIntStateOf(1) } + var times by remember { mutableIntStateOf(1) } Column(modifier = Modifier.padding(32.dp)) { - StepSlider( - value = 5, - steps = persistentListOf(1, 2, 5, 10), - onValueChange = {}, + Slider( + value = dollars, + steps = persistentListOf(1, 5, 10, 20, 50), + onValueChange = { dollars = it }, + ) + VerticalSpacer(32.dp) + Slider( + value = 50, + steps = persistentListOf(1, 3, 5, 10, 50), + onValueChange = { times = it }, + formatLabel = { "${it}×" }, ) } } From af4163d1fa9934ddd0dce9ea632e83297cf1231c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 18:33:24 +0200 Subject: [PATCH 08/14] fix: spacing and copy --- .../bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt | 8 ++++---- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index 23992e2fe..af3495200 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -22,7 +22,7 @@ import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.Caption13Up -import to.bitkit.ui.components.StepSlider +import to.bitkit.ui.components.Slider import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -101,7 +101,7 @@ fun QuickPaySettingsScreenContent( Spacer(modifier = Modifier.height(16.dp)) - StepSlider( + Slider( value = quickPayAmount, steps = sliderSteps, onValueChange = onQuickPayAmountChange, @@ -115,7 +115,7 @@ fun QuickPaySettingsScreenContent( color = Colors.White64, ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) BodyM( text = stringResource(R.string.settings__quickpay__settings__daily_text) @@ -126,7 +126,7 @@ fun QuickPaySettingsScreenContent( Spacer(modifier = Modifier.height(16.dp)) - StepSlider( + Slider( value = quickPayDailyLimitMultiplier, steps = dailyLimitSteps, onValueChange = onQuickPayDailyLimitMultiplierChange, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3cf21d8f5..85b43c59c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -934,7 +934,7 @@ <accent>Frictionless</accent>\npayments QuickPay Daily QuickPay limit - Auto-pay up to ${limit} per day without confirmation or PIN ({multiplier}× your threshold). After that, payments open Confirm. + Auto-pay up to ${limit} per day without PIN ({multiplier}× your threshold). After that, payments open Confirm. Quickpay threshold * Bitkit QuickPay exclusively supports payments from your Spending Balance. If enabled, scanned invoices below ${amount} will be paid automatically without requiring your confirmation or PIN*. From 17f544ca8a6f205b0e76fb0c5dc8f7ba71daf97e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 18:58:30 +0200 Subject: [PATCH 09/14] fix: snap slider tap from current value --- .../java/to/bitkit/ui/components/Slider.kt | 10 +++++--- .../quickPay/QuickPaySettingsScreen.kt | 23 ++++++++++--------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 24fb60d48..607774d51 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -87,6 +88,7 @@ fun Slider( } val valueIndex = steps.indexOf(value).takeIf { it >= 0 } ?: 0 val settledX = compositionStepPositions.getOrElse(valueIndex) { 0f } + val settledXState = rememberUpdatedState(settledX) LaunchedEffect(settledX, isDragging) { if (!isDragging) { @@ -142,8 +144,8 @@ fun Slider( detectTapGestures { offset -> val (closestStep, closestIndex) = findClosestStep(offset.x) coroutineScope.launch { + knobPosition.snapTo(settledXState.value) isDragging = true - knobPosition.snapTo(knobX) knobPosition.animateTo( targetValue = closestStep, animationSpec = SpringSpec(dampingRatio = 0.8f, stiffness = 400f), @@ -200,8 +202,10 @@ fun Slider( .pointerInput(stepPositions, steps, sliderWidth) { detectDragGestures( onDragStart = { - isDragging = true - coroutineScope.launch { knobPosition.snapTo(settledX) } + coroutineScope.launch { + knobPosition.snapTo(settledXState.value) + isDragging = true + } }, onDragEnd = { val (closestStep, closestIndex) = findClosestStep(knobPosition.value) diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index af3495200..53a4d4247 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -2,7 +2,6 @@ package to.bitkit.ui.settings.quickPay import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -22,7 +21,9 @@ import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.Slider +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.SettingsSwitchRow import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -75,7 +76,7 @@ fun QuickPaySettingsScreenContent( Column( modifier = Modifier.padding(horizontal = 16.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) SettingsSwitchRow( title = stringResource(R.string.settings__quickpay__settings__toggle), @@ -84,7 +85,7 @@ fun QuickPaySettingsScreenContent( modifier = Modifier.testTag("QuickpayToggle") ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) BodyM( text = stringResource(R.string.settings__quickpay__settings__text) @@ -92,14 +93,14 @@ fun QuickPaySettingsScreenContent( color = Colors.White64, ) - Spacer(modifier = Modifier.height(32.dp)) + VerticalSpacer(32.dp) Caption13Up( text = stringResource(R.string.settings__quickpay__settings__label), color = Colors.White64, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) Slider( value = quickPayAmount, @@ -108,14 +109,14 @@ fun QuickPaySettingsScreenContent( modifier = Modifier.testTag("QuickpayAmountSlider") ) - Spacer(modifier = Modifier.height(32.dp)) + VerticalSpacer(32.dp) Caption13Up( text = stringResource(R.string.settings__quickpay__settings__daily_label), color = Colors.White64, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) BodyM( text = stringResource(R.string.settings__quickpay__settings__daily_text) @@ -124,7 +125,7 @@ fun QuickPaySettingsScreenContent( color = Colors.White64, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) Slider( value = quickPayDailyLimitMultiplier, @@ -134,7 +135,7 @@ fun QuickPaySettingsScreenContent( modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) - Spacer(modifier = Modifier.weight(1f)) + FillHeight() Image( painter = painterResource(R.drawable.fast_forward), contentDescription = null, @@ -142,14 +143,14 @@ fun QuickPaySettingsScreenContent( .fillMaxWidth() .height(256.dp) ) - Spacer(modifier = Modifier.weight(1f)) + FillHeight() BodyS( text = stringResource(R.string.settings__quickpay__settings__note), color = Colors.White64, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) } } } From 7385c7355556c90a7ccee1712a6ca7552b9e5e8c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 19:59:08 +0200 Subject: [PATCH 10/14] fix: simplify multiplier string templates --- app/src/main/java/to/bitkit/ui/components/Slider.kt | 2 +- .../to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 607774d51..0fe0bedb9 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -463,7 +463,7 @@ private fun PreviewVerticalStack() { value = 50, steps = persistentListOf(1, 3, 5, 10, 50), onValueChange = { times = it }, - formatLabel = { "${it}×" }, + formatLabel = { "$it×" }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index 53a4d4247..a309c85e6 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -131,7 +131,7 @@ fun QuickPaySettingsScreenContent( value = quickPayDailyLimitMultiplier, steps = dailyLimitSteps, onValueChange = onQuickPayDailyLimitMultiplierChange, - formatLabel = { "${it}×" }, + formatLabel = { "$it×" }, modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) From af959c93662aafa6936534bec8440b644135ae4c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 11:24:21 +0200 Subject: [PATCH 11/14] fix: harden quickpay daily spend limits --- .../quickPay/QuickPaySettingsScreenTest.kt | 5 +- .../main/java/to/bitkit/data/CacheStore.kt | 22 +++ .../java/to/bitkit/ui/components/Slider.kt | 36 +++++ .../quickPay/QuickPaySettingsScreen.kt | 11 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 33 ++-- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 151 +++++++++++------- .../java/to/bitkit/data/CacheStoreTest.kt | 15 ++ 7 files changed, 202 insertions(+), 71 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt index 1a179f2b0..cb3457e75 100644 --- a/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreenTest.kt @@ -34,12 +34,14 @@ class QuickPaySettingsScreenTest { QuickPaySettingsScreenContent( isQuickPayEnabled = true, quickPayAmount = 5, + quickPayDailyLimitMultiplier = 5, ) } } composeTestRule.onNodeWithTag("QuickpayToggle").assertIsDisplayed() - composeTestRule.onNodeWithTag("quickpay_amount_slider").assertIsDisplayed() + composeTestRule.onNodeWithTag("QuickpayAmountSlider").assertIsDisplayed() + composeTestRule.onNodeWithTag("QuickpayDailyLimitSlider").assertIsDisplayed() } @Test @@ -52,6 +54,7 @@ class QuickPaySettingsScreenTest { QuickPaySettingsScreenContent( isQuickPayEnabled = false, quickPayAmount = 5, + quickPayDailyLimitMultiplier = 5, onToggleQuickPay = { enabled -> toggleCalled = true toggleValue = enabled diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index 0a8fe8e5d..e6645c207 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -144,6 +144,28 @@ class CacheStore @Inject constructor( return if (data.quickPaySpendDayKey == dayKey) data.quickPaySpentUsdToday else 0.0 } + suspend fun tryReserveQuickPaySpendUsd(amountUsd: Double, dayKey: String, dailyCapUsd: Double): Boolean { + var reserved = false + store.updateData { + val spent = if (it.quickPaySpendDayKey == dayKey) it.quickPaySpentUsdToday else 0.0 + if (spent + amountUsd > dailyCapUsd) return@updateData it + reserved = true + if (it.quickPaySpendDayKey != dayKey) { + it.copy(quickPaySpendDayKey = dayKey, quickPaySpentUsdToday = amountUsd) + } else { + it.copy(quickPaySpentUsdToday = spent + amountUsd) + } + } + return reserved + } + + suspend fun releaseQuickPaySpendUsd(amountUsd: Double, dayKey: String) { + store.updateData { + if (it.quickPaySpendDayKey != dayKey) return@updateData it + it.copy(quickPaySpentUsdToday = (it.quickPaySpentUsdToday - amountUsd).coerceAtLeast(0.0)) + } + } + suspend fun recordQuickPaySpendUsd(amountUsd: Double, dayKey: String) { store.updateData { if (it.quickPaySpendDayKey != dayKey) { diff --git a/app/src/main/java/to/bitkit/ui/components/Slider.kt b/app/src/main/java/to/bitkit/ui/components/Slider.kt index 0fe0bedb9..851ee7312 100644 --- a/app/src/main/java/to/bitkit/ui/components/Slider.kt +++ b/app/src/main/java/to/bitkit/ui/components/Slider.kt @@ -38,6 +38,11 @@ import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints @@ -100,6 +105,12 @@ fun Slider( modifier = modifier .fillMaxWidth() .onSizeChanged { layoutWidthPx = it.width } + .stepSliderSemantics( + valueIndex = valueIndex, + stepCount = steps.size, + stateDescription = formatLabel(value), + onIndexChange = { onValueChange(steps[it]) }, + ) ) { constraints -> val width = constraints.maxWidth val sliderWidth = width.toFloat() @@ -218,6 +229,12 @@ fun Slider( } onValueChange(steps[closestIndex]) }, + onDragCancel = { + coroutineScope.launch { + knobPosition.snapTo(settledXState.value) + isDragging = false + } + }, ) { _, dragAmount -> coroutineScope.launch { val newPosition = (knobPosition.value + dragAmount.x) @@ -262,6 +279,25 @@ fun Slider( private enum class StepSliderSlot { Track, Labels } +private fun Modifier.stepSliderSemantics( + valueIndex: Int, + stepCount: Int, + stateDescription: String, + onIndexChange: (Int) -> Unit, +): Modifier = semantics { + this.stateDescription = stateDescription + val lastIndex = (stepCount - 1).coerceAtLeast(0) + progressBarRangeInfo = ProgressBarRangeInfo( + current = valueIndex.toFloat(), + range = 0f..lastIndex.toFloat(), + steps = (stepCount - 2).coerceAtLeast(0), + ) + setProgress { target -> + onIndexChange(target.roundToInt().coerceIn(0, lastIndex)) + true + } +} + @Composable private fun StepSliderLabels( steps: ImmutableList, diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index a309c85e6..0e7f94ca7 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -21,7 +23,6 @@ import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.Caption13Up -import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.Slider import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.SettingsSwitchRow @@ -74,7 +75,9 @@ fun QuickPaySettingsScreenContent( ) Column( - modifier = Modifier.padding(horizontal = 16.dp) + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) ) { VerticalSpacer(16.dp) @@ -135,7 +138,7 @@ fun QuickPaySettingsScreenContent( modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) - FillHeight() + VerticalSpacer(32.dp) Image( painter = painterResource(R.drawable.fast_forward), contentDescription = null, @@ -143,7 +146,7 @@ fun QuickPaySettingsScreenContent( .fillMaxWidth() .height(256.dp) ) - FillHeight() + VerticalSpacer(32.dp) BodyS( text = stringResource(R.string.settings__quickpay__settings__note), diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index fb54acf8b..beab88153 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -74,6 +74,7 @@ import org.lightningdevkit.ldknode.Txid import to.bitkit.BuildConfig import to.bitkit.R import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.data.resetPin @@ -2633,21 +2634,31 @@ class AppViewModel @Inject constructor( val settings = settingsStore.data.first() if (!settings.isQuickPayEnabled || amountSats == 0uL) return false + return isWithinQuickPayThreshold(amountSats, settings) && isWithinQuickPayDailyCap(amountSats, settings) + } + + private suspend fun isWithinQuickPayThreshold(amountSats: ULong, settings: SettingsData): Boolean { val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() - val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() - if (quickPayAmountSats == null || amountUsd == null || amountSats > quickPayAmountSats) return false + ?: return false + return amountSats <= quickPayAmountSats + } - val dailyCapUsd = settings.quickPayAmount.toDouble() * settings.quickPayDailyLimitMultiplier + private suspend fun isWithinQuickPayDailyCap(amountSats: ULong, settings: SettingsData): Boolean { + val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + ?: return false + val dailyCapSats = quickPayAmountSats * settings.quickPayDailyLimitMultiplier.toULong() + val dailyCapUsd = currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), "USD").getOrNull()?.value?.toDouble() + ?: return false + val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() + ?: return false val spentUsdToday = cacheStore.quickPaySpentUsdForDay(quickPaySpendDayKey()) - if (spentUsdToday + amountUsd > dailyCapUsd) { - Logger.info( - "Skipping QuickPay: daily spend '$spentUsdToday' + '$amountUsd' exceeds cap '$dailyCapUsd'", - context = TAG, - ) - return false - } + if (spentUsdToday + amountUsd <= dailyCapUsd) return true - return true + Logger.info( + "Skipping QuickPay: daily spend '$spentUsdToday' + '$amountUsd' exceeds cap '$dailyCapUsd'", + context = TAG, + ) + return false } private fun resetAmountInput() { diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index e4fb98fa0..136bc941b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -7,11 +7,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentId import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsStore import to.bitkit.ext.WatchResult import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.quickPaySpendDayKey @@ -32,6 +34,7 @@ class QuickPayViewModel @Inject constructor( private val pendingPaymentRepo: PendingPaymentRepo, private val currencyRepo: CurrencyRepo, private val cacheStore: CacheStore, + private val settingsStore: SettingsStore, ) : ViewModel() { companion object { @@ -45,68 +48,99 @@ class QuickPayViewModel @Inject constructor( fun pay(data: QuickPayData) { viewModelScope.launch { - val (bolt11, amount, displaySats) = when (data) { - is QuickPayData.Bolt11 -> { - Logger.info("QuickPay: processing bolt11 invoice") - Triple(data.bolt11, null, data.sats) - } + val prepared = preparePayment(data) ?: return@launch + val dayKey = quickPaySpendDayKey() + if (!reserveSpend(prepared.amountUsd, dayKey)) return@launch - is QuickPayData.LnurlPay -> { - Logger.info("QuickPay: fetching LNURL Pay invoice from callback") - val invoice = lightningRepo.fetchLnurlInvoice( - data = data.data, - amountMsats = data.data.callbackAmountMsats(data.sats), - ) - .getOrElse { error -> - _uiState.update { - it.copy(result = QuickPayResult.Error(error.message.orEmpty())) - } - return@launch - } - Triple(invoice.bolt11, null, data.sats) - } + sendLightning(prepared.bolt11, prepared.amount) + .onSuccess { onPaymentSuccess(it, prepared.displaySats) } + .onFailure { onPaymentFailure(it, prepared.displaySats, prepared.amountUsd, dayKey) } + } + } + + private suspend fun preparePayment(data: QuickPayData): PreparedQuickPay? { + val (bolt11, amount, displaySats) = when (data) { + is QuickPayData.Bolt11 -> { + Logger.info("QuickPay: processing bolt11 invoice") + Triple(data.bolt11, null, data.sats) } - sendLightning(bolt11, amount) - .onSuccess { paymentHash -> - Logger.info("QuickPay lightning payment successful") - recordQuickPaySpend(displaySats) - _uiState.update { - it.copy( - result = QuickPayResult.Success( - paymentHash = paymentHash, - amountWithFee = displaySats.toLong() // TODO GET FEE WHEN AVAILABLE - ) - ) - } - }.onFailure { error -> - if (error is PaymentPendingException) { - Logger.info("QuickPay lightning payment pending", context = TAG) - pendingPaymentRepo.track(error.paymentHash) - recordQuickPaySpend(displaySats) - _uiState.update { - it.copy( - result = QuickPayResult.Pending( - paymentHash = error.paymentHash, - amount = displaySats.toLong(), - ) - ) - } - return@onFailure - } - Logger.error("QuickPay lightning payment failed", error, context = TAG) - - _uiState.update { - it.copy(result = QuickPayResult.Error(error.message.orEmpty())) - } + is QuickPayData.LnurlPay -> { + Logger.info("QuickPay: fetching LNURL Pay invoice from callback") + val invoice = lightningRepo.fetchLnurlInvoice( + data = data.data, + amountMsats = data.data.callbackAmountMsats(data.sats), + ).getOrElse { error -> + _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) } + return null } + Triple(invoice.bolt11, null, data.sats) + } + } + val amountUsd = currencyRepo.convertSatsToFiat(displaySats.toLong(), "USD").getOrNull()?.value?.toDouble() + if (amountUsd == null) { + _uiState.update { it.copy(result = QuickPayResult.Error("Currency conversion failed")) } + return null + } + return PreparedQuickPay(bolt11, amount, displaySats, amountUsd) + } + + private suspend fun reserveSpend(amountUsd: Double, dayKey: String): Boolean { + val dailyCapUsd = resolveDailyCapUsd() + if (dailyCapUsd == null) { + _uiState.update { it.copy(result = QuickPayResult.Error("Currency conversion failed")) } + return false + } + val reserved = cacheStore.tryReserveQuickPaySpendUsd(amountUsd, dayKey, dailyCapUsd) + if (!reserved) { + Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountUsd'", context = TAG) + _uiState.update { it.copy(result = QuickPayResult.Error("Daily QuickPay limit reached")) } } + return reserved } - private suspend fun recordQuickPaySpend(amountSats: ULong) { - val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() - ?: return - cacheStore.recordQuickPaySpendUsd(amountUsd, quickPaySpendDayKey()) + private fun onPaymentSuccess(paymentHash: String, displaySats: ULong) { + Logger.info("QuickPay lightning payment successful") + _uiState.update { + it.copy( + result = QuickPayResult.Success( + paymentHash = paymentHash, + amountWithFee = displaySats.toLong() // TODO GET FEE WHEN AVAILABLE + ) + ) + } + } + + private suspend fun onPaymentFailure( + error: Throwable, + displaySats: ULong, + amountUsd: Double, + dayKey: String, + ) { + if (error is PaymentPendingException) { + Logger.info("QuickPay lightning payment pending", context = TAG) + pendingPaymentRepo.track(error.paymentHash) + _uiState.update { + it.copy( + result = QuickPayResult.Pending( + paymentHash = error.paymentHash, + amount = displaySats.toLong(), + ) + ) + } + return + } + Logger.error("QuickPay lightning payment failed", error, context = TAG) + cacheStore.releaseQuickPaySpendUsd(amountUsd, dayKey) + _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) } + } + + private suspend fun resolveDailyCapUsd(): Double? { + val settings = settingsStore.data.first() + val thresholdSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + ?: return null + val dailyCapSats = thresholdSats * settings.quickPayDailyLimitMultiplier.toULong() + return currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), "USD").getOrNull()?.value?.toDouble() } private suspend fun sendLightning( @@ -134,6 +168,13 @@ class QuickPayViewModel @Inject constructor( } } +private data class PreparedQuickPay( + val bolt11: String, + val amount: ULong?, + val displaySats: ULong, + val amountUsd: Double, +) + sealed class QuickPayResult { data class Success( val paymentHash: String, diff --git a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt index 90855eb81..c595d7f47 100644 --- a/app/src/test/java/to/bitkit/data/CacheStoreTest.kt +++ b/app/src/test/java/to/bitkit/data/CacheStoreTest.kt @@ -116,4 +116,19 @@ class CacheStoreTest : BaseUnitTest() { assertEquals(4.0, sut.quickPaySpentUsdForDay("2026-08-16")) assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) } + + @Test + fun `tryReserveQuickPaySpendUsd reserves under the cap and rejects over it`() = test { + assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) + assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) + assertFalse(sut.tryReserveQuickPaySpendUsd(amountUsd = 10.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) + assertEquals(20.0, sut.quickPaySpentUsdForDay("2026-08-15")) + } + + @Test + fun `releaseQuickPaySpendUsd rolls back a reservation`() = test { + assertTrue(sut.tryReserveQuickPaySpendUsd(amountUsd = 5.0, dayKey = "2026-08-15", dailyCapUsd = 25.0)) + sut.releaseQuickPaySpendUsd(amountUsd = 5.0, dayKey = "2026-08-15") + assertEquals(0.0, sut.quickPaySpentUsdForDay("2026-08-15")) + } } From 7cdae86a21edc7ea3bf2ca636d1bb32689be963d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 13:34:36 +0200 Subject: [PATCH 12/14] fix: localize error messages and texts --- .../quickPay/QuickPaySettingsScreen.kt | 3 ++- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 25 +++++++++++++++---- app/src/main/res/values/strings.xml | 3 +++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt index 0e7f94ca7..ea30a78d9 100644 --- a/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/quickPay/QuickPaySettingsScreen.kt @@ -66,6 +66,7 @@ fun QuickPaySettingsScreenContent( val sliderSteps = remember { persistentListOf(1, 5, 10, 20, 50) } val dailyLimitSteps = remember { persistentListOf(1, 3, 5, 10, 50) } val dailyLimitUsd = quickPayAmount * quickPayDailyLimitMultiplier + val multiplierFormat = stringResource(R.string.settings__quickpay__settings__multiplier_format) ScreenColumn { AppTopBar( @@ -134,7 +135,7 @@ fun QuickPaySettingsScreenContent( value = quickPayDailyLimitMultiplier, steps = dailyLimitSteps, onValueChange = onQuickPayDailyLimitMultiplierChange, - formatLabel = { "$it×" }, + formatLabel = { multiplierFormat.replace("{multiplier}", it.toString()) }, modifier = Modifier.testTag("QuickpayDailyLimitSlider") ) diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 136bc941b..ae9e05a07 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentId +import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.ext.WatchResult @@ -71,7 +72,7 @@ class QuickPayViewModel @Inject constructor( data = data.data, amountMsats = data.data.callbackAmountMsats(data.sats), ).getOrElse { error -> - _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) } + setError(error) return null } Triple(invoice.bolt11, null, data.sats) @@ -79,7 +80,7 @@ class QuickPayViewModel @Inject constructor( } val amountUsd = currencyRepo.convertSatsToFiat(displaySats.toLong(), "USD").getOrNull()?.value?.toDouble() if (amountUsd == null) { - _uiState.update { it.copy(result = QuickPayResult.Error("Currency conversion failed")) } + setError(QuickPayCurrencyConversionError()) return null } return PreparedQuickPay(bolt11, amount, displaySats, amountUsd) @@ -88,13 +89,13 @@ class QuickPayViewModel @Inject constructor( private suspend fun reserveSpend(amountUsd: Double, dayKey: String): Boolean { val dailyCapUsd = resolveDailyCapUsd() if (dailyCapUsd == null) { - _uiState.update { it.copy(result = QuickPayResult.Error("Currency conversion failed")) } + setError(QuickPayCurrencyConversionError()) return false } val reserved = cacheStore.tryReserveQuickPaySpendUsd(amountUsd, dayKey, dailyCapUsd) if (!reserved) { Logger.info("Skipping QuickPay pay: daily spend reserve failed for '$amountUsd'", context = TAG) - _uiState.update { it.copy(result = QuickPayResult.Error("Daily QuickPay limit reached")) } + setError(QuickPayDailyLimitReachedError()) } return reserved } @@ -132,7 +133,17 @@ class QuickPayViewModel @Inject constructor( } Logger.error("QuickPay lightning payment failed", error, context = TAG) cacheStore.releaseQuickPaySpendUsd(amountUsd, dayKey) - _uiState.update { it.copy(result = QuickPayResult.Error(error.message.orEmpty())) } + setError(error) + } + + private fun setError(error: Throwable) { + _uiState.update { it.copy(result = QuickPayResult.Error(errorMessage(error))) } + } + + private fun errorMessage(error: Throwable): String = when (error) { + is QuickPayCurrencyConversionError -> context.getString(R.string.wallet__send_quickpay__currency_conversion) + is QuickPayDailyLimitReachedError -> context.getString(R.string.wallet__send_quickpay__daily_limit) + else -> error.message?.takeIf { it.isNotBlank() } ?: context.getString(R.string.common__error_body) } private suspend fun resolveDailyCapUsd(): Double? { @@ -175,6 +186,10 @@ private data class PreparedQuickPay( val amountUsd: Double, ) +private class QuickPayCurrencyConversionError : AppError("Currency conversion failed") + +private class QuickPayDailyLimitReachedError : AppError("Daily QuickPay limit reached") + sealed class QuickPayResult { data class Success( val paymentHash: String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 85b43c59c..a93e46970 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -936,6 +936,7 @@ Daily QuickPay limit Auto-pay up to ${limit} per day without PIN ({multiplier}× your threshold). After that, payments open Confirm. Quickpay threshold + {multiplier}× * Bitkit QuickPay exclusively supports payments from your Spending Balance. If enabled, scanned invoices below ${amount} will be paid automatically without requiring your confirmation or PIN*. Enable QuickPay @@ -1239,6 +1240,8 @@ Reserve Balance This payment is taking a bit longer than expected. You can continue using Bitkit. Payment Pending + Currency conversion failed + Daily QuickPay limit reached QuickPay Paying\n<accent>invoice...</accent> Confirm From 3593678a85f384c7603eef8ed93e62094b8b061e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 13:34:36 +0200 Subject: [PATCH 13/14] refactor: use USD constant --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 13 +++++++------ .../java/to/bitkit/viewmodels/QuickPayViewModel.kt | 7 ++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index beab88153..653c73fcd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -124,6 +124,7 @@ import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.TransportType +import to.bitkit.models.USD import to.bitkit.models.msatFloorOf import to.bitkit.models.safe import to.bitkit.models.sanitizedDeeplinkLogValue @@ -2638,18 +2639,18 @@ class AppViewModel @Inject constructor( } private suspend fun isWithinQuickPayThreshold(amountSats: ULong, settings: SettingsData): Boolean { - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() ?: return false return amountSats <= quickPayAmountSats } private suspend fun isWithinQuickPayDailyCap(amountSats: ULong, settings: SettingsData): Boolean { - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() ?: return false val dailyCapSats = quickPayAmountSats * settings.quickPayDailyLimitMultiplier.toULong() - val dailyCapUsd = currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), "USD").getOrNull()?.value?.toDouble() + val dailyCapUsd = currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), USD).getOrNull()?.value?.toDouble() ?: return false - val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull()?.value?.toDouble() + val amountUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull()?.value?.toDouble() ?: return false val spentUsdToday = cacheStore.quickPaySpentUsdForDay(quickPaySpendDayKey()) if (spentUsdToday + amountUsd <= dailyCapUsd) return true @@ -2702,7 +2703,7 @@ class AppViewModel @Inject constructor( return } - val amountInUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull() ?: return + val amountInUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), USD).getOrNull() ?: return if ( amountInUsd.value > BigDecimal(SEND_AMOUNT_WARNING_THRESHOLD) && settings.enableSendAmountWarning && @@ -2735,7 +2736,7 @@ class AppViewModel @Inject constructor( return } - val feeInUsd = currencyRepo.convertSatsToFiat(totalFee.toLong(), "USD").getOrNull() ?: return + val feeInUsd = currencyRepo.convertSatsToFiat(totalFee.toLong(), USD).getOrNull() ?: return if ( feeInUsd.value > BigDecimal(TEN_USD) && SanityWarning.FEE_OVER_10_USD !in _sendUiState.value.confirmedWarnings diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index ae9e05a07..7ee91cd50 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -20,6 +20,7 @@ import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.toUserMessage import to.bitkit.ext.watchUntil +import to.bitkit.models.USD import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException @@ -78,7 +79,7 @@ class QuickPayViewModel @Inject constructor( Triple(invoice.bolt11, null, data.sats) } } - val amountUsd = currencyRepo.convertSatsToFiat(displaySats.toLong(), "USD").getOrNull()?.value?.toDouble() + val amountUsd = currencyRepo.convertSatsToFiat(displaySats.toLong(), USD).getOrNull()?.value?.toDouble() if (amountUsd == null) { setError(QuickPayCurrencyConversionError()) return null @@ -148,10 +149,10 @@ class QuickPayViewModel @Inject constructor( private suspend fun resolveDailyCapUsd(): Double? { val settings = settingsStore.data.first() - val thresholdSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() + val thresholdSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), USD).getOrNull() ?: return null val dailyCapSats = thresholdSats * settings.quickPayDailyLimitMultiplier.toULong() - return currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), "USD").getOrNull()?.value?.toDouble() + return currencyRepo.convertSatsToFiat(dailyCapSats.toLong(), USD).getOrNull()?.value?.toDouble() } private suspend fun sendLightning( From 4761c75428fed94aec570aac2e6028ab452fc6ef Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 18 Aug 2026 13:34:36 +0200 Subject: [PATCH 14/14] feat: use quickpay routing fee on ui --- .../to/bitkit/viewmodels/QuickPayViewModel.kt | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt index 7ee91cd50..3fb41e0ae 100644 --- a/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/QuickPayViewModel.kt @@ -21,6 +21,8 @@ import to.bitkit.ext.quickPaySpendDayKey import to.bitkit.ext.toUserMessage import to.bitkit.ext.watchUntil import to.bitkit.models.USD +import to.bitkit.models.msatFloorOf +import to.bitkit.models.safe import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.PaymentPendingException @@ -55,7 +57,7 @@ class QuickPayViewModel @Inject constructor( if (!reserveSpend(prepared.amountUsd, dayKey)) return@launch sendLightning(prepared.bolt11, prepared.amount) - .onSuccess { onPaymentSuccess(it, prepared.displaySats) } + .onSuccess { onPaymentSuccess(it.paymentHash, prepared.displaySats, it.feePaidSats) } .onFailure { onPaymentFailure(it, prepared.displaySats, prepared.amountUsd, dayKey) } } } @@ -101,13 +103,13 @@ class QuickPayViewModel @Inject constructor( return reserved } - private fun onPaymentSuccess(paymentHash: String, displaySats: ULong) { + private fun onPaymentSuccess(paymentHash: String, displaySats: ULong, feePaidSats: ULong) { Logger.info("QuickPay lightning payment successful") _uiState.update { it.copy( result = QuickPayResult.Success( paymentHash = paymentHash, - amountWithFee = displaySats.toLong() // TODO GET FEE WHEN AVAILABLE + amountWithFee = (displaySats.safe() + feePaidSats.safe()).toLong(), ) ) } @@ -158,7 +160,7 @@ class QuickPayViewModel @Inject constructor( private suspend fun sendLightning( bolt11: String, amount: ULong? = null, - ): Result { + ): Result { val hash = lightningRepo.payInvoice(bolt11 = bolt11, sats = amount) .onFailure { exception -> return Result.failure(exception) @@ -168,7 +170,15 @@ class QuickPayViewModel @Inject constructor( // Wait until matching payment event is received (with timeout for hold invoices) val result = lightningRepo.nodeEvents.watchUntil(LightningRepo.SEND_LN_TIMEOUT) { when (it) { - is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete(Result.success(hash)) + is Event.PaymentSuccessful if it.paymentHash == hash -> WatchResult.Complete( + Result.success( + SettledQuickPayPayment( + paymentHash = hash, + feePaidSats = msatFloorOf(it.feePaidMsat ?: 0u), + ) + ) + ) + is Event.PaymentFailed if it.paymentHash == hash -> WatchResult.Complete( Result.failure(AppError(it.reason.toUserMessage(context))) ) @@ -187,6 +197,11 @@ private data class PreparedQuickPay( val amountUsd: Double, ) +private data class SettledQuickPayPayment( + val paymentHash: PaymentId, + val feePaidSats: ULong, +) + private class QuickPayCurrencyConversionError : AppError("Currency conversion failed") private class QuickPayDailyLimitReachedError : AppError("Daily QuickPay limit reached")