Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4a034aa
feat: block numberpad input exceeding available balance
ovitrif Mar 26, 2026
e314904
chore: changelog entry
ovitrif Apr 23, 2026
3e8eff8
chore: merge master
ovitrif May 6, 2026
dbd1935
test: cover fiat max amount input
ovitrif May 7, 2026
4c26649
Merge remote-tracking branch 'origin/master' into fix/block-input-ove…
jvsena42 Jun 8, 2026
dba62ff
fix: cap amount pad and allow delete over cap
jvsena42 Jun 8, 2026
9f78b11
fix: use Unit as LaunchEffect key instead of a viewmodel instance
jvsena42 Jun 8, 2026
7ea4be7
fix: use Unit as LaunchEffect key instead of a viewmodel instance
jvsena42 Jun 8, 2026
bc37389
fix: use Unit as LaunchEffect key instead of a viewmodel instance
jvsena42 Jun 8, 2026
046b6fb
fix: apply modern bitcoin formatting
jvsena42 Jun 8, 2026
ae21007
Merge branch 'master' into fix/block-input-over-max
jvsena42 Jun 9, 2026
04df89d
chore: add docker parameter to just run command to run with bitkit-do…
jvsena42 Jun 9, 2026
52a32aa
chore: forward lnurl-server port in just run docker
jvsena42 Jun 9, 2026
3e2e939
fix: set different message for max invoice value
jvsena42 Jun 9, 2026
8a79faa
fix: lnurl withdraw max exceeded message
jvsena42 Jun 9, 2026
548e6e8
fix: use rememberUpdatedState for prevent stale amount
jvsena42 Jun 9, 2026
b1f1b87
fix: use same value for maxAllowedToSend and balanceAfterFee
jvsena42 Jun 9, 2026
503470d
chore: add journeys
jvsena42 Jun 9, 2026
5cd03c4
fix: disable NumberPad.kt while loading
jvsena42 Jun 9, 2026
c377dfa
refactor: move alpha to inside NumberPad
jvsena42 Jun 9, 2026
7f1b088
fix: cap balanceAfterLspFee to maxClientBalanceSat
jvsena42 Jun 9, 2026
8455f10
test: lsp calc regression tests
jvsena42 Jun 9, 2026
631c98e
test: SendAmountExceededToast
piotr-iohk Jun 10, 2026
a7a77c3
Merge branch 'master' into fix/block-input-over-max
jvsena42 Jun 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ list:
"list" \
"init" \
"compile" \
"run" \
"run [docker]" \
"build [TASK]" \
"release" \
"install" \
Expand Down Expand Up @@ -45,12 +45,18 @@ init:
compile:
{{ gradle }} compileDevDebugKotlin

run:
run mode="":
#!/usr/bin/env sh
set -eu

app_id="to.bitkit.dev"
app_dir="app/build/outputs/apk/dev/debug"
mode="{{ mode }}"

if [ -n "$mode" ] && [ "$mode" != "docker" ]; then
echo "usage: just run [docker]" >&2
exit 1
fi

if ! command -v adb >/dev/null 2>&1; then
echo "adb is required to run the app." >&2
Expand Down Expand Up @@ -90,8 +96,19 @@ run:
fi

echo "Using $device_name ($device_id)"

build_env=""
if [ "$mode" = "docker" ]; then
echo "Forwarding bitkit-docker ports via adb reverse..."
adb -s "$device_id" reverse tcp:60001 tcp:60001 # local Electrum
adb -s "$device_id" reverse tcp:6288 tcp:6288 # local homegate
adb -s "$device_id" reverse tcp:9735 tcp:9735 # local lnd peer
adb -s "$device_id" reverse tcp:3000 tcp:3000 # local lnurl-server
build_env="E2E=true"
fi

echo "Building Debug app..."
{{ gradle }} assembleDevDebug
env $build_env {{ gradle }} assembleDevDebug

