Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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)")
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ extension BookingDetailsViewModel {
@Published var phoneText: String?
@Published var billingAddressText: String?

@MainActor
func update(with customer: Customer) {
let name = [
customer.firstName,
Expand Down
47 changes: 38 additions & 9 deletions WooCommerce/Classes/ViewModels/Booking Details/HeaderContent.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
8 changes: 6 additions & 2 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -3883,8 +3884,9 @@
26FFD32928C6A0F4002E5E5E /* UIImage+Widgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Widgets.swift"; sourceTree = "<group>"; };
2D052FB22E9408AF004111FD /* BookingDetailsView+RowTextStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookingDetailsView+RowTextStyle.swift"; sourceTree = "<group>"; };
2D052FB32E9408AF004111FD /* CustomerDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerDetailsView.swift; sourceTree = "<group>"; };
2D054A292E953E3C004111FD /* BookingDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookingDetailsViewModelTests.swift; sourceTree = "<group>"; };
2D05337D2E951A62004111FD /* BookingDetailsViewModel+PriceFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookingDetailsViewModel+PriceFormatting.swift"; sourceTree = "<group>"; };
2D054A292E953E3C004111FD /* BookingDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookingDetailsViewModelTests.swift; sourceTree = "<group>"; };
2D0555802E9693E1004111FD /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = "<group>"; };
2D05D19E2E82D1A3004111FD /* BookingDetailsViewModel+Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookingDetailsViewModel+Section.swift"; sourceTree = "<group>"; };
2D05D1A02E82D1EF004111FD /* BookingDetailsViewModel+SectionContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BookingDetailsViewModel+SectionContent.swift"; sourceTree = "<group>"; };
2D05D1A12E82D233004111FD /* HeaderContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderContent.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -7968,6 +7970,7 @@
2DAC2C962E82A169008521AF /* Booking Details */ = {
isa = PBXGroup;
children = (
2D0555802E9693E1004111FD /* HeaderView.swift */,
2D052FB22E9408AF004111FD /* BookingDetailsView+RowTextStyle.swift */,
2D052FB32E9408AF004111FD /* CustomerDetailsView.swift */,
2DAC2C972E82A185008521AF /* BookingDetailsView.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)

Expand All @@ -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")
Expand All @@ -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)
}
}