diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 919db1e3a..f9b68f384 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 12328308a..30a829e4d 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 f3bf18dfd..e6f240814 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 ecd8143dd..b3696d8bd 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 74f7faa5a..33f1c3392 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 d06402e55..7a14dd1d7 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 becaf85dc..777c4b12a 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 4d34109e5..5b1982025 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 142f4cc0e..6ef3b9826 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 ff4901609..5c026f560 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 69faa759a..1a3808003 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 e653b2098..4798dfe52 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 000000000..3847bcc23 --- /dev/null +++ b/DashWallet/Sources/UI/SwiftUI Components/HardwareNumericKeyboardView.swift @@ -0,0 +1,268 @@ +// +// 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()) + } + } + + /// 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 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 + 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, + // 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) + } + } +} + +private struct HardwareNumericKeyboardResponder: UIViewRepresentable { + @Binding var value: String + let showDecimalSeparator: Bool + let locale: Locale + let inputEnabled: Bool + let returnEnabled: Bool + let onReturn: () -> Void + + func makeUIView(context: Context) -> HardwareNumericKeyboardTextField { + let field = HardwareNumericKeyboardTextField() + configure(field) + return field + } + + func updateUIView(_ field: HardwareNumericKeyboardTextField, context: Context) { + configure(field) + } + + private func configure(_ field: HardwareNumericKeyboardTextField) { + field.inputEnabled = inputEnabled + field.onKey = { key in + value = HardwareNumericKeyboardInput.applying( + key, + to: value, + showDecimalSeparator: showDecimalSeparator, + locale: locale + ) + } + field.onReturn = returnEnabled ? onReturn : nil + } +} + +/// 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)? + + /// 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}" + + 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) + } + } + } + + 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 } + + 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() + }) + } + } + + 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 3000b654b..60cf83e0f 100644 --- a/DashWalletTests/AmountObjectTests.swift +++ b/DashWalletTests/AmountObjectTests.swift @@ -299,6 +299,68 @@ 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" + ) + } + + 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 final class PastedAmountParserTests: XCTestCase {