app_path="$(
find "$app_dir" -maxdepth 1 -name '*-universal.apk' -type f \
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/models/Toast.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ data class Toast(
enum class ToastType { SUCCESS, INFO, LIGHTNING, WARNING, ERROR }

companion object {
const val VISIBILITY_TIME_SHORT = 1500L
const val VISIBILITY_TIME_DEFAULT = 3000L
}
}
6 changes: 4 additions & 2 deletions app/src/main/java/to/bitkit/ui/components/NumberPad.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
Expand Down Expand Up @@ -170,15 +171,16 @@ fun NumberPad(
viewModel: AmountInputViewModel,
modifier: Modifier = Modifier,
currencies: CurrencyState = LocalCurrencies.current,
enabled: Boolean = true,
type: NumberPadType = viewModel.getNumberPadType(currencies),
availableHeight: Dp = defaultHeight,
decimalSeparator: String = KEY_DECIMAL,
includeNavigationBarsPadding: Boolean = false,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
NumberPad(
onPress = { key -> viewModel.handleNumberPadInput(key, currencies) },
modifier = modifier,
onPress = { key -> if (enabled) viewModel.handleNumberPadInput(key, currencies) },
modifier = modifier.alpha(if (enabled) 1f else 0.5f),
type = type,
availableHeight = availableHeight,
decimalSeparator = decimalSeparator,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Devices.NEXUS_5
Expand All @@ -27,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import to.bitkit.R
import to.bitkit.ext.mockOrder
import to.bitkit.models.Toast
import to.bitkit.models.formatToModernDisplay
import to.bitkit.repositories.CurrencyState
import to.bitkit.ui.LocalCurrencies
import to.bitkit.ui.appViewModel
Expand All @@ -46,6 +48,7 @@ import to.bitkit.ui.scaffold.ScreenColumn
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
import to.bitkit.ui.utils.withAccent
import to.bitkit.viewmodels.AmountInputEffect
import to.bitkit.viewmodels.AmountInputViewModel
import to.bitkit.viewmodels.TransferEffect
import to.bitkit.viewmodels.TransferToSpendingUiState
Expand All @@ -64,12 +67,14 @@ fun SpendingAdvancedScreen(
) {
val currentOnOrderCreated by rememberUpdatedState(onOrderCreated)
val app = appViewModel ?: return
val context = LocalContext.current
val state by viewModel.spendingUiState.collectAsStateWithLifecycle()
val order = state.order ?: return
val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle()
var isLoading by remember { mutableStateOf(false) }

val transferValues by viewModel.transferValues.collectAsStateWithLifecycle()
val currentMaxLspBalance by rememberUpdatedState(transferValues.maxLspBalance)

LaunchedEffect(order.clientBalanceSat) {
viewModel.updateTransferValues(order.clientBalanceSat)
Expand All @@ -79,6 +84,10 @@ fun SpendingAdvancedScreen(
viewModel.onReceivingAmountChange(amountUiState.sats)
}

LaunchedEffect(transferValues.maxLspBalance) {
amountInputViewModel.setMaxAmount(transferValues.maxLspBalance.toLong())
}

LaunchedEffect(Unit) {
viewModel.transferEffects.collect { effect ->
when (effect) {
Expand All @@ -100,6 +109,20 @@ fun SpendingAdvancedScreen(
}
}

LaunchedEffect(Unit) {
amountInputViewModel.effect.collect {
when (it) {
AmountInputEffect.MaxExceeded -> app.toast(
type = Toast.ToastType.WARNING,
title = context.getString(R.string.lightning__spending_advanced__error_max__title),
description = context.getString(R.string.lightning__spending_advanced__error_max__description)
.replace("{amount}", currentMaxLspBalance.formatToModernDisplay()),
visibilityTime = Toast.VISIBILITY_TIME_SHORT,
Comment thread
jvsena42 marked this conversation as resolved.
)
}
}
}

val isValid = transferValues.let {
val amount = amountUiState.sats.toULong()
amount > 0u && it.maxLspBalance > 0u && amount in it.minLspBalance..it.maxLspBalance
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
Expand All @@ -26,6 +27,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import to.bitkit.R
import to.bitkit.models.formatToModernDisplay
import to.bitkit.repositories.CurrencyState
import to.bitkit.ui.LocalCurrencies
import to.bitkit.ui.components.ConnectionIssuesView
Expand All @@ -47,6 +49,7 @@ import to.bitkit.ui.scaffold.ScreenColumn
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
import to.bitkit.ui.utils.withAccent
import to.bitkit.viewmodels.AmountInputEffect
import to.bitkit.viewmodels.AmountInputViewModel
import to.bitkit.viewmodels.TransferEffect
import to.bitkit.viewmodels.TransferToSpendingUiState
Expand All @@ -70,6 +73,7 @@ fun SpendingAmountScreen(
val isNodeRunning by viewModel.isNodeRunning.collectAsStateWithLifecycle()
val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val currentMaxAllowedToSend by rememberUpdatedState(uiState.maxAllowedToSend)

LaunchedEffect(isOffline) {
viewModel.updateLimits()
Expand All @@ -85,6 +89,18 @@ fun SpendingAmountScreen(
}
}

LaunchedEffect(Unit) {
amountInputViewModel.effect.collect {
when (it) {
AmountInputEffect.MaxExceeded -> toast(
context.getString(R.string.lightning__spending_amount__error_max__title),
context.getString(R.string.lightning__spending_amount__error_max__description)
.replace("{amount}", currentMaxAllowedToSend.formatToModernDisplay()),
)
Comment thread
jvsena42 marked this conversation as resolved.
}
}
}

Box {
Content(
isNodeRunning = isNodeRunning,
Expand All @@ -99,7 +115,7 @@ fun SpendingAmountScreen(
toast(
context.getString(R.string.lightning__spending_amount__error_max__title),
context.getString(R.string.lightning__spending_amount__error_max__description)
.replace("{amount}", "$max"),
.replace("{amount}", max.formatToModernDisplay()),
)
}
val cappedQuarter = min(quarter, max)
Expand Down Expand Up @@ -174,6 +190,10 @@ private fun SpendingAmountNodeRunning(
onClickMaxAmount: () -> Unit,
onConfirmAmount: () -> Unit,
) {
LaunchedEffect(uiState.maxAllowedToSend) {
amountInputViewModel.setMaxAmount(uiState.maxAllowedToSend)
}
Comment thread
jvsena42 marked this conversation as resolved.

Column(
modifier = Modifier
.padding(horizontal = 16.dp)
Expand Down Expand Up @@ -244,6 +264,7 @@ private fun SpendingAmountNodeRunning(
NumberPad(
viewModel = amountInputViewModel,
currencies = currencies,
enabled = !uiState.isLoading,
)

PrimaryButton(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import to.bitkit.ui.scaffold.ScreenColumn
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
import to.bitkit.ui.utils.withAccent
import to.bitkit.viewmodels.AmountInputEffect
import to.bitkit.viewmodels.AmountInputViewModel
import to.bitkit.viewmodels.previewAmountInputViewModel
import kotlin.math.min
Expand All @@ -63,6 +64,18 @@ fun ExternalAmountScreen(
viewModel.onAmountChange(amountUiState.sats)
}

LaunchedEffect(uiState.amount.max) {
amountInputViewModel.setMaxAmount(uiState.amount.max)
}

LaunchedEffect(Unit) {
amountInputViewModel.effect.collect {
when (it) {
AmountInputEffect.MaxExceeded -> viewModel.onMaxExceeded()
}
}
}

Content(
amountInputViewModel = amountInputViewModel,
amountState = uiState.amount,
Expand Down Expand Up @@ -167,7 +180,7 @@ private fun Content(
PrimaryButton(
text = stringResource(R.string.common__continue),
onClick = { onContinueClick() },
enabled = amountUiState.sats != 0L,
enabled = amountUiState.sats in 1..amountState.max,
modifier = Modifier.testTag("ExternalAmountContinue")
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,21 +97,20 @@ class ExternalNodeViewModel @Inject constructor(
}

fun onAmountChange(sats: Long) {
val maxAmount = _uiState.value.amount.max
_uiState.update { it.copy(amount = it.amount.copy(sats = sats)) }
}

if (sats > maxAmount) {
viewModelScope.launch {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.lightning__spending_amount__error_max__title),
description = context.getString(R.string.lightning__spending_amount__error_max__description)
.replace("{amount}", maxAmount.formatToModernDisplay()),
)
}
return
fun onMaxExceeded() {
val maxAmount = _uiState.value.amount.max
viewModelScope.launch {
ToastEventBus.send(
type = Toast.ToastType.WARNING,
title = context.getString(R.string.lightning__spending_amount__error_max__title),
description = context.getString(R.string.lightning__spending_amount__error_max__description)
.replace("{amount}", maxAmount.formatToModernDisplay()),
visibilityTime = Toast.VISIBILITY_TIME_SHORT,
)
}

_uiState.update { it.copy(amount = it.amount.copy(sats = sats)) }
}

fun onAmountContinue() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import to.bitkit.ui.shared.modifiers.sheetHeight
import to.bitkit.ui.shared.util.gradientBackground
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.theme.Colors
import to.bitkit.viewmodels.AmountInputEffect
import to.bitkit.viewmodels.AmountInputUiState
import to.bitkit.viewmodels.AmountInputViewModel
import to.bitkit.viewmodels.LnurlParams
Expand All @@ -73,13 +74,30 @@ fun SendAmountScreen(
onBack: () -> Unit,
onEvent: (SendEvent) -> Unit,
currencies: CurrencyState = LocalCurrencies.current,
balances: BalanceState = LocalBalances.current,
amountInputViewModel: AmountInputViewModel = hiltViewModel(),
) {
val app = appViewModel
val context = LocalContext.current
val amountInputUiState: AmountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle()
val currentOnEvent by rememberUpdatedState(onEvent)

val maxExceededMessage = run {
val lnurl = uiState.lnurl
val lnurlPayMaxExceeded = lnurl is LnurlParams.LnurlPay &&
lnurl.data.maxSendableSat().toLong() <
(balances.maxSendLightningSats.safe() - uiState.estimatedRoutingFee.safe()).toLong()
when {
lnurl is LnurlParams.LnurlWithdraw ->
R.string.wallet__lnurl_w_error_max__title to R.string.wallet__lnurl_w_error_max__description
lnurlPayMaxExceeded ->
R.string.wallet__lnurl_pay__error_max__title to R.string.wallet__lnurl_pay__error_max__description
else ->
R.string.wallet__send_amount_exceeded__title to R.string.wallet__send_amount_exceeded__description
}
}
val currentMaxExceededMessage by rememberUpdatedState(maxExceededMessage)

LaunchedEffect(Unit) {
if (uiState.amount > 0u) {
amountInputViewModel.setSats(uiState.amount.toLong(), currencies)
Expand All @@ -90,6 +108,23 @@ fun SendAmountScreen(
currentOnEvent(SendEvent.AmountChange(amountInputUiState.sats.toULong()))
}

LaunchedEffect(Unit) {
amountInputViewModel.effect.collect {
when (it) {
AmountInputEffect.MaxExceeded -> {
val (titleRes, descriptionRes) = currentMaxExceededMessage
app?.toast(
type = Toast.ToastType.WARNING,
title = context.getString(titleRes),
description = context.getString(descriptionRes),
visibilityTime = Toast.VISIBILITY_TIME_SHORT,
testTag = "SendAmountExceededToast",
)
}
}
}
}

LaunchedEffect(uiState.decodedInvoice, uiState.payMethod) {
if (uiState.payMethod == SendMethod.LIGHTNING && uiState.decodedInvoice != null) {
currentOnEvent(SendEvent.EstimateMaxRoutingFee)
Expand Down Expand Up @@ -203,6 +238,15 @@ private fun SendAmountNodeRunning(
}
}

val maxAllowed = when (val lnurl = uiState.lnurl) {
is LnurlParams.LnurlPay -> minOf(lnurl.data.maxSendableSat().toLong(), availableAmount)
else -> availableAmount
}

LaunchedEffect(maxAllowed) {
amountInputViewModel.setMaxAmount(maxAllowed)
}

Column(
modifier = Modifier.padding(horizontal = 16.dp)
) {
Expand Down
Loading
Loading