From 7354a3451aad41fd871ad0217187fe6dd7863ddc Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 22 Aug 2026 18:33:13 -0500 Subject: [PATCH 1/2] fix: support hardware input on numeric keypads --- DashWallet.xcodeproj/project.pbxproj | 6 + .../Transfer Amount/TransferAmountView.swift | 2 +- .../SwiftUI/ContactProfileSheet.swift | 2 +- .../Components/DashSpendSinglePanel.swift | 2 +- .../DashSpend/DashSpendUserAuthScreen.swift | 2 +- .../EvonodeWithdrawalScreen.swift | 2 +- .../UI/Payments/Amount/SendAmountScreen.swift | 2 +- .../Amount/SpecifyAmountViewController.swift | 2 +- .../InternalTransferScreen.swift | 2 +- .../Sources/UI/Payments/Pay/SendScreen.swift | 2 +- .../Buy/EnterAmount/BuyEnterAmountView.swift | 2 +- .../UI/Swap/Convert/SwapConvertView.swift | 2 +- .../HardwareNumericKeyboardView.swift | 186 ++++++++++++++++++ DashWalletTests/AmountObjectTests.swift | 52 +++++ 14 files changed, 255 insertions(+), 11 deletions(-) create mode 100644 DashWallet/Sources/UI/SwiftUI Components/HardwareNumericKeyboardView.swift diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 919db1e3ae..f9b68f3847 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -668,6 +668,8 @@ 750CED612C94BFD7000FB837 /* SettingsMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 750CED5F2C94BFD7000FB837 /* SettingsMenuViewModel.swift */; }; 750CEFA12CCA6EA100E87A32 /* TextInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 750CEFA02CCA6EA100E87A32 /* TextInput.swift */; }; 750CEFA22CCA6EA100E87A32 /* TextInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 750CEFA02CCA6EA100E87A32 /* TextInput.swift */; }; + C0DE00010000000000000002 /* HardwareNumericKeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00010000000000000001 /* HardwareNumericKeyboardView.swift */; }; + C0DE00010000000000000003 /* HardwareNumericKeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00010000000000000001 /* HardwareNumericKeyboardView.swift */; }; 750CEFA42CCA713300E87A32 /* CreateUsernameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 750CEFA32CCA712E00E87A32 /* CreateUsernameViewController.swift */; }; 7513DA882AB175E0005D55F6 /* TopperViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7527720E2AA9F58E0066557E /* TopperViewModel.swift */; }; 7513DA892AB17606005D55F6 /* Topper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E2F3C92AA4D1B900C3B458 /* Topper.swift */; }; @@ -2851,6 +2853,7 @@ 750C6CC01B5C8EB60038AAE9 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = ""; }; 750CED5F2C94BFD7000FB837 /* SettingsMenuViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMenuViewModel.swift; sourceTree = ""; }; 750CEFA02CCA6EA100E87A32 /* TextInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInput.swift; sourceTree = ""; }; + C0DE00010000000000000001 /* HardwareNumericKeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareNumericKeyboardView.swift; sourceTree = ""; }; 750CEFA32CCA712E00E87A32 /* CreateUsernameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateUsernameViewController.swift; sourceTree = ""; }; 7511E8CB1AE5FF240025F1B3 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; lineEnding = 0; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.simpleColoring; }; 7511E8CF1AE5FF2D0025F1B3 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; @@ -6864,6 +6867,7 @@ isa = PBXGroup; children = ( 51DFA7F72FA8A9DF001EDB3A /* DashStepper.swift */, + C0DE00010000000000000001 /* HardwareNumericKeyboardView.swift */, 516983C02FCAEDD600BA91A2 /* Icons */, 7581B19C2CE3509A00714007 /* Dialogs */, 75EBAA112BB99B6B004488E3 /* BottomSheet.swift */, @@ -9747,6 +9751,7 @@ 478C983229433C5700FAA0F0 /* PaymentMethodsController.swift in Sources */, 47AE8C1328C5F0A700490F5E /* POIDetailsViewController.swift in Sources */, 750CEFA22CCA6EA100E87A32 /* TextInput.swift in Sources */, + C0DE00010000000000000002 /* HardwareNumericKeyboardView.swift in Sources */, 47AE8C1C28C6AA2500490F5E /* PointOfUseLocationServicePopup.swift in Sources */, 472D13E8299E4EE7006903F1 /* BalanceModel.swift in Sources */, 47F2C6822860319400C2B774 /* TxReclassifyTransactionsInfoViewController.swift in Sources */, @@ -10707,6 +10712,7 @@ C9D2C77E2A320AA000D15901 /* ConfirmationTransactionQRController.swift in Sources */, C9D2C7812A320AA000D15901 /* Numbers+Dash.swift in Sources */, 750CEFA12CCA6EA100E87A32 /* TextInput.swift in Sources */, + C0DE00010000000000000003 /* HardwareNumericKeyboardView.swift in Sources */, C9D2C7822A320AA000D15901 /* PaymentMethodsController.swift in Sources */, C9D2C7832A320AA000D15901 /* POIDetailsViewController.swift in Sources */, C9D2C7842A320AA000D15901 /* PointOfUseLocationServicePopup.swift in Sources */, diff --git a/DashWallet/Sources/UI/Coinbase/Transfer Amount/TransferAmountView.swift b/DashWallet/Sources/UI/Coinbase/Transfer Amount/TransferAmountView.swift index 12328308a1..30a829e4d5 100644 --- a/DashWallet/Sources/UI/Coinbase/Transfer Amount/TransferAmountView.swift +++ b/DashWallet/Sources/UI/Coinbase/Transfer Amount/TransferAmountView.swift @@ -98,7 +98,7 @@ struct TransferAmountView: View { // MARK: - Keyboard private var keyboard: some View { - DashUIKit.NumericKeyboardView( + HardwareNumericKeyboardView( value: Binding( get: { viewModel.inputValue }, set: { viewModel.setInput($0) } diff --git a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift index f3bf18dfd4..e6f2408140 100644 --- a/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift +++ b/DashWallet/Sources/UI/DashPay/Contacts/SwiftUI/ContactProfileSheet.swift @@ -799,7 +799,7 @@ struct PayContactSheet: View { .font(.system(size: 12)) .foregroundColor(.dash.tertiaryText) - NumericKeyboardView( + HardwareNumericKeyboardView( value: $amountText, showDecimalSeparator: true, actionButtonText: NSLocalizedString("Pay", comment: "DashPay Contacts"), diff --git a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/Components/DashSpendSinglePanel.swift b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/Components/DashSpendSinglePanel.swift index ecd8143ddc..b3696d8bdc 100644 --- a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/Components/DashSpendSinglePanel.swift +++ b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/Components/DashSpendSinglePanel.swift @@ -131,7 +131,7 @@ struct DashSpendSinglePanel: View { Spacer(minLength: 0) - DashUIKit.NumericKeyboardView( + HardwareNumericKeyboardView( value: $input, showDecimalSeparator: true, locale: .autoupdatingCurrent, diff --git a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendUserAuthScreen.swift b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendUserAuthScreen.swift index 74f7faa5ab..33f1c33923 100644 --- a/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendUserAuthScreen.swift +++ b/DashWallet/Sources/UI/Explore Dash/Views/DashSpend/DashSpendUserAuthScreen.swift @@ -146,7 +146,7 @@ struct DashSpendUserAuthScreen: View { // Bottom section with button or keyboard if authType == .otp { - DashUIKit.NumericKeyboardView( + HardwareNumericKeyboardView( value: $viewModel.input, showDecimalSeparator: false, locale: .autoupdatingCurrent, diff --git a/DashWallet/Sources/UI/Menu/Tools/Masternode Withdrawal/EvonodeWithdrawalScreen.swift b/DashWallet/Sources/UI/Menu/Tools/Masternode Withdrawal/EvonodeWithdrawalScreen.swift index d06402e55a..7a14dd1d7a 100644 --- a/DashWallet/Sources/UI/Menu/Tools/Masternode Withdrawal/EvonodeWithdrawalScreen.swift +++ b/DashWallet/Sources/UI/Menu/Tools/Masternode Withdrawal/EvonodeWithdrawalScreen.swift @@ -297,7 +297,7 @@ struct EvonodeWithdrawalScreen: View { // MARK: Keyboard private var keyboardSection: some View { - NumericKeyboardView( + HardwareNumericKeyboardView( value: keypadBinding, showDecimalSeparator: true, actionButtonText: NSLocalizedString("Continue", comment: ""), diff --git a/DashWallet/Sources/UI/Payments/Amount/SendAmountScreen.swift b/DashWallet/Sources/UI/Payments/Amount/SendAmountScreen.swift index becaf85dcd..777c4b12a0 100644 --- a/DashWallet/Sources/UI/Payments/Amount/SendAmountScreen.swift +++ b/DashWallet/Sources/UI/Payments/Amount/SendAmountScreen.swift @@ -235,7 +235,7 @@ private struct SendAmountKeyboardSection: View { var body: some View { VStack(spacing: 0) { - DashUIKit.NumericKeyboardView( + HardwareNumericKeyboardView( value: $value, showDecimalSeparator: true, locale: locale, diff --git a/DashWallet/Sources/UI/Payments/Amount/SpecifyAmountViewController.swift b/DashWallet/Sources/UI/Payments/Amount/SpecifyAmountViewController.swift index 4d34109e55..5b19820250 100644 --- a/DashWallet/Sources/UI/Payments/Amount/SpecifyAmountViewController.swift +++ b/DashWallet/Sources/UI/Payments/Amount/SpecifyAmountViewController.swift @@ -183,7 +183,7 @@ private struct SpecifyAmountView: View { Spacer(minLength: 0) - DashUIKit.NumericKeyboardView( + HardwareNumericKeyboardView( value: Binding( get: { model.currentInputString }, set: { model.updateKeyboardInputString($0) } diff --git a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift index 142f4cc0e9..6ef3b98269 100644 --- a/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift +++ b/DashWallet/Sources/UI/Payments/InternalTransfer/InternalTransferScreen.swift @@ -163,7 +163,7 @@ struct InternalTransferScreen: View { // MARK: - Keyboard private var keyboardSection: some View { - NumericKeyboardView( + HardwareNumericKeyboardView( value: keypadBinding, showDecimalSeparator: true, actionButtonText: NSLocalizedString("Continue", comment: ""), diff --git a/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift b/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift index ff49016092..5c026f5605 100644 --- a/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift +++ b/DashWallet/Sources/UI/Payments/Pay/SendScreen.swift @@ -456,7 +456,7 @@ struct ExternalSendAmountScreen: View { // MARK: - Keyboard private var keyboardSection: some View { - NumericKeyboardView( + HardwareNumericKeyboardView( value: keypadBinding, showDecimalSeparator: true, actionButtonText: NSLocalizedString("Continue", comment: ""), diff --git a/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift b/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift index 69faa759af..1a38080039 100644 --- a/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift +++ b/DashWallet/Sources/UI/Swap/Buy/EnterAmount/BuyEnterAmountView.swift @@ -129,7 +129,7 @@ struct BuyEnterAmountView: View { // MARK: - Keyboard private var keyboard: some View { - NumericKeyboardView( + HardwareNumericKeyboardView( value: Binding( get: { viewModel.inputValue }, set: { viewModel.setInput($0) } diff --git a/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift b/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift index e653b20983..4798dfe52a 100644 --- a/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift +++ b/DashWallet/Sources/UI/Swap/Convert/SwapConvertView.swift @@ -152,7 +152,7 @@ struct SwapConvertView: View { // MARK: - Keyboard private var keyboard: some View { - NumericKeyboardView( + HardwareNumericKeyboardView( value: Binding( get: { viewModel.inputValue }, set: { viewModel.setInput($0) } diff --git a/DashWallet/Sources/UI/SwiftUI Components/HardwareNumericKeyboardView.swift b/DashWallet/Sources/UI/SwiftUI Components/HardwareNumericKeyboardView.swift new file mode 100644 index 0000000000..bf78b04db4 --- /dev/null +++ b/DashWallet/Sources/UI/SwiftUI Components/HardwareNumericKeyboardView.swift @@ -0,0 +1,186 @@ +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import DashUIKit +import SwiftUI +import UIKit + +enum HardwareNumericKeyboardKey: Equatable { + case digit(String) + case decimalSeparator + case delete +} + +enum HardwareNumericKeyboardInput { + static func applying( + _ key: HardwareNumericKeyboardKey, + to value: String, + showDecimalSeparator: Bool, + locale: Locale + ) -> String { + switch key { + case .digit(let digit): + return value + digit + case .decimalSeparator: + let separator = locale.decimalSeparator ?? "." + guard showDecimalSeparator, !value.contains(separator) else { return value } + return value + separator + case .delete: + guard !value.isEmpty else { return value } + return String(value.dropLast()) + } + } +} + +/// DashUIKit's numeric keypad is made of buttons and therefore has no text +/// responder for a connected keyboard. This wrapper keeps the visible keypad +/// unchanged while installing a responder for physical-keyboard input. +struct HardwareNumericKeyboardView: View { + @Binding private var value: String + private let showDecimalSeparator: Bool + private let locale: Locale + private let actionButtonText: String + private let actionEnabled: Bool + private let inProgress: Bool + private let helperText: String? + private let actionHandler: () -> Void + + init( + value: Binding, + showDecimalSeparator: Bool, + locale: Locale = .autoupdatingCurrent, + actionButtonText: String, + actionEnabled: Bool, + inProgress: Bool, + helperText: String? = nil, + actionHandler: @escaping () -> Void + ) { + _value = value + self.showDecimalSeparator = showDecimalSeparator + self.locale = locale + self.actionButtonText = actionButtonText + self.actionEnabled = actionEnabled + self.inProgress = inProgress + self.helperText = helperText + self.actionHandler = actionHandler + } + + var body: some View { + DashUIKit.NumericKeyboardView( + value: $value, + showDecimalSeparator: showDecimalSeparator, + locale: locale, + actionButtonText: actionButtonText, + actionEnabled: actionEnabled, + inProgress: inProgress, + helperText: helperText, + actionHandler: actionHandler + ) + .background { + HardwareNumericKeyboardResponder( + value: $value, + showDecimalSeparator: showDecimalSeparator, + locale: locale, + inputEnabled: !inProgress + ) + .frame(width: 1, height: 1) + .accessibilityHidden(true) + } + } +} + +private struct HardwareNumericKeyboardResponder: UIViewRepresentable { + @Binding var value: String + let showDecimalSeparator: Bool + let locale: Locale + let inputEnabled: Bool + + func makeCoordinator() -> Coordinator { + Coordinator(value: $value) + } + + func makeUIView(context: Context) -> HardwareNumericKeyboardResponderView { + let view = HardwareNumericKeyboardResponderView() + configure(view, coordinator: context.coordinator) + return view + } + + func updateUIView(_ view: HardwareNumericKeyboardResponderView, context: Context) { + context.coordinator.value = $value + configure(view, coordinator: context.coordinator) + } + + private func configure(_ view: HardwareNumericKeyboardResponderView, coordinator: Coordinator) { + view.inputEnabled = inputEnabled + view.onKey = { [weak coordinator] key in + guard let coordinator else { return } + coordinator.value.wrappedValue = HardwareNumericKeyboardInput.applying( + key, + to: coordinator.value.wrappedValue, + showDecimalSeparator: showDecimalSeparator, + locale: locale + ) + } + } + + final class Coordinator { + var value: Binding + + init(value: Binding) { + self.value = value + } + } +} + +private final class HardwareNumericKeyboardResponderView: UIView { + var inputEnabled = true + var onKey: ((HardwareNumericKeyboardKey) -> Void)? + + override var canBecomeFirstResponder: Bool { true } + + override var keyCommands: [UIKeyCommand]? { + let inputs = (0 ... 9).map(String.init) + [".", ","] + var commands = inputs.map { + UIKeyCommand(input: $0, modifierFlags: [], action: #selector(handleKeyCommand(_:))) + } + commands.append(UIKeyCommand(input: UIKeyCommand.inputDelete, modifierFlags: [], action: #selector(handleKeyCommand(_:)))) + return commands + } + + override func didMoveToWindow() { + super.didMoveToWindow() + guard window != nil else { return } + DispatchQueue.main.async { [weak self] in + self?.becomeFirstResponder() + } + } + + @objc private func handleKeyCommand(_ command: UIKeyCommand) { + guard let input = command.input else { return } + + guard inputEnabled else { return } + switch input { + case UIKeyCommand.inputDelete: + onKey?(.delete) + case ".", ",": + onKey?(.decimalSeparator) + default: + if input.count == 1, input.first?.isNumber == true { + onKey?(.digit(input)) + } + } + } +} diff --git a/DashWalletTests/AmountObjectTests.swift b/DashWalletTests/AmountObjectTests.swift index 3000b654b0..111bb90932 100644 --- a/DashWalletTests/AmountObjectTests.swift +++ b/DashWalletTests/AmountObjectTests.swift @@ -299,6 +299,58 @@ final class BaseAmountModelKeyboardInputTests: XCTestCase { } } +// MARK: - HardwareNumericKeyboardInputTests + +final class HardwareNumericKeyboardInputTests: XCTestCase { + func testDigitsAppendAndDelete() { + let locale = Locale(identifier: "en_US") + var value = "" + + value = HardwareNumericKeyboardInput.applying(.digit("1"), to: value, showDecimalSeparator: true, locale: locale) + value = HardwareNumericKeyboardInput.applying(.digit("2"), to: value, showDecimalSeparator: true, locale: locale) + XCTAssertEqual(value, "12") + + value = HardwareNumericKeyboardInput.applying(.delete, to: value, showDecimalSeparator: true, locale: locale) + XCTAssertEqual(value, "1") + value = HardwareNumericKeyboardInput.applying(.delete, to: value, showDecimalSeparator: true, locale: locale) + value = HardwareNumericKeyboardInput.applying(.delete, to: value, showDecimalSeparator: true, locale: locale) + XCTAssertEqual(value, "") + } + + func testDecimalUsesLocaleSeparatorAndIsAddedOnlyOnce() { + for (localeIdentifier, expected) in [("en_US", "1."), ("de_DE", "1,")] { + let locale = Locale(identifier: localeIdentifier) + var value = HardwareNumericKeyboardInput.applying( + .decimalSeparator, + to: "1", + showDecimalSeparator: true, + locale: locale + ) + XCTAssertEqual(value, expected) + + value = HardwareNumericKeyboardInput.applying( + .decimalSeparator, + to: value, + showDecimalSeparator: true, + locale: locale + ) + XCTAssertEqual(value, expected) + } + } + + func testDecimalIsIgnoredWhenHidden() { + XCTAssertEqual( + HardwareNumericKeyboardInput.applying( + .decimalSeparator, + to: "1", + showDecimalSeparator: false, + locale: Locale(identifier: "en_US") + ), + "1" + ) + } +} + // MARK: - PastedAmountParserTests final class PastedAmountParserTests: XCTestCase { From 5d4ef10beddbd77e64e3a093d96dded9430bea28 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 22 Aug 2026 19:25:11 -0500 Subject: [PATCH 2/2] refactor: route hardware keypad input through a hidden text field Replace the UIKeyCommand-based responder with a hidden UITextField that overrides insertText/deleteBackward and suppresses the software keyboard via an empty inputView, matching the existing DWPinField and TwoFactorAuthViewController pattern instead of introducing a fourth keyboard mechanism. This also makes hardware Return trigger the action button (gated the same way as the visible button, including the non-empty-value check), reclaims first responder after a sheet or alert with a text field steals it, and gains native key auto-repeat and paste handling. Co-Authored-By: Claude Fable 5 --- .../HardwareNumericKeyboardView.swift | 180 +++++++++++++----- DashWalletTests/AmountObjectTests.swift | 10 + 2 files changed, 141 insertions(+), 49 deletions(-) diff --git a/DashWallet/Sources/UI/SwiftUI Components/HardwareNumericKeyboardView.swift b/DashWallet/Sources/UI/SwiftUI Components/HardwareNumericKeyboardView.swift index bf78b04db4..3847bcc238 100644 --- a/DashWallet/Sources/UI/SwiftUI Components/HardwareNumericKeyboardView.swift +++ b/DashWallet/Sources/UI/SwiftUI Components/HardwareNumericKeyboardView.swift @@ -43,11 +43,25 @@ enum HardwareNumericKeyboardInput { return String(value.dropLast()) } } + + /// Both "." and "," map to the decimal separator: European numpads emit "," + /// for the decimal key, and the keypad has no grouping-separator concept. + static func key(for character: Character) -> HardwareNumericKeyboardKey? { + if character == "." || character == "," { + return .decimalSeparator + } + if let digit = character.wholeNumberValue, (0 ... 9).contains(digit) { + return .digit(String(digit)) + } + return nil + } } /// DashUIKit's numeric keypad is made of buttons and therefore has no text /// responder for a connected keyboard. This wrapper keeps the visible keypad -/// unchanged while installing a responder for physical-keyboard input. +/// unchanged while installing a hidden text responder for physical-keyboard +/// input — the same hidden-text-field pattern the PIN (`DWPinField`), 2FA +/// (`TwoFactorAuthViewController`), and legacy amount screens already use. struct HardwareNumericKeyboardView: View { @Binding private var value: String private let showDecimalSeparator: Bool @@ -94,7 +108,11 @@ struct HardwareNumericKeyboardView: View { value: $value, showDecimalSeparator: showDecimalSeparator, locale: locale, - inputEnabled: !inProgress + inputEnabled: !inProgress, + // Mirror the visible action button's gate, which DashUIKit + // computes as `!value.isEmpty && actionEnabled`. + returnEnabled: !value.isEmpty && actionEnabled && !inProgress, + onReturn: actionHandler ) .frame(width: 1, height: 1) .accessibilityHidden(true) @@ -107,80 +125,144 @@ private struct HardwareNumericKeyboardResponder: UIViewRepresentable { let showDecimalSeparator: Bool let locale: Locale let inputEnabled: Bool + let returnEnabled: Bool + let onReturn: () -> Void - func makeCoordinator() -> Coordinator { - Coordinator(value: $value) - } - - func makeUIView(context: Context) -> HardwareNumericKeyboardResponderView { - let view = HardwareNumericKeyboardResponderView() - configure(view, coordinator: context.coordinator) - return view + func makeUIView(context: Context) -> HardwareNumericKeyboardTextField { + let field = HardwareNumericKeyboardTextField() + configure(field) + return field } - func updateUIView(_ view: HardwareNumericKeyboardResponderView, context: Context) { - context.coordinator.value = $value - configure(view, coordinator: context.coordinator) + func updateUIView(_ field: HardwareNumericKeyboardTextField, context: Context) { + configure(field) } - private func configure(_ view: HardwareNumericKeyboardResponderView, coordinator: Coordinator) { - view.inputEnabled = inputEnabled - view.onKey = { [weak coordinator] key in - guard let coordinator else { return } - coordinator.value.wrappedValue = HardwareNumericKeyboardInput.applying( + private func configure(_ field: HardwareNumericKeyboardTextField) { + field.inputEnabled = inputEnabled + field.onKey = { key in + value = HardwareNumericKeyboardInput.applying( key, - to: coordinator.value.wrappedValue, + to: value, showDecimalSeparator: showDecimalSeparator, locale: locale ) } - } - - final class Coordinator { - var value: Binding - - init(value: Binding) { - self.value = value - } + field.onReturn = returnEnabled ? onReturn : nil } } -private final class HardwareNumericKeyboardResponderView: UIView { +/// Hidden `UITextField` whose `UIKeyInput` overrides forward hardware keys to +/// the keypad's value instead of the field's own text. An empty `inputView` +/// suppresses the software keyboard (as in `TwoFactorAuthViewController`). +final class HardwareNumericKeyboardTextField: UITextField, UITextFieldDelegate { var inputEnabled = true var onKey: ((HardwareNumericKeyboardKey) -> Void)? + var onReturn: (() -> Void)? - override var canBecomeFirstResponder: Bool { true } + /// The field's text stays pinned to this zero-width sentinel so `hasText` + /// is always true and UIKit keeps routing hardware Backspace to + /// `deleteBackward()` even when the keypad's logical value is empty. + private static let sentinel = "\u{200B}" - override var keyCommands: [UIKeyCommand]? { - let inputs = (0 ... 9).map(String.init) + [".", ","] - var commands = inputs.map { - UIKeyCommand(input: $0, modifierFlags: [], action: #selector(handleKeyCommand(_:))) + private var editingObservers: [NSObjectProtocol] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + text = Self.sentinel + inputView = UIView() + autocorrectionType = .no + spellCheckingType = .no + tintColor = .clear + textColor = .clear + backgroundColor = .clear + inputAssistantItem.leadingBarButtonGroups = [] + inputAssistantItem.trailingBarButtonGroups = [] + delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + editingObservers.forEach(NotificationCenter.default.removeObserver) + } + + // MARK: - UIKeyInput + + override func insertText(_ text: String) { + guard inputEnabled else { return } + for character in text { + if character == "\n" || character == "\r" { + onReturn?() + } else if let key = HardwareNumericKeyboardInput.key(for: character) { + onKey?(key) + } } - commands.append(UIKeyCommand(input: UIKeyCommand.inputDelete, modifierFlags: [], action: #selector(handleKeyCommand(_:)))) - return commands } + override func deleteBackward() { + guard inputEnabled else { return } + onKey?(.delete) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + onReturn?() + return false + } + + // MARK: - First responder management + override func didMoveToWindow() { super.didMoveToWindow() + + editingObservers.forEach(NotificationCenter.default.removeObserver) + editingObservers = [] guard window != nil else { return } - DispatchQueue.main.async { [weak self] in - self?.becomeFirstResponder() + + claimFirstResponderIfIdle() + + // A sheet or alert with a text field (currency-picker search, PIN + // prompt) steals first responder while this view stays in the window, + // so `didMoveToWindow` alone would leave hardware input dead after + // dismissal. Reclaim whenever any other editor ends editing. + for name in [UITextField.textDidEndEditingNotification, UITextView.textDidEndEditingNotification] { + editingObservers.append(NotificationCenter.default.addObserver( + forName: name, object: nil, queue: .main + ) { [weak self] notification in + guard let self, (notification.object as? UIView) !== self else { return } + self.claimFirstResponderIfIdle() + }) } } - @objc private func handleKeyCommand(_ command: UIKeyCommand) { - guard let input = command.input else { return } - - guard inputEnabled else { return } - switch input { - case UIKeyCommand.inputDelete: - onKey?(.delete) - case ".", ",": - onKey?(.decimalSeparator) - default: - if input.count == 1, input.first?.isNumber == true { - onKey?(.digit(input)) + private func claimFirstResponderIfIdle() { + DispatchQueue.main.async { [weak self] in + guard let self, self.window != nil, !self.isFirstResponder else { return } + // Don't yank focus from a text input the user is actively editing + // (e.g. the contact Alias field beneath a Pay sheet). + if let current = UIResponder.currentFirstResponder, current !== self, current is UITextInput { + return } + self.becomeFirstResponder() } } } + +private extension UIResponder { + private enum FirstResponderProbe { + static weak var current: UIResponder? + } + + static var currentFirstResponder: UIResponder? { + FirstResponderProbe.current = nil + UIApplication.shared.sendAction(#selector(captureFirstResponder), to: nil, from: nil, for: nil) + return FirstResponderProbe.current + } + + @objc private func captureFirstResponder() { + FirstResponderProbe.current = self + } +} diff --git a/DashWalletTests/AmountObjectTests.swift b/DashWalletTests/AmountObjectTests.swift index 111bb90932..60cf83e0f0 100644 --- a/DashWalletTests/AmountObjectTests.swift +++ b/DashWalletTests/AmountObjectTests.swift @@ -349,6 +349,16 @@ final class HardwareNumericKeyboardInputTests: XCTestCase { "1" ) } + + func testCharacterMapping() { + XCTAssertEqual(HardwareNumericKeyboardInput.key(for: "0"), .digit("0")) + XCTAssertEqual(HardwareNumericKeyboardInput.key(for: "9"), .digit("9")) + XCTAssertEqual(HardwareNumericKeyboardInput.key(for: "."), .decimalSeparator) + XCTAssertEqual(HardwareNumericKeyboardInput.key(for: ","), .decimalSeparator) + XCTAssertNil(HardwareNumericKeyboardInput.key(for: "a")) + XCTAssertNil(HardwareNumericKeyboardInput.key(for: "-")) + XCTAssertNil(HardwareNumericKeyboardInput.key(for: " ")) + } } // MARK: - PastedAmountParserTests