From a55d1957b67aae7693450a6a37a5be97043167a4 Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Mon, 6 Oct 2025 17:19:34 +0300 Subject: [PATCH 01/12] Fetch and display customer content in booking details --- .../BookingDetailsViewModel.swift | 97 +++++++++++++++---- .../Booking Details/CustomerContent.swift | 42 +++++++- .../BookingDetailsView+RowTextStyle.swift | 18 ++++ .../Booking Details/BookingDetailsView.swift | 95 ++---------------- .../Booking Details/CustomerDetailsView.swift | 80 +++++++++++++++ .../WooCommerce.xcodeproj/project.pbxproj | 8 ++ 6 files changed, 231 insertions(+), 109 deletions(-) create mode 100644 WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView+RowTextStyle.swift create mode 100644 WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift diff --git a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift index fcc64ec24bc..6e733f19e9b 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift @@ -1,14 +1,31 @@ import Foundation import struct Networking.Booking +import struct Networking.Customer +import struct Networking.Address +import WooFoundation +import Yosemite +import SwiftUI // Added for withAnimation +@MainActor final class BookingDetailsViewModel: ObservableObject { - let sections: [Section] + private let stores: StoresManager + let navigationTitle: String - init(booking: Booking) { + private let booking: Booking + private let customerContent = CustomerContent() + + @Published private(set) var sections: [Section] = [] + + init(booking: Booking, stores: StoresManager = ServiceLocator.stores) { + self.booking = booking + self.stores = stores navigationTitle = Self.navigationTitle(for: booking) + setupSections() + } - let headerSection = Section.init( + private func setupSections() { + let headerSection = Section( content: .header(HeaderContent(booking)) ) @@ -23,21 +40,6 @@ final class BookingDetailsViewModel: ObservableObject { content: .attendance(AttendanceContent()) ) - let customerSection = Section( - header: .title(Localization.customerSectionHeaderTitle.uppercased()), - content: .customer( - /// Temporary hardcode - CustomerContent( - nameText: "Margarita Nikolaevna", - emailText: "margarita.n@mail.com", - phoneText: "+1 742582943798", - billingAddressText: """ - 238 Willow Creek Drive
Montgomery
AL 36109 - """ - ) - ) - ) - let paymentSection = Section( header: .title(Localization.paymentSectionHeaderTitle.uppercased()), content: .payment(PaymentContent(booking: booking)) @@ -51,7 +53,6 @@ final class BookingDetailsViewModel: ObservableObject { sections = [ headerSection, appointmentDetailsSection, - customerSection, attendanceSection, paymentSection, bookingNotes @@ -59,6 +60,64 @@ final class BookingDetailsViewModel: ObservableObject { } } +// MARK: Syncing + +extension BookingDetailsViewModel { + func syncData() async { + await syncCustomer() + } +} + +private extension BookingDetailsViewModel { + func syncCustomer() async { + guard shouldSyncCustomer else { + return + } + + do { + let fetchedCustomer = try await retrieveCustomer() + customerContent.update(with: fetchedCustomer) + + let customerSection = Section( + header: .title(Localization.customerSectionHeaderTitle.uppercased()), + content: .customer(customerContent) + ) + withAnimation { + sections.insert(customerSection, at: 2) + } + } catch { + DDLogError("⛔️ Error synchronizing Customer for Booking: \(error)") + } + } + + func retrieveCustomer() async throws -> Customer { + try await withCheckedThrowingContinuation { continuation in + let action = CustomerAction.retrieveCustomer( + siteID: booking.siteID, + customerID: booking.customerID + ) { result in + switch result { + case .success(let customer): + continuation.resume(returning: customer) + case .failure(let error): + continuation.resume(throwing: error) + } + } + stores.dispatch(action) + } + } + + /// Returns true when the `customerID` is non-zero and customer section doesn't exist + var shouldSyncCustomer: Bool { + return booking.customerID > 0 && !sections.contains(where: { + if case .customer = $0.content { + return true + } + return false + }) + } +} + private extension BookingDetailsViewModel { static func navigationTitle(for booking: Booking) -> String { let titleFormat = NSLocalizedString( diff --git a/WooCommerce/Classes/ViewModels/Booking Details/CustomerContent.swift b/WooCommerce/Classes/ViewModels/Booking Details/CustomerContent.swift index 0a962538144..6c47b2f5cde 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/CustomerContent.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/CustomerContent.swift @@ -1,10 +1,42 @@ import Foundation +import Networking extension BookingDetailsViewModel { - struct CustomerContent { - let nameText: String - let emailText: String - let phoneText: String - let billingAddressText: String? + final class CustomerContent: ObservableObject { + @Published var nameText: String? + @Published var emailText: String? + @Published var phoneText: String? + @Published var billingAddressText: String? + + func update(with customer: Customer) { + let name = [ + customer.firstName, + customer.lastName + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " ") + + let billingAddress = customer.billing.flatMap(formatAddress) + + nameText = name + emailText = customer.email + phoneText = customer.billing?.phone ?? "" + billingAddressText = billingAddress + } + + private func formatAddress(_ address: Address) -> String { + [ + address.address1, + address.address2, + address.city, + address.state, + address.postcode, + address.country + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: "\n") + } } } diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView+RowTextStyle.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView+RowTextStyle.swift new file mode 100644 index 00000000000..280e69a8317 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView+RowTextStyle.swift @@ -0,0 +1,18 @@ +import SwiftUI + +private extension BookingDetailsView { + struct RowTextStyle: ViewModifier { + func body(content: Content) -> some View { + content + .font(TextFont.bodyMedium) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + } + } +} + +extension View { + func rowTextStyle() -> some View { + self.modifier(BookingDetailsView.RowTextStyle()) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift index 4570b91a2c9..9e0b108414b 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift @@ -9,7 +9,7 @@ struct BookingDetailsView: View { @ObservedObject private var viewModel: BookingDetailsViewModel - private enum Layout { + enum Layout { static let contentSidePadding: CGFloat = 16 static let contentVerticalPadding: CGFloat = 16 static let headerContentVerticalPadding: CGFloat = 6 @@ -18,7 +18,7 @@ struct BookingDetailsView: View { static let rowTextVerticalPadding: CGFloat = 11 } - fileprivate enum TextFont { + enum TextFont { static var bodyMedium: Font { Font.body.weight(.medium) } @@ -36,8 +36,13 @@ struct BookingDetailsView: View { } } } + .onAppear { + Task { + await viewModel.syncData() + } + } .refreshable { - print("Refresh triggered") + await viewModel.syncData() } .navigationBarTitleDisplayMode(.inline) .navigationTitle(viewModel.navigationTitle) @@ -127,7 +132,7 @@ private extension BookingDetailsView { case .attendance(let content): attendanceView(with: content) case .customer(let content): - customerDetailsView(with: content) + CustomerDetailsView(content: content) case .payment(let content): paymentDetailsView(with: content) case .bookingNotes: @@ -194,71 +199,6 @@ private extension BookingDetailsView { } } - func customerDetailsView(with content: BookingDetailsViewModel.CustomerContent) -> some View { - VStack(spacing: 0) { - /// Name - HStack { - Text(content.nameText) - .rowTextStyle() - Spacer() - } - .padding(.vertical, Layout.rowTextVerticalPadding) - - Divider() - .padding(.trailing, -Layout.contentSidePadding) - - /// Email - HStack { - Text(content.emailText) - .rowTextStyle() - Spacer() - Image(systemName: "doc.on.doc") - .font(TextFont.bodyMedium) - .foregroundStyle(Color.accentColor) - } - .padding(.vertical, Layout.rowTextVerticalPadding) - .tappable { - print("On email copy") - } - - Divider() - .padding(.trailing, -Layout.contentSidePadding) - - /// Phone - HStack { - Text(content.phoneText) - .rowTextStyle() - Spacer() - Image(systemName: "ellipsis") - .font(TextFont.bodyMedium) - .foregroundStyle(Color.accentColor) - } - .padding(.vertical, Layout.rowTextVerticalPadding) - .tappable { - print("On phone ellipsis") - } - - Divider() - .padding(.trailing, -Layout.contentSidePadding) - - /// Billing address - if let billingAddressText = content.billingAddressText, !billingAddressText.isEmpty { - HStack { - VStack(alignment: .leading) { - Text(Localization.billingAddressRowTitle) - .rowTextStyle() - Text(billingAddressText) - .font(TextFont.bodyMedium) - .foregroundStyle(.secondary) - .multilineTextAlignment(.leading) - } - Spacer() - } - .padding(.vertical, Layout.rowTextVerticalPadding) - } - } - } - func paymentDetailsView(with content: BookingDetailsViewModel.PaymentContent) -> some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 8) { @@ -317,22 +257,7 @@ private extension BookingDetailsView { } } -private struct RowTextStyle: ViewModifier { - func body(content: Content) -> some View { - content - .font(BookingDetailsView.TextFont.bodyMedium) - .foregroundStyle(.primary) - .multilineTextAlignment(.leading) - } -} - -private extension View { - func rowTextStyle() -> some View { - self.modifier(RowTextStyle()) - } -} - -private extension BookingDetailsView { +extension BookingDetailsView { enum Localization { static let markAsPaid = NSLocalizedString( "BookingDetailsView.options.markAsPaid", diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift new file mode 100644 index 00000000000..e654587632d --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift @@ -0,0 +1,80 @@ +import Combine +import SwiftUI + +extension BookingDetailsView { + struct CustomerDetailsView: View { + @ObservedObject var content: BookingDetailsViewModel.CustomerContent + + var body: some View { + VStack(spacing: 0) { + /// Name + if let nameText = content.nameText, !nameText.isEmpty { + HStack { + Text(nameText) + .rowTextStyle() + Spacer() + } + .padding(.vertical, Layout.rowTextVerticalPadding) + + Divider() + .padding(.trailing, -Layout.contentSidePadding) + } + + /// Email + if let emailText = content.emailText, !emailText.isEmpty { + HStack { + Text(emailText) + .rowTextStyle() + Spacer() + Image(systemName: "doc.on.doc") + .font(TextFont.bodyMedium) + .foregroundStyle(Color.accentColor) + } + .padding(.vertical, Layout.rowTextVerticalPadding) + .tappable { + print("On email copy") + } + + Divider() + .padding(.trailing, -Layout.contentSidePadding) + } + + /// Phone + if let phoneText = content.phoneText, !phoneText.isEmpty { + HStack { + Text(phoneText) + .rowTextStyle() + Spacer() + Image(systemName: "ellipsis") + .font(TextFont.bodyMedium) + .foregroundStyle(Color.accentColor) + } + .padding(.vertical, Layout.rowTextVerticalPadding) + .tappable { + print("On phone ellipsis") + } + + Divider() + .padding(.trailing, -Layout.contentSidePadding) + } + + + /// Billing address + if let billingAddressText = content.billingAddressText, !billingAddressText.isEmpty { + HStack { + VStack(alignment: .leading) { + Text(Localization.billingAddressRowTitle) + .rowTextStyle() + Text(billingAddressText) + .font(TextFont.bodyMedium) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + } + Spacer() + } + .padding(.vertical, Layout.rowTextVerticalPadding) + } + } + } + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 7c9fe8912b4..fb149bb8478 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -984,6 +984,8 @@ 26FE09E124DB8FA000B9BDF5 /* SurveyCoordinatorControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26FE09E024DB8FA000B9BDF5 /* SurveyCoordinatorControllerTests.swift */; }; 26FFC50C2BED7C5A0067B3A4 /* WatchDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B249702BEC801400730730 /* WatchDependencies.swift */; }; 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 */; }; 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 */; }; @@ -3877,6 +3879,8 @@ 26FE09E024DB8FA000B9BDF5 /* SurveyCoordinatorControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyCoordinatorControllerTests.swift; sourceTree = ""; }; 26FFD32628C6A0A4002E5E5E /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -7959,6 +7963,8 @@ 2DAC2C962E82A169008521AF /* Booking Details */ = { isa = PBXGroup; children = ( + 2D052FB22E9408AF004111FD /* BookingDetailsView+RowTextStyle.swift */, + 2D052FB32E9408AF004111FD /* CustomerDetailsView.swift */, 2DAC2C972E82A185008521AF /* BookingDetailsView.swift */, 2D05FE952E8D71EA004111FD /* UpdateAttendanceStatusView.swift */, ); @@ -14837,6 +14843,8 @@ 020B2F8F23BD9F1F00BD79AD /* IntegerInputFormatter.swift in Sources */, 0204F0CA29C047A400CFC78F /* SelfSizingHostingController.swift in Sources */, 7441EBC9226A71AA008BF83D /* TitleBodyTableViewCell.swift in Sources */, + 2D052FB42E9408AF004111FD /* CustomerDetailsView.swift in Sources */, + 2D052FB52E9408AF004111FD /* BookingDetailsView+RowTextStyle.swift in Sources */, EECB6D1E2AFBFE0000040BC9 /* WooSubscriptionProductsEligibilityChecker.swift in Sources */, CEA455C72BB5CA5E00D932CF /* AnalyticsSessionsUnavailableCard.swift in Sources */, EE45E29D2A381A250085F227 /* ProductDescriptionGenerationCelebrationView.swift in Sources */, From 70eaee26415744f87e10dcd517e44ab3470e2e87 Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Tue, 7 Oct 2025 13:52:47 +0300 Subject: [PATCH 02/12] Trigger data sync on init --- .../Bookings/Booking Details/BookingDetailsView.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift index 9e0b108414b..12118353686 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift @@ -26,6 +26,10 @@ struct BookingDetailsView: View { init(_ viewModel: BookingDetailsViewModel) { self.viewModel = viewModel + + Task { + await viewModel.syncData() + } } var body: some View { @@ -36,11 +40,6 @@ struct BookingDetailsView: View { } } } - .onAppear { - Task { - await viewModel.syncData() - } - } .refreshable { await viewModel.syncData() } From 98a2c9d65c08f06808d0cf447d7a26d22e94ebfd Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Tue, 7 Oct 2025 14:50:11 +0300 Subject: [PATCH 03/12] Load local customer for booking details --- .../Yosemite/Actions/CustomerAction.swift | 3 ++ .../Yosemite/Stores/CustomerStore.swift | 18 +++++++ .../BookingDetailsViewModel.swift | 51 +++++++++++++++---- .../Booking Details/BookingDetailsView.swift | 4 ++ 4 files changed, 67 insertions(+), 9 deletions(-) diff --git a/Modules/Sources/Yosemite/Actions/CustomerAction.swift b/Modules/Sources/Yosemite/Actions/CustomerAction.swift index 2d161fe10b9..d1107f59c1f 100644 --- a/Modules/Sources/Yosemite/Actions/CustomerAction.swift +++ b/Modules/Sources/Yosemite/Actions/CustomerAction.swift @@ -100,4 +100,7 @@ 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 6225093c782..f8014db07ad 100644 --- a/Modules/Sources/Yosemite/Stores/CustomerStore.swift +++ b/Modules/Sources/Yosemite/Stores/CustomerStore.swift @@ -80,6 +80,8 @@ 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) } } @@ -251,6 +253,16 @@ 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: @@ -429,3 +441,9 @@ private extension CustomerStore { storageCustomer.update(with: readOnlyCustomer) } } + +// MARK: - Errors + +enum CustomerStoreError: Error { + case notFound +} diff --git a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift index 6e733f19e9b..e1c7a3091b4 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift @@ -60,6 +60,30 @@ final class BookingDetailsViewModel: ObservableObject { } } +// MARK: Local Data + +extension BookingDetailsViewModel { + func loadLocalData() { + loadCustomerData() + } +} + +private extension BookingDetailsViewModel { + func loadCustomerData() { + guard booking.customerID > 0 else { + return + } + + 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) + } + } + stores.dispatch(action) + } +} + // MARK: Syncing extension BookingDetailsViewModel { @@ -76,15 +100,7 @@ private extension BookingDetailsViewModel { do { let fetchedCustomer = try await retrieveCustomer() - customerContent.update(with: fetchedCustomer) - - let customerSection = Section( - header: .title(Localization.customerSectionHeaderTitle.uppercased()), - content: .customer(customerContent) - ) - withAnimation { - sections.insert(customerSection, at: 2) - } + updateCustomerSection(with: fetchedCustomer) } catch { DDLogError("⛔️ Error synchronizing Customer for Booking: \(error)") } @@ -107,6 +123,23 @@ private extension BookingDetailsViewModel { } } + private func updateCustomerSection(with customer: Customer) { + customerContent.update(with: customer) + + // Avoid adding if it already exists + guard !sections.contains(where: { if case .customer = $0.content { return true } else { return false } }) else { + return + } + + let customerSection = Section( + header: .title(Localization.customerSectionHeaderTitle.uppercased()), + content: .customer(customerContent) + ) + withAnimation { + sections.insert(customerSection, at: 2) + } + } + /// Returns true when the `customerID` is non-zero and customer section doesn't exist var shouldSyncCustomer: Bool { return booking.customerID > 0 && !sections.contains(where: { diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift index 12118353686..aff1ff3efe7 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift @@ -27,6 +27,10 @@ struct BookingDetailsView: View { init(_ viewModel: BookingDetailsViewModel) { self.viewModel = viewModel + /// Trigger local data load + viewModel.loadLocalData() + + /// Trigger remote data sync Task { await viewModel.syncData() } From 24bafde10bb9b137a37d92c1c835abea190c2bc9 Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Tue, 7 Oct 2025 15:02:00 +0300 Subject: [PATCH 04/12] Rework customer details content view for proper divider rendering --- .../Booking Details/CustomerDetailsView.swift | 151 +++++++++++------- 1 file changed, 92 insertions(+), 59 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift index e654587632d..1eeaa9bc51c 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift @@ -5,76 +5,109 @@ extension BookingDetailsView { struct CustomerDetailsView: View { @ObservedObject var content: BookingDetailsViewModel.CustomerContent + private enum Row: Hashable { + case name(String) + case email(String) + case phone(String) + case billingAddress(String) + } + + private var rows: [Row] { + var result = [Row]() + if let name = content.nameText, !name.isEmpty { + result.append(.name(name)) + } + if let email = content.emailText, !email.isEmpty { + result.append(.email(email)) + } + if let phone = content.phoneText, !phone.isEmpty { + result.append(.phone(phone)) + } + if let address = content.billingAddressText, !address.isEmpty { + result.append(.billingAddress(address)) + } + return result + } + var body: some View { VStack(spacing: 0) { - /// Name - if let nameText = content.nameText, !nameText.isEmpty { - HStack { - Text(nameText) - .rowTextStyle() - Spacer() - } - .padding(.vertical, Layout.rowTextVerticalPadding) - - Divider() - .padding(.trailing, -Layout.contentSidePadding) - } + ForEach(Array(rows.enumerated()), id: \.element) { index, row in + view(for: row) - /// Email - if let emailText = content.emailText, !emailText.isEmpty { - HStack { - Text(emailText) - .rowTextStyle() - Spacer() - Image(systemName: "doc.on.doc") - .font(TextFont.bodyMedium) - .foregroundStyle(Color.accentColor) - } - .padding(.vertical, Layout.rowTextVerticalPadding) - .tappable { - print("On email copy") + if index < rows.count - 1 { + Divider() + .padding(.trailing, -Layout.contentSidePadding) } - - Divider() - .padding(.trailing, -Layout.contentSidePadding) } + } + } - /// Phone - if let phoneText = content.phoneText, !phoneText.isEmpty { - HStack { - Text(phoneText) - .rowTextStyle() - Spacer() - Image(systemName: "ellipsis") - .font(TextFont.bodyMedium) - .foregroundStyle(Color.accentColor) - } - .padding(.vertical, Layout.rowTextVerticalPadding) - .tappable { - print("On phone ellipsis") - } + @ViewBuilder + private func view(for row: Row) -> some View { + switch row { + case .name(let nameText): + nameView(with: nameText) + case .email(let emailText): + emailView(with: emailText) + case .phone(let phoneText): + phoneView(with: phoneText) + case .billingAddress(let billingAddressText): + billingAddressView(with: billingAddressText) + } + } - Divider() - .padding(.trailing, -Layout.contentSidePadding) - } + private func nameView(with nameText: String) -> some View { + HStack { + Text(nameText) + .rowTextStyle() + Spacer() + } + .padding(.vertical, Layout.rowTextVerticalPadding) + } + private func emailView(with emailText: String) -> some View { + HStack { + Text(emailText) + .rowTextStyle() + Spacer() + Image(systemName: "doc.on.doc") + .font(TextFont.bodyMedium) + .foregroundStyle(Color.accentColor) + } + .padding(.vertical, Layout.rowTextVerticalPadding) + .tappable { + print("On email copy") + } + } - /// Billing address - if let billingAddressText = content.billingAddressText, !billingAddressText.isEmpty { - HStack { - VStack(alignment: .leading) { - Text(Localization.billingAddressRowTitle) - .rowTextStyle() - Text(billingAddressText) - .font(TextFont.bodyMedium) - .foregroundStyle(.secondary) - .multilineTextAlignment(.leading) - } - Spacer() - } - .padding(.vertical, Layout.rowTextVerticalPadding) + private func phoneView(with phoneText: String) -> some View { + HStack { + Text(phoneText) + .rowTextStyle() + Spacer() + Image(systemName: "ellipsis") + .font(TextFont.bodyMedium) + .foregroundStyle(Color.accentColor) + } + .padding(.vertical, Layout.rowTextVerticalPadding) + .tappable { + print("On phone ellipsis") + } + } + + private func billingAddressView(with billingAddressText: String) -> some View { + HStack { + VStack(alignment: .leading) { + Text(Localization.billingAddressRowTitle) + .rowTextStyle() + Text(billingAddressText) + .font(TextFont.bodyMedium) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) } + Spacer() } + .padding(.vertical, Layout.rowTextVerticalPadding) } } } From dde75552afb604e47370925e7233ac059e36ff62 Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Tue, 7 Oct 2025 15:13:56 +0300 Subject: [PATCH 05/12] Copy email to pasteboard --- .../Booking Details/CustomerDetailsView.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift index 1eeaa9bc51c..b6378defbae 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift @@ -76,7 +76,9 @@ extension BookingDetailsView { } .padding(.vertical, Layout.rowTextVerticalPadding) .tappable { - print("On email copy") + emailText.sendToPasteboard() + let notice = Notice(title: Localization.emailCopiedMessage, feedbackType: .success) + ServiceLocator.noticePresenter.enqueue(notice: notice) } } @@ -98,8 +100,6 @@ extension BookingDetailsView { private func billingAddressView(with billingAddressText: String) -> some View { HStack { VStack(alignment: .leading) { - Text(Localization.billingAddressRowTitle) - .rowTextStyle() Text(billingAddressText) .font(TextFont.bodyMedium) .foregroundStyle(.secondary) @@ -111,3 +111,13 @@ extension BookingDetailsView { } } } + +private extension BookingDetailsView.CustomerDetailsView { + enum Localization { + static let emailCopiedMessage = NSLocalizedString( + "BookingDetailsView.customer.emailCopied.toastMessage", + value: "Email address copied", + comment: "Toast message shown when the user copies the customer's email address." + ) + } +} From 1dd7b077d1680cb771adc79b267fef9e9ab714ef Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Tue, 7 Oct 2025 18:54:45 +0300 Subject: [PATCH 06/12] Add test for customer content --- .../WooCommerce.xcodeproj/project.pbxproj | 4 + .../BookingDetailsViewModelTests.swift | 93 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingDetailsViewModelTests.swift diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index fb149bb8478..e71ab0d5928 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -986,6 +986,7 @@ 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 */; }; 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 */; }; @@ -3881,6 +3882,7 @@ 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 = ""; }; 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 = ""; }; @@ -12515,6 +12517,7 @@ isa = PBXGroup; children = ( DED1E3152E8556270089909C /* BookingListViewModelTests.swift */, + 2D054A292E953E3C004111FD /* BookingDetailsViewModelTests.swift */, ); path = Bookings; sourceTree = ""; @@ -15825,6 +15828,7 @@ DE61979528A25842005E4362 /* StorePickerViewModelTests.swift in Sources */, EEB4E2D329B2047700371C3C /* StoreOnboardingViewHostingControllerTests.swift in Sources */, B57C745120F56EE900EEFC87 /* UITableViewCellHelpersTests.swift in Sources */, + 2D054A2A2E953E3C004111FD /* BookingDetailsViewModelTests.swift in Sources */, 267D6882296485850072ED0C /* ProductVariationGeneratorTests.swift in Sources */, 0225C42824768A4C00C5B4F0 /* FilterProductListViewModelTests.swift in Sources */, D85136C9231E12B600DD0539 /* ReviewViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingDetailsViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingDetailsViewModelTests.swift new file mode 100644 index 00000000000..459496b0dcd --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingDetailsViewModelTests.swift @@ -0,0 +1,93 @@ +import XCTest +import TestKit +import Yosemite +import Storage +import Fakes +import Networking + +@testable import WooCommerce + +@MainActor +final class BookingDetailsViewModelTests: XCTestCase { + private var storesManager: MockStoresManager! + private var storageManager: MockStorageManager! + + override func setUp() { + super.setUp() + storesManager = MockStoresManager(sessionManager: .makeForTesting()) + storageManager = MockStorageManager() + } + + override func tearDown() { + super.tearDown() + storesManager = nil + storageManager = nil + } + + func testLoadCustomerDataPopulatesCustomerContent() { + // Given + let customerID: Int64 = 123 + let mockBooking = Networking.Booking.fake().copy(customerID: customerID) + + let billingAddress = Networking.Address.fake().copy( + address1: "123 Fake St", + address2: "Apt 4B", + city: "Faketown", + state: "FS", + postcode: "12345", + country: "FK", + phone: "123-456-7890" + ) + let mockReadOnlyCustomer = Networking.Customer.fake().copy( + customerID: customerID, + email: "john.doe@example.com", + firstName: "John", + lastName: "Doe", + billing: billingAddress + ) + 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())) + } + + // When + viewModel.loadLocalData() + + // Then + let customerSection = viewModel.sections.first { section in + if case .customer = section.content { + return true + } + return false + } + + guard let customerSection = customerSection, + case let .customer(customerContent) = customerSection.content else { + XCTFail("Customer section not found in view model sections") + return + } + + XCTAssertEqual(customerContent.nameText, "\(mockStorageCustomer.firstName ?? "") \(mockStorageCustomer.lastName ?? "")") + XCTAssertEqual(customerContent.emailText, mockStorageCustomer.email) + XCTAssertEqual(customerContent.phoneText, mockStorageCustomer.billingPhone) + + let expectedBillingAddress = [ + mockStorageCustomer.billingAddress1, + mockStorageCustomer.billingAddress2, + mockStorageCustomer.billingCity, + mockStorageCustomer.billingState, + mockStorageCustomer.billingPostcode, + mockStorageCustomer.billingCountry + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: "\n") + + XCTAssertEqual(customerContent.billingAddressText, expectedBillingAddress) + } +} From 0d279402771678bf23ec8962cef447c7506ca7bf Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Wed, 8 Oct 2025 00:29:00 +0300 Subject: [PATCH 07/12] Bring back Billing Address title --- .../Bookings/Booking Details/BookingDetailsView.swift | 7 ------- .../Bookings/Booking Details/CustomerDetailsView.swift | 9 +++++++++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift index c74618f3671..20c280ffb3e 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift @@ -318,13 +318,6 @@ extension BookingDetailsView { comment: "'Status' row title in attendance section in booking details view." ) - /// Customer section - static let billingAddressRowTitle = NSLocalizedString( - "BookingDetailsView.customer.billingAddress.title", - value: "Billing address", - comment: "Billing address row title in customer section in booking details view." - ) - /// Booking notes static let bookingNotesRowText = NSLocalizedString( "BookingDetailsView.bookingNotes.addANoteRow.title", diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift index b6378defbae..0cacd5715b9 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift @@ -100,6 +100,8 @@ extension BookingDetailsView { private func billingAddressView(with billingAddressText: String) -> some View { HStack { VStack(alignment: .leading) { + Text(Localization.billingAddressRowTitle) + .rowTextStyle() Text(billingAddressText) .font(TextFont.bodyMedium) .foregroundStyle(.secondary) @@ -119,5 +121,12 @@ private extension BookingDetailsView.CustomerDetailsView { value: "Email address copied", comment: "Toast message shown when the user copies the customer's email address." ) + + /// Customer section + static let billingAddressRowTitle = NSLocalizedString( + "BookingDetailsView.customer.billingAddress.title", + value: "Billing address", + comment: "Billing address row title in customer section in booking details view." + ) } } From 00b218488f8eca80118abee4a934084300f9bdd9 Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Wed, 8 Oct 2025 00:29:10 +0300 Subject: [PATCH 08/12] Delete unused import --- .../ViewModels/Booking Details/BookingDetailsViewModel.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift index f5dc566bf24..c9b2baa6558 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift @@ -2,7 +2,6 @@ import Foundation import struct Networking.Booking import struct Networking.Customer import struct Networking.Address -import WooFoundation import Yosemite import SwiftUI // Added for withAnimation From 7f122e2a596520865b2db534a6f21cd9664f79e8 Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Wed, 8 Oct 2025 15:27:30 +0300 Subject: [PATCH 09/12] Remove customer section absence as sync condition --- .../Booking Details/BookingDetailsViewModel.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift index c9b2baa6558..3474dcfe505 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift @@ -140,12 +140,7 @@ private extension BookingDetailsViewModel { /// Returns true when the `customerID` is non-zero and customer section doesn't exist var shouldSyncCustomer: Bool { - return booking.customerID > 0 && !sections.contains(where: { - if case .customer = $0.content { - return true - } - return false - }) + return booking.customerID > 0 } } From be1c57237bc8a03d053cd89effa0be34c5764016 Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Thu, 9 Oct 2025 14:44:46 +0300 Subject: [PATCH 10/12] Delete unnecessary imports --- .../ViewModels/Booking Details/BookingDetailsViewModel.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift index 3474dcfe505..55f131e6833 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift @@ -1,7 +1,4 @@ import Foundation -import struct Networking.Booking -import struct Networking.Customer -import struct Networking.Address import Yosemite import SwiftUI // Added for withAnimation From 396a379a0295fcd2cbfd799375ec7d2b5bd76428 Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Thu, 9 Oct 2025 14:54:58 +0300 Subject: [PATCH 11/12] Apple contentShape modifier for tappable to have the whole area interactive --- WooCommerce/Classes/View Modifiers/View+Tappable.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/WooCommerce/Classes/View Modifiers/View+Tappable.swift b/WooCommerce/Classes/View Modifiers/View+Tappable.swift index 7d02d739f91..80767318123 100644 --- a/WooCommerce/Classes/View Modifiers/View+Tappable.swift +++ b/WooCommerce/Classes/View Modifiers/View+Tappable.swift @@ -8,6 +8,7 @@ private struct TappableViewModifier: ViewModifier { onTap() } label: { content + .contentShape(Rectangle()) } .buttonStyle(.plain) } From 7c2634ebddefa1985c927e81fc949aa38a6559d1 Mon Sep 17 00:00:00 2001 From: RafaelKayumov Date: Thu, 9 Oct 2025 15:23:10 +0300 Subject: [PATCH 12/12] Use .notice view modifier and pass notice presented to booking details view --- .../Bookings/Booking Details/BookingDetailsView.swift | 6 +++++- .../Bookings/Booking Details/CustomerDetailsView.swift | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift index 20c280ffb3e..3ab738a9a13 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift @@ -7,6 +7,7 @@ struct BookingDetailsView: View { @State private var showingOptions = false @State private var showingStatusSheet = false @State private var showingCancelAlert = false + @State private var notice: Notice? @ObservedObject private var viewModel: BookingDetailsViewModel @@ -95,6 +96,7 @@ struct BookingDetailsView: View { } message: { Text(viewModel.cancellationAlertMessage) } + .notice($notice) } } @@ -144,7 +146,9 @@ private extension BookingDetailsView { case .attendance(let content): attendanceView(with: content) case .customer(let content): - CustomerDetailsView(content: content) + CustomerDetailsView(content: content, showNotice: { + notice = $0 + }) case .payment(let content): paymentDetailsView(with: content) case .bookingNotes: diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift index 0cacd5715b9..643ad23c4fc 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/CustomerDetailsView.swift @@ -4,6 +4,7 @@ import SwiftUI extension BookingDetailsView { struct CustomerDetailsView: View { @ObservedObject var content: BookingDetailsViewModel.CustomerContent + let showNotice: (Notice) -> Void private enum Row: Hashable { case name(String) @@ -77,8 +78,12 @@ extension BookingDetailsView { .padding(.vertical, Layout.rowTextVerticalPadding) .tappable { emailText.sendToPasteboard() - let notice = Notice(title: Localization.emailCopiedMessage, feedbackType: .success) - ServiceLocator.noticePresenter.enqueue(notice: notice) + showNotice( + Notice( + title: Localization.emailCopiedMessage, + feedbackType: .success + ) + ) } }