diff --git a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift index 3474dcfe505..65165dcb9b3 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift @@ -9,6 +9,7 @@ final class BookingDetailsViewModel: ObservableObject { private let stores: StoresManager private let booking: Booking + private let headerContent: HeaderContent private let customerContent = CustomerContent() let navigationTitle: String @@ -17,13 +18,14 @@ final class BookingDetailsViewModel: ObservableObject { init(booking: Booking, stores: StoresManager = ServiceLocator.stores) { self.booking = booking self.stores = stores + self.headerContent = HeaderContent(booking) navigationTitle = Self.navigationTitle(for: booking) setupSections() } private func setupSections() { let headerSection = Section( - content: .header(HeaderContent(booking)) + content: .header(headerContent) ) let appointmentDetailsSection = Section( @@ -74,7 +76,10 @@ private extension BookingDetailsViewModel { let action = CustomerAction.loadCustomer(siteID: booking.siteID, customerID: booking.customerID) { [weak self] result in guard let self = self else { return } if case .success(let customer) = result { - self.updateCustomerSection(with: customer) + Task { + await self.updateCustomerSection(with: customer) + await self.updateHeader(with: customer) + } } } stores.dispatch(action) @@ -97,7 +102,8 @@ private extension BookingDetailsViewModel { do { let fetchedCustomer = try await retrieveCustomer() - updateCustomerSection(with: fetchedCustomer) + await updateCustomerSection(with: fetchedCustomer) + await updateHeader(with: fetchedCustomer) } catch { DDLogError("⛔️ Error synchronizing Customer for Booking: \(error)") } @@ -121,9 +127,18 @@ private extension BookingDetailsViewModel { } } + @MainActor func updateCustomerSection(with customer: Customer) { customerContent.update(with: customer) + insertCustomerSectionIfAbsent() + } + + @MainActor + func updateHeader(with customer: Customer) { + headerContent.update(with: customer) + } + func insertCustomerSectionIfAbsent() { // Avoid adding if it already exists guard !sections.contains(where: { if case .customer = $0.content { return true } else { return false } }) else { return diff --git a/WooCommerce/Classes/ViewModels/Booking Details/CustomerContent.swift b/WooCommerce/Classes/ViewModels/Booking Details/CustomerContent.swift index 6c47b2f5cde..73fa95dc435 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/CustomerContent.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/CustomerContent.swift @@ -8,6 +8,7 @@ extension BookingDetailsViewModel { @Published var phoneText: String? @Published var billingAddressText: String? + @MainActor func update(with customer: Customer) { let name = [ customer.firstName, diff --git a/WooCommerce/Classes/ViewModels/Booking Details/HeaderContent.swift b/WooCommerce/Classes/ViewModels/Booking Details/HeaderContent.swift index a88f1fc1380..d0dd8f591ce 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/HeaderContent.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/HeaderContent.swift @@ -1,26 +1,55 @@ import Foundation -import struct Networking.Booking +import struct Yosemite.Booking +import struct Yosemite.Customer extension BookingDetailsViewModel { - struct HeaderContent: Hashable { + final class HeaderContent: ObservableObject { let bookingDate: String - let serviceAndCustomerLine: String let status: [Status] - init(_ booking: Booking) { + @Published var serviceAndCustomerLine: String + + init(_ booking: Booking, customerName: String? = nil) { bookingDate = booking.startDate.formatted( date: .numeric, time: .shortened ) - /// Temporary hardcode - serviceAndCustomerLine = [ - "Women's Haircut", - "Margarita Nikolaevna" - ].joined(separator: Constants.dotSeparator) + /// Temporary hardcode for service name + let serviceName = "Women's Haircut" + if let customerName = customerName, !customerName.isEmpty { + serviceAndCustomerLine = [ + serviceName, + customerName + ].joined(separator: Constants.dotSeparator) + } else { + serviceAndCustomerLine = serviceName + } status = [.booked, .payAtLocation] } + + @MainActor + func update(with customer: Customer) { + let customerName = [ + customer.firstName, + customer.lastName + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " ") + + /// Temporary hardcode for service name + let serviceName = "Women's Haircut" + if !customerName.isEmpty { + serviceAndCustomerLine = [ + serviceName, + customerName + ].joined(separator: Constants.dotSeparator) + } else { + serviceAndCustomerLine = serviceName + } + } } } diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift index 20c280ffb3e..d0b35c6dab5 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift @@ -138,7 +138,7 @@ private extension BookingDetailsView { func sectionContentView(_ content: BookingDetailsViewModel.SectionContent) -> some View { switch content { case .header(let content): - headerView(with: content) + HeaderView(content: content) case .appointmentDetails(let content): appointmentDetailsView(with: content) case .attendance(let content): @@ -152,30 +152,6 @@ private extension BookingDetailsView { } } - func headerView(with headerContent: BookingDetailsViewModel.HeaderContent) -> some View { - VStack(alignment: .leading, spacing: Layout.headerContentVerticalPadding) { - Text(headerContent.bookingDate) - .font(TextFont.bodyMedium) - .foregroundColor(.primary) - Text(headerContent.serviceAndCustomerLine) - .font(.footnote.weight(.medium)) - .foregroundColor(.secondary) - HStack { - ForEach(headerContent.status, id: \.self) { status in - Text(status.labelText) - .font(.caption2) - .padding(.vertical, 4.5) - .padding(.horizontal, 8) - .background(status.labelColor) - .cornerRadius(4) - } - } - .padding(.top, Layout.headerBadgesAdditionalTopPadding) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical) - } - func attendanceView(with content: BookingDetailsViewModel.AttendanceContent) -> some View { TitleAndValueRow( title: Localization.statusRowTitle, diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/HeaderView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/HeaderView.swift new file mode 100644 index 00000000000..663cb6570a4 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/HeaderView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +extension BookingDetailsView { + struct HeaderView: View { + @ObservedObject var content: BookingDetailsViewModel.HeaderContent + + 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) + HStack { + ForEach(content.status, id: \.self) { status in + Text(status.labelText) + .font(.caption2) + .padding(.vertical, 4.5) + .padding(.horizontal, 8) + .background(status.labelColor) + .cornerRadius(4) + } + } + .padding(.top, Layout.headerBadgesAdditionalTopPadding) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical) + } + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 92e54030a75..3b3071cc163 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -986,8 +986,9 @@ 26FFC50D2BED7C5B0067B3A4 /* WatchDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B249702BEC801400730730 /* WatchDependencies.swift */; }; 2D052FB42E9408AF004111FD /* CustomerDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D052FB32E9408AF004111FD /* CustomerDetailsView.swift */; }; 2D052FB52E9408AF004111FD /* BookingDetailsView+RowTextStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D052FB22E9408AF004111FD /* BookingDetailsView+RowTextStyle.swift */; }; - 2D054A2A2E953E3C004111FD /* BookingDetailsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D054A292E953E3C004111FD /* BookingDetailsViewModelTests.swift */; }; 2D05337E2E951A62004111FD /* BookingDetailsViewModel+PriceFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D05337D2E951A62004111FD /* BookingDetailsViewModel+PriceFormatting.swift */; }; + 2D054A2A2E953E3C004111FD /* BookingDetailsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D054A292E953E3C004111FD /* BookingDetailsViewModelTests.swift */; }; + 2D0555812E9693E6004111FD /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0555802E9693E1004111FD /* HeaderView.swift */; }; 2D05D19F2E82D1A8004111FD /* BookingDetailsViewModel+Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D05D19E2E82D1A3004111FD /* BookingDetailsViewModel+Section.swift */; }; 2D05D1A22E82D235004111FD /* HeaderContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D05D1A12E82D233004111FD /* HeaderContent.swift */; }; 2D05D1A42E82D266004111FD /* AppointmentDetailsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D05D1A32E82D25F004111FD /* AppointmentDetailsContent.swift */; }; @@ -3883,8 +3884,9 @@ 26FFD32928C6A0F4002E5E5E /* UIImage+Widgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Widgets.swift"; sourceTree = ""; }; 2D052FB22E9408AF004111FD /* BookingDetailsView+RowTextStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookingDetailsView+RowTextStyle.swift"; sourceTree = ""; }; 2D052FB32E9408AF004111FD /* CustomerDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerDetailsView.swift; sourceTree = ""; }; - 2D054A292E953E3C004111FD /* BookingDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookingDetailsViewModelTests.swift; sourceTree = ""; }; 2D05337D2E951A62004111FD /* BookingDetailsViewModel+PriceFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookingDetailsViewModel+PriceFormatting.swift"; sourceTree = ""; }; + 2D054A292E953E3C004111FD /* BookingDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookingDetailsViewModelTests.swift; sourceTree = ""; }; + 2D0555802E9693E1004111FD /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = ""; }; 2D05D19E2E82D1A3004111FD /* BookingDetailsViewModel+Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookingDetailsViewModel+Section.swift"; sourceTree = ""; }; 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 = ""; }; @@ -7968,6 +7970,7 @@ 2DAC2C962E82A169008521AF /* Booking Details */ = { isa = PBXGroup; children = ( + 2D0555802E9693E1004111FD /* HeaderView.swift */, 2D052FB22E9408AF004111FD /* BookingDetailsView+RowTextStyle.swift */, 2D052FB32E9408AF004111FD /* CustomerDetailsView.swift */, 2DAC2C972E82A185008521AF /* BookingDetailsView.swift */, @@ -15217,6 +15220,7 @@ 032E481D2982996E00469D92 /* CardPresentModalTapToPayConnectingFailedNonRetryable.swift in Sources */, DED9740D2AD7D27000122EB4 /* BlazeCampaignListViewModel.swift in Sources */, EEC0120F2CE34B87003B865B /* WooAnalyticsEvent+WPCOMSuspendedSite.swift in Sources */, + 2D0555812E9693E6004111FD /* HeaderView.swift in Sources */, 0379C51B27BFE23F00A7E284 /* RefundConfirmationCardDetailsCell.swift in Sources */, D85136B9231CED5800DD0539 /* ReviewAge.swift in Sources */, 209AD3D02AC1EDDA00825D76 /* WooPaymentsPayoutsCurrencyOverviewViewModel.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingDetailsViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingDetailsViewModelTests.swift index 459496b0dcd..de0e00ce4d5 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingDetailsViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingDetailsViewModelTests.swift @@ -24,8 +24,9 @@ final class BookingDetailsViewModelTests: XCTestCase { storageManager = nil } - func testLoadCustomerDataPopulatesCustomerContent() { + func test_load_local_data_when_customer_exists_in_storage_populates_customer_content() { // Given + let expectation = self.expectation(description: "The view model's customer content should be populated.") let customerID: Int64 = 123 let mockBooking = Networking.Booking.fake().copy(customerID: customerID) @@ -45,6 +46,7 @@ final class BookingDetailsViewModelTests: XCTestCase { lastName: "Doe", billing: billingAddress ) + let mockStorageCustomer = storageManager.insertSampleCustomer(readOnlyCustomer: mockReadOnlyCustomer) let viewModel = BookingDetailsViewModel(booking: mockBooking, stores: storesManager) @@ -53,19 +55,19 @@ final class BookingDetailsViewModelTests: XCTestCase { return } onCompletion(.success(mockStorageCustomer.toReadOnly())) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + expectation.fulfill() + } } // When viewModel.loadLocalData() - // Then - let customerSection = viewModel.sections.first { section in - if case .customer = section.content { - return true - } - return false - } + // Wait for async updates to finish + wait(for: [expectation], timeout: 1.0) + // Then + let customerSection = viewModel.sections.first { if case .customer = $0.content { true } else { false } } guard let customerSection = customerSection, case let .customer(customerContent) = customerSection.content else { XCTFail("Customer section not found in view model sections") @@ -90,4 +92,47 @@ final class BookingDetailsViewModelTests: XCTestCase { XCTAssertEqual(customerContent.billingAddressText, expectedBillingAddress) } + + func test_load_local_data_when_customer_exists_in_storage_populates_header_content() { + // Given + let expectation = self.expectation(description: "The view model's header content should be populated.") + let customerID: Int64 = 123 + let mockBooking = Networking.Booking.fake().copy(customerID: customerID) + + let mockReadOnlyCustomer = Networking.Customer.fake().copy( + customerID: customerID, + firstName: "John", + lastName: "Doe" + ) + + let mockStorageCustomer = storageManager.insertSampleCustomer(readOnlyCustomer: mockReadOnlyCustomer) + let viewModel = BookingDetailsViewModel(booking: mockBooking, stores: storesManager) + + storesManager.whenReceivingAction(ofType: CustomerAction.self) { action in + guard case let .loadCustomer(_, _, onCompletion) = action else { + return + } + onCompletion(.success(mockStorageCustomer.toReadOnly())) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + expectation.fulfill() + } + } + + // When + viewModel.loadLocalData() + + // Wait for async updates to finish + wait(for: [expectation], timeout: 1.0) + + // Then + let headerSection = viewModel.sections.first { if case .header = $0.content { true } else { false } } + guard let headerSection = headerSection, + case let .header(headerContent) = headerSection.content else { + XCTFail("Header section not found in view model sections") + return + } + + let expectedHeaderLine = "Women's Haircut • John Doe" + XCTAssertEqual(headerContent.serviceAndCustomerLine, expectedHeaderLine) + } }