diff --git a/Bitkit/Components/MoneyStack.swift b/Bitkit/Components/MoneyStack.swift index 48901024..2d292f55 100644 --- a/Bitkit/Components/MoneyStack.swift +++ b/Bitkit/Components/MoneyStack.swift @@ -8,6 +8,7 @@ struct MoneyStack: View { var showEyeIcon: Bool = false var enableSwipeGesture: Bool = false var testIdPrefix: String = "TotalBalance" + var onTap: (() -> Void)? @EnvironmentObject var app: AppViewModel @EnvironmentObject var currency: CurrencyViewModel @@ -111,6 +112,11 @@ struct MoneyStack: View { .accessibilityIdentifier(testIdPrefix) .contentShape(Rectangle()) .onTapGesture { + if let onTap { + onTap() + return + } + let previousDisplay = currency.primaryDisplay withAnimation(springAnimation) { diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index d702ce18..5c8b6c15 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -8,6 +8,8 @@ class AppViewModel: ObservableObject { @Published var scannedLightningInvoice: LightningInvoice? @Published var scannedOnchainInvoice: OnChainInvoice? @Published var selectedWalletToPayFrom: WalletType = .onchain + @Published var manualEntryInput: String = "" + @Published var isManualEntryInputValid: Bool = false // LNURL @Published var lnurlPayData: LnurlPayData? @@ -59,6 +61,7 @@ class AppViewModel: ObservableObject { private let coreService: CoreService private let sheetViewModel: SheetViewModel private let navigationViewModel: NavigationViewModel + private var manualEntryValidationSequence: UInt64 = 0 init( lightningService: LightningService = .shared, @@ -327,6 +330,38 @@ extension AppViewModel { selectedWalletToPayFrom = .onchain // Reset to default lnurlPayData = nil lnurlWithdrawData = nil + resetManualEntryInput() + } +} + +// MARK: Manual entry validation + +extension AppViewModel { + func normalizeManualEntry(_ value: String) -> String { + value.filter { !$0.isWhitespace } + } + + func resetManualEntryInput() { + manualEntryValidationSequence &+= 1 + manualEntryInput = "" + isManualEntryInputValid = false + } + + func validateManualEntryInput(_ rawValue: String) async { + manualEntryValidationSequence &+= 1 + let currentSequence = manualEntryValidationSequence + + let normalized = normalizeManualEntry(rawValue) + + guard !normalized.isEmpty else { + isManualEntryInputValid = false + return + } + + let isValid = await (try? decode(invoice: normalized)) != nil + + guard currentSequence == manualEntryValidationSequence else { return } + isManualEntryInputValid = isValid } } diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift index 86b2f9f4..33d3ff50 100644 --- a/Bitkit/Views/Wallets/Send/SendAmountView.swift +++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift @@ -127,9 +127,18 @@ struct SendAmountView: View { .padding(.horizontal, 16) .sheetBackground() .onAppear { - if let invoice = app.scannedOnchainInvoice { + if let invoice = app.scannedOnchainInvoice, invoice.amountSatoshis > 0 { // Set the amount to the scanned onchain invoice amount if it exists amountViewModel.updateFromSats(invoice.amountSatoshis, currency: currency) + wallet.sendAmountSats = invoice.amountSatoshis + } else if let lightningInvoice = app.scannedLightningInvoice, + lightningInvoice.amountSatoshis > 0, + wallet.sendAmountSats == nil || wallet.sendAmountSats == 0 + { + amountViewModel.updateFromSats(lightningInvoice.amountSatoshis, currency: currency) + wallet.sendAmountSats = lightningInvoice.amountSatoshis + } else if let existingAmount = wallet.sendAmountSats, existingAmount > 0 { + amountViewModel.updateFromSats(existingAmount, currency: currency) } // Calculate max sendable amount for onchain transactions diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 43e77c79..95e0f07e 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -79,12 +79,22 @@ struct SendConfirmationView: View { VStack(alignment: .leading, spacing: 0) { if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { - MoneyStack(sats: Int(wallet.sendAmountSats ?? invoice.amountSatoshis), showSymbol: true, testIdPrefix: "ReviewAmount") - .padding(.bottom, 44) + MoneyStack( + sats: Int(wallet.sendAmountSats ?? invoice.amountSatoshis), + showSymbol: true, + testIdPrefix: "ReviewAmount", + onTap: navigateToAmount + ) + .padding(.bottom, 44) lightningView(invoice) } else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice { - MoneyStack(sats: Int(wallet.sendAmountSats ?? invoice.amountSatoshis), showSymbol: true, testIdPrefix: "ReviewAmount") - .padding(.bottom, 44) + MoneyStack( + sats: Int(wallet.sendAmountSats ?? invoice.amountSatoshis), + showSymbol: true, + testIdPrefix: "ReviewAmount", + onTap: navigateToAmount + ) + .padding(.bottom, 44) onchainView(invoice) } } @@ -421,12 +431,10 @@ struct SendConfirmationView: View { @ViewBuilder func onchainView(_ invoice: OnChainInvoice) -> some View { VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("wallet__send_to")) - BodySSBText(invoice.address.ellipsis(maxLength: 20)) - .lineLimit(1) - .truncationMode(.middle) - } + editableInvoiceSection( + title: t("wallet__send_to"), + value: invoice.address + ) .padding(.bottom) .frame(maxWidth: .infinity, alignment: .leading) @@ -487,14 +495,12 @@ struct SendConfirmationView: View { } @ViewBuilder - func lightningView(_: LightningInvoice) -> some View { + func lightningView(_ invoice: LightningInvoice) -> some View { VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 8) { - CaptionMText(t("wallet__send_invoice")) - BodySSBText(app.scannedLightningInvoice?.bolt11.ellipsis(maxLength: 20) ?? "") - .lineLimit(1) - .truncationMode(.middle) - } + editableInvoiceSection( + title: t("wallet__send_invoice"), + value: invoice.bolt11 + ) .padding(.bottom) .frame(maxWidth: .infinity, alignment: .leading) @@ -565,6 +571,45 @@ struct SendConfirmationView: View { } } + @ViewBuilder + private func editableInvoiceSection(title: String, value: String) -> some View { + Button { + navigateToManual(with: value) + } label: { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(title) + BodySSBText(value.ellipsis(maxLength: 20)) + .lineLimit(1) + .truncationMode(.middle) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("ReviewUri") + } + + private func navigateToManual(with value: String) { + guard !value.isEmpty else { return } + app.manualEntryInput = value + Task { await app.validateManualEntryInput(value) } + + if let manualIndex = navigationPath.firstIndex(of: .manual) { + navigationPath = Array(navigationPath.prefix(manualIndex + 1)) + } else { + navigationPath = [.manual] + } + } + + private func navigateToAmount() { + if let amountIndex = navigationPath.lastIndex(of: .amount) { + navigationPath = Array(navigationPath.prefix(amountIndex + 1)) + } else { + if let confirmIndex = navigationPath.lastIndex(of: .confirm) { + navigationPath = Array(navigationPath.prefix(confirmIndex)) + } + navigationPath.append(.amount) + } + } + private func calculateTransactionFee() async { guard app.selectedWalletToPayFrom == .onchain else { return diff --git a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift index 71e25266..58d845ee 100644 --- a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift +++ b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift @@ -5,9 +5,18 @@ struct SendEnterManuallyView: View { @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var settings: SettingsViewModel @Binding var navigationPath: [SendRoute] - @State private var text = "" @FocusState private var isTextEditorFocused: Bool + private var manualEntryBinding: Binding { + Binding( + get: { app.manualEntryInput }, + set: { newValue in + app.manualEntryInput = newValue + Task { await app.validateManualEntryInput(newValue) } + } + ) + } + var body: some View { VStack { SheetHeader(title: t("wallet__send_bitcoin"), showBackButton: true) @@ -16,12 +25,12 @@ struct SendEnterManuallyView: View { .frame(maxWidth: .infinity, alignment: .leading) ZStack(alignment: .topLeading) { - if text.isEmpty { + if app.manualEntryInput.isEmpty { TitleText(t("wallet__send_address_placeholder"), textColor: .textSecondary) .padding(20) } - TextEditor(text: $text) + TextEditor(text: manualEntryBinding) .focused($isTextEditorFocused) .padding(EdgeInsets(top: -10, leading: -5, bottom: -5, trailing: -5)) .padding(20) @@ -31,8 +40,8 @@ struct SendEnterManuallyView: View { .foregroundColor(.textPrimary) .accentColor(.brandAccent) .submitLabel(.done) - .dismissKeyboardOnReturn(text: $text, isFocused: $isTextEditorFocused) - .accessibilityValue(text) + .dismissKeyboardOnReturn(text: manualEntryBinding, isFocused: $isTextEditorFocused) + .accessibilityValue(app.manualEntryInput) .accessibilityIdentifier("RecipientInput") } .background(Color.white06) @@ -40,7 +49,7 @@ struct SendEnterManuallyView: View { Spacer(minLength: 16) - CustomButton(title: "Continue", isDisabled: text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) { + CustomButton(title: "Continue", isDisabled: !app.isManualEntryInputValid) { await handleContinue() } .buttonBottomPadding(isFocused: isTextEditorFocused) @@ -56,7 +65,9 @@ struct SendEnterManuallyView: View { } func handleContinue() async { - let uri = text.trimmingCharacters(in: .whitespacesAndNewlines) + let uri = app.normalizeManualEntry(app.manualEntryInput) + + guard !uri.isEmpty, app.isManualEntryInputValid else { return } do { try await app.handleScannedData(uri) diff --git a/Bitkit/Views/Wallets/Send/SendOptionsView.swift b/Bitkit/Views/Wallets/Send/SendOptionsView.swift index a7287884..36eb7dc5 100644 --- a/Bitkit/Views/Wallets/Send/SendOptionsView.swift +++ b/Bitkit/Views/Wallets/Send/SendOptionsView.swift @@ -58,6 +58,7 @@ struct SendOptionsView: View { title: t("wallet__recipient_manual"), testID: "RecipientManual" ) { + app.resetManualEntryInput() navigationPath.append(.manual) } }