From 4ace4df1167965ac6aad0af0b3bbd1b20e75d6ae Mon Sep 17 00:00:00 2001 From: Nadya Karaban Date: Wed, 17 Jun 2026 10:18:07 +0200 Subject: [PATCH 01/11] feat(GiniInternalPaymentSDK): Add Liquid Glass Done button for decimal pad keyboard on iOS 26 On iOS 26+, use ToolbarItemGroup(placement: .keyboard) so the Done button renders natively with Liquid Glass. iOS <26 retains the doneButtonBar via safeAreaInset for portrait/landscape-bottomSheet and PaymentReviewContentView.toolbar for landscape documentCollection. - Extract dismissAmountKeyboard() to deduplicate shared action in doneButtonBar and the iOS 26 ToolbarItemGroup - Extract Transaction.withoutAnimation to replace the repeated disablesAnimations boilerplate across PaymentReviewPaymentInformationView and PaymentReviewContentView HEAL-508 --- .../Extensions/Transaction+Extensions.swift | 16 +++ .../PaymentReviewPaymentInformationView.swift | 100 +++++++++++------- .../Views/PaymentReviewContentView.swift | 37 +++---- 3 files changed, 93 insertions(+), 60 deletions(-) create mode 100644 GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/Extensions/Transaction+Extensions.swift diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/Extensions/Transaction+Extensions.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/Extensions/Transaction+Extensions.swift new file mode 100644 index 0000000000..0bfa3401f6 --- /dev/null +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/Extensions/Transaction+Extensions.swift @@ -0,0 +1,16 @@ +// +// Transaction+Extensions.swift +// +// Copyright © 2026 Gini GmbH. All rights reserved. +// + +import SwiftUI + +extension Transaction { + // Disables ambient UIKit animation inheritance (e.g. keyboard CATransaction). + static var withoutAnimation: Transaction { + var transaction = Transaction() + transaction.disablesAnimations = true + return transaction + } +} diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift index 49b01607fa..36b605c653 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift @@ -78,10 +78,27 @@ struct PaymentReviewPaymentInformationView: View { .background(Color(.systemBackground)) .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } - keyboardHeight = frame.height + // Strip the keyboard UIKit animation so SwiftUI applies safeAreaInset changes + // instantly (one scroll evaluation against the final viewport). + withTransaction(.withoutAnimation) { keyboardHeight = frame.height } } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in - keyboardHeight = 0 + withTransaction(.withoutAnimation) { keyboardHeight = 0 } + } + // iOS 26+ only: sheet/toolbar interop is fixed so one ToolbarItemGroup covers all modes. + // iOS <26 falls back to doneButtonBar (portrait/landscape-bottomSheet) and + // PaymentReviewContentView.toolbar (landscape documentCollection). + .toolbar { + if #available(iOS 26, *) { + ToolbarItemGroup(placement: .keyboard) { + if focusedField == .amount { + Spacer() + Button(viewModelStrings.keyboardDoneButtonTitle) { + dismissAmountKeyboard() + } + } + } + } } } @@ -95,42 +112,34 @@ struct PaymentReviewPaymentInformationView: View { .ignoresSafeArea(.keyboard) .safeAreaInset(edge: .bottom) { VStack(spacing: 0) { - // Done button shown here only in portrait (bottom-sheet mode). In landscape - // the outer PaymentReviewContentView renders a full-width Done toolbar above - // the keyboard, so the narrow per-panel bar is suppressed. - if !giniLayout.isLandscape && focusedField == .amount && keyboardHeight > 0 { - doneButtonBar + // iOS <26: landscape documentCollection is handled by PaymentReviewContentView.toolbar — + // showing it here too would add 44pt and cause auto-scroll to overshoot. + if #unavailable(iOS 26) { + let isBottomSheetMode = viewModel.model.displayMode == .bottomSheet + if (!giniLayout.isLandscape || isBottomSheetMode) && focusedField == .amount && keyboardHeight > 0 { + doneButtonBar + } } - // In landscape the keyboard sits below the inline view; re-inject its - // height so the ScrollView scrolls content above it. In portrait the - // sheet already repositions above the keyboard — no extra space needed. - Color.clear.frame(height: giniLayout.isLandscape ? keyboardHeight : 0) + // Landscape: re-inject keyboard height so content scrolls above it; portrait doesn't need it. + // allowsHitTesting(false): safeAreaInset overlays scroll content — without this the spacer swallows taps. + Color.clear + .frame(height: giniLayout.isLandscape ? keyboardHeight : 0) + .allowsHitTesting(false) } } } - @ViewBuilder - private var doneButtonBar: some View { - HStack { - Spacer() - Button(viewModelStrings.keyboardDoneButtonTitle) { - onKeyboardDismissed() - viewModel.handleAmountFocusChange(isFocused: false) - focusedField = nil - } - .padding(.horizontal, Constants.doneButtonHorizontalPadding) - } - .frame(height: Constants.doneButtonBarHeight) - .background(Color(UIColor.systemGroupedBackground)) - .overlay(alignment: .top) { Divider() } - } - private var baseScrollView: some View { ScrollView { VStack(spacing: 0) { if showBanner { infoBannerView - .transition(.move(edge: .top).combined(with: .opacity)) + // Removal uses opacity-only: .move animates height, triggering repeated scroll + // evaluations while the keyboard is visible. Opacity is instant for layout. + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .opacity + )) .onAppear { UIAccessibility.post(notification: .announcement, argument: viewModel.model.strings.infoBarMessage) @@ -412,7 +421,28 @@ struct PaymentReviewPaymentInformationView: View { } .padding(.top, Constants.poweredByGiniTopPadding) } - + + // Done bar substituting the return key absent from .decimalPad; shown on iOS <26. + @ViewBuilder + private var doneButtonBar: some View { + HStack { + Spacer() + Button(viewModelStrings.keyboardDoneButtonTitle) { + dismissAmountKeyboard() + } + .padding(.horizontal, Constants.doneButtonHorizontalPadding) + } + .frame(height: Constants.doneButtonBarHeight) + .background(.regularMaterial) + .overlay(alignment: .top) { Divider() } + } + + private func dismissAmountKeyboard() { + onKeyboardDismissed() + viewModel.handleAmountFocusChange(isFocused: false) + focusedField = nil + } + // MARK: Private methods private func clearFocus() { @@ -436,12 +466,6 @@ struct PaymentReviewPaymentInformationView: View { // MARK: - Orientation Helpers - /** - Handles focus changes on the active field. - When focus moves to a field, the model's `activeField` and `isAmountFieldFocused` are updated. - When focus is cleared, amount-focus is cleared immediately and `activeField` is cleared - after a short delay — distinguishing a user keyboard dismissal from a rotation-triggered view recreation. - */ private func handleFocusedFieldChange(_ newField: ActivePaymentField?) { if let field = newField { viewModel.activeField = field @@ -466,11 +490,7 @@ struct PaymentReviewPaymentInformationView: View { } } - /** - Re-applies keyboard focus after the view is recreated by an orientation change. - The delay lets the rotation animation and any pending keyboard dismissal finish - before requesting focus again. - */ + // Re-applies focus after rotation recreates the view; delays 400ms for the animation to finish. private func restoreFocusIfNeeded() { guard let field = viewModel.activeField else { return } Task { @MainActor in diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentReviewContentView.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentReviewContentView.swift index c6446649bc..d83fba6fb1 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentReviewContentView.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentReviewContentView.swift @@ -57,25 +57,24 @@ public struct PaymentReviewContentView: View { .onAppear { viewModel.dismissBannerAfterDelay() } - // Full-width Done toolbar rendered above the keyboard in landscape (documentCollection) - // mode. `ToolbarItemGroup(placement: .keyboard)` is the correct way to place content - // above the keyboard — `safeAreaInset` on the HStack would place it behind the keyboard - // because the outer container suppresses the keyboard safe area with `ignoresSafeArea`. - // The inner form view's narrow Done bar is suppressed in landscape so only this - // full-width version appears. + // Full-width Done toolbar above the keyboard for landscape documentCollection on iOS <26. + // iOS 26+ is handled entirely by PaymentReviewPaymentInformationView.toolbar; without + // this guard both ToolbarItemGroups fire and two Done buttons appear simultaneously. .toolbar { - ToolbarItemGroup(placement: .keyboard) { - if giniLayout.isLandscape && !viewModel.isBottomSheetMode && viewModel.isAmountFieldFocused { - Spacer() - Button(viewModel.keyboardDoneButtonTitle) { - viewModel.trackKeyboardDismissed() - viewModel.validateAmountFieldOnKeyboardDismiss() - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), - to: nil, - from: nil, - for: nil) + if #unavailable(iOS 26) { + ToolbarItemGroup(placement: .keyboard) { + if giniLayout.isLandscape && !viewModel.isBottomSheetMode && viewModel.isAmountFieldFocused { + Spacer() + Button(viewModel.keyboardDoneButtonTitle) { + viewModel.trackKeyboardDismissed() + viewModel.validateAmountFieldOnKeyboardDismiss() + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil) + } + .padding(.trailing, Constants.doneButtonHorizontalPadding) } - .padding(.trailing, Constants.doneButtonHorizontalPadding) } } } @@ -107,9 +106,7 @@ public struct PaymentReviewContentView: View { DispatchQueue.main.asyncAfter(deadline: .now() + Constants.layoutTransitionDuration) { // Re-check conditions in case the mode changed during the delay. if !viewModel.isBottomSheetMode && !showBottomSheet { - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { + withTransaction(.withoutAnimation) { showBottomSheet = true } } From 470c27843e21a1e9a5725bc74f4b47b4991b6ac5 Mon Sep 17 00:00:00 2001 From: Nadya Karaban Date: Wed, 17 Jun 2026 10:59:34 +0200 Subject: [PATCH 02/11] fix(GiniInternalPaymentSDK): Fix Done button position and scroll in landscape bottomSheet mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyboard height spacer in safeAreaInset was applied whenever giniLayout.isLandscape, which is true for any iPhone in landscape (verticalSizeClass == .compact). In bottomSheet mode the form is inside a sheet that repositions above the keyboard naturally — adding keyboardHeight spacer collapsed the visible scroll area and pushed doneButtonBar toward the top of the sheet instead of above the keyboard. Guard the spacer with !isBottomSheetMode so it only applies to the inline landscape documentCollection layout where it is actually needed. HEAL-508 --- .../PaymentReviewPaymentInformationView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift index 36b605c653..f997a3bb81 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift @@ -111,19 +111,21 @@ struct PaymentReviewPaymentInformationView: View { // an automatic gap. Keyboard space is re-injected explicitly via safeAreaInset. .ignoresSafeArea(.keyboard) .safeAreaInset(edge: .bottom) { + let isBottomSheetMode = viewModel.model.displayMode == .bottomSheet VStack(spacing: 0) { // iOS <26: landscape documentCollection is handled by PaymentReviewContentView.toolbar — // showing it here too would add 44pt and cause auto-scroll to overshoot. if #unavailable(iOS 26) { - let isBottomSheetMode = viewModel.model.displayMode == .bottomSheet if (!giniLayout.isLandscape || isBottomSheetMode) && focusedField == .amount && keyboardHeight > 0 { doneButtonBar } } - // Landscape: re-inject keyboard height so content scrolls above it; portrait doesn't need it. + // Landscape documentCollection: re-inject keyboard height so content scrolls above it. + // Landscape bottomSheet: the sheet repositions above the keyboard — spacer not needed + // and would push doneButtonBar toward the top and collapse the visible scroll area. // allowsHitTesting(false): safeAreaInset overlays scroll content — without this the spacer swallows taps. Color.clear - .frame(height: giniLayout.isLandscape ? keyboardHeight : 0) + .frame(height: giniLayout.isLandscape && !isBottomSheetMode ? keyboardHeight : 0) .allowsHitTesting(false) } } From b75b1036f418ded6a2c15db4ad220f6a9757f717 Mon Sep 17 00:00:00 2001 From: Nadya Karaban Date: Wed, 17 Jun 2026 13:48:25 +0200 Subject: [PATCH 03/11] fix(GiniInternalPaymentSDK): Restore full-width Done button for landscape documentCollection on iOS <26 ToolbarItemGroup(placement: .keyboard) places the Done button as a native full-width keyboard accessory, matching the original behaviour. The toolbar is guarded with #unavailable(iOS 26) and the landscape documentCollection condition so it only fires where needed; iOS 26+ is handled by the inner view's ToolbarItemGroup added in the previous commit. Also extends the doneButtonBar safeAreaInset condition to cover landscape bottomSheet mode (previously portrait-only on iOS <26). HEAL-508 --- .../PaymentReviewObservableModel.swift | 28 ++++++------------- .../PaymentReviewPaymentInformationView.swift | 15 +++++----- .../Views/PaymentReviewContentView.swift | 3 +- .../PaymentReviewObservableModelTests.swift | 1 - 4 files changed, 16 insertions(+), 31 deletions(-) diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/PaymentReviewObservableModel.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/PaymentReviewObservableModel.swift index e686107854..410ec37604 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/PaymentReviewObservableModel.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/PaymentReviewObservableModel.swift @@ -39,18 +39,11 @@ final class PaymentReviewObservableModel: ObservableObject { model.strings.invoiceImageAccessibilityLabel } - /** - Reflects whether the amount field inside the payment information form is currently focused. - Changes trigger a re-render of `PaymentReviewContentView` so the landscape Done toolbar - can appear or disappear in sync with keyboard focus. - */ + // Reflects amount-field focus so PaymentReviewContentView re-renders the landscape toolbar. var isAmountFieldFocused: Bool { paymentInformationObservableModel.isAmountFieldFocused } - /** - The localized title for the keyboard Done button. - */ var keyboardDoneButtonTitle: String { containerViewModel.strings.keyboardDoneButtonTitle } @@ -70,19 +63,14 @@ final class PaymentReviewObservableModel: ObservableObject { model.delegate?.trackOnPaymentReviewCloseKeyboardClicked() } - /** - Validates the amount field as if it had just lost focus. - Called explicitly by the landscape Done button, which resigns first responder via - UIKit rather than setting `focusedField = nil`. SwiftUI's `@FocusState` update from - a UIKit `resignFirstResponder` call is not guaranteed to be synchronous, so relying - solely on `onChange(of: focusedField)` to trigger validation can cause the error - message to appear only on the second Done press. Calling this directly — mirroring - what the portrait Done button does — ensures validation runs immediately. - */ + // Validates the amount field as if it just lost focus. + // Called by the landscape Done button which resigns first responder via UIKit — SwiftUI's + // @FocusState update is not guaranteed synchronous, so validation would otherwise fire + // only on the second Done press. func validateAmountFieldOnKeyboardDismiss() { paymentInformationObservableModel.handleAmountFocusChange(isFocused: false) } - + @Published private var showBanner: Bool @Published var cellViewModels: [PageCollectionCellViewModel] = [] @@ -209,8 +197,8 @@ final class PaymentReviewObservableModel: ObservableObject { } private func setupBindings() { - // Forward `isAmountFieldFocused` changes from the inner observable model so that - // `PaymentReviewContentView` re-renders when the amount field gains or loses focus. + // Forward isAmountFieldFocused changes so PaymentReviewContentView re-renders + // its landscape keyboard toolbar when the amount field gains or loses focus. paymentInformationObservableModel.$isAmountFieldFocused .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &cancellables) diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift index f997a3bb81..a904bd2b4e 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift @@ -78,12 +78,10 @@ struct PaymentReviewPaymentInformationView: View { .background(Color(.systemBackground)) .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } - // Strip the keyboard UIKit animation so SwiftUI applies safeAreaInset changes - // instantly (one scroll evaluation against the final viewport). - withTransaction(.withoutAnimation) { keyboardHeight = frame.height } + keyboardHeight = frame.height } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in - withTransaction(.withoutAnimation) { keyboardHeight = 0 } + keyboardHeight = 0 } // iOS 26+ only: sheet/toolbar interop is fixed so one ToolbarItemGroup covers all modes. // iOS <26 falls back to doneButtonBar (portrait/landscape-bottomSheet) and @@ -113,16 +111,17 @@ struct PaymentReviewPaymentInformationView: View { .safeAreaInset(edge: .bottom) { let isBottomSheetMode = viewModel.model.displayMode == .bottomSheet VStack(spacing: 0) { - // iOS <26: landscape documentCollection is handled by PaymentReviewContentView.toolbar — - // showing it here too would add 44pt and cause auto-scroll to overshoot. + // iOS <26: landscape documentCollection is handled by PaymentReviewContentView.toolbar + // (full-width native accessory). Portrait and landscape-bottomSheet use doneButtonBar + // here — those contexts are behind a sheet boundary where toolbar items cannot propagate. if #unavailable(iOS 26) { if (!giniLayout.isLandscape || isBottomSheetMode) && focusedField == .amount && keyboardHeight > 0 { doneButtonBar } } // Landscape documentCollection: re-inject keyboard height so content scrolls above it. - // Landscape bottomSheet: the sheet repositions above the keyboard — spacer not needed - // and would push doneButtonBar toward the top and collapse the visible scroll area. + // Landscape bottomSheet: sheet repositions above keyboard — spacer not needed and + // would collapse the visible scroll area. // allowsHitTesting(false): safeAreaInset overlays scroll content — without this the spacer swallows taps. Color.clear .frame(height: giniLayout.isLandscape && !isBottomSheetMode ? keyboardHeight : 0) diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentReviewContentView.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentReviewContentView.swift index d83fba6fb1..9a94fc7249 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentReviewContentView.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentReviewContentView.swift @@ -58,8 +58,7 @@ public struct PaymentReviewContentView: View { viewModel.dismissBannerAfterDelay() } // Full-width Done toolbar above the keyboard for landscape documentCollection on iOS <26. - // iOS 26+ is handled entirely by PaymentReviewPaymentInformationView.toolbar; without - // this guard both ToolbarItemGroups fire and two Done buttons appear simultaneously. + // iOS 26+ is handled entirely by PaymentReviewPaymentInformationView.toolbar. .toolbar { if #unavailable(iOS 26) { ToolbarItemGroup(placement: .keyboard) { diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/PaymentReviewObservableModelTests.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/PaymentReviewObservableModelTests.swift index aff6308e1b..abeb54e9c4 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/PaymentReviewObservableModelTests.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/PaymentReviewObservableModelTests.swift @@ -33,7 +33,6 @@ struct PaymentReviewObservableModelTests { displayMode: .bottomSheet) let sut = PaymentReviewObservableModel(model: model) - // The default .test(keyboardDoneButtonTitle:) uses "Done" #expect(sut.keyboardDoneButtonTitle == "Done", "keyboardDoneButtonTitle must return the default configured string") } From e7be6d0c9200e97a15c4e02379d7e612642e7f48 Mon Sep 17 00:00:00 2001 From: Nadya Karaban Date: Wed, 17 Jun 2026 19:12:08 +0200 Subject: [PATCH 04/11] fix(GiniInternalPaymentSDK): Fix landscape scroll, keyboard avoidance and Done button for documentCollection - Replace manual keyboardHeight spacer approach with native GeometryReader avoidance in landscape documentCollection; use ScrollViewReader to explicitly scroll focused fields into view since UIKit-backed text fields do not auto-scroll - Track maximum keyboard height (max() + keyboardHideToken debounce) to prevent layout jumps when switching between keyboard types with different heights (decimalPad+toolbar vs default+toolbar) - Animate spacer growth in landscape to smooth the first transition when a larger keyboard appears; use minimum-scroll anchor to avoid unnecessary scroll between adjacent fields (IBAN/amount row) - ContentView: always-registered ToolbarItemGroup for iOS <26 landscape keeps the toolbar bar permanently attached, eliminating the detach glitch when leaving the amount field - iOS 26: Liquid Glass Done button via ToolbarItemGroup for landscape documentCollection; doneButtonBar in safeAreaInset for portrait and landscape-bottomSheet (keyboard toolbar overlaps sheet content there) - Exclude landscape-bottomSheet from iOS 26 ToolbarItemGroup to fix TUIKeyplane height constraint conflict and incorrect button positioning - Apply .toolbar modifier only on iOS 26+ to avoid zero-width _UIToolbarContentView UIKit constraint warning on iOS <26 - Clear focusedField when keyboard truly dismisses (keyboardHideToken guard distinguishes true dismiss from keyboard-type switch) - Clear viewModel.activeField when sheet is dismissed normally so text fields do not appear focused on sheet reopen - Promote isDocCollection and isSheetContext to computed properties; inline doneButtonBar at its single call site; refactor scrollView branching; shorten all comments to single-line WHY notes HEAL-508 Co-Authored-By: Claude Sonnet 4.6 --- ...iewPaymentInformationObservableModel.swift | 2 +- .../PaymentReviewPaymentInformationView.swift | 252 ++++++++++-------- .../Views/PaymentReviewContentView.swift | 21 +- 3 files changed, 147 insertions(+), 128 deletions(-) diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationObservableModel.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationObservableModel.swift index 47451f937a..a6935f7344 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationObservableModel.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationObservableModel.swift @@ -12,7 +12,7 @@ import GiniUtilites /** Identifies which payment form field is currently focused. Stored in the observable model so focus can be restored after orientation changes recreate the view. */ -enum ActivePaymentField: Equatable { +enum ActivePaymentField: Hashable { case recipient case iban case amount diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift index a904bd2b4e..4682fa9324 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift @@ -25,6 +25,18 @@ struct PaymentReviewPaymentInformationView: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize @State private var keyboardHeight: CGFloat = 0 + // Cancels a pending keyboardWillHide zero when a new keyboardWillShow fires (keyboard-type switch). + @State private var keyboardHideToken = 0 + + // Landscape doc-collection layout: side-by-side panels, keyboard scroll is manual. + private var isDocCollection: Bool { + giniLayout.isLandscape && viewModel.model.displayMode != .bottomSheet + } + + // Sheet context: sheet repositions above keyboard; Done bar and toolbar button are ours to manage. + private var isSheetContext: Bool { + !giniLayout.isLandscape || viewModel.model.displayMode == .bottomSheet + } private var textFieldConfiguration: TextFieldConfiguration { viewModel.model.defaultStyleInputFieldConfiguration @@ -57,120 +69,167 @@ struct PaymentReviewPaymentInformationView: View { } var body: some View { - scrollView + let base = scrollView .onAppear { viewModel.isViewVisible = true - viewModel.populateFieldsIfNeeded() - // Notify VoiceOver that a new screen (the sheet) appeared, - // so it moves focus into the sheet content. + // Move VoiceOver focus into sheet content on appear. UIAccessibility.post(notification: .screenChanged, argument: nil) - // After a rotation the view is recreated; restore keyboard if it was open. + // Restore focus after rotation recreates the view. restoreFocusIfNeeded() } .onDisappear { viewModel.isViewVisible = false } - // Track which field is active so it can be restored after orientation changes. .onChange(of: focusedField) { newField in handleFocusedFieldChange(newField) } .background(Color(.systemBackground)) .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } - keyboardHeight = frame.height + keyboardHideToken += 1 + let newHeight = max(keyboardHeight, frame.height) + guard newHeight != keyboardHeight else { return } + // Animate spacer growth in landscape so a size change between keyboard types + // (e.g. decimalPad+toolbar → default+toolbar) slides smoothly rather than jumps. + if isDocCollection { + let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25 + withAnimation(.easeInOut(duration: duration)) { + keyboardHeight = newHeight + } + } else { + keyboardHeight = newHeight + } } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in - keyboardHeight = 0 + // Defer so a keyboard-type switch (hide → show in the same run-loop) cancels the zero. + let token = keyboardHideToken + DispatchQueue.main.async { + guard keyboardHideToken == token else { return } + keyboardHeight = 0 + focusedField = nil + } } - // iOS 26+ only: sheet/toolbar interop is fixed so one ToolbarItemGroup covers all modes. - // iOS <26 falls back to doneButtonBar (portrait/landscape-bottomSheet) and - // PaymentReviewContentView.toolbar (landscape documentCollection). - .toolbar { - if #available(iOS 26, *) { + // Apply the toolbar only on iOS 26+. An empty .toolbar{} on iOS <26 creates a + // zero-width UIToolbar causing a harmless but noisy UIKit constraint warning. + if #available(iOS 26, *) { + base.toolbar { + // Liquid Glass Done button only for landscape-docCollection where the keyboard + // toolbar floats correctly above the panel. Portrait and landscape-bottomSheet + // use doneButtonBar in safeAreaInset to avoid overlapping sheet content. + if isDocCollection && focusedField == .amount && keyboardHeight > 0 { ToolbarItemGroup(placement: .keyboard) { - if focusedField == .amount { - Spacer() - Button(viewModelStrings.keyboardDoneButtonTitle) { - dismissAmountKeyboard() - } + Spacer() + Button(viewModelStrings.keyboardDoneButtonTitle) { + dismissAmountKeyboard() } } } } + } else { + base + } } // MARK: Private views @ViewBuilder private var scrollView: some View { - baseScrollView - // Keyboard safe area is suppressed so neither portrait nor landscape creates - // an automatic gap. Keyboard space is re-injected explicitly via safeAreaInset. - .ignoresSafeArea(.keyboard) - .safeAreaInset(edge: .bottom) { - let isBottomSheetMode = viewModel.model.displayMode == .bottomSheet - VStack(spacing: 0) { - // iOS <26: landscape documentCollection is handled by PaymentReviewContentView.toolbar - // (full-width native accessory). Portrait and landscape-bottomSheet use doneButtonBar - // here — those contexts are behind a sheet boundary where toolbar items cannot propagate. - if #unavailable(iOS 26) { - if (!giniLayout.isLandscape || isBottomSheetMode) && focusedField == .amount && keyboardHeight > 0 { - doneButtonBar - } + if isSheetContext { + // Sheet repositions above the keyboard; suppress double-adjustment. + baseScrollView + .ignoresSafeArea(.keyboard) + .safeAreaInset(edge: .bottom) { + // doneButtonBar for all sheet contexts and all iOS versions. + // In portrait/bottomSheet the sheet sits above the keyboard; a ToolbarItemGroup + // button would land at the keyboard edge and overlap the last row of content. + if focusedField == .amount && keyboardHeight > 0 { + doneButtonBar } - // Landscape documentCollection: re-inject keyboard height so content scrolls above it. - // Landscape bottomSheet: sheet repositions above keyboard — spacer not needed and - // would collapse the visible scroll area. - // allowsHitTesting(false): safeAreaInset overlays scroll content — without this the spacer swallows taps. + } + } else { + // Landscape docCollection: manual spacer keeps content above the keyboard. + // Done button: ContentView toolbar (iOS <26) or Liquid Glass toolbar (iOS 26+). + baseScrollView + .ignoresSafeArea(.keyboard) + .safeAreaInset(edge: .bottom) { Color.clear - .frame(height: giniLayout.isLandscape && !isBottomSheetMode ? keyboardHeight : 0) + .frame(height: keyboardHeight) .allowsHitTesting(false) } + } + } + + @ViewBuilder + private var doneButtonBar: some View { + HStack { + Spacer() + Button(viewModelStrings.keyboardDoneButtonTitle) { + dismissAmountKeyboard() } + .padding(.horizontal, Constants.doneButtonHorizontalPadding) + } + .frame(height: Constants.doneButtonBarHeight) + .background(.regularMaterial) + .overlay(alignment: .top) { Divider() } } private var baseScrollView: some View { - ScrollView { - VStack(spacing: 0) { - if showBanner { - infoBannerView - // Removal uses opacity-only: .move animates height, triggering repeated scroll - // evaluations while the keyboard is visible. Opacity is instant for layout. - .transition(.asymmetric( - insertion: .move(edge: .top).combined(with: .opacity), - removal: .opacity - )) - .onAppear { - UIAccessibility.post(notification: .announcement, - argument: viewModel.model.strings.infoBarMessage) - } - } + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + if showBanner { + infoBannerView + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .opacity + )) + .onAppear { + UIAccessibility.post(notification: .announcement, + argument: viewModel.model.strings.infoBarMessage) + } + } - VStack(spacing: Constants.textFieldsContainerSpacing) { - recipientTextField + VStack(spacing: Constants.textFieldsContainerSpacing) { + recipientTextField + .id(ActivePaymentField.recipient) - adaptiveStack(spacing: Constants.textFieldsContainerSpacing) { - ibanTextField - amountTextField - } + adaptiveStack(spacing: Constants.textFieldsContainerSpacing) { + ibanTextField + .id(ActivePaymentField.iban) + amountTextField + .id(ActivePaymentField.amount) + } - paymentPurposeTextField + paymentPurposeTextField + .id(ActivePaymentField.paymentPurpose) - adaptiveStack(spacing: Constants.buttonsContainerSpacing) { - paymentProviderSelectionPicker - payButton - } - .padding(.bottom, Constants.buttonsContainerBottomPadding) + adaptiveStack(spacing: Constants.buttonsContainerSpacing) { + paymentProviderSelectionPicker + payButton + } + .padding(.bottom, Constants.buttonsContainerBottomPadding) - if viewModel.shouldShowBrandedView { - poweredByGiniView + if viewModel.shouldShowBrandedView { + poweredByGiniView + } } + .padding(.horizontal, Constants.textFieldsContainerHorizontalPadding) + .padding(.top, Constants.textFieldsContainerTopPadding) + } + .getHeight(for: $contentHeight) + } + // Landscape: UIKit-backed fields don't auto-scroll; drive it from keyboardHeight changes. + // No anchor → minimum scroll: if the field is already visible nothing happens, + // which avoids a spurious micro-scroll when switching between adjacent fields (IBAN/amount). + .onChange(of: keyboardHeight) { height in + guard giniLayout.isLandscape else { return } + if height > 0, let field = focusedField { + proxy.scrollTo(field) + } else if height == 0 { + proxy.scrollTo(ActivePaymentField.recipient, anchor: .top) } - .padding(.horizontal, Constants.textFieldsContainerHorizontalPadding) - .padding(.top, Constants.textFieldsContainerTopPadding) } - .getHeight(for: $contentHeight) } } @@ -223,9 +282,7 @@ struct PaymentReviewPaymentInformationView: View { } } .onChange(of: viewModel.recipientInputState.text) { _ in - // Clearing the error while the field is focused triggers a `.error → .focused` - // style-state transition that causes SwiftUI to replace the underlying UITextField, - // dismissing the keyboard. Only clear when the field is not focused. + // Clearing error while focused replaces the UITextField and dismisses the keyboard. guard focusedField != .recipient else { return } viewModel.clearErrorOnTextChange(for: \.recipientInputState) } @@ -254,9 +311,7 @@ struct PaymentReviewPaymentInformationView: View { } } .onChange(of: viewModel.ibanInputState.text) { _ in - // Clearing the error while the field is focused triggers a `.error → .focused` - // style-state transition that causes SwiftUI to replace the underlying UITextField, - // dismissing the keyboard. Only clear when the field is not focused. + // Clearing error while focused replaces the UITextField and dismisses the keyboard. guard focusedField != .iban else { return } viewModel.clearErrorOnTextChange(for: \.ibanInputState) } @@ -268,8 +323,7 @@ struct PaymentReviewPaymentInformationView: View { .focused($focusedField, equals: .amount) .onChange(of: viewModel.amountInputState.text) { newValue in viewModel.handleAmountTextChange(updatedText: newValue) - // Amount error clearing is handled by `handleAmountFocusChange` and - // `clearAmountErrorAfterKeyboardAppears` — not by text change. + // Error clearing is handled by handleAmountFocusChange, not text change. } .onChange(of: focusedField) { newFocus in Task { @MainActor in @@ -306,9 +360,7 @@ struct PaymentReviewPaymentInformationView: View { } } .onChange(of: viewModel.paymentPurposeInputState.text) { _ in - // Clearing the error while the field is focused triggers a `.error → .focused` - // style-state transition that causes SwiftUI to replace the underlying UITextField, - // dismissing the keyboard. Only clear when the field is not focused. + // Clearing error while focused replaces the UITextField and dismisses the keyboard. guard focusedField != .paymentPurpose else { return } viewModel.clearErrorOnTextChange(for: \.paymentPurposeInputState) } @@ -344,11 +396,7 @@ struct PaymentReviewPaymentInformationView: View { .accessibilityHidden(true) } } - // At xxxLarge and above, adaptiveStack places the picker above the pay button - // in a VStack. Expand to full width so both controls align, instead of leaving - // a narrow 96-pt button floating above a full-width button. - // minWidth == maxWidth in the default case pins the width at exactly 96 pt - // (equivalent to width:), preventing the picker from shrinking below that. + // At xxxLarge+ the picker is in a VStack; expand to full width so it aligns with payButton. .frame(minWidth: dynamicTypeSize >= .xxxLarge ? 0 : Constants.paymentProviderPickerSize.width, maxWidth: dynamicTypeSize >= .xxxLarge ? .infinity : Constants.paymentProviderPickerSize.width, minHeight: Constants.paymentProviderPickerSize.height) @@ -381,8 +429,7 @@ struct PaymentReviewPaymentInformationView: View { viewModel.paymentPurposeError] .compactMap { $0 }.first if let firstError { - // Delay allows VoiceOver to finish announcing the button activation - // before the error announcement is posted, preventing it from being dropped. + // Delay so VoiceOver finishes announcing button activation before error. DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { UIAccessibility.post(notification: .announcement, argument: firstError) } @@ -405,8 +452,7 @@ struct PaymentReviewPaymentInformationView: View { @ViewBuilder private func adaptiveStack(spacing: CGFloat, @ViewBuilder content: () -> Content) -> some View { - // Switch to VStack at xxxLarge and above so that wide content like IBAN numbers - // and the bank picker have full width; isAccessibilitySize covers AX1–AX5 (~190%+). + // xxxLarge+ uses VStack so wide content gets full width. if dynamicTypeSize >= .xxxLarge { VStack(spacing: spacing) { content() } } else { @@ -423,21 +469,6 @@ struct PaymentReviewPaymentInformationView: View { .padding(.top, Constants.poweredByGiniTopPadding) } - // Done bar substituting the return key absent from .decimalPad; shown on iOS <26. - @ViewBuilder - private var doneButtonBar: some View { - HStack { - Spacer() - Button(viewModelStrings.keyboardDoneButtonTitle) { - dismissAmountKeyboard() - } - .padding(.horizontal, Constants.doneButtonHorizontalPadding) - } - .frame(height: Constants.doneButtonBarHeight) - .background(.regularMaterial) - .overlay(alignment: .top) { Divider() } - } - private func dismissAmountKeyboard() { onKeyboardDismissed() viewModel.handleAmountFocusChange(isFocused: false) @@ -472,16 +503,11 @@ struct PaymentReviewPaymentInformationView: View { viewModel.activeField = field viewModel.isAmountFieldFocused = (field == .amount) } else { - // Clear amount-focus immediately so the Done toolbar hides right away. + // Hide Done toolbar immediately; delay clearing activeField to distinguish dismiss from rotation. viewModel.isAmountFieldFocused = false - // Don't clear activeField immediately: the same nil event fires during rotation - // (view is destroyed) AND when the user manually dismisses the keyboard. - // Capture the current field so the task can verify nothing changed during the delay: - // - Still visible + focus still nil + same field → user dismissed → clear activeField - // - Gone (rotation) or focus moved to another field → keep activeField for restoration let fieldToClear = viewModel.activeField Task { @MainActor in - try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 s + try? await Task.sleep(for: .milliseconds(100)) if viewModel.isViewVisible, focusedField == nil, viewModel.activeField == fieldToClear { @@ -491,11 +517,11 @@ struct PaymentReviewPaymentInformationView: View { } } - // Re-applies focus after rotation recreates the view; delays 400ms for the animation to finish. + // Delays 400 ms for rotation animation before re-applying focus. private func restoreFocusIfNeeded() { guard let field = viewModel.activeField else { return } Task { @MainActor in - try? await Task.sleep(nanoseconds: 400_000_000) // 0.4 s + try? await Task.sleep(for: .milliseconds(400)) focusedField = field } } diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentReviewContentView.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentReviewContentView.swift index 9a94fc7249..c2370eb9e8 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentReviewContentView.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentReviewContentView.swift @@ -31,6 +31,7 @@ public struct PaymentReviewContentView: View { if giniLayout.isLandscape && !viewModel.isBottomSheetMode { landscapeLayout(geometry: geometry) .transition(.opacity) + // GeometryReader shrinks for the keyboard; ScrollViewReader scrolls the focused field. } else { portraitLayout(geometry: geometry) .transition(.opacity) @@ -39,8 +40,7 @@ public struct PaymentReviewContentView: View { .ignoresSafeArea(.keyboard) .animation(.easeInOut(duration: Constants.layoutTransitionDuration), value: giniLayout.isLandscape) .onChange(of: giniLayout.isLandscape) { landscape in - // Belt-and-suspenders for iOS 17+: the sheet is already dismissed imperatively - // from viewWillTransition on iOS 16, but on iOS 17 we keep this path as well. + // Belt-and-suspenders: iOS 16 uses viewWillTransition; iOS 17+ uses this path. if landscape && !viewModel.isBottomSheetMode && showBottomSheet { viewModel.isDismissingForRotation = true showBottomSheet = false @@ -57,8 +57,9 @@ public struct PaymentReviewContentView: View { .onAppear { viewModel.dismissBannerAfterDelay() } - // Full-width Done toolbar above the keyboard for landscape documentCollection on iOS <26. - // iOS 26+ is handled entirely by PaymentReviewPaymentInformationView.toolbar. + // iOS <26 landscape: always-registered ToolbarItemGroup (condition inside) so the bar + // never detaches mid-transition, which would cause a glitch when leaving the amount field. + // UIKit may log a _UIToolbarContentView.width==0 warning on initial layout; it recovers harmlessly. .toolbar { if #unavailable(iOS 26) { ToolbarItemGroup(placement: .keyboard) { @@ -94,16 +95,9 @@ public struct PaymentReviewContentView: View { } } .onAppear { - // On iOS 16/17, rotating to landscape destroys portraitLayout which - // dismisses the sheet and sets showBottomSheet to false. Restore it - // when portraitLayout reappears in documentCollection mode. - // Delay so the layout crossfade finishes before the sheet appears. - // disablesAnimations suppresses the sheet's default slide-up animation - // so it appears instantly at its resting position, matching the - // no-animation dismiss applied when rotating to landscape. + // Restore sheet after rotation back to portrait; suppress slide-up to match no-animation dismiss. if !viewModel.isBottomSheetMode && !showBottomSheet { DispatchQueue.main.asyncAfter(deadline: .now() + Constants.layoutTransitionDuration) { - // Re-check conditions in case the mode changed during the delay. if !viewModel.isBottomSheetMode && !showBottomSheet { withTransaction(.withoutAnimation) { showBottomSheet = true @@ -165,8 +159,7 @@ public struct PaymentReviewContentView: View { // MARK: - Private Views - // Replacement for the system drag indicator, which is hidden by .fullScreenCover adaptation in landscape. - // Wrapping in Button ensures VoiceOver announces it as an interactive element and double-tap dismisses. + // Replaces the system drag indicator hidden by .fullScreenCover in landscape; Button gives VoiceOver double-tap dismiss. private var landscapeGrabberCapsule: some View { Button(action: viewModel.didTapClose) { Capsule() From 9a9a40e562186da824ae425fe818cf944bc3d879 Mon Sep 17 00:00:00 2001 From: Nadya Karaban Date: Wed, 17 Jun 2026 19:20:50 +0200 Subject: [PATCH 05/11] fix(GiniInternalPaymentSDK): Restore Liquid Glass Done button for sheet modes and fix rotation focus - Restore ToolbarItemGroup (Liquid Glass) for iOS 26 in portrait and landscape-bottomSheet; use a Color.clear safeAreaInset spacer (doneButtonBarHeight) to prevent the floating button from overlapping the last content row at the sheet/keyboard boundary - Remove focusedField = nil from keyboardWillHide deferred block which was triggering handleFocusedFieldChange during rotation while isViewVisible was still true, causing activeField to be cleared and breaking keyboard restoration after rotation in bottomSheet mode HEAL-508 Co-Authored-By: Claude Sonnet 4.6 --- .../PaymentReviewPaymentInformationView.swift | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift index 4682fa9324..df117d8e5c 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift @@ -107,17 +107,16 @@ struct PaymentReviewPaymentInformationView: View { DispatchQueue.main.async { guard keyboardHideToken == token else { return } keyboardHeight = 0 - focusedField = nil } } // Apply the toolbar only on iOS 26+. An empty .toolbar{} on iOS <26 creates a // zero-width UIToolbar causing a harmless but noisy UIKit constraint warning. if #available(iOS 26, *) { base.toolbar { - // Liquid Glass Done button only for landscape-docCollection where the keyboard - // toolbar floats correctly above the panel. Portrait and landscape-bottomSheet - // use doneButtonBar in safeAreaInset to avoid overlapping sheet content. - if isDocCollection && focusedField == .amount && keyboardHeight > 0 { + // Liquid Glass Done button for all modes on iOS 26. + // The button floats above the keyboard; a Color.clear spacer in safeAreaInset + // ensures scroll content doesn't slide under it in sheet contexts. + if focusedField == .amount && keyboardHeight > 0 { ToolbarItemGroup(placement: .keyboard) { Spacer() Button(viewModelStrings.keyboardDoneButtonTitle) { @@ -140,11 +139,16 @@ struct PaymentReviewPaymentInformationView: View { baseScrollView .ignoresSafeArea(.keyboard) .safeAreaInset(edge: .bottom) { - // doneButtonBar for all sheet contexts and all iOS versions. - // In portrait/bottomSheet the sheet sits above the keyboard; a ToolbarItemGroup - // button would land at the keyboard edge and overlap the last row of content. if focusedField == .amount && keyboardHeight > 0 { - doneButtonBar + if #available(iOS 26, *) { + // Reserve height for the floating Liquid Glass button so content + // doesn't slide under it at the sheet/keyboard boundary. + Color.clear + .frame(height: Constants.doneButtonBarHeight) + .allowsHitTesting(false) + } else { + doneButtonBar + } } } } else { From de33245302c777f68ecf300b1a48ba4a4a4dd7ea Mon Sep 17 00:00:00 2001 From: Nadya Karaban Date: Wed, 17 Jun 2026 19:28:01 +0200 Subject: [PATCH 06/11] refactor(GiniInternalPaymentSDK): Clean up PR review findings - Remove isSheetContext (exact inverse of isDocCollection); replace all usages with !isDocCollection to eliminate the two-property sync risk - Extract iOS 26 keyboard toolbar conditional into keyboardToolbarIfNeeded(_:) @ViewBuilder helper, removing the let-binding pattern in body - Inline doneButtonBar (single call site) and remove the property HEAL-508 Co-Authored-By: Claude Sonnet 4.6 --- .../PaymentReviewPaymentInformationView.swift | 117 ++++++++---------- 1 file changed, 55 insertions(+), 62 deletions(-) diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift index df117d8e5c..869a6cedc5 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift @@ -32,11 +32,6 @@ struct PaymentReviewPaymentInformationView: View { private var isDocCollection: Bool { giniLayout.isLandscape && viewModel.model.displayMode != .bottomSheet } - - // Sheet context: sheet repositions above keyboard; Done bar and toolbar button are ours to manage. - private var isSheetContext: Bool { - !giniLayout.isLandscape || viewModel.model.displayMode == .bottomSheet - } private var textFieldConfiguration: TextFieldConfiguration { viewModel.model.defaultStyleInputFieldConfiguration @@ -69,53 +64,56 @@ struct PaymentReviewPaymentInformationView: View { } var body: some View { - let base = scrollView - .onAppear { - viewModel.isViewVisible = true - viewModel.populateFieldsIfNeeded() - // Move VoiceOver focus into sheet content on appear. - UIAccessibility.post(notification: .screenChanged, argument: nil) - // Restore focus after rotation recreates the view. - restoreFocusIfNeeded() - } - .onDisappear { - viewModel.isViewVisible = false - } - .onChange(of: focusedField) { newField in - handleFocusedFieldChange(newField) - } - .background(Color(.systemBackground)) - .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in - guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } - keyboardHideToken += 1 - let newHeight = max(keyboardHeight, frame.height) - guard newHeight != keyboardHeight else { return } - // Animate spacer growth in landscape so a size change between keyboard types - // (e.g. decimalPad+toolbar → default+toolbar) slides smoothly rather than jumps. - if isDocCollection { - let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25 - withAnimation(.easeInOut(duration: duration)) { + keyboardToolbarIfNeeded( + scrollView + .onAppear { + viewModel.isViewVisible = true + viewModel.populateFieldsIfNeeded() + // Move VoiceOver focus into sheet content on appear. + UIAccessibility.post(notification: .screenChanged, argument: nil) + // Restore focus after rotation recreates the view. + restoreFocusIfNeeded() + } + .onDisappear { + viewModel.isViewVisible = false + } + .onChange(of: focusedField) { newField in + handleFocusedFieldChange(newField) + } + .background(Color(.systemBackground)) + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in + guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + keyboardHideToken += 1 + let newHeight = max(keyboardHeight, frame.height) + guard newHeight != keyboardHeight else { return } + // Animate spacer growth in landscape so a size change between keyboard types + // (e.g. decimalPad+toolbar → default+toolbar) slides smoothly rather than jumps. + if isDocCollection { + let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25 + withAnimation(.easeInOut(duration: duration)) { + keyboardHeight = newHeight + } + } else { keyboardHeight = newHeight } - } else { - keyboardHeight = newHeight } - } - .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in - // Defer so a keyboard-type switch (hide → show in the same run-loop) cancels the zero. - let token = keyboardHideToken - DispatchQueue.main.async { - guard keyboardHideToken == token else { return } - keyboardHeight = 0 + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + // Defer so a keyboard-type switch (hide → show in the same run-loop) cancels the zero. + let token = keyboardHideToken + DispatchQueue.main.async { + guard keyboardHideToken == token else { return } + keyboardHeight = 0 + } } - } - // Apply the toolbar only on iOS 26+. An empty .toolbar{} on iOS <26 creates a - // zero-width UIToolbar causing a harmless but noisy UIKit constraint warning. + ) + } + + // Attaches the iOS 26 Liquid Glass keyboard toolbar without creating an empty UIToolbar + // on iOS <26 (which would log a harmless but noisy _UIToolbarContentView.width==0 warning). + @ViewBuilder + private func keyboardToolbarIfNeeded(_ base: V) -> some View { if #available(iOS 26, *) { base.toolbar { - // Liquid Glass Done button for all modes on iOS 26. - // The button floats above the keyboard; a Color.clear spacer in safeAreaInset - // ensures scroll content doesn't slide under it in sheet contexts. if focusedField == .amount && keyboardHeight > 0 { ToolbarItemGroup(placement: .keyboard) { Spacer() @@ -134,7 +132,7 @@ struct PaymentReviewPaymentInformationView: View { @ViewBuilder private var scrollView: some View { - if isSheetContext { + if !isDocCollection { // Sheet repositions above the keyboard; suppress double-adjustment. baseScrollView .ignoresSafeArea(.keyboard) @@ -147,7 +145,16 @@ struct PaymentReviewPaymentInformationView: View { .frame(height: Constants.doneButtonBarHeight) .allowsHitTesting(false) } else { - doneButtonBar + HStack { + Spacer() + Button(viewModelStrings.keyboardDoneButtonTitle) { + dismissAmountKeyboard() + } + .padding(.horizontal, Constants.doneButtonHorizontalPadding) + } + .frame(height: Constants.doneButtonBarHeight) + .background(.regularMaterial) + .overlay(alignment: .top) { Divider() } } } } @@ -164,20 +171,6 @@ struct PaymentReviewPaymentInformationView: View { } } - @ViewBuilder - private var doneButtonBar: some View { - HStack { - Spacer() - Button(viewModelStrings.keyboardDoneButtonTitle) { - dismissAmountKeyboard() - } - .padding(.horizontal, Constants.doneButtonHorizontalPadding) - } - .frame(height: Constants.doneButtonBarHeight) - .background(.regularMaterial) - .overlay(alignment: .top) { Divider() } - } - private var baseScrollView: some View { ScrollViewReader { proxy in ScrollView { From 647685124349c139b929176c4af63688385ba923 Mon Sep 17 00:00:00 2001 From: Nadya Karaban Date: Thu, 18 Jun 2026 16:32:26 +0200 Subject: [PATCH 07/11] fix(GiniInternalPaymentSDK): Fix scroll guard and iOS 26 toolbar detach found in review - Fix onChange(of: keyboardHeight) guard from giniLayout.isLandscape to isDocCollection: the old guard also fired in landscape-bottomSheet mode (isDocCollection==false), causing spurious proxy.scrollTo calls inside the sheet's scroll view which has no keyboard-height spacer, potentially leaving the focused field obscured by the keyboard - Move iOS 26 ToolbarItemGroup condition inside the group (always-registered) matching the iOS <26 ContentView pattern: condition-outside removes and re-registers the toolbar mid-transition when switching fields, causing the same Liquid Glass button detach glitch the always-registered approach was introduced to prevent - Simplify keyboardToolbarIfNeeded to use 'some View' parameter (Swift 5.7+ primary associated types make the explicit generic unnecessary) HEAL-508 Co-Authored-By: Claude Sonnet 4.6 --- .../PaymentReviewPaymentInformationView.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift index 869a6cedc5..a3f12463d9 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift @@ -111,11 +111,13 @@ struct PaymentReviewPaymentInformationView: View { // Attaches the iOS 26 Liquid Glass keyboard toolbar without creating an empty UIToolbar // on iOS <26 (which would log a harmless but noisy _UIToolbarContentView.width==0 warning). @ViewBuilder - private func keyboardToolbarIfNeeded(_ base: V) -> some View { + private func keyboardToolbarIfNeeded(_ base: some View) -> some View { if #available(iOS 26, *) { base.toolbar { - if focusedField == .amount && keyboardHeight > 0 { - ToolbarItemGroup(placement: .keyboard) { + // Always-registered so the bar never detaches mid-transition (same rationale + // as ContentView's iOS <26 ToolbarItemGroup). Condition is inside the group. + ToolbarItemGroup(placement: .keyboard) { + if focusedField == .amount && keyboardHeight > 0 { Spacer() Button(viewModelStrings.keyboardDoneButtonTitle) { dismissAmountKeyboard() @@ -220,7 +222,7 @@ struct PaymentReviewPaymentInformationView: View { // No anchor → minimum scroll: if the field is already visible nothing happens, // which avoids a spurious micro-scroll when switching between adjacent fields (IBAN/amount). .onChange(of: keyboardHeight) { height in - guard giniLayout.isLandscape else { return } + guard isDocCollection else { return } if height > 0, let field = focusedField { proxy.scrollTo(field) } else if height == 0 { From 82451a60eab1cbad64b07c25d0886f9ade837220 Mon Sep 17 00:00:00 2001 From: Nadya Karaban Date: Thu, 18 Jun 2026 16:53:59 +0200 Subject: [PATCH 08/11] refactor(GiniInternalPaymentSDK): Extract keyboard handlers and scroll views for readability - Extract onReceive keyboard notification closures into private funcs handleKeyboardWillShow(_:) and handleKeyboardWillHide() to keep body readable without deep inline logic - Split scrollView into sheetScrollView and docCollectionScrollView named @ViewBuilder properties so each layout path is self-describing - Restore doneButtonBar as a named @ViewBuilder property (sheet path references it clearly rather than inlining 10 lines anonymously) - Add // MARK: Private keyboard handlers section to separate concerns HEAL-508 Co-Authored-By: Claude Sonnet 4.6 --- .../PaymentReviewPaymentInformationView.swift | 130 +++++++++++------- 1 file changed, 78 insertions(+), 52 deletions(-) diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift index a3f12463d9..57bfe0891d 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift @@ -82,32 +82,42 @@ struct PaymentReviewPaymentInformationView: View { } .background(Color(.systemBackground)) .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in - guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } - keyboardHideToken += 1 - let newHeight = max(keyboardHeight, frame.height) - guard newHeight != keyboardHeight else { return } - // Animate spacer growth in landscape so a size change between keyboard types - // (e.g. decimalPad+toolbar → default+toolbar) slides smoothly rather than jumps. - if isDocCollection { - let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25 - withAnimation(.easeInOut(duration: duration)) { - keyboardHeight = newHeight - } - } else { - keyboardHeight = newHeight - } + handleKeyboardWillShow(notification) } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in - // Defer so a keyboard-type switch (hide → show in the same run-loop) cancels the zero. - let token = keyboardHideToken - DispatchQueue.main.async { - guard keyboardHideToken == token else { return } - keyboardHeight = 0 - } + handleKeyboardWillHide() } ) } + // MARK: Private keyboard handlers + + private func handleKeyboardWillShow(_ notification: Notification) { + guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + keyboardHideToken += 1 + let newHeight = max(keyboardHeight, frame.height) + guard newHeight != keyboardHeight else { return } + // Animate spacer growth in landscape so a size change between keyboard types + // (e.g. decimalPad+toolbar → default+toolbar) slides smoothly rather than jumps. + if isDocCollection { + let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25 + withAnimation(.easeInOut(duration: duration)) { + keyboardHeight = newHeight + } + } else { + keyboardHeight = newHeight + } + } + + private func handleKeyboardWillHide() { + // Defer so a keyboard-type switch (hide → show in the same run-loop) cancels the zero. + let token = keyboardHideToken + DispatchQueue.main.async { + guard keyboardHideToken == token else { return } + keyboardHeight = 0 + } + } + // Attaches the iOS 26 Liquid Glass keyboard toolbar without creating an empty UIToolbar // on iOS <26 (which would log a harmless but noisy _UIToolbarContentView.width==0 warning). @ViewBuilder @@ -135,42 +145,58 @@ struct PaymentReviewPaymentInformationView: View { @ViewBuilder private var scrollView: some View { if !isDocCollection { - // Sheet repositions above the keyboard; suppress double-adjustment. - baseScrollView - .ignoresSafeArea(.keyboard) - .safeAreaInset(edge: .bottom) { - if focusedField == .amount && keyboardHeight > 0 { - if #available(iOS 26, *) { - // Reserve height for the floating Liquid Glass button so content - // doesn't slide under it at the sheet/keyboard boundary. - Color.clear - .frame(height: Constants.doneButtonBarHeight) - .allowsHitTesting(false) - } else { - HStack { - Spacer() - Button(viewModelStrings.keyboardDoneButtonTitle) { - dismissAmountKeyboard() - } - .padding(.horizontal, Constants.doneButtonHorizontalPadding) - } + sheetScrollView + } else { + docCollectionScrollView + } + } + + // Sheet context: sheet repositions above the keyboard; suppress double-adjustment. + @ViewBuilder + private var sheetScrollView: some View { + baseScrollView + .ignoresSafeArea(.keyboard) + .safeAreaInset(edge: .bottom) { + if focusedField == .amount && keyboardHeight > 0 { + if #available(iOS 26, *) { + // Reserve height for the floating Liquid Glass button so content + // doesn't slide under it at the sheet/keyboard boundary. + Color.clear .frame(height: Constants.doneButtonBarHeight) - .background(.regularMaterial) - .overlay(alignment: .top) { Divider() } - } + .allowsHitTesting(false) + } else { + doneButtonBar } } - } else { - // Landscape docCollection: manual spacer keeps content above the keyboard. - // Done button: ContentView toolbar (iOS <26) or Liquid Glass toolbar (iOS 26+). - baseScrollView - .ignoresSafeArea(.keyboard) - .safeAreaInset(edge: .bottom) { - Color.clear - .frame(height: keyboardHeight) - .allowsHitTesting(false) - } + } + } + + // Landscape docCollection: manual spacer keeps content above the keyboard. + // Done button: ContentView toolbar (iOS <26) or Liquid Glass toolbar (iOS 26+). + @ViewBuilder + private var docCollectionScrollView: some View { + baseScrollView + .ignoresSafeArea(.keyboard) + .safeAreaInset(edge: .bottom) { + Color.clear + .frame(height: keyboardHeight) + .allowsHitTesting(false) + } + } + + // Done bar substituting the missing return key on .decimalPad; iOS <26 only. + @ViewBuilder + private var doneButtonBar: some View { + HStack { + Spacer() + Button(viewModelStrings.keyboardDoneButtonTitle) { + dismissAmountKeyboard() + } + .padding(.horizontal, Constants.doneButtonHorizontalPadding) } + .frame(height: Constants.doneButtonBarHeight) + .background(.regularMaterial) + .overlay(alignment: .top) { Divider() } } private var baseScrollView: some View { From 0d3c1dadd6331b9ade72e1dfc448be559477be71 Mon Sep 17 00:00:00 2001 From: Nadya Karaban Date: Thu, 18 Jun 2026 17:26:24 +0200 Subject: [PATCH 09/11] fix(GiniInternalPaymentSDK): Fix Done button alignment and constraint warning for bottom sheet on iOS 26 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sheetScrollView.safeAreaInset: add Color.clear spacer (doneButtonBarHeight) on iOS 26 so content has breathing room above the Liquid Glass button which floats at the keyboard/sheet boundary - keyboardToolbarIfNeeded: condition stays inside ToolbarItemGroup (always- registered) for all modes — moving it outside switches view identity when the keyboard appears, causing the view to be destroyed and recreated which breaks keyboard presentation and causes a jump HEAL-508 Co-Authored-By: Claude Sonnet 4.6 --- .../PaymentReviewPaymentInformationView.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift index 57bfe0891d..977287f49c 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift @@ -123,9 +123,10 @@ struct PaymentReviewPaymentInformationView: View { @ViewBuilder private func keyboardToolbarIfNeeded(_ base: some View) -> some View { if #available(iOS 26, *) { + // Condition must stay inside ToolbarItemGroup (always-registered) for all modes. + // Moving it outside switches the view identity when the keyboard appears, which + // destroys and recreates the view — causing a jump and breaking keyboard presentation. base.toolbar { - // Always-registered so the bar never detaches mid-transition (same rationale - // as ContentView's iOS <26 ToolbarItemGroup). Condition is inside the group. ToolbarItemGroup(placement: .keyboard) { if focusedField == .amount && keyboardHeight > 0 { Spacer() @@ -159,8 +160,8 @@ struct PaymentReviewPaymentInformationView: View { .safeAreaInset(edge: .bottom) { if focusedField == .amount && keyboardHeight > 0 { if #available(iOS 26, *) { - // Reserve height for the floating Liquid Glass button so content - // doesn't slide under it at the sheet/keyboard boundary. + // Reserve height for the Liquid Glass button (ToolbarItemGroup) so + // content doesn't slide behind it at the sheet/keyboard boundary. Color.clear .frame(height: Constants.doneButtonBarHeight) .allowsHitTesting(false) From b4c6cb0c925b7da8c4c39f71f1e5532cb8512041 Mon Sep 17 00:00:00 2001 From: Nadya Karaban Date: Mon, 22 Jun 2026 16:12:48 +0200 Subject: [PATCH 10/11] =?UTF-8?q?fix(GiniInternalPaymentSDK):=20Address=20?= =?UTF-8?q?Copilot=20review=20=E2=80=94=20doc=20style=20and=20test=20cover?= =?UTF-8?q?age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert // comments to /** */ on isAmountFieldFocused, validateAmountFieldOnKeyboardDismiss, and Transaction.withoutAnimation to match the file's declaration-doc house style - Add ActivePaymentFieldTests and TransactionExtensionsTests to cover new Hashable conformance and Transaction.withoutAnimation (resolves SonarQube coverage gate failure) HEAL-508 Co-Authored-By: Claude Sonnet 4.6 --- .../Extensions/Transaction+Extensions.swift | 4 +- .../PaymentReviewObservableModel.swift | 14 +++-- .../ActivePaymentFieldTests.swift | 63 +++++++++++++++++++ .../TransactionExtensionsTests.swift | 30 +++++++++ 4 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/ActivePaymentFieldTests.swift create mode 100644 GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/TransactionExtensionsTests.swift diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/Extensions/Transaction+Extensions.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/Extensions/Transaction+Extensions.swift index 0bfa3401f6..472db66936 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/Extensions/Transaction+Extensions.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/Extensions/Transaction+Extensions.swift @@ -7,7 +7,9 @@ import SwiftUI extension Transaction { - // Disables ambient UIKit animation inheritance (e.g. keyboard CATransaction). + /** + Disables ambient UIKit animation inheritance (e.g. keyboard CATransaction). + */ static var withoutAnimation: Transaction { var transaction = Transaction() transaction.disablesAnimations = true diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/PaymentReviewObservableModel.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/PaymentReviewObservableModel.swift index 410ec37604..8519632d52 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/PaymentReviewObservableModel.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/PaymentReviewObservableModel.swift @@ -39,7 +39,9 @@ final class PaymentReviewObservableModel: ObservableObject { model.strings.invoiceImageAccessibilityLabel } - // Reflects amount-field focus so PaymentReviewContentView re-renders the landscape toolbar. + /** + Reflects amount-field focus so PaymentReviewContentView re-renders the landscape toolbar. + */ var isAmountFieldFocused: Bool { paymentInformationObservableModel.isAmountFieldFocused } @@ -63,10 +65,12 @@ final class PaymentReviewObservableModel: ObservableObject { model.delegate?.trackOnPaymentReviewCloseKeyboardClicked() } - // Validates the amount field as if it just lost focus. - // Called by the landscape Done button which resigns first responder via UIKit — SwiftUI's - // @FocusState update is not guaranteed synchronous, so validation would otherwise fire - // only on the second Done press. + /** + Validates the amount field as if it just lost focus. + Called by the landscape Done button which resigns first responder via UIKit — SwiftUI's + @FocusState update is not guaranteed synchronous, so validation would otherwise fire + only on the second Done press. + */ func validateAmountFieldOnKeyboardDismiss() { paymentInformationObservableModel.handleAmountFocusChange(isFocused: false) } diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/ActivePaymentFieldTests.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/ActivePaymentFieldTests.swift new file mode 100644 index 0000000000..e8101827fe --- /dev/null +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/ActivePaymentFieldTests.swift @@ -0,0 +1,63 @@ +// +// ActivePaymentFieldTests.swift +// GiniInternalPaymentSDKTests +// +// Copyright © 2026 Gini GmbH. All rights reserved. +// + +import Testing +@testable import GiniInternalPaymentSDK + +// MARK: - Hashable conformance +// ActivePaymentField is used as a SwiftUI .id() tag for ScrollViewReader.scrollTo(_:), +// which requires Hashable. These tests guard against regressions in that conformance. + +@Suite("ActivePaymentField — Hashable") +struct ActivePaymentFieldHashableTests { + + @Test("all four cases are distinct Set members") + func allCasesAreDistinctInSet() { + let set: Set = [.recipient, .iban, .amount, .paymentPurpose] + + #expect(set.count == 4, + "All four ActivePaymentField cases must hash to distinct values so ScrollViewReader can scroll to each field independently") + } + + @Test("inserting the same case twice does not grow the Set") + func duplicateCaseDoesNotGrowSet() { + var set = Set() + set.insert(.amount) + set.insert(.amount) + + #expect(set.count == 1, + "Inserting .amount twice must deduplicate — Equatable equality must be consistent with Hashable identity") + } + + @Test("each case can be used as a Dictionary key", arguments: [ + ActivePaymentField.recipient, + .iban, + .amount, + .paymentPurpose + ]) + func canBeUsedAsDictionaryKey(field: ActivePaymentField) { + var dict = [ActivePaymentField: String]() + dict[field] = "sentinel" + + #expect(dict[field] == "sentinel", + "\(field) must be usable as a Dictionary key — required for Hashable conformance used by SwiftUI .id() and ScrollViewReader.scrollTo()") + } + + @Test("each case round-trips through a Set without mutation", arguments: [ + ActivePaymentField.recipient, + .iban, + .amount, + .paymentPurpose + ]) + func caseRoundTripsThroughSet(field: ActivePaymentField) { + var set = Set() + set.insert(field) + + #expect(set.contains(field), + "\(field) inserted into a Set must be retrievable via contains — hash and equality must agree") + } +} diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/TransactionExtensionsTests.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/TransactionExtensionsTests.swift new file mode 100644 index 0000000000..9195fd5216 --- /dev/null +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/TransactionExtensionsTests.swift @@ -0,0 +1,30 @@ +// +// TransactionExtensionsTests.swift +// GiniInternalPaymentSDKTests +// +// Copyright © 2026 Gini GmbH. All rights reserved. +// + +import Testing +import SwiftUI +@testable import GiniInternalPaymentSDK + +@Suite("Transaction+Extensions") +struct TransactionExtensionsTests { + + @Test("withoutAnimation sets disablesAnimations to true") + func withoutAnimationSetsDisablesAnimations() { + let transaction = Transaction.withoutAnimation + + #expect(transaction.disablesAnimations == true, + "Transaction.withoutAnimation must set disablesAnimations = true to suppress ambient UIKit animation inheritance from keyboard CATransaction") + } + + @Test("a default Transaction does not disable animations") + func defaultTransactionDoesNotDisableAnimations() { + let transaction = Transaction() + + #expect(transaction.disablesAnimations == false, + "A default Transaction must have disablesAnimations = false — only withoutAnimation opts in to suppressing animations") + } +} From f760fe4fd7fdb07c9c8e7897155dde279f497cde Mon Sep 17 00:00:00 2001 From: Nadya Karaban Date: Mon, 22 Jun 2026 17:16:29 +0200 Subject: [PATCH 11/11] style(GiniInternalPaymentSDK): convert declaration comments to /** */ doc style HEAL-508 --- ...mentReviewPaymentInformationObservableModel.swift | 3 ++- .../PaymentReviewPaymentInformationView.swift | 12 +++++++++--- .../ActivePaymentFieldTests.swift | 6 ++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationObservableModel.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationObservableModel.swift index a6935f7344..30c82c1ccf 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationObservableModel.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationObservableModel.swift @@ -9,7 +9,8 @@ import GiniHealthAPILibrary import SwiftUI import GiniUtilites -/** Identifies which payment form field is currently focused. +/** + Identifies which payment form field is currently focused. Stored in the observable model so focus can be restored after orientation changes recreate the view. */ enum ActivePaymentField: Hashable { diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift index 977287f49c..52ac801436 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Sources/GiniInternalPaymentSDK/PaymentReview/Views/PaymentInformation/PaymentReviewPaymentInformationView.swift @@ -25,10 +25,14 @@ struct PaymentReviewPaymentInformationView: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize @State private var keyboardHeight: CGFloat = 0 - // Cancels a pending keyboardWillHide zero when a new keyboardWillShow fires (keyboard-type switch). + /** + Cancels a pending `keyboardWillHide` zero when a new `keyboardWillShow` fires (keyboard-type switch). + */ @State private var keyboardHideToken = 0 - // Landscape doc-collection layout: side-by-side panels, keyboard scroll is manual. + /** + Landscape doc-collection layout: side-by-side panels, keyboard scroll is manual. + */ private var isDocCollection: Bool { giniLayout.isLandscape && viewModel.model.displayMode != .bottomSheet } @@ -543,7 +547,9 @@ struct PaymentReviewPaymentInformationView: View { } } - // Delays 400 ms for rotation animation before re-applying focus. + /** + Delays 400 ms for rotation animation before re-applying focus. + */ private func restoreFocusIfNeeded() { guard let field = viewModel.activeField else { return } Task { @MainActor in diff --git a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/ActivePaymentFieldTests.swift b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/ActivePaymentFieldTests.swift index e8101827fe..9a44cfebd9 100644 --- a/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/ActivePaymentFieldTests.swift +++ b/GiniComponents/InternalPaymentSDK/GiniInternalPaymentSDK/Tests/GiniInternalPaymentSDKTests/ActivePaymentFieldTests.swift @@ -9,9 +9,11 @@ import Testing @testable import GiniInternalPaymentSDK // MARK: - Hashable conformance -// ActivePaymentField is used as a SwiftUI .id() tag for ScrollViewReader.scrollTo(_:), -// which requires Hashable. These tests guard against regressions in that conformance. +/** + `ActivePaymentField` is used as a SwiftUI `.id()` tag for `ScrollViewReader.scrollTo(_:)`, + which requires `Hashable`. These tests guard against regressions in that conformance. + */ @Suite("ActivePaymentField — Hashable") struct ActivePaymentFieldHashableTests {