Skip to content
Merged
2 changes: 1 addition & 1 deletion Bitkit/Components/NumberPadTextField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import SwiftUI
/// NumberPadTextField - Amount view to be used with number pad
struct NumberPadTextField: View {
@EnvironmentObject var currency: CurrencyViewModel
@ObservedObject var viewModel: AmountInputViewModel
var viewModel: AmountInputViewModel

var showConversion: Bool = true
var showEditButton: Bool = false
Expand Down
3 changes: 3 additions & 0 deletions Bitkit/Models/Toast.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ struct Toast: Equatable {
case success, info, lightning, warning, error
}

/// Brief visibility for transient feedback (e.g. blocked number pad input).
static let visibilityTimeShort = 1.5

let id: UUID
let type: ToastType
let title: String
Expand Down
8 changes: 8 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@
"lightning__spending_confirm__default" = "Use Defaults";
"lightning__spending_advanced__title" = "Receiving\n<accent>capacity</accent>";
"lightning__spending_advanced__fee" = "Liquidity fee";
"lightning__spending_advanced__error_max__title" = "Receiving Capacity Maximum";
"lightning__spending_advanced__error_max__description" = "The receiving capacity is currently limited to ₿ {amount}.";
"lightning__liquidity__title" = "Liquidity\n<accent>& routing</accent>";
"lightning__liquidity__text" = "Your Spending Balance uses the Lightning Network to make your payments cheaper, faster, and more private.\n\nThis works like internet access, but you pay for liquidity & routing instead of bandwidth.\n\nThis setup includes some one-time costs.";
"lightning__liquidity__label" = "Spending Balance Liquidity";
Expand Down Expand Up @@ -1145,6 +1147,8 @@
"wallet__send_available_savings" = "Available (savings)";
"wallet__send_max_spending__title" = "Reserve Balance";
"wallet__send_max_spending__description" = "The maximum spendable amount is a bit lower due to a required reserve balance.";
"wallet__send_amount_exceeded__title" = "Insufficient balance";
"wallet__send_amount_exceeded__description" = "The amount exceeds your available balance.";
"wallet__send_review" = "Confirm";
"wallet__send_confirming_in" = "Confirming in";
"wallet__send_invoice_expiration" = "Invoice expiration";
Expand Down Expand Up @@ -1357,9 +1361,13 @@
"wallet__lnurl_w_max" = "AvailablE TO WITHDRAW";
"wallet__lnurl_w_text" = "The funds you withdraw will be deposited into your Bitkit spending balance.";
"wallet__lnurl_w_button" = "Withdraw";
"wallet__lnurl_w_error_max__title" = "Amount Too High";
"wallet__lnurl_w_error_max__description" = "The amount exceeds the maximum you can withdraw.";
"wallet__lnurl_p_title" = "Pay Bitcoin";
"wallet__lnurl_pay__error_min__title" = "Amount Too Low";
"wallet__lnurl_pay__error_min__description" = "The minimum amount for this invoice is ₿ {amount}.";
"wallet__lnurl_pay__error_max__title" = "Amount Too High";
"wallet__lnurl_pay__error_max__description" = "The amount exceeds this invoice's maximum.";
"wallet__lnurl_p_max" = "Maximum amount";
"wallet__balance_hidden_title" = "Wallet Balance Hidden";
"wallet__balance_hidden_message" = "Swipe your wallet balance to reveal it again.";
Expand Down
45 changes: 39 additions & 6 deletions Bitkit/ViewModels/AmountInputViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import Foundation
import SwiftUI

@Observable
@MainActor
class AmountInputViewModel: ObservableObject {
@Published var amountSats: UInt64 = 0
@Published var displayText: String = ""
@Published var errorKey: String?
final class AmountInputViewModel {
var amountSats: UInt64 = 0
var displayText: String = ""
var errorKey: String?

/// Optional per-screen cap (e.g. the max sendable balance in the send flow).
/// When set, input is additionally blocked above this value, on top of `maxAmount`.
var maxAmountOverride: UInt64?

/// Incremented each time input is blocked by the screen-specific cap (`maxAmountOverride`),
/// so views can react (e.g. show a toast). Not bumped when only the global
/// `maxAmount` blocks input.
private(set) var maxExceededCount = 0

// MARK: - Constants

Expand All @@ -15,6 +25,12 @@ class AmountInputViewModel: ObservableObject {
private let classicBitcoinDecimals = 8
private let fiatDecimals = 2

/// The active upper bound for input: the global `maxAmount`, further restricted by `maxAmountOverride` when set.
private var effectiveMaxAmount: UInt64 {
guard let maxAmountOverride else { return maxAmount }
return Swift.min(maxAmount, maxAmountOverride)
}

// MARK: - Private Properties

private var rawInputText: String = ""
Expand All @@ -38,17 +54,25 @@ class AmountInputViewModel: ObservableObject {
maxDecimals: maxDecimals
)

// Deletions must always apply, even when the amount is above the cap (e.g. a
// prefilled invoice amount over the available balance, or a cap that dropped
// after input). The cap only blocks growing the amount; without this, each
// delete still leaves the amount over the cap and gets rejected, trapping the
// user with an invalid amount they can't reduce.
let isDeletion = key == "delete"

// For decimal input (classic Bitcoin and fiat), preserve the text as-is
// For integer input (modern Bitcoin), format the final amount
if currency.primaryDisplay == .bitcoin && currency.displayUnit == .modern {
let newAmount = convertToSats(newText, currency: currency)

if newAmount <= maxAmount {
if isDeletion || newAmount <= effectiveMaxAmount {
rawInputText = newText
displayText = formatDisplayTextFromAmount(newAmount, currency: currency)
amountSats = newAmount
errorKey = nil
} else {
notifyMaxExceededIfCapped()
Haptics.notify(.warning)
errorKey = key
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Expand All @@ -59,7 +83,7 @@ class AmountInputViewModel: ObservableObject {
// For decimal input, check limits before updating state
if !newText.isEmpty {
let newAmount = convertToSats(newText, currency: currency)
if newAmount <= maxAmount {
if isDeletion || newAmount <= effectiveMaxAmount {
// Update both raw input and display text
rawInputText = newText
// Format with grouping separators but not decimal formatting
Expand All @@ -72,6 +96,7 @@ class AmountInputViewModel: ObservableObject {
errorKey = nil
} else {
// Block input when limit exceeded
notifyMaxExceededIfCapped()
Haptics.notify(.warning)
errorKey = key
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Expand Down Expand Up @@ -218,6 +243,14 @@ class AmountInputViewModel: ObservableObject {

// MARK: - Private Methods

/// Signals blocked input to observers, but only when the screen-specific cap is the
/// limiting bound. Hitting the global `maxAmount` stays silent.
private func notifyMaxExceededIfCapped() {
if effectiveMaxAmount < maxAmount {
maxExceededCount += 1
}
}

private func formatDisplayTextFromAmount(_ amountSats: UInt64, currency: CurrencyViewModel) -> String {
if amountSats == 0 {
return ""
Expand Down
30 changes: 28 additions & 2 deletions Bitkit/Views/Transfer/FundManualAmountView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@ struct FundManualAmountView: View {

let lnPeer: LnPeer

@StateObject private var amountViewModel = AmountInputViewModel()
@State private var amountViewModel = AmountInputViewModel()
@State private var didAttemptPeerConnection = false

var amountSats: UInt64 {
amountViewModel.amountSats
}

private var fundableBalanceSats: UInt64 {
UInt64(max(0, wallet.channelFundableBalanceSats))
}

private var isValidAmount: Bool {
amountSats > 0 && amountSats <= fundableBalanceSats
}

var body: some View {
VStack(spacing: 0) {
NavigationBar(title: t("lightning__external__nav_title"))
Expand Down Expand Up @@ -58,7 +66,7 @@ struct FundManualAmountView: View {
amountViewModel.handleNumberPadInput(key, currency: currency)
}

CustomButton(title: t("common__continue"), isDisabled: amountSats == 0) {
CustomButton(title: t("common__continue"), isDisabled: !isValidAmount) {
navigation.navigate(.fundManualConfirm(lnPeer: lnPeer, amountSats: amountSats))
}
.accessibilityIdentifier("ExternalAmountContinue")
Expand All @@ -70,6 +78,24 @@ struct FundManualAmountView: View {
.task {
await connectToPeerIfNeeded()
}
.onChange(of: wallet.channelFundableBalanceSats, initial: true) { updateInputCap() }
.onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() }
}

private func updateInputCap() {
amountViewModel.maxAmountOverride = fundableBalanceSats > 0 ? fundableBalanceSats : nil
}

private func showMaxExceededToast() {
app.toast(
type: .warning,
title: t("lightning__spending_amount__error_max__title"),
description: t(
"lightning__spending_amount__error_max__description",
variables: ["amount": CurrencyFormatter.formatSats(fundableBalanceSats)]
),
visibilityTime: Toast.visibilityTimeShort
)
}

private var numberPadButtons: some View {
Expand Down
21 changes: 20 additions & 1 deletion Bitkit/Views/Transfer/SpendingAdvancedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct SpendingAdvancedView: View {
@EnvironmentObject var transfer: TransferViewModel
@Environment(\.dismiss) var dismiss

@StateObject private var amountViewModel = AmountInputViewModel()
@State private var amountViewModel = AmountInputViewModel()
@State private var feeEstimate: UInt64?
@State private var isLoading = false
@State private var feeEstimateTask: Task<Void, Never>?
Expand Down Expand Up @@ -116,6 +116,25 @@ struct SpendingAdvancedView: View {
feeEstimate = nil
}
}
.onChange(of: transfer.transferValues.maxLspBalance, initial: true) { updateInputCap() }
.onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() }
}

private func updateInputCap() {
let maxLspBalance = transfer.transferValues.maxLspBalance
amountViewModel.maxAmountOverride = maxLspBalance > 0 ? maxLspBalance : nil
}

private func showMaxExceededToast() {
app.toast(
type: .warning,
title: t("lightning__spending_advanced__error_max__title"),
description: t(
"lightning__spending_advanced__error_max__description",
variables: ["amount": CurrencyFormatter.formatSats(transfer.transferValues.maxLspBalance)]
),
visibilityTime: Toast.visibilityTimeShort
)
}

private var actionButtons: some View {
Expand Down
20 changes: 19 additions & 1 deletion Bitkit/Views/Transfer/SpendingAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct SpendingAmount: View {
@EnvironmentObject var transfer: TransferViewModel
@EnvironmentObject var wallet: WalletViewModel

@StateObject private var amountViewModel = AmountInputViewModel()
@State private var amountViewModel = AmountInputViewModel()
@State private var isLoading = false
@State private var availableAmount: UInt64?
@State private var maxTransferAmount: UInt64?
Expand Down Expand Up @@ -87,6 +87,24 @@ struct SpendingAmount: View {
await calculateMaxTransferAmount()
}
}
.onChange(of: maxTransferAmount) { updateInputCap() }
.onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() }
}

private func updateInputCap() {
amountViewModel.maxAmountOverride = (maxTransferAmount ?? 0) > 0 ? maxTransferAmount : nil
}

private func showMaxExceededToast() {
app.toast(
type: .warning,
title: t("lightning__spending_amount__error_max__title"),
description: t(
"lightning__spending_amount__error_max__description",
variables: ["amount": CurrencyFormatter.formatSats(maxTransferAmount ?? 0)]
),
visibilityTime: Toast.visibilityTimeShort
)
}

private var actionButtons: some View {
Expand Down
19 changes: 18 additions & 1 deletion Bitkit/Views/Wallets/LnurlWithdraw/LnurlWithdrawAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ struct LnurlWithdrawAmount: View {
@EnvironmentObject var wallet: WalletViewModel
let onContinue: () -> Void

@StateObject private var amountViewModel = AmountInputViewModel()
@State private var amountViewModel = AmountInputViewModel()

var minAmount: Int {
Int(max(1, app.lnurlWithdrawData!.minWithdrawableSat))
Expand Down Expand Up @@ -78,6 +78,23 @@ struct LnurlWithdrawAmount: View {
amountViewModel.updateFromSats(UInt64(minAmount), currency: currency)
}
}
.onChange(of: maxAmount, initial: true) { updateInputCap() }
.onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() }
}

private func updateInputCap() {
let cap = max(minAmount, maxAmount)
amountViewModel.maxAmountOverride = cap > 0 ? UInt64(cap) : nil
}

private func showMaxExceededToast() {
app.toast(
type: .warning,
title: t("wallet__lnurl_w_error_max__title"),
description: t("wallet__lnurl_w_error_max__description"),
visibilityTime: Toast.visibilityTimeShort,
accessibilityIdentifier: "SendAmountExceededToast"
)
}

private func handleContinue() {
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Views/Wallets/Receive/ReceiveCjitAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ struct ReceiveCjitAmount: View {

@Binding var navigationPath: [ReceiveRoute]

@StateObject private var amountViewModel = AmountInputViewModel()
@State private var amountViewModel = AmountInputViewModel()

var minimumAmount: UInt64 {
blocktank.minCjitSats ?? 0
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Views/Wallets/Receive/ReceiveEdit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ struct ReceiveEdit: View {

@Binding var navigationPath: [ReceiveRoute]

@StateObject private var amountViewModel = AmountInputViewModel()
@State private var amountViewModel = AmountInputViewModel()
@State private var note = ""
@State private var isAmountInputFocused: Bool = false
@FocusState private var isNoteEditorFocused: Bool
Expand Down
21 changes: 20 additions & 1 deletion Bitkit/Views/Wallets/Send/LnurlPayAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ struct LnurlPayAmount: View {

@Binding var navigationPath: [SendRoute]

@StateObject private var amountViewModel = AmountInputViewModel()
@State private var amountViewModel = AmountInputViewModel()

var maxAmount: UInt64 {
// TODO: subtract fee
Expand Down Expand Up @@ -81,6 +81,25 @@ struct LnurlPayAmount: View {
.navigationBarHidden(true)
.padding(.horizontal, 16)
.sheetBackground()
.onChange(of: maxAmount, initial: true) { updateInputCap() }
.onChange(of: amountViewModel.maxExceededCount) { showMaxExceededToast() }
}

private func updateInputCap() {
amountViewModel.maxAmountOverride = maxAmount > 0 ? maxAmount : nil
}

private func showMaxExceededToast() {
// The cap is min(invoice max, spending balance); word the message for whichever bound is hit.
let isInvoiceCapped = app.lnurlPayData!.maxSendableSat < UInt64(wallet.totalLightningSats)

app.toast(
type: .warning,
title: t(isInvoiceCapped ? "wallet__lnurl_pay__error_max__title" : "wallet__send_amount_exceeded__title"),
description: t(isInvoiceCapped ? "wallet__lnurl_pay__error_max__description" : "wallet__send_amount_exceeded__description"),
visibilityTime: Toast.visibilityTimeShort,
accessibilityIdentifier: "SendAmountExceededToast"
)
}

private func onContinue() {
Expand Down
Loading
Loading