From d092d53c96eda4212e85cd2352c7cb0159576947 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 14:18:33 +0200 Subject: [PATCH 01/10] 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 e6cecbb97..aa616e2eb 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 afb29020e..200261465 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2150,7 +2150,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( @@ -2163,6 +2163,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) } @@ -2187,8 +2202,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 a44d5908390a8ee7b66b79bdee55ee9b7335458d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 14:26:10 +0200 Subject: [PATCH 02/10] 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 04a3df54a260a7a9cdc15d6d6823ecbfcb8f005c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 15:43:38 +0200 Subject: [PATCH 03/10] 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 aa616e2eb..f1f1e968f 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 200261465..37610cf50 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 @@ -116,8 +117,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 @@ -219,6 +222,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)) @@ -2150,7 +2154,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( @@ -2163,8 +2167,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 @@ -2182,6 +2186,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" @@ -2202,8 +2221,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) } @@ -3189,9 +3208,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 fdec08dc0672cabf10b610b6bc37f2d12dfebff2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 15:52:28 +0200 Subject: [PATCH 04/10] 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 a75d21c350a593f8de86d2150a6ac71f9334450b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 16:46:32 +0200 Subject: [PATCH 05/10] 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 2900df5fde26f49aa001682ba7cacf7d8f50336d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 17:34:33 +0200 Subject: [PATCH 06/10] 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 b2b29e0cbfaa5b4a39405dd8ab9a537c133a1555 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 18:33:14 +0200 Subject: [PATCH 07/10] 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 55cc68faa2416974d253ccdf255d9e2bc2abf6d8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 18:33:24 +0200 Subject: [PATCH 08/10] 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 48653dd7945ed4ad4e625f1f47839e0c34174d5b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 18:58:30 +0200 Subject: [PATCH 09/10] 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 7b9ea1915772c08e458e9edc4f4ebab59c29e525 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 15 Aug 2026 19:59:08 +0200 Subject: [PATCH 10/10] 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") )