diff --git a/Modules/Sources/Yosemite/Actions/CustomerAction.swift b/Modules/Sources/Yosemite/Actions/CustomerAction.swift index d1107f59c1f..2d161fe10b9 100644 --- a/Modules/Sources/Yosemite/Actions/CustomerAction.swift +++ b/Modules/Sources/Yosemite/Actions/CustomerAction.swift @@ -100,7 +100,4 @@ public enum CustomerAction: Action { ///- `siteID`: The site for which customers should be delete. ///- `onCompletion`: Invoked when the operation finishes. case deleteAllCustomers(siteID: Int64, onCompletion: () -> Void) - - /// Loads a customer for the specified `siteID` and `customerID` from storage. - case loadCustomer(siteID: Int64, customerID: Int64, onCompletion: (Result) -> Void) } diff --git a/Modules/Sources/Yosemite/Stores/CustomerStore.swift b/Modules/Sources/Yosemite/Stores/CustomerStore.swift index f8014db07ad..6225093c782 100644 --- a/Modules/Sources/Yosemite/Stores/CustomerStore.swift +++ b/Modules/Sources/Yosemite/Stores/CustomerStore.swift @@ -80,8 +80,6 @@ public final class CustomerStore: Store { synchronizeAllCustomers(siteID: siteID, pageNumber: pageNumber, pageSize: pageSize, onCompletion: onCompletion) case .deleteAllCustomers(siteID: let siteID, onCompletion: let onCompletion): deleteAllCustomers(from: siteID, onCompletion: onCompletion) - case let .loadCustomer(siteID, customerID, onCompletion): - loadCustomer(siteID: siteID, customerID: customerID, onCompletion: onCompletion) } } @@ -253,16 +251,6 @@ public final class CustomerStore: Store { }, completion: onCompletion, on: .main) } - private func loadCustomer(siteID: Int64, customerID: Int64, onCompletion: @escaping (Result) -> Void) { - let customers = storageManager.viewStorage.loadCustomers(siteID: siteID, matching: [customerID]) - if let storageCustomer = customers.first { - let customer = storageCustomer.toReadOnly() - onCompletion(.success(customer)) - } else { - onCompletion(.failure(CustomerStoreError.notFound)) - } - } - /// Maps CustomerSearchResult to Customer objects /// /// - Parameters: @@ -441,9 +429,3 @@ private extension CustomerStore { storageCustomer.update(with: readOnlyCustomer) } } - -// MARK: - Errors - -enum CustomerStoreError: Error { - case notFound -} diff --git a/WooCommerce/Classes/Extensions/Booking+Helpers.swift b/WooCommerce/Classes/Extensions/Booking+Helpers.swift index 88463ad54bd..8c86a703be5 100644 --- a/WooCommerce/Classes/Extensions/Booking+Helpers.swift +++ b/WooCommerce/Classes/Extensions/Booking+Helpers.swift @@ -2,7 +2,6 @@ import Foundation import struct Yosemite.Booking extension Booking { - var summaryText: String { let productName = orderInfo?.productInfo?.name let customerName: String = { diff --git a/WooCommerce/Classes/ViewModels/Booking Details/AppointmentDetailsContent.swift b/WooCommerce/Classes/ViewModels/Booking Details/AppointmentDetailsContent.swift index 3511930aac7..744a2eb63b0 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/AppointmentDetailsContent.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/AppointmentDetailsContent.swift @@ -2,7 +2,7 @@ import Foundation import Yosemite extension BookingDetailsViewModel { - struct AppointmentDetailsContent { + final class AppointmentDetailsContent: ObservableObject { struct Row: Identifiable { let title: String let value: String @@ -12,9 +12,9 @@ extension BookingDetailsViewModel { } } - let rows: [Row] + @Published private(set) var rows: [Row] = [] - init(_ booking: Booking, resource: BookingResource?) { + func update(with booking: Booking, resource: BookingResource?) { let appointmentDate = booking.startDate.toString(dateStyle: .short, timeStyle: .none, timeZone: BookingListTab.utcTimeZone) let appointmentTimeFrame = [ booking.startDate.toString(dateStyle: .none, timeStyle: .short, timeZone: BookingListTab.utcTimeZone), diff --git a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel+Status.swift b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel+Status.swift deleted file mode 100644 index 91c35425009..00000000000 --- a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel+Status.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation -import SwiftUI - -extension BookingDetailsViewModel { - enum Status { - case booked, paid, payAtLocation - } -} - -extension BookingDetailsViewModel.Status { - var labelText: String { - switch self { - case .booked: - return Localization.bookingStatusBooked - case .paid: - return Localization.bookingStatusPaid - case .payAtLocation: - return Localization.bookingStatusPayAtLocation - } - } - - var labelColor: Color { - switch self { - case .booked: - return Color(UIColor.systemGray6) - case .paid: - return Color(UIColor.systemGray6) - case .payAtLocation: - return Color(UIColor(hexString: "FFE365")) - } - } -} - -private extension BookingDetailsViewModel.Status { - enum Localization { - static let bookingStatusBooked = NSLocalizedString( - "BookingDetailsView.statusLabel.booked", - value: "Booked", - comment: "Title for the 'Booked' status label in the header view." - ) - - static let bookingStatusPaid = NSLocalizedString( - "BookingDetailsView.statusLabel.paid", - value: "Paid", - comment: "Title for the 'Paid' status label in the header view." - ) - - static let bookingStatusPayAtLocation = NSLocalizedString( - "BookingDetailsView.statusLabel.payAtLocation", - value: "Pay at location", - comment: "Title for the 'Pay at location' status label in the header view." - ) - } -} diff --git a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift index 0859bf02328..4d618e82f14 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift @@ -1,12 +1,23 @@ import Foundation import Yosemite import protocol Storage.StorageManagerType +import SwiftUI final class BookingDetailsViewModel: ObservableObject { private let stores: StoresManager - private var booking: Booking private var bookingResource: BookingResource? + private var booking: Booking { + didSet { + updateDisplayProperties(from: booking) + } + } + + private let headerContent = HeaderContent() + private let customerContent = CustomerContent() + private let appointmentDetailsContent = AppointmentDetailsContent() + private let attendanceContent = AttendanceContent() + private let paymentContent = PaymentContent() // EntityListener: Update / Deletion Notifications. /// @@ -14,7 +25,7 @@ final class BookingDetailsViewModel: ObservableObject { return EntityListener(storageManager: ServiceLocator.storageManager, readOnlyEntity: booking) }() - let navigationTitle: String + @Published private(set) var navigationTitle = "" @Published private(set) var sections: [Section] = [] init(booking: Booking, @@ -22,43 +33,40 @@ final class BookingDetailsViewModel: ObservableObject { storage: StorageManagerType = ServiceLocator.storageManager) { self.booking = booking self.stores = stores + self.bookingResource = storage.viewStorage.loadBookingResource( + siteID: booking.siteID, + resourceID: booking.resourceID + )?.toReadOnly() - navigationTitle = Self.navigationTitle(for: booking) - let resource = storage.viewStorage.loadBookingResource(siteID: booking.siteID, resourceID: booking.resourceID)?.toReadOnly() - self.bookingResource = resource - setupSections(with: booking, resource: resource) + setupSections() configureEntityListener() + + updateDisplayProperties(from: booking) } +} - private func setupSections(with booking: Booking, resource: BookingResource?) { - let headerContent = HeaderContent(booking) +// MARK: Private + +private extension BookingDetailsViewModel { + func setupSections() { let headerSection = Section( content: .header(headerContent) ) let appointmentDetailsSection = Section( header: .title(Localization.appointmentDetailsSectionHeaderTitle.uppercased()), - content: .appointmentDetails(AppointmentDetailsContent(booking, resource: resource)) + content: .appointmentDetails(appointmentDetailsContent) ) - let customerSection: Section? = { - guard let billingAddress = booking.orderInfo?.customerInfo?.billingAddress else { return nil } - let customerContent = CustomerContent(billingAddress: billingAddress) - return Section( - header: .title(Localization.customerSectionHeaderTitle.uppercased()), - content: .customer(customerContent) - ) - }() - let attendanceSection = Section( header: .title(Localization.attendanceSectionHeaderTitle.uppercased()), footerText: Localization.attendanceSectionFooterText, - content: .attendance(AttendanceContent()) + content: .attendance(attendanceContent) ) let paymentSection = Section( header: .title(Localization.paymentSectionHeaderTitle.uppercased()), - content: .payment(PaymentContent(booking: booking)) + content: .payment(paymentContent) ) let bookingNotes = Section( @@ -69,42 +77,67 @@ final class BookingDetailsViewModel: ObservableObject { sections = [ headerSection, appointmentDetailsSection, - customerSection, attendanceSection, paymentSection, bookingNotes - ].compactMap { $0 } + ] } -} -// MARK: Syncing + func updateDisplayProperties(from booking: Booking) { + navigationTitle = Self.navigationTitle(for: booking) -extension BookingDetailsViewModel { - func syncData() async { - if let resource = await fetchResource() { - self.bookingResource = resource // only update resource if fetching succeeds + if let billingAddress = booking.orderInfo?.customerInfo?.billingAddress, !billingAddress.isEmpty { + customerContent.update(with: billingAddress) + insertCustomerSectionIfAbsent() + } + headerContent.update(with: booking) + appointmentDetailsContent.update(with: booking, resource: bookingResource) + paymentContent.update(with: booking) + } + + func insertCustomerSectionIfAbsent() { + // Avoid adding if it already exists + let customerSectionExists = sections.contains { + if case .customer = $0.content { + return true + } + + return false + } + + guard !customerSectionExists else { + return + } + + let customerSection = Section( + header: .title(Localization.customerSectionHeaderTitle.uppercased()), + content: .customer(customerContent) + ) + withAnimation { + sections.insert(customerSection, at: 2) } - await syncBooking() } -} -private extension BookingDetailsViewModel { func configureEntityListener() { entityListener.onUpsert = { [weak self] booking in guard let self else { return } self.booking = booking - self.setupSections(with: booking, resource: bookingResource) } } +} - func syncBooking() async { - do { - try await retrieveBooking() - } catch { - DDLogError("⛔️ Error synchronizing Customer for Booking: \(error)") +// MARK: Syncing + +extension BookingDetailsViewModel { + func syncData() async { + if let resource = await fetchResource() { + self.bookingResource = resource // only update resource if fetching succeeds } + await fetchBooking() } +} +private extension BookingDetailsViewModel { @MainActor func fetchResource() async -> BookingResource? { do { @@ -125,25 +158,35 @@ private extension BookingDetailsViewModel { } @MainActor - func retrieveBooking() async throws { - try await withCheckedThrowingContinuation { continuation in - let action = BookingAction.synchronizeBooking( - siteID: booking.siteID, - bookingID: booking.bookingID - ) { result in - continuation.resume(with: result) + func fetchBooking() async { + do { + try await withCheckedThrowingContinuation { continuation in + let action = BookingAction.synchronizeBooking( + siteID: booking.siteID, + bookingID: booking.bookingID + ) { result in + continuation.resume(with: result) + } + stores.dispatch(action) } - stores.dispatch(action) + } catch { + DDLogError("⛔️ Error synchronizing Booking: \(error)") } } } extension BookingDetailsViewModel { var cancellationAlertMessage: String { - // Temporary hardcoded - //TODO: - replace with associated customer data - let productName = "Women's Haircut" - let customerName = "Margarita Nikolaevna" + let productName = booking.orderInfo?.productInfo?.name ?? "" + + let customerName: String = { + guard let address = booking.orderInfo?.customerInfo?.billingAddress else { + return "" + } + return [address.firstName, address.lastName] + .compactMap { $0 } + .joined(separator: " ") + }() let date = booking.startDate.formatted( date: .long, diff --git a/WooCommerce/Classes/ViewModels/Booking Details/CustomerContent.swift b/WooCommerce/Classes/ViewModels/Booking Details/CustomerContent.swift index 78bd2ceab58..ffd92738bd8 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/CustomerContent.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/CustomerContent.swift @@ -8,7 +8,7 @@ extension BookingDetailsViewModel { @Published var phoneText: String? @Published var billingAddressText: String? - init(billingAddress: Address) { + func update(with billingAddress: Address) { nameText = billingAddress.fullName emailText = billingAddress.email ?? "" phoneText = billingAddress.phone ?? "" diff --git a/WooCommerce/Classes/ViewModels/Booking Details/HeaderContent.swift b/WooCommerce/Classes/ViewModels/Booking Details/HeaderContent.swift index ecc2dc7cb14..94d54d35819 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/HeaderContent.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/HeaderContent.swift @@ -1,23 +1,28 @@ import Foundation import struct Yosemite.Booking +import struct Yosemite.BookingProductInfo import struct Yosemite.Customer +import struct Yosemite.Address extension BookingDetailsViewModel { final class HeaderContent: ObservableObject { - let bookingDate: String - let status: [Status] + @Published private(set) var bookingDate: String = "" + @Published private(set) var status: [String] = [] + @Published private(set) var serviceAndCustomerLine: String = "" - @Published var serviceAndCustomerLine: String - - init(_ booking: Booking) { + func update(with booking: Booking) { bookingDate = booking.startDate.toString( dateStyle: .short, timeStyle: .short, timeZone: BookingListTab.utcTimeZone ) - serviceAndCustomerLine = booking.summaryText - status = [.booked, .payAtLocation] + + let bookingStatus = booking.bookingStatus + status = [ + "Booked", + booking.bookingStatus.localizedTitle + ] } } } diff --git a/WooCommerce/Classes/ViewModels/Booking Details/PaymentContent.swift b/WooCommerce/Classes/ViewModels/Booking Details/PaymentContent.swift index 33323cb6505..1de7916a44f 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/PaymentContent.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/PaymentContent.swift @@ -3,11 +3,11 @@ import struct Networking.Booking import class WooFoundationCore.CurrencyFormatter extension BookingDetailsViewModel { - struct PaymentContent { - let amounts: [Amount] - let actions: [Action] + final class PaymentContent: ObservableObject { + @Published var amounts: [Amount] = [] + @Published var actions: [Action] = [] - init(booking: Booking) { + func update(with booking: Booking) { let total = booking.orderInfo?.paymentInfo?.total ?? booking.cost let totalTax = booking.orderInfo?.paymentInfo?.totalTax ?? "0" let subtotal = booking.orderInfo?.paymentInfo?.subtotal ?? "0" @@ -18,6 +18,7 @@ extension BookingDetailsViewModel { } return (totalDecimal - subtotalDecimal).description }() + amounts = [ .init(value: BookingDetailsViewModel.formatPrice(for: booking, priceString: subtotal), type: .service), .init(value: BookingDetailsViewModel.formatPrice(for: booking, priceString: totalTax), type: .tax), diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift index 7af9ea07739..ebfd276fec3 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift @@ -18,6 +18,12 @@ struct BookingDetailsView: View { static let headerBadgesAdditionalTopPadding: CGFloat = 4 static let sectionFooterTextVerticalPadding: CGFloat = 8 static let rowTextVerticalPadding: CGFloat = 11 + static let defaultBadgeColor = Color( + uiColor: .init( + light: .systemGray6, + dark: .systemGray5 + ) + ) } enum TextFont { diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/HeaderView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/HeaderView.swift index 663cb6570a4..2eab2f64aaa 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/HeaderView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/HeaderView.swift @@ -6,19 +6,25 @@ extension BookingDetailsView { var body: some View { VStack(alignment: .leading, spacing: Layout.headerContentVerticalPadding) { - Text(content.bookingDate) - .font(TextFont.bodyMedium) - .foregroundColor(.primary) - Text(content.serviceAndCustomerLine) - .font(.footnote.weight(.medium)) - .foregroundColor(.secondary) + if !content.bookingDate.isEmpty { + Text(content.bookingDate) + .font(TextFont.bodyMedium) + .foregroundColor(.primary) + } + if !content.serviceAndCustomerLine.isEmpty { + Text(content.serviceAndCustomerLine) + .font(.footnote.weight(.medium)) + .foregroundColor(.secondary) + } HStack { - ForEach(content.status, id: \.self) { status in - Text(status.labelText) + ForEach(content.status, id: \.self) { statusString in + Text(statusString) .font(.caption2) .padding(.vertical, 4.5) .padding(.horizontal, 8) - .background(status.labelColor) + .background( + BookingDetailsView.Layout.defaultBadgeColor + ) .cornerRadius(4) } } diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index d425966aa45..d569fc5784e 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -997,7 +997,6 @@ 2D05D1A22E82D235004111FD /* HeaderContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D05D1A12E82D233004111FD /* HeaderContent.swift */; }; 2D05D1A42E82D266004111FD /* AppointmentDetailsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D05D1A32E82D25F004111FD /* AppointmentDetailsContent.swift */; }; 2D05D1A52E82D3F6004111FD /* BookingDetailsViewModel+SectionContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D05D1A02E82D1EF004111FD /* BookingDetailsViewModel+SectionContent.swift */; }; - 2D05D1A72E82D49F004111FD /* BookingDetailsViewModel+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D05D1A62E82D49D004111FD /* BookingDetailsViewModel+Status.swift */; }; 2D05E80F2E86BE50004111FD /* AttendanceContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D05E80E2E86BE4F004111FD /* AttendanceContent.swift */; }; 2D05E8112E8A9905004111FD /* CustomerContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D05E8102E8A98FE004111FD /* CustomerContent.swift */; }; 2D05E8132E8AADB9004111FD /* PaymentContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D05E8122E8AADB2004111FD /* PaymentContent.swift */; }; @@ -3906,7 +3905,6 @@ 2D05D1A02E82D1EF004111FD /* BookingDetailsViewModel+SectionContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookingDetailsViewModel+SectionContent.swift"; sourceTree = ""; }; 2D05D1A12E82D233004111FD /* HeaderContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderContent.swift; sourceTree = ""; }; 2D05D1A32E82D25F004111FD /* AppointmentDetailsContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppointmentDetailsContent.swift; sourceTree = ""; }; - 2D05D1A62E82D49D004111FD /* BookingDetailsViewModel+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookingDetailsViewModel+Status.swift"; sourceTree = ""; }; 2D05E80E2E86BE4F004111FD /* AttendanceContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttendanceContent.swift; sourceTree = ""; }; 2D05E8102E8A98FE004111FD /* CustomerContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerContent.swift; sourceTree = ""; }; 2D05E8122E8AADB2004111FD /* PaymentContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentContent.swift; sourceTree = ""; }; @@ -7976,7 +7974,6 @@ 2D05D1A12E82D233004111FD /* HeaderContent.swift */, 2D05D1A32E82D25F004111FD /* AppointmentDetailsContent.swift */, 2DAC251F2E82A02C008521AF /* BookingDetailsViewModel.swift */, - 2D05D1A62E82D49D004111FD /* BookingDetailsViewModel+Status.swift */, 2D05D19E2E82D1A3004111FD /* BookingDetailsViewModel+Section.swift */, 2D05D1A02E82D1EF004111FD /* BookingDetailsViewModel+SectionContent.swift */, 2D05337D2E951A62004111FD /* BookingDetailsViewModel+PriceFormatting.swift */, @@ -15071,7 +15068,6 @@ 010F7D8D2E7A8447002B02EA /* ProductImageThumbnail+Extensions.swift in Sources */, 26A630FE253F63C300CBC3B1 /* RefundableOrderItem.swift in Sources */, CEE006052077D1280079161F /* SummaryTableViewCell.swift in Sources */, - 2D05D1A72E82D49F004111FD /* BookingDetailsViewModel+Status.swift in Sources */, DEE215342D1297CD004A11F3 /* UserDefaults+EditStoreList.swift in Sources */, CE63024E2BAC664900E3325C /* EmailView.swift in Sources */, DE4B3B5826A7041800EEF2D8 /* EdgeInsets+Woo.swift in Sources */,