diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift index a4fa2d55c92..c08fedf2ac7 100644 --- a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift +++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift @@ -7,6 +7,7 @@ struct PointOfSaleEntryPointView: View { @State private var posModel: PointOfSaleAggregateModel? @StateObject private var posModalManager = POSModalManager() @StateObject private var posSheetManager = POSSheetManager() + @StateObject private var posCoverManager = POSFullScreenCoverManager() @State private var posEntryPointController: POSEntryPointController @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -77,6 +78,7 @@ struct PointOfSaleEntryPointView: View { } .environmentObject(posModalManager) .environmentObject(posSheetManager) + .environmentObject(posCoverManager) .injectKeyboardObserver() .onAppear { onPointOfSaleModeActiveStateChange(true) diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSFullScreenCover.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSFullScreenCover.swift new file mode 100644 index 00000000000..66e66145d3f --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSFullScreenCover.swift @@ -0,0 +1,111 @@ +import SwiftUI + +extension View { + /// Shows a full screen cover that automatically manages modal hierarchy. + /// This is a wrapper around SwiftUI's fullScreenCover that integrates with POS modal system. + /// + /// Full screen covers automatically establish a new presentation layer, so: + /// - Modals/sheets from views behind the cover will not show + /// - Modals/sheet within the cover content will show normally + /// - Multiple covers can be stacked with proper hierarchy management + /// + /// - Parameters: + /// - isPresented: Binding to control when the full screen cover is shown + /// - onDismiss: Optional closure called when the cover is dismissed + /// - content: Content to show in full screen + /// - Returns: a modified view that shows full screen content with automatic modal hierarchy + // periphery:ignore + func posFullScreenCover( + isPresented: Binding, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content + ) -> some View { + self.modifier( + POSFullScreenCoverModifier( + isPresented: isPresented, + onDismiss: onDismiss, + coverContent: content + ) + ) + } + + /// - Parameters: + /// - item: Binding to control when the full screen cover is shown + /// - onDismiss: Optional closure called when the cover is dismissed + /// - content: Content to show in full screen + /// - Returns: a modified view that shows full screen content with automatic modal hierarchy + // periphery:ignore + func posFullScreenCover( + item: Binding, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping (Item) -> Content + ) -> some View { + self.modifier( + POSFullScreenCoverModifierForItem( + item: item, + onDismiss: onDismiss, + coverContent: content + ) + ) + } +} + +final class POSFullScreenCoverManager: ObservableObject { + @Published fileprivate(set) var isPresented: Bool = false +} + +// MARK: - Modifiers +// periphery:ignore +struct POSFullScreenCoverModifier: ViewModifier { + @Binding var isPresented: Bool + let onDismiss: (() -> Void)? + let coverContent: () -> CoverContent + + @EnvironmentObject var parentCoverManager: POSFullScreenCoverManager + @StateObject private var modalManager = POSModalManager() + @StateObject private var sheetManager = POSSheetManager() + @StateObject private var coverManager = POSFullScreenCoverManager() + + @State private var sheetId = UUID().uuidString + + func body(content: Content) -> some View { + content + .fullScreenCover(isPresented: $isPresented, onDismiss: onDismiss, content: { + coverContent() + .posRootModal() + .environmentObject(modalManager) + .environmentObject(sheetManager) + .environmentObject(coverManager) + }) + .onChange(of: isPresented) { newValue in + parentCoverManager.isPresented = newValue + } + } +} +// periphery:ignore +struct POSFullScreenCoverModifierForItem: ViewModifier { + @Binding var item: Item? + let onDismiss: (() -> Void)? + let coverContent: (Item) -> CoverContent + + @EnvironmentObject var parentCoverManager: POSFullScreenCoverManager + @StateObject private var modalManager = POSModalManager() + @StateObject private var sheetManager = POSSheetManager() + @StateObject private var coverManager = POSFullScreenCoverManager() + + @State private var sheetId = UUID().uuidString + + func body(content: Content) -> some View { + content + .fullScreenCover(item: $item, onDismiss: onDismiss, content: { + coverContent($0) + .posRootModal() + .environmentObject(modalManager) + .environmentObject(sheetManager) + .environmentObject(coverManager) + }) + .onChange(of: item) { newValue in + parentCoverManager.isPresented = newValue != nil + } + } +} diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSModalViewModifier.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSModalViewModifier.swift index 98bdb5cdee1..a61e93ba06f 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSModalViewModifier.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSModalViewModifier.swift @@ -74,6 +74,7 @@ extension View { struct POSModalViewModifier: ViewModifier { @EnvironmentObject var modalManager: POSModalManager + @EnvironmentObject var coverManager: POSFullScreenCoverManager @Binding var item: Item? let onDismiss: (() -> Void)? let modalContent: (Item) -> ModalContent @@ -81,6 +82,9 @@ struct POSModalViewModifier: func body(content: Content) -> some View { content .onChange(of: item) { newItem in + // Don't show a modal if a full screen overlay is presented on top + guard !coverManager.isPresented else { return } + if let newItem = newItem { modalManager.present(onDismiss: { // Internal dismissal, i.e. from tapping the background @@ -100,6 +104,7 @@ struct POSModalViewModifier: struct POSModalViewModifierForBool: ViewModifier { @EnvironmentObject var modalManager: POSModalManager + @EnvironmentObject var coverManager: POSFullScreenCoverManager @Binding var isPresented: Bool let onDismiss: (() -> Void)? let modalContent: () -> ModalContent @@ -107,6 +112,9 @@ struct POSModalViewModifierForBool: ViewModifier { func body(content: Content) -> some View { content .onChange(of: isPresented) { newValue in + // Don't show a modal if a full screen overlay is presented on top + guard !coverManager.isPresented else { return } + if newValue { modalManager.present(onDismiss: { // Internal dismissal, i.e. from tapping the background diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSheet.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSheet.swift index bd1773daaef..f0fcaa7a38b 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSheet.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSheet.swift @@ -32,15 +32,25 @@ final class POSSheetManager: ObservableObject { struct POSSheetViewModifier: ViewModifier { @EnvironmentObject var sheetManager: POSSheetManager + @EnvironmentObject var coverManager: POSFullScreenCoverManager @Binding var isPresented: Bool let onDismiss: (() -> Void)? let sheetContent: () -> SheetContent @State private var sheetId = UUID().uuidString + private var sheetIsPresented: Binding { + Binding(get: { + // Don't show a sheet if a full screen overlay is presented on top + return self.$isPresented.wrappedValue && !coverManager.isPresented + }, set: { + self.$isPresented.wrappedValue = $0 + }) + } + func body(content: Content) -> some View { content - .sheet(isPresented: $isPresented, onDismiss: onDismiss, content: sheetContent) + .sheet(isPresented: sheetIsPresented, onDismiss: onDismiss, content: sheetContent) .onChange(of: isPresented) { newValue in if newValue { sheetManager.registerSheetPresented(id: sheetId) @@ -53,16 +63,27 @@ struct POSSheetViewModifier: ViewModifier { struct POSSheetViewModifierForItem: ViewModifier { @EnvironmentObject var sheetManager: POSSheetManager + @EnvironmentObject var coverManager: POSFullScreenCoverManager @Binding var item: Item? let onDismiss: (() -> Void)? let sheetContent: (Item) -> SheetContent @State private var sheetId = UUID().uuidString + private var sheetItem: Binding { + Binding(get: { + // Don't show a sheet if a full screen overlay is presented on top + guard !coverManager.isPresented else { return nil } + return self.$item.wrappedValue + }, set: { + self.$item.wrappedValue = $0 + }) + } + func body(content: Content) -> some View { content - .sheet(item: $item, onDismiss: onDismiss, content: sheetContent) - .onChange(of: item) { newItem in + .sheet(item: sheetItem, onDismiss: onDismiss, content: sheetContent) + .onChange(of: sheetItem.wrappedValue) { newItem in let newValue = newItem != nil if newValue { sheetManager.registerSheetPresented(id: sheetId) diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index cb1e134cf72..dd5a13d8b9b 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -76,6 +76,7 @@ 0196FF942DA8067A0063CEF1 /* CouponCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0196FF932DA806720063CEF1 /* CouponCardView.swift */; }; 019A86842D89C13800ABBB71 /* TapToPayCardReaderPaymentAlertsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019A86832D89C13800ABBB71 /* TapToPayCardReaderPaymentAlertsProvider.swift */; }; 01A3093C2DAE768600B672F6 /* MockPointOfSaleCouponService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A3093B2DAE768000B672F6 /* MockPointOfSaleCouponService.swift */; }; + 01AA4FA12E4CB22900FA9B4C /* POSFullScreenCover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AA4FA02E4CB22700FA9B4C /* POSFullScreenCover.swift */; }; 01AAD8142D92E37A0081D60B /* PointOfSaleOrderSyncCouponsErrorMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AAD8132D92E37A0081D60B /* PointOfSaleOrderSyncCouponsErrorMessageView.swift */; }; 01AB2D122DDC7AD300AA67FD /* PointOfSaleItemListAnalyticsTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AB2D112DDC7AD100AA67FD /* PointOfSaleItemListAnalyticsTrackerTests.swift */; }; 01AB2D142DDC7CD200AA67FD /* POSItemActionHandlerFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AB2D132DDC7CD000AA67FD /* POSItemActionHandlerFactoryTests.swift */; }; @@ -3267,6 +3268,7 @@ 0196FF932DA806720063CEF1 /* CouponCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CouponCardView.swift; sourceTree = ""; }; 019A86832D89C13800ABBB71 /* TapToPayCardReaderPaymentAlertsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayCardReaderPaymentAlertsProvider.swift; sourceTree = ""; }; 01A3093B2DAE768000B672F6 /* MockPointOfSaleCouponService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPointOfSaleCouponService.swift; sourceTree = ""; }; + 01AA4FA02E4CB22700FA9B4C /* POSFullScreenCover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSFullScreenCover.swift; sourceTree = ""; }; 01AAD8132D92E37A0081D60B /* PointOfSaleOrderSyncCouponsErrorMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderSyncCouponsErrorMessageView.swift; sourceTree = ""; }; 01AB2D112DDC7AD100AA67FD /* PointOfSaleItemListAnalyticsTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleItemListAnalyticsTrackerTests.swift; sourceTree = ""; }; 01AB2D132DDC7CD000AA67FD /* POSItemActionHandlerFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSItemActionHandlerFactoryTests.swift; sourceTree = ""; }; @@ -6447,8 +6449,9 @@ 01620C4C2C5394A400D3EA2F /* Reusable Views */ = { isa = PBXGroup; children = ( - 016DE5322E40B03200F53DF7 /* POSSheet.swift */, 0210A2452D55EC140054C78D /* Buttons */, + 016DE5322E40B03200F53DF7 /* POSSheet.swift */, + 01AA4FA02E4CB22700FA9B4C /* POSFullScreenCover.swift */, 01620C4D2C5394B200D3EA2F /* POSProgressViewStyle.swift */, 204D1D612C5A50840064A6BE /* POSModalViewModifier.swift */, 20D2CCA42C7E328300051705 /* POSModalCloseButton.swift */, @@ -16201,6 +16204,7 @@ 0212276124498A270042161F /* ProductFormBottomSheetListSelectorCommand.swift in Sources */, D831E2E0230E0BA7000037D0 /* Logs.swift in Sources */, 02CEBB8224C98861002EDF35 /* ProductFormDataModel.swift in Sources */, + 01AA4FA12E4CB22900FA9B4C /* POSFullScreenCover.swift in Sources */, 3120491B26DD80E000A4EC4F /* ActivitySpinnerAndLabelTableViewCell.swift in Sources */, DEC51AFD276AEAE3009F3DF4 /* SystemStatusReportView.swift in Sources */, CECC759C23D61C1400486676 /* AggregateDataHelper.swift in Sources */,