diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrder.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrder.swift index 7cec0d8e474..93ccadbc5b9 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrder.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrder.swift @@ -11,67 +11,47 @@ public struct POSOrder: Equatable, Hashable { public let number: String public let dateCreated: Date public let status: OrderStatusEnum - public let total: String + public let formattedTotal: String + public let formattedSubtotal: String public let customerEmail: String? public let paymentMethodID: String public let paymentMethodTitle: String public let lineItems: [POSOrderItem] public let refunds: [POSOrderRefund] - public let currency: String - public let currencySymbol: String + public let formattedDiscountTotal: String? + public let formattedTotalTax: String + public let formattedPaymentTotal: String + public let formattedNetAmount: String? public init(id: Int64, number: String, dateCreated: Date, status: OrderStatusEnum, - total: String, + formattedTotal: String, + formattedSubtotal: String, customerEmail: String? = nil, paymentMethodID: String, paymentMethodTitle: String, lineItems: [POSOrderItem] = [], refunds: [POSOrderRefund] = [], - currency: String, - currencySymbol: String) { + formattedTotalTax: String, + formattedDiscountTotal: String?, + formattedPaymentTotal: String, + formattedNetAmount: String? = nil) { self.id = id self.number = number self.dateCreated = dateCreated self.status = status - self.total = total + self.formattedTotal = formattedTotal + self.formattedSubtotal = formattedSubtotal self.customerEmail = customerEmail self.paymentMethodID = paymentMethodID self.paymentMethodTitle = paymentMethodTitle self.lineItems = lineItems self.refunds = refunds - self.currency = currency - self.currencySymbol = currencySymbol - } -} - -// MARK: - Conversion from NetworkingCore.Order -public extension POSOrder { - init(from order: NetworkingCore.Order) { - // Extract customer email from billing address - let customerEmail = order.billingAddress?.email - - // Convert line items to POS format - let posLineItems = order.items.map { POSOrderItem(from: $0) } - - // Convert refunds to POS format - let posRefunds = order.refunds.map { POSOrderRefund(from: $0) } - - self.init( - id: order.orderID, - number: order.number, - dateCreated: order.dateCreated, - status: order.status, - total: order.total, - customerEmail: customerEmail, - paymentMethodID: order.paymentMethodID, - paymentMethodTitle: order.paymentMethodTitle, - lineItems: posLineItems, - refunds: posRefunds, - currency: order.currency, - currencySymbol: order.currencySymbol - ) + self.formattedTotalTax = formattedTotalTax + self.formattedDiscountTotal = formattedDiscountTotal + self.formattedPaymentTotal = formattedPaymentTotal + self.formattedNetAmount = formattedNetAmount } } diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderItem.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderItem.swift index 6913fc821f4..4e6f37410d9 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderItem.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderItem.swift @@ -1,31 +1,32 @@ import Foundation -import struct NetworkingCore.OrderItem public struct POSOrderItem: Equatable, Hashable { public let itemID: Int64 public let name: String + // periphery:ignore - Will be used for images + public let productID: Int64 + // periphery:ignore - Will be used for images + public let variationID: Int64 public let quantity: Decimal - public let total: String + public let formattedPrice: String + public let formattedTotal: String + public let attributes: [OrderItemAttribute] public init(itemID: Int64, name: String, + productID: Int64, + variationID: Int64, quantity: Decimal, - total: String) { + formattedPrice: String, + formattedTotal: String, + attributes: [OrderItemAttribute]) { self.itemID = itemID self.name = name + self.productID = productID + self.variationID = variationID self.quantity = quantity - self.total = total - } -} - -// MARK: - Conversion from NetworkingCore.OrderItem -public extension POSOrderItem { - init(from orderItem: OrderItem) { - self.init( - itemID: orderItem.itemID, - name: orderItem.name, - quantity: orderItem.quantity, - total: orderItem.total - ) + self.formattedPrice = formattedPrice + self.formattedTotal = formattedTotal + self.attributes = attributes } } diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderMapper.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderMapper.swift new file mode 100644 index 00000000000..c0f7a6216f0 --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderMapper.swift @@ -0,0 +1,75 @@ +import Foundation +import class WooFoundationCore.CurrencyFormatter +import struct NetworkingCore.Order +import struct NetworkingCore.OrderItem +import struct NetworkingCore.OrderItemAttribute +import struct NetworkingCore.OrderRefundCondensed + +struct POSOrderMapper { + private let currencyFormatter: CurrencyFormatter + + init(currencyFormatter: CurrencyFormatter) { + self.currencyFormatter = currencyFormatter + } + + func map(order: NetworkingCore.Order) -> POSOrder { + let customerEmail = order.billingAddress?.email + + let posLineItems = order.items.map { map(orderItem: $0, currency: order.currency) } + + let posRefunds = order.refunds.map { map(orderRefund: $0, currency: order.currency) } + + let formattedDiscountTotal: String? = { + guard let discountTotalValue = Double(order.discountTotal), discountTotalValue > 0 else { + return nil + } + return currencyFormatter.formatAmount(order.discountTotal, with: order.currency, isNegative: true) ?? "" + }() + + let formattedNetAmount: String? = { + guard !order.refunds.isEmpty else { + return nil + } + return order.netAmount(currencyFormatter: currencyFormatter) + }() + + return POSOrder( + id: order.orderID, + number: order.number, + dateCreated: order.dateCreated, + status: order.status, + formattedTotal: currencyFormatter.formatAmount(order.total, with: order.currency) ?? "", + formattedSubtotal: order.subtotalValue(currencyFormatter: currencyFormatter), + customerEmail: customerEmail, + paymentMethodID: order.paymentMethodID, + paymentMethodTitle: order.paymentMethodTitle, + lineItems: posLineItems, + refunds: posRefunds, + formattedTotalTax: currencyFormatter.formatAmount(order.totalTax, with: order.currency) ?? "", + formattedDiscountTotal: formattedDiscountTotal, + formattedPaymentTotal: order.paymentTotal(currencyFormatter: currencyFormatter), + formattedNetAmount: formattedNetAmount + ) + } + + private func map(orderItem: NetworkingCore.OrderItem, currency: String) -> POSOrderItem { + return POSOrderItem( + itemID: orderItem.itemID, + name: orderItem.name, + productID: orderItem.productID, + variationID: orderItem.variationID, + quantity: orderItem.quantity, + formattedPrice: currencyFormatter.formatAmount(orderItem.price, with: currency) ?? "", + formattedTotal: currencyFormatter.formatAmount(orderItem.total, with: currency) ?? "", + attributes: orderItem.attributes + ) + } + + private func map(orderRefund: NetworkingCore.OrderRefundCondensed, currency: String) -> POSOrderRefund { + return POSOrderRefund( + refundID: orderRefund.refundID, + formattedTotal: currencyFormatter.formatAmount(orderRefund.total, with: currency) ?? "", + reason: orderRefund.reason + ) + } +} diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderRefund.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderRefund.swift index 53c2fd1d9dc..aa3311b5f53 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderRefund.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/POSOrderRefund.swift @@ -1,27 +1,17 @@ import Foundation import struct NetworkingCore.OrderRefundCondensed +import class WooFoundationCore.CurrencyFormatter public struct POSOrderRefund: Equatable, Hashable { public let refundID: Int64 - public let total: String + public let formattedTotal: String public let reason: String? public init(refundID: Int64, - total: String, + formattedTotal: String, reason: String? = nil) { self.refundID = refundID - self.total = total + self.formattedTotal = formattedTotal self.reason = reason } } - -// MARK: - Conversion from NetworkingCore.OrderRefundCondensed -public extension POSOrderRefund { - init(from refund: OrderRefundCondensed) { - self.init( - refundID: refund.refundID, - total: refund.total, - reason: refund.reason - ) - } -} diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategyFactory.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategyFactory.swift index 51df3241284..a350d54ed1b 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategyFactory.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategyFactory.swift @@ -1,6 +1,7 @@ import Foundation import class Networking.AlamofireNetwork import class Networking.OrdersRemote +import class WooFoundationCore.CurrencyFormatter public protocol PointOfSaleOrderListFetchStrategyFactoryProtocol { func defaultStrategy() -> PointOfSaleOrderListFetchStrategy @@ -9,16 +10,24 @@ public protocol PointOfSaleOrderListFetchStrategyFactoryProtocol { public final class PointOfSaleOrderListFetchStrategyFactory: PointOfSaleOrderListFetchStrategyFactoryProtocol { private let siteID: Int64 private let ordersRemote: OrdersRemote + private let currencyFormatter: CurrencyFormatter public init(siteID: Int64, - credentials: Credentials?) { + credentials: Credentials?, + currencyFormatter: CurrencyFormatter) { self.siteID = siteID let network = AlamofireNetwork(credentials: credentials) self.ordersRemote = OrdersRemote(network: network) + self.currencyFormatter = currencyFormatter } public func defaultStrategy() -> PointOfSaleOrderListFetchStrategy { - PointOfSaleDefaultOrderListFetchStrategy(orderListService: PointOfSaleOrderListService(siteID: siteID, - ordersRemote: ordersRemote)) + PointOfSaleDefaultOrderListFetchStrategy( + orderListService: PointOfSaleOrderListService( + siteID: siteID, + ordersRemote: ordersRemote, + currencyFormatter: currencyFormatter + ) + ) } } diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift index 9b8ad7ff7a9..22748a5600e 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift @@ -3,14 +3,21 @@ import enum Alamofire.AFError import struct NetworkingCore.PagedItems import struct NetworkingCore.Order import protocol NetworkingCore.POSOrdersRemoteProtocol +import class WooFoundationCore.CurrencyFormatter public final class PointOfSaleOrderListService: PointOfSaleOrderListServiceProtocol { private let ordersRemote: POSOrdersRemoteProtocol private let siteID: Int64 + private let mapper: POSOrderMapper - public init(siteID: Int64, ordersRemote: POSOrdersRemoteProtocol) { + public init( + siteID: Int64, + ordersRemote: POSOrdersRemoteProtocol, + currencyFormatter: CurrencyFormatter + ) { self.siteID = siteID self.ordersRemote = ordersRemote + self.mapper = POSOrderMapper(currencyFormatter: currencyFormatter) } public func providePointOfSaleOrders(pageNumber: Int = 1) async throws -> PagedItems { @@ -26,7 +33,7 @@ public final class PointOfSaleOrderListService: PointOfSaleOrderListServiceProto } // Convert Order objects to POSOrder objects - let posOrders = pagedOrders.items.map { POSOrder(from: $0) } + let posOrders = pagedOrders.items.map { mapper.map(order: $0) } return .init(items: posOrders, hasMorePages: pagedOrders.hasMorePages, diff --git a/Modules/Sources/Yosemite/Stores/Order/Order+CurrencyFormattedValues.swift b/Modules/Sources/Yosemite/Stores/Order/Order+CurrencyFormattedValues.swift index 8b357296083..c0bd576435c 100644 --- a/Modules/Sources/Yosemite/Stores/Order/Order+CurrencyFormattedValues.swift +++ b/Modules/Sources/Yosemite/Stores/Order/Order+CurrencyFormattedValues.swift @@ -7,26 +7,49 @@ public extension Order { } var netAmount: String? { - guard let netDecimal = calculateNetAmount() else { + netAmount(currencyFormatter: currencyFormatter) + } + + var totalValue: String { + totalValue(currencyFormatter: currencyFormatter) + } + + var subtotal: Decimal { + let subtotal = items.reduce(.zero) { (output, item) in + let itemSubtotal = Decimal(string: item.subtotal) ?? .zero + return output + itemSubtotal + } + + return subtotal + } + + func netAmount(currencyFormatter: CurrencyFormatter) -> String? { + guard let netDecimal = calculateNetAmount(currencyFormatter: currencyFormatter) else { return nil } return currencyFormatter.formatAmount(netDecimal, with: currency) } - var paymentTotal: String { + func paymentTotal(currencyFormatter: CurrencyFormatter) -> String { if datePaid == nil { return currencyFormatter.formatAmount("0.00", with: currency) ?? String() } - return totalValue + return totalValue(currencyFormatter: currencyFormatter) } - var totalValue: String { + func totalValue(currencyFormatter: CurrencyFormatter) -> String { return currencyFormatter.formatAmount(total, with: currency) ?? String() } - private func calculateNetAmount() -> NSDecimalNumber? { + func subtotalValue(currencyFormatter: CurrencyFormatter) -> String { + let subAmount = NSDecimalNumber(decimal: subtotal).stringValue + + return currencyFormatter.formatAmount(subAmount, with: currency) ?? String() + } + + private func calculateNetAmount(currencyFormatter: CurrencyFormatter) -> NSDecimalNumber? { guard let orderTotal = currencyFormatter.convertToDecimal(total) else { return .zero } diff --git a/Modules/Tests/YosemiteTests/PointOfSale/POSOrderMapperTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/POSOrderMapperTests.swift new file mode 100644 index 00000000000..63feee5e2b9 --- /dev/null +++ b/Modules/Tests/YosemiteTests/PointOfSale/POSOrderMapperTests.swift @@ -0,0 +1,211 @@ +import Foundation +import Testing +import WooFoundation +import NetworkingCore +@testable import Yosemite + +struct POSOrderMapperTests { + private let currencyFormatter: CurrencyFormatter + private let sut: POSOrderMapper + + init() { + currencyFormatter = CurrencyFormatter(currencySettings: CurrencySettings()) + sut = POSOrderMapper(currencyFormatter: currencyFormatter) + } + + // MARK: - formattedDiscountTotal Logic Tests + + @Test + func formattedDiscountTotal_returns_nil_when_discount_total_is_zero() { + // Given + let order = makeOrder(discountTotal: "0.00") + + // When + let result = sut.map(order: order) + + // Then + #expect(result.formattedDiscountTotal == nil) + } + + @Test + func formattedDiscountTotal_returns_nil_when_discount_total_is_negative() { + // Given + let order = makeOrder(discountTotal: "-5.00") + + // When + let result = sut.map(order: order) + + // Then + #expect(result.formattedDiscountTotal == nil) + } + + @Test + func formattedDiscountTotal_returns_nil_when_discount_total_is_invalid() { + // Given + let order = makeOrder(discountTotal: "invalid") + + // When + let result = sut.map(order: order) + + // Then + #expect(result.formattedDiscountTotal == nil) + } + + @Test + func formattedDiscountTotal_returns_formatted_negative_value_when_discount_total_is_positive() { + // Given + let order = makeOrder(discountTotal: "15.50", currency: "USD") + + // When + let result = sut.map(order: order) + + // Then + #expect(result.formattedDiscountTotal == "-$15.50") + } + + // MARK: - formattedPaymentTotal Logic Tests + + @Test + func formattedPaymentTotal_returns_zero_when_order_is_not_paid() { + // Given + let order = makeOrder(total: "25.99", datePaid: nil, currency: "USD") + + // When + let result = sut.map(order: order) + + // Then + #expect(result.formattedPaymentTotal == "$0.00") + } + + @Test + func formattedPaymentTotal_returns_total_value_when_order_is_paid() { + // Given + let order = makeOrder(total: "25.99", datePaid: Date(), currency: "USD") + + // When + let result = sut.map(order: order) + + // Then + #expect(result.formattedPaymentTotal == "$25.99") + } + + // MARK: - formattedNetAmount Logic Tests + + @Test + func formattedNetAmount_returns_nil_when_no_refunds_exist() { + // Given + let order = makeOrder(total: "25.99", currency: "USD", refunds: []) + + // When + let result = sut.map(order: order) + + // Then + #expect(result.formattedNetAmount == nil) + } + + @Test + func formattedNetAmount_returns_calculated_net_amount_when_refunds_exist() { + // Given + let refund = makeRefund(refundID: 1, total: "-10.00") + let order = makeOrder(total: "25.99", currency: "USD", refunds: [refund]) + + // When + let result = sut.map(order: order) + + // Then + #expect(result.formattedNetAmount == "$15.99") + } + + @Test + func formattedNetAmount_returns_zero_when_refund_equals_total() { + // Given + let refund = makeRefund(refundID: 1, total: "-25.99") + let order = makeOrder(total: "25.99", currency: "USD", refunds: [refund]) + + // When + let result = sut.map(order: order) + + // Then + #expect(result.formattedNetAmount == "$0.00") + } + + @Test + func formattedNetAmount_handles_multiple_refunds() { + // Given + let refund1 = makeRefund(refundID: 1, total: "-10.00") + let refund2 = makeRefund(refundID: 2, total: "-5.00") + let order = makeOrder(total: "25.99", currency: "USD", refunds: [refund1, refund2]) + + // When + let result = sut.map(order: order) + + // Then + #expect(result.formattedNetAmount == "$10.99") + } +} + +// MARK: - Test Helpers + +private extension POSOrderMapperTests { + func makeOrder( + orderID: Int64 = 1, + number: String = "1001", + dateCreated: Date = Date(), + status: OrderStatusEnum = .completed, + total: String = "25.99", + datePaid: Date? = nil, + discountTotal: String = "0.00", + totalTax: String = "2.50", + currency: String = "USD", + refunds: [OrderRefundCondensed] = [], + items: [OrderItem] = [] + ) -> NetworkingCore.Order { + return NetworkingCore.Order.fake().copy( + orderID: orderID, + number: number, + status: status, + currency: currency, + dateCreated: dateCreated, + datePaid: datePaid, + discountTotal: discountTotal, + total: total, + totalTax: totalTax, + items: items.isEmpty ? [makeOrderItem()] : items, + refunds: refunds + ) + } + + func makeOrderItem( + itemID: Int64 = 1, + name: String = "Test Item", + productID: Int64 = 101, + variationID: Int64 = 0, + quantity: Decimal = 1.0, + price: NSDecimalNumber = NSDecimalNumber(string: "10.00"), + subtotal: String = "10.00", + total: String = "10.00" + ) -> NetworkingCore.OrderItem { + return NetworkingCore.OrderItem.fake().copy( + itemID: itemID, + name: name, + productID: productID, + variationID: variationID, + quantity: quantity, + price: price, + subtotal: subtotal, + total: total + ) + } + + func makeRefund( + refundID: Int64, + total: String, + reason: String? = nil + ) -> NetworkingCore.OrderRefundCondensed { + return NetworkingCore.OrderRefundCondensed( + refundID: refundID, + reason: reason, + total: total + ) + } +} diff --git a/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleOrderServiceTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleOrderServiceTests.swift index cda5bc77cab..30ff81bc467 100644 --- a/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleOrderServiceTests.swift +++ b/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleOrderServiceTests.swift @@ -3,6 +3,7 @@ import XCTest import struct NetworkingCore.PagedItems import struct NetworkingCore.Order import enum NetworkingCore.OrderStatusEnum +import WooFoundation final class PointOfSaleOrderServiceTests: XCTestCase { private let siteID: Int64 = 13092 @@ -12,7 +13,11 @@ final class PointOfSaleOrderServiceTests: XCTestCase { override func setUp() { super.setUp() mockOrdersRemote = MockPOSOrdersRemote() - orderProvider = PointOfSaleOrderListService(siteID: siteID, ordersRemote: mockOrdersRemote) + orderProvider = PointOfSaleOrderListService( + siteID: siteID, + ordersRemote: mockOrdersRemote, + currencyFormatter: CurrencyFormatter(currencySettings: CurrencySettings()) + ) } override func tearDown() { diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift index 0a110f83441..49cb6718f0d 100644 --- a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift @@ -10,20 +10,23 @@ import struct Yosemite.POSOrderRefund import class Yosemite.Store protocol PointOfSaleOrderListControllerProtocol { - var ordersViewState: OrderListState { get } + var ordersViewState: POSOrderListState { get } + var selectedOrder: POSOrder? { get } func loadOrders() async func refreshOrders() async func loadNextOrders() async + func selectOrder(_ order: POSOrder?) } @Observable final class PointOfSaleOrderListController: PointOfSaleOrderListControllerProtocol { - var ordersViewState: OrderListState + var ordersViewState: POSOrderListState private let paginationTracker: AsyncPaginationTracker private var fetchStrategy: PointOfSaleOrderListFetchStrategy private var cachedOrders: [POSOrder] = [] + private(set) var selectedOrder: POSOrder? init(orderListFetchStrategyFactory: PointOfSaleOrderListFetchStrategyFactoryProtocol, - initialState: OrderListState = .loading([])) { + initialState: POSOrderListState = .loading([])) { self.ordersViewState = initialState self.paginationTracker = .init() self.fetchStrategy = orderListFetchStrategyFactory.defaultStrategy() @@ -56,7 +59,7 @@ protocol PointOfSaleOrderListControllerProtocol { } catch { ordersViewState = .inlineError(currentOrders, error: .errorOnLoadingOrdersNextPage(error: error), - context: OrderListState.InlineErrorContext.pagination) + context: POSOrderListState.InlineErrorContext.pagination) } } @@ -74,7 +77,7 @@ protocol PointOfSaleOrderListControllerProtocol { } else { ordersViewState = .inlineError(orders, error: .errorOnLoadingOrders(error: error), - context: OrderListState.InlineErrorContext.refresh) + context: POSOrderListState.InlineErrorContext.refresh) } } } @@ -118,4 +121,9 @@ protocol PointOfSaleOrderListControllerProtocol { ordersViewState = .loading(cachedOrders) } + + @MainActor + func selectOrder(_ order: POSOrder?) { + selectedOrder = order + } } diff --git a/WooCommerce/Classes/POS/Models/OrdersViewState.swift b/WooCommerce/Classes/POS/Models/POSOrdersViewState.swift similarity index 90% rename from WooCommerce/Classes/POS/Models/OrdersViewState.swift rename to WooCommerce/Classes/POS/Models/POSOrdersViewState.swift index 6efc01a4cb6..06a488ab4d2 100644 --- a/WooCommerce/Classes/POS/Models/OrdersViewState.swift +++ b/WooCommerce/Classes/POS/Models/POSOrdersViewState.swift @@ -3,7 +3,7 @@ import struct Yosemite.POSOrder import struct Yosemite.POSOrderItem import struct Yosemite.POSOrderRefund -enum OrderListState: Equatable { +enum POSOrderListState: Equatable { case loading([POSOrder]) case loaded([POSOrder], hasMoreItems: Bool) case inlineError([POSOrder], error: PointOfSaleErrorState, context: InlineErrorContext) @@ -28,8 +28,8 @@ enum OrderListState: Equatable { switch self { case .loaded: return false - case .loading(let items): - return items.isEmpty + case .loading(let orders): + return orders.isEmpty default: return true } diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsEmptyView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsEmptyView.swift new file mode 100644 index 00000000000..1d5241d8921 --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsEmptyView.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct PointOfSaleOrderDetailsEmptyView: View { + var body: some View { + // TODO: WOOMOB-1136 + VStack(spacing: 0) { + POSPageHeaderView( + title: "Orders", + backButtonConfiguration: nil + ) + + VStack { + Spacer() + Text("No Orders Loaded") + .font(.posBodyLargeRegular()) + .foregroundStyle(Color.posOnSurfaceVariantHighest) + Spacer() + } + } + .background(Color.posSurface) + .navigationBarHidden(true) + } +} + +#if DEBUG +#Preview { + PointOfSaleOrderDetailsEmptyView() +} +#endif diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsLoadingView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsLoadingView.swift new file mode 100644 index 00000000000..7bc2aaac6af --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsLoadingView.swift @@ -0,0 +1,182 @@ +import SwiftUI + +struct PointOfSaleOrderDetailsLoadingView: View { + var body: some View { + VStack(spacing: 0) { + POSPageHeaderView( + title: Localization.orderDetailsTitle, + backButtonConfiguration: nil, + trailingContent: { shimmeringHeaderTrailingContent }, + bottomContent: { shimmeringHeaderBottomContent } + ) + + ScrollView { + VStack(alignment: .leading, spacing: POSSpacing.medium) { + shimmeringProductsSection + shimmeringTotalsSection + } + .padding(.horizontal, POSPadding.medium) + } + } + .background(Color.posSurface) + .navigationBarHidden(true) + } + + // MARK: - Shimmer Components + + @ViewBuilder + private var shimmeringHeaderTrailingContent: some View { + GeometryReader { geometry in + HStack { + Spacer() + Rectangle() + .fill(Color.posOnSurfaceVariantLowest) + .frame(width: geometry.size.width * 0.3, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .shimmering() + } + } + .frame(height: 16) + } + + @ViewBuilder + private var shimmeringHeaderBottomContent: some View { + GeometryReader { geometry in + VStack(alignment: .leading, spacing: POSSpacing.xSmall) { + Rectangle() + .fill(Color.posOnSurfaceVariantLowest) + .frame(width: geometry.size.width * 0.5, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .shimmering() + } + } + .frame(height: 16) + } + + @ViewBuilder + private var shimmeringProductsSection: some View { + VStack(alignment: .leading, spacing: POSSpacing.medium) { + Text(Localization.productsTitle) + .font(.posBodyLargeBold) + .foregroundStyle(Color.posOnSurface) + + VStack(spacing: POSSpacing.small) { + ForEach(0..<2, id: \.self) { _ in + shimmeringProductRow + } + } + } + .padding(POSPadding.medium) + .background(Color.posSurfaceContainerLowest) + .posItemCardBorderStyles() + } + + @ViewBuilder + private var shimmeringProductRow: some View { + GeometryReader { geometry in + HStack(alignment: .top, spacing: POSSpacing.medium) { + Rectangle() + .fill(Color.posOnSurfaceVariantLowest) + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: POSCornerRadiusStyle.small.value)) + .shimmering() + + VStack(alignment: .leading, spacing: POSSpacing.xSmall) { + Rectangle() + .fill(Color.posOnSurfaceVariantLowest) + .frame(width: geometry.size.width * 0.45, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .shimmering() + + Rectangle() + .fill(Color.posOnSurfaceVariantLowest) + .frame(width: geometry.size.width * 0.35, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .shimmering() + } + + Spacer() + + Rectangle() + .fill(Color.posOnSurfaceVariantLowest) + .frame(width: geometry.size.width * 0.2, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .shimmering() + } + .padding(.vertical, POSPadding.small) + } + .frame(height: 60) + } + + @ViewBuilder + private var shimmeringTotalsSection: some View { + VStack(alignment: .leading, spacing: POSSpacing.medium) { + Text(Localization.totalsTitle) + .font(.posBodyLargeBold) + .foregroundStyle(Color.posOnSurface) + + VStack(spacing: POSSpacing.medium) { + shimmeringTotalsRow + shimmeringTotalsRow + shimmeringTotalsRow + + Divider() + .background(Color.posSurfaceDim) + + shimmeringTotalsRow + shimmeringTotalsRow + } + } + .padding(POSPadding.medium) + .background(Color.posSurfaceContainerLowest) + .posItemCardBorderStyles() + } + + @ViewBuilder + private var shimmeringTotalsRow: some View { + GeometryReader { geometry in + HStack { + Rectangle() + .fill(Color.posOnSurfaceVariantLowest) + .frame(width: geometry.size.width * 0.3, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .shimmering() + + Spacer() + + Rectangle() + .fill(Color.posOnSurfaceVariantLowest) + .frame(width: geometry.size.width * 0.25, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .shimmering() + } + } + .frame(height: 20) + } +} + +private enum Localization { + static let orderDetailsTitle = NSLocalizedString( + "pos.orderDetailsLoadingView.title", + value: "Order", + comment: "Title for the order details screen when no specific order is selected" + ) + + static let productsTitle = NSLocalizedString( + "pos.orderDetailsLoadingView.productsTitle", + value: "Products", + comment: "Section title for the products list" + ) + + static let totalsTitle = NSLocalizedString( + "pos.orderDetailsLoadingView.totalsTitle", + value: "Totals", + comment: "Section title for the order totals breakdown" + ) +} + +#if DEBUG +#Preview { + PointOfSaleOrderDetailsLoadingView() +} +#endif diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift index a1b3c2c9d31..525bd289ece 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderDetailsView.swift @@ -2,22 +2,15 @@ import SwiftUI import struct Yosemite.POSOrder import struct Yosemite.POSOrderItem import struct Yosemite.POSOrderRefund +import enum Yosemite.OrderStatusEnum +import typealias Yosemite.OrderItemAttribute struct PointOfSaleOrderDetailsView: View { - let orderID: String? + let order: POSOrder let onBack: () -> Void - @Environment(PointOfSaleOrderListModel.self) private var orderListModel - - private var order: POSOrder? { - guard let orderID = orderID, - let orderIDInt = Int64(orderID) else { return nil } - return orderListModel.ordersController.ordersViewState.orders.first { $0.id == orderIDInt } - } - - // Show back button when in compact mode (phone) where the detail view - // is presented as a pushed view, not when in regular mode (tablet split view) @Environment(\.horizontalSizeClass) private var horizontalSizeClass + private var shouldShowBackButton: Bool { horizontalSizeClass == .compact } @@ -25,204 +18,355 @@ struct PointOfSaleOrderDetailsView: View { var body: some View { VStack(spacing: 0) { POSPageHeaderView( - title: order.map { "Order #\($0.number)" } ?? "Order Details", - backButtonConfiguration: shouldShowBackButton ? .init(state: .enabled, action: onBack) : nil + title: Localization.orderTitle(order.number), + backButtonConfiguration: shouldShowBackButton ? .init(state: .enabled, action: onBack) : nil, + trailingContent: { PointOfSaleOrderBadgeView(order: order) }, + bottomContent: { headerBottomContent(for: order) } ) - if let order = order { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - orderSummarySection(order) + ScrollView { + VStack(alignment: .leading, spacing: POSSpacing.medium) { + if !order.lineItems.isEmpty { productsSection(order) - totalsSection(order) } - .padding() - } - } else { - VStack { - Spacer() - Text("Order not found") - Spacer() + totalsSection(order) } + .padding(.horizontal, POSPadding.medium) } } .background(Color.posSurface) .navigationBarHidden(true) } +} +// MARK: - Main Sections - +private extension PointOfSaleOrderDetailsView { @ViewBuilder - private func orderSummarySection(_ order: POSOrder) -> some View { - VStack(spacing: 12) { - HStack { - Text("Order #\(order.number)") - .font(.headline) - Spacer() - Text(order.status.rawValue) - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.gray.opacity(0.2)) - .cornerRadius(4) - } + func productsSection(_ order: POSOrder) -> some View { + VStack(alignment: .leading, spacing: POSSpacing.medium) { + Text(Localization.productsTitle) + .font(.posBodyLargeBold) + .foregroundStyle(Color.posOnSurface) - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Date & Time") - .font(.caption) - .foregroundColor(.secondary) - Text(order.dateCreated, style: .date) - Text(order.dateCreated, style: .time) - } - Spacer() - VStack(alignment: .trailing, spacing: 4) { - Text("Total") - .font(.caption) - .foregroundColor(.secondary) - Text("\(order.currencySymbol)\(order.total)") - .font(.title2) - .fontWeight(.semibold) + VStack(spacing: POSSpacing.small) { + ForEach(order.lineItems, id: \.itemID) { item in + productRow(item: item) } } + } + .padding(POSPadding.medium) + .background(Color.posSurfaceContainerLowest) + .posItemCardBorderStyles() + } - if let customerEmail = order.customerEmail { - HStack { - Text("Customer Email") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Text(customerEmail) - } - } + @ViewBuilder + func totalsSection(_ order: POSOrder) -> some View { - HStack { - Text("Payment Method") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Text(order.paymentMethodTitle) + VStack(alignment: .leading, spacing: POSSpacing.medium) { + Text(Localization.totalsTitle) + .font(.posBodyLargeBold) + .foregroundStyle(Color.posOnSurface) + + VStack(spacing: POSSpacing.medium) { + productsSubtotalRow(order) + discountTotalRow(order) + taxTotalRow(order) + + Divider() + .background(Color.posSurfaceDim) + + mainTotalRow(order) + paidAmountRow(order) + refundsSection(order) } + } + .padding(POSPadding.medium) + .background(Color.posSurfaceContainerLowest) + .posItemCardBorderStyles() + } +} + +// MARK: - Header Components + +private extension PointOfSaleOrderDetailsView { + @ViewBuilder + func headerBottomContent(for order: POSOrder) -> some View { + VStack(alignment: .leading, spacing: POSSpacing.xSmall) { + Text(DateFormatter.dateAndTimeFormatter.string(from: order.dateCreated)) + .font(.posBodySmallRegular()) + .foregroundStyle(Color.posOnSurfaceVariantHighest) + .fixedSize(horizontal: false, vertical: true) - HStack { - Text("Currency") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Text(order.currency) + if let customerEmail = order.customerEmail, customerEmail.isNotEmpty { + Text(customerEmail) + .font(.posBodySmallRegular()) + .foregroundStyle(Color.posOnSurfaceVariantHighest) + .fixedSize(horizontal: false, vertical: true) } } - .padding() - .background(Color.posSurface) - .cornerRadius(12) + .multilineTextAlignment(.leading) } +} + +// MARK: - Product Components + +private extension PointOfSaleOrderDetailsView { @ViewBuilder - private func productsSection(_ order: POSOrder) -> some View { - VStack(alignment: .leading, spacing: 12) { - Text("Products") - .font(.headline) + func productRow(item: POSOrderItem) -> some View { + HStack(alignment: .top, spacing: POSSpacing.medium) { + productImageView() + productDetailsView(item: item) + Spacer() + productTotalView(item: item) + } + .padding(.vertical, POSPadding.small) + } - VStack(spacing: 8) { - ForEach(order.lineItems, id: \.itemID) { item in - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(item.name) - .fontWeight(.medium) - Text("Qty: \(item.quantity.intValue)") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Text("\(order.currencySymbol)\(item.total)") - .fontWeight(.semibold) - } - .padding() - .background(Color.posSurface) - .cornerRadius(8) - } + @ViewBuilder + func productImageView() -> some View { + POSItemImageView(imageSource: nil, imageSize: 40, scale: 1) + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: POSCornerRadiusStyle.small.value)) + } + + @ViewBuilder + func productDetailsView(item: POSOrderItem) -> some View { + VStack(alignment: .leading, spacing: POSSpacing.xSmall) { + Text(item.name) + .font(.posBodyMediumBold) + .foregroundStyle(Color.posOnSurface) + .fixedSize(horizontal: false, vertical: true) + + if !item.attributes.isEmpty { + productAttributesView(item.attributes) } + + Text(Localization.quantityLabel(item.quantity.intValue, + item.formattedPrice)) + .font(.posBodySmallRegular()) + .foregroundStyle(Color.posOnSurfaceVariantHighest) } } @ViewBuilder - private func totalsSection(_ order: POSOrder) -> some View { - VStack(alignment: .leading, spacing: 12) { - Text("Totals") - .font(.headline) + func productAttributesView(_ attributes: [OrderItemAttribute]) -> some View { + let attributeText = attributes.map { "\($0.name): \($0.value)" }.joined(separator: ", ") + Text(attributeText) + .font(.posBodySmallRegular()) + .foregroundStyle(Color.posOnSurfaceVariantHighest) + } - VStack(spacing: 8) { - HStack { - Text("Subtotal") - Spacer() - Text("\(order.currencySymbol)\(order.total)") - } + @ViewBuilder + func productTotalView(item: POSOrderItem) -> some View { + Text(item.formattedTotal) + .font(.posBodyMediumRegular()) + .foregroundStyle(Color.posOnSurface) + } +} - Divider() +// MARK: - Totals Components - HStack { - Text("Total") - .fontWeight(.semibold) - Spacer() - Text("\(order.currencySymbol)\(order.total)") - .fontWeight(.semibold) - } +private extension PointOfSaleOrderDetailsView { + @ViewBuilder + func productsSubtotalRow(_ order: POSOrder) -> some View { + totalsRow( + title: Localization.productsLabel, + amount: order.formattedSubtotal + ) + } - HStack { - Text("Paid") - Spacer() - Text("\(order.currencySymbol)\(order.total)") - } + @ViewBuilder + func discountTotalRow(_ order: POSOrder) -> some View { + if let formattedDiscountTotal = order.formattedDiscountTotal { + totalsRow( + title: Localization.discountTotalLabel, + amount: formattedDiscountTotal + ) + } + } - if !order.refunds.isEmpty { - let refundedTotal = order.refunds.reduce(0.0) { $0 + (Double($1.total) ?? 0.0) } - HStack { - Text("Refunded") - Spacer() - Text("-\(order.currencySymbol)\(String(format: "%.2f", refundedTotal))") - .foregroundColor(.red) - } + @ViewBuilder + func taxTotalRow(_ order: POSOrder) -> some View { + totalsRow( + title: Localization.taxesLabel, + amount: order.formattedTotalTax + ) + } - ForEach(order.refunds, id: \.refundID) { refund in - VStack(alignment: .leading, spacing: 2) { - HStack { - Text("Refund #\(refund.refundID)") - .font(.caption) - Spacer() - Text("-\(order.currencySymbol)\(refund.total)") - .font(.caption) - .foregroundColor(.red) - } - if let reason = refund.reason, !reason.isEmpty { - Text("Reason: \(reason)") - .font(.caption2) - .foregroundColor(.secondary) - } - } - } + @ViewBuilder + func mainTotalRow(_ order: POSOrder) -> some View { + totalsRow( + title: Localization.totalLabel, + amount: order.formattedTotal, + titleFont: .posBodyMediumBold + ) + } - let netPayment = (Double(order.total) ?? 0.0) - refundedTotal - HStack { - Text("Net Payment") - .fontWeight(.semibold) - Spacer() - Text("\(order.currencySymbol)\(String(format: "%.2f", netPayment))") - .fontWeight(.semibold) - } - } + @ViewBuilder + func paidAmountRow(_ order: POSOrder) -> some View { + VStack(alignment: .leading, spacing: POSSpacing.xSmall) { + totalsRow( + title: Localization.paidLabel, + amount: order.formattedPaymentTotal, + titleFont: .posBodyMediumBold + ) + + if order.paymentMethodTitle.isNotEmpty { + Text(order.paymentMethodTitle) + .font(.posBodySmallRegular()) + .foregroundStyle(Color.posOnSurfaceVariantHighest) } - .padding() - .background(Color.posSurface) - .cornerRadius(8) } } + @ViewBuilder + func refundsSection(_ order: POSOrder) -> some View { + if !order.refunds.isEmpty { + ForEach(order.refunds, id: \.refundID) { refund in + refundRow(refund: refund) + } + + if let netAmount = order.formattedNetAmount { + netPaymentRow(netAmount: netAmount) + } + } + } + + @ViewBuilder + func refundRow(refund: POSOrderRefund) -> some View { + VStack(alignment: .leading, spacing: POSSpacing.xSmall) { + totalsRow( + title: Localization.refundLabel, + amount: refund.formattedTotal, + titleFont: .posBodyMediumBold + ) + + if let reason = refund.reason, !reason.isEmpty { + Text(Localization.reasonLabel(reason)) + .font(.posBodySmallRegular()) + .foregroundStyle(Color.posOnSurfaceVariantHighest) + } + } + } + + @ViewBuilder + func netPaymentRow(netAmount: String) -> some View { + totalsRow( + title: Localization.netPaymentLabel, + amount: netAmount, + titleFont: .posBodyMediumBold + ) + } + + @ViewBuilder + func totalsRow( + title: String, + amount: String, + titleFont: POSFontStyle = .posBodyMediumRegular(), + amountFont: POSFontStyle = .posBodyMediumRegular() + ) -> some View { + HStack { + Text(title) + .font(titleFont) + Spacer() + Text(amount) + .font(amountFont) + } + } } +// MARK: - Localization + +private enum Localization { + static func orderTitle(_ orderNumber: String) -> String { + let format = NSLocalizedString( + "pos.orderDetailsView.orderTitle", + value: "Order #%1$@", + comment: "Order title with order number. %1$@ is the order number." + ) + return String(format: format, orderNumber) + } + + static let productsTitle = NSLocalizedString( + "pos.orderDetailsView.productsTitle", + value: "Products", + comment: "Section title for the products list" + ) + + static func quantityLabel(_ quantity: Int, _ unitPrice: String) -> String { + let format = NSLocalizedString( + "pos.orderDetailsView.quantityLabel", + value: "%1$d × %2$@", + comment: "Product quantity and price label. %1$d is the quantity, %2$@ is the unit price." + ) + return String(format: format, quantity, unitPrice) + } + + static let totalsTitle = NSLocalizedString( + "pos.orderDetailsView.totalsTitle", + value: "Totals", + comment: "Section title for the order totals breakdown" + ) + + static let productsLabel = NSLocalizedString( + "pos.orderDetailsView.productsLabel", + value: "Products", + comment: "Label for products subtotal in the totals section" + ) + + static let discountTotalLabel = NSLocalizedString( + "pos.orderDetailsView.discountTotalLabel", + value: "Discount total", + comment: "Label for discount total in the totals section" + ) + + static let taxesLabel = NSLocalizedString( + "pos.orderDetailsView.taxesLabel", + value: "Taxes", + comment: "Label for taxes in the totals section" + ) + + static let totalLabel = NSLocalizedString( + "pos.orderDetailsView.totalLabel", + value: "Total", + comment: "Label for the order total" + ) + + static let paidLabel = NSLocalizedString( + "pos.orderDetailsView.paidLabel", + value: "Paid", + comment: "Label for the paid amount" + ) + + static let refundLabel = NSLocalizedString( + "pos.orderDetailsView.refundLabel", + value: "Refunded", + comment: "Label for a refund entry. %1$lld is the refund ID." + ) + + static func reasonLabel(_ reason: String) -> String { + let format = NSLocalizedString( + "pos.orderDetailsView.reasonLabel", + value: "Reason: %1$@", + comment: "Label for refund reason. %1$@ is the reason text." + ) + return String(format: format, reason) + } + + static let netPaymentLabel = NSLocalizedString( + "pos.orderDetailsView.netPaymentLabel", + value: "Net Payment", + comment: "Label for net payment amount after refunds" + ) +} #if DEBUG #Preview("Order Details") { - PointOfSaleOrderDetailsView(orderID: "1", onBack: {}) - .environment(POSPreviewHelpers.makePreviewOrdersModel()) + PointOfSaleOrderDetailsView( + order: POSPreviewHelpers.makePreviewOrder(), + onBack: {} + ) } #endif diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderListView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderListView.swift index 555f2b96f97..7877c180b6d 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderListView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderListView.swift @@ -1,16 +1,14 @@ import SwiftUI import struct Yosemite.POSOrder import enum Yosemite.OrderPaymentMethod -import WooFoundation struct PointOfSaleOrderListView: View { - @Binding var selectedOrderID: String? let onClose: () -> Void @Environment(PointOfSaleOrderListModel.self) private var orderListModel @StateObject private var infiniteScrollTriggerDeterminer = ThresholdInfiniteScrollTriggerDeterminer() - private var ordersViewState: OrderListState { + private var ordersViewState: POSOrderListState { orderListModel.ordersController.ordersViewState } @@ -51,9 +49,9 @@ struct PointOfSaleOrderListView: View { let orders = ordersViewState.orders ForEach(orders, id: \.id) { order in Button(action: { - selectedOrderID = String(order.id) + orderListModel.ordersController.selectOrder(order) }) { - OrderRowView(order: order, isSelected: selectedOrderID == String(order.id)) + OrderRowView(order: order, isSelected: orderListModel.ordersController.selectedOrder?.id == order.id) } .buttonStyle(PlainButtonStyle()) } @@ -123,16 +121,10 @@ private struct OrderRowView: View { @ScaledMetric private var scale: CGFloat = 1.0 @Environment(\.dynamicTypeSize) var dynamicTypeSize - private let currencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings) - private var minHeight: CGFloat { min(Constants.orderCardMinHeight * scale, Constants.maximumOrderCardHeight) } - private var formattedTotal: String { - currencyFormatter.formatAmount(order.total, with: order.currency) ?? "" - } - var body: some View { HStack(alignment: .center, spacing: POSSpacing.medium) { VStack(alignment: .leading, spacing: POSSpacing.xSmall) { @@ -159,20 +151,11 @@ private struct OrderRowView: View { Spacer() VStack(alignment: .trailing, spacing: POSSpacing.xSmall) { - Text(formattedTotal) + Text(order.formattedTotal) .font(.posBodyLargeBold) .foregroundStyle(Color.posOnSurface) - HStack(spacing: POSSpacing.xSmall) { - if let paymentMethodIcon = paymentMethodIcon { - Image(systemName: paymentMethodIcon) - .foregroundStyle(statusColor) - .font(.caption) - } - Text(order.status.localizedName) - .font(.posBodySmallRegular()) - .foregroundStyle(statusColor) - } + PointOfSaleOrderBadgeView(order: order) } .multilineTextAlignment(.trailing) } @@ -185,28 +168,7 @@ private struct OrderRowView: View { } private extension OrderRowView { - var paymentMethodIcon: String? { - let paymentMethod = OrderPaymentMethod(rawValue: order.paymentMethodID) - switch paymentMethod { - case .cod: - return "banknote" - case .stripe, .woocommercePayments: - return "creditcard" - default: - return nil - } - } - - var statusColor: Color { - switch order.status { - case .completed: - return .posSuccess - case .failed: - return .posError - default: - return .posOnSurfaceVariantLowest - } - } + // No additional helpers needed - using shared PointOfSaleOrderBadgeView } private struct GhostOrderRowView: View { @@ -218,35 +180,41 @@ private struct GhostOrderRowView: View { } var body: some View { - HStack(alignment: .center, spacing: POSSpacing.medium) { - VStack(alignment: .leading, spacing: POSSpacing.xSmall) { - Rectangle() - .fill(Color.posOnSurfaceVariantLowest) - .frame(width: 70, height: 16) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .shimmering() - - Rectangle() - .fill(Color.posOnSurfaceVariantLowest) - .frame(width: 160, height: 14) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .shimmering() - } + GeometryReader { geometry in + VStack { + Spacer() + HStack(alignment: .center, spacing: POSSpacing.medium) { + VStack(alignment: .leading, spacing: POSSpacing.xSmall) { + Rectangle() + .fill(Color.posOnSurfaceVariantLowest) + .frame(width: geometry.size.width * 0.2, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .shimmering() + + Rectangle() + .fill(Color.posOnSurfaceVariantLowest) + .frame(width: geometry.size.width * 0.4, height: 14) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .shimmering() + } - Spacer() + Spacer() - VStack(alignment: .trailing, spacing: POSSpacing.xSmall) { - Rectangle() - .fill(Color.posOnSurfaceVariantLowest) - .frame(width: 80, height: 18) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .shimmering() - - Rectangle() - .fill(Color.posOnSurfaceVariantLowest) - .frame(width: 90, height: 14) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .shimmering() + VStack(alignment: .trailing, spacing: POSSpacing.xSmall) { + Rectangle() + .fill(Color.posOnSurfaceVariantLowest) + .frame(width: geometry.size.width * 0.25, height: 18) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .shimmering() + + Rectangle() + .fill(Color.posOnSurfaceVariantLowest) + .frame(width: geometry.size.width * 0.28, height: 14) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .shimmering() + } + } + Spacer() } } .padding(.horizontal, POSPadding.medium * (1 / scale)) @@ -258,6 +226,56 @@ private struct GhostOrderRowView: View { } } +// MARK: - Order Badge View + +struct PointOfSaleOrderBadgeView: View { + let order: POSOrder + + init(order: POSOrder) { + self.order = order + } + + var body: some View { + HStack(spacing: POSSpacing.xSmall) { + if let paymentMethodIcon = paymentMethodIcon { + Image(systemName: paymentMethodIcon) + .foregroundStyle(statusColor) + .font(.caption) + } + Text(order.status.localizedName) + .font(.posCaptionRegular) + .foregroundStyle(statusColor) + } + .padding(.horizontal, POSPadding.small) + .padding(.vertical, POSPadding.xSmall) + .background(statusColor.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: POSCornerRadiusStyle.small.value)) + } + + private var paymentMethodIcon: String? { + let paymentMethod = OrderPaymentMethod(rawValue: order.paymentMethodID) + switch paymentMethod { + case .cod: + return "banknote" + case .stripe, .woocommercePayments: + return "creditcard" + default: + return nil + } + } + + private var statusColor: Color { + switch order.status { + case .completed: + return .posSuccess + case .failed: + return .posError + default: + return .posOnSurfaceVariantLowest + } + } +} + private enum Constants { static let orderCardMinHeight: CGFloat = 90 static let maximumOrderCardHeight: CGFloat = Constants.orderCardMinHeight * 2 @@ -273,7 +291,7 @@ private enum Localization { #if DEBUG #Preview("List") { NavigationSplitView { - PointOfSaleOrderListView(selectedOrderID: .constant("1"), onClose: {}) + PointOfSaleOrderListView(onClose: {}) .navigationSplitViewColumnWidth(450) .environment(POSPreviewHelpers.makePreviewOrdersModel()) } detail: { diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrdersView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrdersView.swift index 1929b233b79..5abf77a0be7 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrdersView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrdersView.swift @@ -3,26 +3,34 @@ import UIKit struct PointOfSaleOrdersView: View { @Binding var isPresented: Bool - @State private var selectedOrderID: String? @Environment(PointOfSaleOrderListModel.self) private var orderListModel @Environment(\.horizontalSizeClass) private var horizontalSizeClass var body: some View { - CustomNavigationSplitView(selection: $selectedOrderID) { _ in - PointOfSaleOrderListView(selectedOrderID: $selectedOrderID) { + CustomNavigationSplitView(selection: Binding( + get: { orderListModel.ordersController.selectedOrder }, + set: { orderListModel.ordersController.selectOrder($0) } + )) { _ in + PointOfSaleOrderListView() { isPresented = false } } detail: { selection in PointOfSaleOrderDetailsView( - orderID: selection, + order: selection, onBack: { - $selectedOrderID.wrappedValue = nil + orderListModel.ordersController.selectOrder(nil) } ) + } detailPlaceholderView: { + if orderListModel.ordersController.ordersViewState.isLoading { + PointOfSaleOrderDetailsLoadingView() + } else { + PointOfSaleOrderDetailsEmptyView() + } } setDefaultValue: { - if selectedOrderID == nil, + if orderListModel.ordersController.selectedOrder == nil, let firstOrder = orderListModel.ordersController.ordersViewState.orders.first { - selectedOrderID = String(firstOrder.id) + orderListModel.ordersController.selectOrder(firstOrder) } } .onChange(of: orderListModel.ordersController.ordersViewState.orders) { oldOrders, newOrders in @@ -32,12 +40,13 @@ struct PointOfSaleOrdersView: View { return } - if let selectedOrderID, newOrders.map(\.number).contains(selectedOrderID) { + if let selectedOrder = orderListModel.ordersController.selectedOrder, newOrders.map(\.id).contains(selectedOrder.id) { return } - self.selectedOrderID = String(firstOrder.id) + orderListModel.ordersController.selectOrder(firstOrder) } + .animation(.default, value: orderListModel.ordersController.ordersViewState.orders.isEmpty) } } @@ -46,23 +55,26 @@ struct PointOfSaleOrdersView: View { /// Just as NavigationSplitView, it adapts to a list -> details navigation on smaller screens /// It may be used as a common component in the future /// -private struct CustomNavigationSplitView: View { +private struct CustomNavigationSplitView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Binding private var selection: SelectionValue? private let sidebar: (Binding) -> Sidebar private let detail: (SelectionValue) -> Detail + private let detailPlaceholderView: () -> DetailPlaceholder private let setDefaultValue: (() -> Void)? init( selection: Binding = .constant(nil), @ViewBuilder sidebar: @escaping (Binding) -> Sidebar, @ViewBuilder detail: @escaping (SelectionValue) -> Detail, + @ViewBuilder detailPlaceholderView: @escaping () -> DetailPlaceholder, setDefaultValue: (() -> Void)? = nil ) { self._selection = selection self.sidebar = sidebar self.detail = detail + self.detailPlaceholderView = detailPlaceholderView self.setDefaultValue = setDefaultValue } @@ -78,7 +90,8 @@ private struct CustomNavigationSplitView: View { +struct POSPageHeaderView: View { private let items: [POSPageHeaderItem] private let backButtonConfiguration: POSPageHeaderBackButtonConfiguration? private let trailingContent: TrailingContent? + private let bottomContent: BottomContent? private var hStackAlignment: VerticalAlignment { items.first?.subtitle == nil ? .center: .firstTextBaseline @@ -56,21 +57,25 @@ struct POSPageHeaderView: View { subtitle: String? = nil, isLoading: Bool = false, backButtonConfiguration: POSPageHeaderBackButtonConfiguration? = nil, - @ViewBuilder trailingContent: () -> TrailingContent = { EmptyView() } + @ViewBuilder trailingContent: () -> TrailingContent = { EmptyView() }, + @ViewBuilder bottomContent: () -> BottomContent = { EmptyView() } ) { self.items = [.init(title: title, subtitle: subtitle, isSelected: true, isLoading: isLoading)] self.backButtonConfiguration = backButtonConfiguration self.trailingContent = trailingContent() + self.bottomContent = bottomContent() } init( items: [POSPageHeaderItem], backButtonConfiguration: POSPageHeaderBackButtonConfiguration? = nil, - @ViewBuilder trailingContent: () -> TrailingContent = { EmptyView() } + @ViewBuilder trailingContent: () -> TrailingContent = { EmptyView() }, + @ViewBuilder bottomContent: () -> BottomContent = { EmptyView() } ) { self.items = items self.backButtonConfiguration = backButtonConfiguration self.trailingContent = trailingContent() + self.bottomContent = bottomContent() } var body: some View { @@ -114,6 +119,10 @@ struct POSPageHeaderView: View { .dynamicTypeSize(...POSHeaderLayoutConstants.maximumDynamicTypeSize) .foregroundColor(.posOnSurface) } + + if let bottomContent { + bottomContent + } } } } @@ -181,22 +190,22 @@ private enum Constants { // Header with trailing content. POSPageHeaderView( title: "Products", - backButtonConfiguration: .init(state: .enabled, action: {}) - ) { - HStack(spacing: 16) { - Button(action: {}) { - Text(Image(systemName: "info.circle")) - .font(.posButtonSymbolLarge) - } - .foregroundColor(.posOnSurface) + backButtonConfiguration: .init(state: .enabled, action: {}), + trailingContent: { + HStack(spacing: 16) { + Button(action: {}) { + Text(Image(systemName: "info.circle")) + .font(.posButtonSymbolLarge) + } + .foregroundColor(.posOnSurface) - Button(action: {}) { - Text(Image(systemName: "trash")) - .font(.posButtonSymbolLarge) + Button(action: {}) { + Text(Image(systemName: "trash")) + .font(.posButtonSymbolLarge) + } + .foregroundColor(.posOnSurface) } - .foregroundColor(.posOnSurface) - } - } + }) // Header with subtitle. POSPageHeaderView( @@ -223,36 +232,66 @@ private enum Constants { POSPageHeaderView( title: "Title", subtitle: "Subtitle", - backButtonConfiguration: .init(state: .enabled, action: {}) - ) { - Button(action: {}) { - Text(Image(systemName: "info.circle")) - .font(.posButtonSymbolLarge) - } - .foregroundColor(.posOnSurface) - } + backButtonConfiguration: .init(state: .enabled, action: {}), + trailingContent: { + Button(action: {}) { + Text(Image(systemName: "info.circle")) + .font(.posButtonSymbolLarge) + } + .foregroundColor(.posOnSurface) + }, + bottomContent: { + Text("Bottom content") + }) // Header with two items and trailing content. POSPageHeaderView( items: [ .init(title: "Products", isSelected: isProductsSelected) { isProductsSelected.toggle() }, .init(title: "Coupons", isSelected: !isProductsSelected) { isProductsSelected.toggle() } - ] - ) { - HStack(spacing: 16) { - Button(action: {}) { - Text(Image(systemName: "plus")) - .font(.posButtonSymbolLarge) + ], + trailingContent: { + HStack(spacing: 16) { + Button(action: {}) { + Text(Image(systemName: "plus")) + .font(.posButtonSymbolLarge) + } + .foregroundColor(.posOnSurface) + + Button(action: {}) { + Text(Image(systemName: "magnifyingglass")) + .font(.posButtonSymbolLarge) + } + .foregroundColor(.posOnSurface) } - .foregroundColor(.posOnSurface) + }) - Button(action: {}) { - Text(Image(systemName: "magnifyingglass")) - .font(.posButtonSymbolLarge) + // Header with bottom content. + POSPageHeaderView( + title: "Order Details", + subtitle: "Created: 2024-01-01 • customer@example.com", + backButtonConfiguration: .init(state: .enabled, action: {}), + trailingContent: { + Text("Completed") + .font(.posBodySmallRegular()) + .foregroundStyle(Color.posSuccess) + .padding(.horizontal, POSPadding.small) + .padding(.vertical, POSPadding.xSmall) + .background(Color.posSuccess.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + }, + bottomContent: { + HStack { + Button("Print Receipt") {} + .buttonStyle(.bordered) + Spacer() + Button("Refund") {} + .buttonStyle(.borderedProminent) } - .foregroundColor(.posOnSurface) + .padding(.horizontal, POSHeaderLayoutConstants.sectionHorizontalPadding) + .padding(.top, POSSpacing.medium) } - } + ) } .background(Color.posSurface) } diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 10111ac142e..cb36b197379 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -5,6 +5,7 @@ import Yosemite import class WooFoundation.CurrencySettings import protocol Storage.StorageManagerType import protocol Storage.GRDBManagerProtocol +import class WooFoundationCore.CurrencyFormatter /// View controller that provides the tab bar item for the Point of Sale tab. /// It is never visible on the screen, only used to provide the tab bar item as all POS UI is full-screen. @@ -126,8 +127,11 @@ private extension POSTabCoordinator { couponsSearchController: PointOfSaleCouponsController(itemProvider: posCouponProvider, fetchStrategyFactory: posCouponFetchStrategyFactory), ordersController: PointOfSaleOrderListController( - orderListFetchStrategyFactory: PointOfSaleOrderListFetchStrategyFactory(siteID: siteID, - credentials: credentials) + orderListFetchStrategyFactory: PointOfSaleOrderListFetchStrategyFactory( + siteID: siteID, + credentials: credentials, + currencyFormatter: CurrencyFormatter(currencySettings: currencySettings) + ) ), onPointOfSaleModeActiveStateChange: { [weak self] isEnabled in diff --git a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift index c733320b228..eb56ee7adae 100644 --- a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift +++ b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift @@ -23,6 +23,9 @@ import enum Yosemite.PointOfSaleBarcodeScanError import Combine import struct Yosemite.PaymentIntent import struct Yosemite.POSOrder +import struct Yosemite.POSOrderItem +import struct Yosemite.POSOrderRefund +import typealias Yosemite.OrderItemAttribute import class Yosemite.PointOfSaleOrderListService import class Yosemite.PointOfSaleOrderListFetchStrategyFactory @@ -237,63 +240,153 @@ struct POSPreviewHelpers { static func makePreviewOrdersModel() -> PointOfSaleOrderListModel { return PointOfSaleOrderListModel(ordersController: PointOfSalePreviewOrderListController()) } + + static func makePreviewOrder() -> POSOrder { + return POSOrder( + id: 1, + number: "1001", + dateCreated: Date(), + status: .completed, + formattedTotal: "$45.75", + formattedSubtotal: "$41.99", + customerEmail: "customer@example.com", + paymentMethodID: "cod", + paymentMethodTitle: "Cash on Delivery", + lineItems: [ + POSOrderItem(itemID: 1, + name: "Premium Coffee Beans", + productID: 101, + variationID: 0, + quantity: 2.0, + formattedPrice: "$12.50", + formattedTotal: "$25.00", + attributes: []), + POSOrderItem( + itemID: 2, + name: "Organic Tea - Earl Grey", + productID: 102, + variationID: 203, + quantity: 1.0, + formattedPrice: "$15.99", + formattedTotal: "$15.99", + attributes: [ + OrderItemAttribute(metaID: 1, name: "Size", value: "Large"), + OrderItemAttribute(metaID: 2, name: "Type", value: "Loose Leaf") + ] + ) + ], + refunds: [], + formattedTotalTax: "$3.76", + formattedDiscountTotal: "$0.00", + formattedPaymentTotal: "$45.75", + formattedNetAmount: nil + ) + } } // MARK: - Preview Orders Controller final class PointOfSalePreviewOrderListController: PointOfSaleOrderListControllerProtocol { - var ordersViewState: OrderListState { - .loaded( - [ - POSOrder( - id: 1, - number: "1001", - dateCreated: Date(), - status: .completed, - total: "25.00", - customerEmail: "customer1@example.com", - paymentMethodID: "cod", - paymentMethodTitle: "Cash", - lineItems: [], - refunds: [], - currency: "USD", - currencySymbol: "$" - ), - POSOrder( - id: 2, - number: "1002", - dateCreated: Date().addingTimeInterval(-3600), - status: .processing, - total: "45.50", - customerEmail: "a.long.customer.name@withalongdomain.com", - paymentMethodID: "woocommerce_payments", - paymentMethodTitle: "Credit Card", - lineItems: [], - refunds: [], - currency: "USD", - currencySymbol: "$" + var ordersViewState: POSOrderListState { + let orders = [ + POSOrder( + id: 1, + number: "1001", + dateCreated: Date(), + status: .completed, + formattedTotal: "$45.75", + formattedSubtotal: "$40.99", + customerEmail: "customer@example.com", + paymentMethodID: "cod", + paymentMethodTitle: "Cash on Delivery", + lineItems: [ + POSOrderItem(itemID: 1, + name: "Premium Coffee Beans", + productID: 101, + variationID: 0, + quantity: 2.0, + formattedPrice: "$12.50", + formattedTotal: "$25.00", + attributes: []), + POSOrderItem( + itemID: 2, + name: "Organic Tea - Earl Grey", + productID: 102, + variationID: 203, + quantity: 1.0, + formattedPrice: "$15.99", + formattedTotal: "$15.99", + attributes: [ + OrderItemAttribute(metaID: 1, name: "Size", value: "Large"), + OrderItemAttribute(metaID: 2, name: "Type", value: "Loose Leaf") + ] + ) + ], + refunds: [], + formattedTotalTax: "$4.75", + formattedDiscountTotal: "-$5.24", + formattedPaymentTotal: "$45.75", + formattedNetAmount: nil + ), + POSOrder( + id: 2, + number: "1002", + dateCreated: Date().addingTimeInterval(-3600), + status: .processing, + formattedTotal: "$89.50", + formattedSubtotal: "$89.96", + customerEmail: "very.long.customer.email@withverylongdomainname.com", + paymentMethodID: "woocommerce_payments", + paymentMethodTitle: "WooCommerce Payments", + lineItems: [ + POSOrderItem( + itemID: 3, + name: "Artisan Chocolate Box", + productID: 103, + variationID: 0, + quantity: 3.0, + formattedPrice: "$19.99", + formattedTotal: "$59.97", + attributes: [] ), - POSOrder( - id: 3, - number: "1003", - dateCreated: Date().addingTimeInterval(-7200), - status: .completed, - total: "12.75", - customerEmail: nil, - paymentMethodID: "woocommerce_payments", - paymentMethodTitle: "Credit Card", - lineItems: [], - refunds: [], - currency: "USD", - currencySymbol: "$" + POSOrderItem( + itemID: 4, + name: "Gourmet Cookie Set - Mixed", + productID: 104, + variationID: 401, + quantity: 1.0, + formattedPrice: "$29.99", + formattedTotal: "$29.99", + attributes: [ + OrderItemAttribute(metaID: 3, name: "Flavor", value: "Mixed"), + OrderItemAttribute(metaID: 4, name: "Packaging", value: "Gift Box") + ] ) ], - hasMoreItems: false + refunds: [ + POSOrderRefund( + refundID: 1, + formattedTotal: "-$19.99", + reason: "Customer requested partial refund" + ) + ], + formattedTotalTax: "$8.95", + formattedDiscountTotal: "-$15.00", + formattedPaymentTotal: "$89.50", + formattedNetAmount: "$69.51" ) + ] + + return .loaded(orders, hasMoreItems: false) + } + + var selectedOrder: POSOrder? { + ordersViewState.orders.first } func loadOrders() async {} func loadNextOrders() async {} - func refreshOrders() async { } + func refreshOrders() async {} + func selectOrder(_ order: POSOrder?) {} } // MARK: - Barcode Scan Service diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift index a6631eab166..8dfd54897d4 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift @@ -648,7 +648,7 @@ private extension OrderDetailsDataSource { private func configureCustomerPaid(cell: TwoColumnHeadlineFootnoteTableViewCell) { let paymentViewModel = OrderPaymentDetailsViewModel(order: order) cell.leftText = Titles.paidByCustomer - cell.rightText = order.paymentTotal + cell.rightText = order.paymentTotal(currencyFormatter: currencyFormatter) cell.updateFootnoteText(paymentViewModel.paymentSummary) } @@ -692,7 +692,7 @@ private extension OrderDetailsDataSource { private func configureNetAmount(cell: TwoColumnHeadlineFootnoteTableViewCell) { cell.leftText = Titles.netAmount - cell.rightText = order.netAmount + cell.rightText = order.netAmount(currencyFormatter: currencyFormatter) cell.hideFootnote() } diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderPaymentDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderPaymentDetailsViewModel.swift index 49a5990ab7d..73b7a1eb3b1 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderPaymentDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderPaymentDetailsViewModel.swift @@ -8,18 +8,11 @@ final class OrderPaymentDetailsViewModel { private let currencyFormatter: CurrencyFormatter var subtotal: Decimal { - let subtotal = order.items.reduce(Constants.decimalZero) { (output, item) in - let itemSubtotal = Decimal(string: item.subtotal) ?? Constants.decimalZero - return output + itemSubtotal - } - - return subtotal + return order.subtotal } var subtotalValue: String { - let subAmount = NSDecimalNumber(decimal: subtotal).stringValue - - return currencyFormatter.formatAmount(subAmount, with: order.currency) ?? String() + return order.subtotalValue(currencyFormatter: currencyFormatter) ?? String() } var shouldHideSubtotal: Bool { @@ -66,11 +59,11 @@ final class OrderPaymentDetailsViewModel { } var totalValue: String { - order.totalValue + order.totalValue(currencyFormatter: currencyFormatter) } var paymentTotal: String { - order.paymentTotal + order.paymentTotal(currencyFormatter: currencyFormatter) } private var feesTotal: Decimal { diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index cc02e4233ae..3b8cabcc0bf 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -33,7 +33,7 @@ 011DF3462C53A919000AFDD9 /* PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011DF3452C53A919000AFDD9 /* PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift */; }; 012ACB742E5C830500A49458 /* PointOfSaleOrderListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012ACB732E5C830500A49458 /* PointOfSaleOrderListController.swift */; }; 012ACB762E5C83EC00A49458 /* PointOfSaleOrderListControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012ACB752E5C83EC00A49458 /* PointOfSaleOrderListControllerTests.swift */; }; - 012ACB782E5C84A200A49458 /* OrdersViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012ACB772E5C84A200A49458 /* OrdersViewState.swift */; }; + 012ACB782E5C84A200A49458 /* POSOrdersViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012ACB772E5C84A200A49458 /* POSOrdersViewState.swift */; }; 012ACB7A2E5C84D200A49458 /* MockPointOfSaleOrderListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012ACB792E5C84D200A49458 /* MockPointOfSaleOrderListService.swift */; }; 012ACB7C2E5C9BD400A49458 /* PointOfSaleOrderListModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012ACB7B2E5C9BD400A49458 /* PointOfSaleOrderListModel.swift */; }; 012ACB822E5D8DCD00A49458 /* MockPointOfSaleOrderListFetchStrategyFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012ACB812E5D8DCD00A49458 /* MockPointOfSaleOrderListFetchStrategyFactory.swift */; }; @@ -103,6 +103,8 @@ 01BD774C2C58D2BE00147191 /* PointOfSaleCardPresentPaymentDisconnectedMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BD774B2C58D2BE00147191 /* PointOfSaleCardPresentPaymentDisconnectedMessageViewModel.swift */; }; 01BE94002DDCB1110063541C /* Error+Connectivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BE93FF2DDCB1110063541C /* Error+Connectivity.swift */; }; 01BE94042DDCC7670063541C /* PointOfSaleEmptyErrorStateViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BE94032DDCC7650063541C /* PointOfSaleEmptyErrorStateViewLayout.swift */; }; + 01C21AB62E66EB80008E4D77 /* PointOfSaleOrderDetailsLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C21AB52E66EB70008E4D77 /* PointOfSaleOrderDetailsLoadingView.swift */; }; + 01C21AB82E66EC26008E4D77 /* PointOfSaleOrderDetailsEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C21AB72E66EC14008E4D77 /* PointOfSaleOrderDetailsEmptyView.swift */; }; 01C9C59F2DA3D98400CD81D8 /* CartRowRemoveButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C9C59E2DA3D97E00CD81D8 /* CartRowRemoveButton.swift */; }; 01D082402C5B9EAB007FE81F /* POSBackgroundAppearanceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D0823F2C5B9EAB007FE81F /* POSBackgroundAppearanceKey.swift */; }; 01E62EC82DFADF56003A6D9E /* Cart+BarcodeScanError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E62EC72DFADF4B003A6D9E /* Cart+BarcodeScanError.swift */; }; @@ -3241,7 +3243,7 @@ 011DF3452C53A919000AFDD9 /* PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift; sourceTree = ""; }; 012ACB732E5C830500A49458 /* PointOfSaleOrderListController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderListController.swift; sourceTree = ""; }; 012ACB752E5C83EC00A49458 /* PointOfSaleOrderListControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderListControllerTests.swift; sourceTree = ""; }; - 012ACB772E5C84A200A49458 /* OrdersViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrdersViewState.swift; sourceTree = ""; }; + 012ACB772E5C84A200A49458 /* POSOrdersViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSOrdersViewState.swift; sourceTree = ""; }; 012ACB792E5C84D200A49458 /* MockPointOfSaleOrderListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPointOfSaleOrderListService.swift; sourceTree = ""; }; 012ACB7B2E5C9BD400A49458 /* PointOfSaleOrderListModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderListModel.swift; sourceTree = ""; }; 012ACB812E5D8DCD00A49458 /* MockPointOfSaleOrderListFetchStrategyFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPointOfSaleOrderListFetchStrategyFactory.swift; sourceTree = ""; }; @@ -3311,6 +3313,8 @@ 01BD774B2C58D2BE00147191 /* PointOfSaleCardPresentPaymentDisconnectedMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentDisconnectedMessageViewModel.swift; sourceTree = ""; }; 01BE93FF2DDCB1110063541C /* Error+Connectivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Error+Connectivity.swift"; sourceTree = ""; }; 01BE94032DDCC7650063541C /* PointOfSaleEmptyErrorStateViewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleEmptyErrorStateViewLayout.swift; sourceTree = ""; }; + 01C21AB52E66EB70008E4D77 /* PointOfSaleOrderDetailsLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderDetailsLoadingView.swift; sourceTree = ""; }; + 01C21AB72E66EC14008E4D77 /* PointOfSaleOrderDetailsEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderDetailsEmptyView.swift; sourceTree = ""; }; 01C9C59E2DA3D97E00CD81D8 /* CartRowRemoveButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartRowRemoveButton.swift; sourceTree = ""; }; 01D0823F2C5B9EAB007FE81F /* POSBackgroundAppearanceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSBackgroundAppearanceKey.swift; sourceTree = ""; }; 01E62EC72DFADF4B003A6D9E /* Cart+BarcodeScanError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cart+BarcodeScanError.swift"; sourceTree = ""; }; @@ -6544,6 +6548,8 @@ 01ABA0272E57579300829DC0 /* Orders */ = { isa = PBXGroup; children = ( + 01C21AB72E66EC14008E4D77 /* PointOfSaleOrderDetailsEmptyView.swift */, + 01C21AB52E66EB70008E4D77 /* PointOfSaleOrderDetailsLoadingView.swift */, 01ABA0242E57579300829DC0 /* PointOfSaleOrderDetailsView.swift */, 01ABA0252E57579300829DC0 /* PointOfSaleOrderListView.swift */, 01ABA0262E57579300829DC0 /* PointOfSaleOrdersView.swift */, @@ -10028,7 +10034,7 @@ 204415902CE622BA0070BF54 /* PointOfSaleOrderTotals.swift */, 209B7A672CEB6742003BDEF0 /* PointOfSalePaymentState.swift */, 209566242D4CF00100977124 /* PointOfSalePaymentMethod.swift */, - 012ACB772E5C84A200A49458 /* OrdersViewState.swift */, + 012ACB772E5C84A200A49458 /* POSOrdersViewState.swift */, ); path = Models; sourceTree = ""; @@ -15764,7 +15770,7 @@ B58B4AC02108FF6100076FDD /* Array+Helpers.swift in Sources */, B90C65CD29ACE2D6004CAB9E /* CardPresentPaymentOnboardingStateCache.swift in Sources */, 028AFFB32484ED2800693C09 /* Dictionary+Logging.swift in Sources */, - 012ACB782E5C84A200A49458 /* OrdersViewState.swift in Sources */, + 012ACB782E5C84A200A49458 /* POSOrdersViewState.swift in Sources */, 026CAF802AC2B7FF002D23BB /* ConfigurableBundleProductView.swift in Sources */, 45BBFBC1274FD94300213001 /* HubMenuCoordinator.swift in Sources */, 01E62EC82DFADF56003A6D9E /* Cart+BarcodeScanError.swift in Sources */, @@ -16256,6 +16262,7 @@ 0300201029C0EBA400B09777 /* ReaderConnectionUnderlyingErrorDisplaying.swift in Sources */, EEB4E2DA29B5F8FC00371C3C /* CouponLineDetailsViewModel.swift in Sources */, B6C78B8E293BAE68008934A1 /* AnalyticsHubLastMonthRangeData.swift in Sources */, + 01C21AB62E66EB80008E4D77 /* PointOfSaleOrderDetailsLoadingView.swift in Sources */, B517EA18218B232700730EC4 /* StringFormatter+Notes.swift in Sources */, 318109DC25E5B51900EE0BE7 /* ImageTableViewCell.swift in Sources */, 262EB5B0298C7C3A009DCC36 /* SupportFormsDataSources.swift in Sources */, @@ -16973,6 +16980,7 @@ 68707A172E570EB200500CD8 /* PointOfSaleSettingsHardwareDetailView.swift in Sources */, CC4D1D8625E6CDDE00B6E4E7 /* RenameAttributesViewModel.swift in Sources */, DEFA3D932897D8930076FAE1 /* NoWooErrorViewModel.swift in Sources */, + 01C21AB82E66EC26008E4D77 /* PointOfSaleOrderDetailsEmptyView.swift in Sources */, 209B15672AD85F070094152A /* OperatingSystemVersion+Localization.swift in Sources */, 0220F4952C16DC98003723C2 /* PointOfSaleCardPresentPaymentFoundMultipleReadersView.swift in Sources */, 020A55F127F6C605007843F0 /* CardReaderConnectionAnalyticsTracker.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift index f7016e88b36..8ab64976f55 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift @@ -34,15 +34,31 @@ final class MockPointOfSaleOrderListService: PointOfSaleOrderListServiceProtocol if shouldSimulateTwoPages { if shouldSimulateThreePages && pageNumber > 1 { - return .init(items: MockPointOfSaleOrderListService.makeSecondPageOrders(), hasMorePages: true, totalItems: 6) + return .init( + items: MockPointOfSaleOrderListService.makeSecondPageOrders(), + hasMorePages: true, + totalItems: 6 + ) } else if pageNumber > 1 { - return .init(items: MockPointOfSaleOrderListService.makeSecondPageOrders(), hasMorePages: false, totalItems: 4) + return .init( + items: MockPointOfSaleOrderListService.makeSecondPageOrders(), + hasMorePages: false, + totalItems: 4 + ) } else { - return .init(items: MockPointOfSaleOrderListService.makeInitialOrders(), hasMorePages: shouldSimulateTwoPages, totalItems: 4) + return .init( + items: MockPointOfSaleOrderListService.makeInitialOrders(), + hasMorePages: shouldSimulateTwoPages, + totalItems: 4 + ) } } - return .init(items: (orderPages[safe: pageNumber - 1] ?? []), hasMorePages: orderPages.count > pageNumber, totalItems: 2) + return .init( + items: (orderPages[safe: pageNumber - 1] ?? []), + hasMorePages: orderPages.count > pageNumber, + totalItems: 2 + ) } } @@ -55,16 +71,38 @@ extension MockPointOfSaleOrderListService { number: "1001", dateCreated: baseDate, status: .completed, - total: "25.99", + formattedTotal: "$25.99", + formattedSubtotal: "$25.99", customerEmail: "customer1@example.com", paymentMethodID: "cod", paymentMethodTitle: "Cash", lineItems: [ - POSOrderItem(itemID: 1, name: "Coffee", quantity: 2, total: "20.00"), - POSOrderItem(itemID: 2, name: "Muffin", quantity: 1, total: "5.99") + POSOrderItem( + itemID: 1, + name: "Coffee", + productID: 101, + variationID: 0, + quantity: 2, + formattedPrice: "$10.00", + formattedTotal: "$20.00", + attributes: [] + ), + POSOrderItem( + itemID: 2, + name: "Muffin", + productID: 102, + variationID: 0, + quantity: 1, + formattedPrice: "$5.99", + formattedTotal: "$5.99", + attributes: [] + ) ], - currency: "USD", - currencySymbol: "$" + refunds: [], + formattedTotalTax: "$0.00", + formattedDiscountTotal: nil, + formattedPaymentTotal: "$25.99", + formattedNetAmount: nil ) let order2 = POSOrder( @@ -72,15 +110,28 @@ extension MockPointOfSaleOrderListService { number: "1002", dateCreated: baseDate.addingTimeInterval(3600), status: .completed, - total: "15.50", + formattedTotal: "$15.50", + formattedSubtotal: "$15.50", customerEmail: "customer2@example.com", paymentMethodID: "cod", paymentMethodTitle: "Card", lineItems: [ - POSOrderItem(itemID: 3, name: "Tea", quantity: 1, total: "15.50") + POSOrderItem( + itemID: 3, + name: "Tea", + productID: 103, + variationID: 0, + quantity: 1, + formattedPrice: "$15.50", + formattedTotal: "$15.50", + attributes: [] + ) ], - currency: "USD", - currencySymbol: "$" + refunds: [], + formattedTotalTax: "$0.00", + formattedDiscountTotal: nil, + formattedPaymentTotal: "$15.50", + formattedNetAmount: nil ) return [order1, order2] @@ -94,16 +145,38 @@ extension MockPointOfSaleOrderListService { number: "1003", dateCreated: baseDate.addingTimeInterval(7200), status: .completed, - total: "42.75", + formattedTotal: "$42.75", + formattedSubtotal: "$42.75", customerEmail: "customer3@example.com", paymentMethodID: "cod", paymentMethodTitle: "Cash", lineItems: [ - POSOrderItem(itemID: 4, name: "Sandwich", quantity: 1, total: "12.00"), - POSOrderItem(itemID: 5, name: "Soup", quantity: 2, total: "30.75") + POSOrderItem( + itemID: 4, + name: "Sandwich", + productID: 104, + variationID: 0, + quantity: 1, + formattedPrice: "$12.00", + formattedTotal: "$12.00", + attributes: [] + ), + POSOrderItem( + itemID: 5, + name: "Soup", + productID: 105, + variationID: 0, + quantity: 2, + formattedPrice: "$15.38", + formattedTotal: "$30.75", + attributes: [] + ) ], - currency: "USD", - currencySymbol: "$" + refunds: [], + formattedTotalTax: "$0.00", + formattedDiscountTotal: nil, + formattedPaymentTotal: "$42.75", + formattedNetAmount: nil ) let order4 = POSOrder( @@ -111,18 +184,30 @@ extension MockPointOfSaleOrderListService { number: "1004", dateCreated: baseDate.addingTimeInterval(10800), status: .refunded, - total: "12.00", + formattedTotal: "$12.00", + formattedSubtotal: "$12.00", customerEmail: "customer4@example.com", paymentMethodID: "cod", paymentMethodTitle: "Card", lineItems: [ - POSOrderItem(itemID: 6, name: "Cookies", quantity: 1, total: "12.00") + POSOrderItem( + itemID: 6, + name: "Cookies", + productID: 106, + variationID: 0, + quantity: 1, + formattedPrice: "$12.00", + formattedTotal: "$12.00", + attributes: [] + ) ], refunds: [ - POSOrderRefund(refundID: 1001, total: "12.00", reason: "Customer request") + POSOrderRefund(refundID: 1001, formattedTotal: "-$12.00", reason: "Customer request") ], - currency: "USD", - currencySymbol: "$" + formattedTotalTax: "$0.00", + formattedDiscountTotal: nil, + formattedPaymentTotal: "$12.00", + formattedNetAmount: "$0.00" ) return [order3, order4]