diff --git a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderDetailsView.swift b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderDetailsView.swift index 2f09ee828e1..3a84cfa658d 100644 --- a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderDetailsView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderDetailsView.swift @@ -312,7 +312,7 @@ private extension POSOrderDetailsView { @ViewBuilder func refundsSection(_ order: POSOrder) -> some View { - ForEach(order.refunds, id: \.refundID) { refund in + ForEach(order.refunds.sorted(by: { $0.refundID < $1.refundID }), id: \.refundID) { refund in refundRow(refund: refund) divider } diff --git a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListEmptyViewModel.swift b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListEmptyViewModel.swift index c2e2693bbfb..b8184fcb0ba 100644 --- a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListEmptyViewModel.swift +++ b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListEmptyViewModel.swift @@ -21,7 +21,7 @@ struct POSOrderListEmptyViewModel: POSListEmptyViewModelProtocol { } var icon: Image { - PointOfSaleAssets.magnifierNotFound.decorativeImage + isSearching ? PointOfSaleAssets.magnifierNotFound.decorativeImage : PointOfSaleAssets.noOrders.decorativeImage } } @@ -33,15 +33,15 @@ private enum Localization { ) static let emptyOrdersSubtitle = NSLocalizedString( - "pos.orderListView.emptyOrdersSubtitle", - value: "Orders will appear here once you start processing sales on the POS.", + "pos.orderListView.emptyOrdersSubtitle2", + value: "Explore how you can increase your store sales.", comment: "Subtitle appearing when there are no orders to display." ) static let emptyOrdersButtonTitle = NSLocalizedString( - "pos.orderListView.emptyOrdersButtonTitle", - value: "Refresh", - comment: "Button text for refreshing orders when list is empty." + "pos.orderListView.emptyOrdersButtonTitle2", + value: "Learn more", + comment: "Button text for opening an information view when orders when list is empty." ) static let emptyOrdersSearchTitle = NSLocalizedString( diff --git a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift index 37782c0f67c..76c68580805 100644 --- a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift @@ -4,18 +4,15 @@ import struct Yosemite.POSOrder import enum Yosemite.OrderPaymentMethod struct POSOrderListView: View { + @Binding var isSearching: Bool + @Binding var searchTerm: String let onClose: () -> Void @Environment(POSOrderListModel.self) private var orderListModel - @Environment(\.keyboardObserver) private var keyboardObserver @Environment(\.posAnalytics) private var analytics @Environment(\.siteTimezone) private var siteTimezone @StateObject private var infiniteScrollTriggerDeterminer = ThresholdInfiniteScrollTriggerDeterminer() - @State private var isSearching: Bool = false - @State private var searchTerm: String = "" - @Namespace private var searchTransition - private var ordersViewState: POSOrderListState { orderListModel.ordersController.ordersViewState } @@ -46,8 +43,11 @@ struct POSOrderListView: View { setSearch(true) } .accessibilityLabel(Localization.searchButtonAccessibilityLabel) - .matchedGeometryEffect(id: Constants.searchControlID, in: searchTransition) - .transition(.opacity.combined(with: .scale)) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity) + .animation(.easeInOut(duration: Constants.animationDuration).delay(Constants.animationDuration)), + removal: .opacity.animation(.easeInOut(duration: Constants.animationDuration * 0.5)) + )) } if isSearching { @@ -59,8 +59,12 @@ struct POSOrderListView: View { } ) .posSearchTextFieldUnfocusedBorderColor(.posOutlineVariant) - .matchedGeometryEffect(id: Constants.searchControlID, in: searchTransition) - .transition(.opacity.combined(with: .move(edge: .leading))) + .transition(.asymmetric( + insertion: .opacity.combined(with: .move(edge: .trailing)) + .animation(.easeInOut(duration: Constants.animationDuration).delay(Constants.animationDuration * 0.5)), + removal: .opacity.combined(with: .move(edge: .trailing)) + .animation(.easeInOut(duration: Constants.animationDuration)) + )) .onChange(of: searchTerm) { _, newValue in if newValue.isEmpty { orderListModel.ordersController.clearSearchOrders() @@ -71,23 +75,21 @@ struct POSOrderListView: View { ) .animation(.easeInOut(duration: Constants.animationDuration), value: isSearching) - switch ordersViewState { - case .empty: + switch (ordersViewState, isSearching) { + case (.empty, true): POSListEmptyView( - viewModel: POSOrderListEmptyViewModel(isSearching: isSearching) + viewModel: POSOrderListEmptyViewModel(isSearching: true) ) { Task { @MainActor in await orderListModel.ordersController.loadOrders() } } - .padding(.bottom, keyboardObserver.keyboardHeight) - case .error(let errorState): + case (.error(let errorState), true): POSListErrorView(error: errorState) { Task { @MainActor in await orderListModel.ordersController.loadOrders() } } - .padding(.bottom, keyboardObserver.keyboardHeight) default: listView } @@ -99,9 +101,6 @@ struct POSOrderListView: View { analytics.track(event: WooAnalyticsEvent.PointOfSale.ordersListPullToRefresh()) await orderListModel.ordersController.refreshOrders() } - .task { - await orderListModel.ordersController.loadOrders() - } } @ViewBuilder @@ -128,7 +127,7 @@ struct POSOrderListView: View { await orderListModel.ordersController.loadNextOrders() }, content: { - LazyVStack(spacing: POSSpacing.small) { + LazyVStack(spacing: POSSpacing.medium) { headerRows .id(Constants.scrollTopID) @@ -384,7 +383,6 @@ private enum Constants { static let orderCardMinHeight: CGFloat = 112 static let maximumOrderCardHeight: CGFloat = Constants.orderCardMinHeight * 2 static let animationDuration: CGFloat = 0.2 - static let searchControlID = "searchControl" static let scrollTopID = "orderListViewTopID" } @@ -450,7 +448,7 @@ private enum Localization { #if DEBUG #Preview("List") { NavigationSplitView(columnVisibility: .constant(.all)) { - POSOrderListView(onClose: {}) + POSOrderListView(isSearching: .constant(false), searchTerm: .constant(""), onClose: {}) .navigationSplitViewColumnWidth(450) .environment(POSPreviewHelpers.makePreviewOrdersModel(state: POSPreviewHelpers.loadedState())) } detail: { @@ -459,9 +457,9 @@ private enum Localization { .navigationSplitViewStyle(.balanced) } -#Preview("Empty State") { +#Preview("Empty State in Search") { NavigationSplitView(columnVisibility: .constant(.all)) { - POSOrderListView(onClose: {}) + POSOrderListView(isSearching: .constant(true), searchTerm: .constant(""), onClose: {}) .navigationSplitViewColumnWidth(450) .environment(POSPreviewHelpers.makePreviewOrdersModel(state: .empty)) } detail: { @@ -469,9 +467,9 @@ private enum Localization { } } -#Preview("Error State") { +#Preview("Error State in Search") { NavigationSplitView(columnVisibility: .constant(.all)) { - POSOrderListView(onClose: {}) + POSOrderListView(isSearching: .constant(true), searchTerm: .constant(""), onClose: {}) .navigationSplitViewColumnWidth(450) .environment(POSPreviewHelpers.makePreviewOrdersModel(state: .error(.errorOnLoadingOrders()))) } detail: { @@ -481,7 +479,7 @@ private enum Localization { #Preview("Loading State") { NavigationSplitView(columnVisibility: .constant(.all)) { - POSOrderListView(onClose: {}) + POSOrderListView(isSearching: .constant(false), searchTerm: .constant(""), onClose: {}) .navigationSplitViewColumnWidth(450) .environment(POSPreviewHelpers.makePreviewOrdersModel(state: .loading([]))) } detail: { @@ -491,7 +489,7 @@ private enum Localization { #Preview("Inline Error - Refresh") { NavigationSplitView(columnVisibility: .constant(.all)) { - POSOrderListView(onClose: {}) + POSOrderListView(isSearching: .constant(false), searchTerm: .constant(""), onClose: {}) .navigationSplitViewColumnWidth(450) .environment(POSPreviewHelpers.makePreviewOrdersModel( state: .inlineError(POSPreviewHelpers.makePreviewOrders(), @@ -505,7 +503,7 @@ private enum Localization { #Preview("Inline Error - Pagination") { NavigationSplitView(columnVisibility: .constant(.all)) { - POSOrderListView(onClose: {}) + POSOrderListView(isSearching: .constant(false), searchTerm: .constant(""), onClose: {}) .navigationSplitViewColumnWidth(450) .environment(POSPreviewHelpers.makePreviewOrdersModel( state: .inlineError(POSPreviewHelpers.makePreviewOrders(), @@ -519,7 +517,7 @@ private enum Localization { #Preview("Search Empty State") { NavigationSplitView(columnVisibility: .constant(.all)) { - POSOrderListView(onClose: {}) + POSOrderListView(isSearching: .constant(true), searchTerm: .constant(""), onClose: {}) .navigationSplitViewColumnWidth(450) .environment(POSPreviewHelpers.makePreviewOrdersModel(state: .empty)) } detail: { diff --git a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrdersView.swift b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrdersView.swift index 47f10cdd13b..9d85793b1c8 100644 --- a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrdersView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrdersView.swift @@ -1,23 +1,37 @@ import SwiftUI import UIKit import struct WooFoundation.WooAnalyticsEvent +import struct WooFoundation.SafariView struct POSOrdersView: View { @Binding var isPresented: Bool @Environment(POSOrderListModel.self) private var orderListModel @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.posAnalytics) private var analytics + @State private var isSearching: Bool = false + @State private var searchTerm: String = "" + @State private var showBlog = false var body: some View { - switch orderListModel.ordersController.ordersViewState { - case .error(let error): + contentView + .task { + await orderListModel.ordersController.loadOrders() + } + } + + @ViewBuilder + private var contentView: some View { + switch (orderListModel.ordersController.ordersViewState, isSearching) { + case (.error(let error), false): errorView(error) + case (.empty, false): + emptyView() default: CustomNavigationSplitView(selection: Binding( get: { orderListModel.ordersController.selectedOrder }, set: { orderListModel.ordersController.selectOrder($0) } )) { _ in - POSOrderListView() { + POSOrderListView(isSearching: $isSearching, searchTerm: $searchTerm) { isPresented = false } } detail: { selection in @@ -65,20 +79,55 @@ struct POSOrdersView: View { @ViewBuilder private func errorView(_ error: PointOfSaleErrorState) -> some View { - VStack(spacing: 0) { - POSPageHeaderView( - title: POSOrderListView.Localization.ordersTitle, - backButtonConfiguration: .init(state: .enabled, action: { - isPresented = false - })) - .posHeaderBackButtonIcon(systemName: "xmark") + ZStack { + VStack { + Spacer() + POSListErrorView(error: error) { + Task { @MainActor in + await orderListModel.ordersController.loadOrders() + } + } + Spacer() + } + + VStack { + POSPageHeaderView( + title: POSOrderListView.Localization.ordersTitle, + backButtonConfiguration: .init(state: .enabled, action: { + isPresented = false + })) + .posHeaderBackButtonIcon(systemName: "xmark") + Spacer() + } + } + } - POSListErrorView(error: error) { - Task { @MainActor in - await orderListModel.ordersController.loadOrders() + @ViewBuilder + private func emptyView() -> some View { + ZStack { + VStack { + Spacer() + POSListEmptyView( + viewModel: POSOrderListEmptyViewModel(isSearching: false) + ) { + showBlog = true } + Spacer() + } + + VStack { + POSPageHeaderView( + title: POSOrderListView.Localization.ordersTitle, + backButtonConfiguration: .init(state: .enabled, action: { + isPresented = false + })) + .posHeaderBackButtonIcon(systemName: "xmark") + Spacer() } } + .posFullScreenCover(isPresented: $showBlog) { + SafariView(url: POSConstants.URLs.wooCommerceBlog.asURL()) + } } } @@ -158,8 +207,18 @@ private enum Constants { } #if DEBUG -#Preview("Orders View") { +#Preview("Orders View List") { + POSOrdersView(isPresented: .constant(true)) + .environment(POSPreviewHelpers.makePreviewOrdersModel(state: POSPreviewHelpers.loadedState())) +} + +#Preview("Orders View Empty") { POSOrdersView(isPresented: .constant(true)) .environment(POSPreviewHelpers.makePreviewOrdersModel(state: .empty)) } + +#Preview("Orders View Error") { + POSOrdersView(isPresented: .constant(true)) + .environment(POSPreviewHelpers.makePreviewOrdersModel(state: .error(.errorOnLoadingOrders()))) +} #endif diff --git a/Modules/Sources/PointOfSale/Presentation/PointOfSaleAssets.swift b/Modules/Sources/PointOfSale/Presentation/PointOfSaleAssets.swift index 16aea632ea9..86560c6f231 100644 --- a/Modules/Sources/PointOfSale/Presentation/PointOfSaleAssets.swift +++ b/Modules/Sources/PointOfSale/Presentation/PointOfSaleAssets.swift @@ -20,6 +20,7 @@ enum PointOfSaleAssets: CaseIterable { case netum1228BCHIDBarcode case netum1228BCPairBarcode case testEan13Barcode + case noOrders var image: Image { Image(imageName, bundle: .module) @@ -67,6 +68,8 @@ enum PointOfSaleAssets: CaseIterable { "netum-1228bc-hid-barcode" case .netum1228BCPairBarcode: "netum-1228bc-pair-barcode" + case .noOrders: + "pos-no-orders" } } } diff --git a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSListEmptyView.swift b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSListEmptyView.swift index 496606da323..998df352377 100644 --- a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSListEmptyView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSListEmptyView.swift @@ -68,7 +68,7 @@ struct POSListEmptyView: View { }, label: { Text(buttonTitle) }) - .buttonStyle(POSOutlinedButtonStyle(size: .normal)) + .buttonStyle(POSFilledButtonStyle(size: .normal)) .frame(width: viewWidth / 2) .padding([.leading, .trailing]) } diff --git a/Modules/Sources/PointOfSale/Resources/Images.xcassets/pos-no-orders.imageset/Contents.json b/Modules/Sources/PointOfSale/Resources/Images.xcassets/pos-no-orders.imageset/Contents.json new file mode 100644 index 00000000000..4f211fffaff --- /dev/null +++ b/Modules/Sources/PointOfSale/Resources/Images.xcassets/pos-no-orders.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "pos-no-orders.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Modules/Sources/PointOfSale/Resources/Images.xcassets/pos-no-orders.imageset/pos-no-orders.pdf b/Modules/Sources/PointOfSale/Resources/Images.xcassets/pos-no-orders.imageset/pos-no-orders.pdf new file mode 100644 index 00000000000..89c2f301f72 Binary files /dev/null and b/Modules/Sources/PointOfSale/Resources/Images.xcassets/pos-no-orders.imageset/pos-no-orders.pdf differ diff --git a/Modules/Sources/PointOfSale/Utils/POSConstants.swift b/Modules/Sources/PointOfSale/Utils/POSConstants.swift index f3df9d0a3b7..ea2d8be028c 100644 --- a/Modules/Sources/PointOfSale/Utils/POSConstants.swift +++ b/Modules/Sources/PointOfSale/Utils/POSConstants.swift @@ -20,6 +20,10 @@ enum POSConstants { /// case inPersonPaymentsLearnMoreWCPay = "https://woocommerce.com/document/woocommerce-payments/in-person-payments/getting-started-with-in-person-payments/" + + /// URL for WooCommerce blog + /// + case wooCommerceBlog = "https://woocommerce.com/blog/" } }