Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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