Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
@@ -0,0 +1,47 @@
import Foundation
import struct Networking.Booking

extension BookingDetailsViewModel {
struct AppointmentDetailsContent {
static let appointmentDateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "EEEE, dd MMMM yyyy"
return dateFormatter
}()

static let appointmentTimeFrameFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "hh:mm a"
return dateFormatter
}()

struct Row: Identifiable {
let title: String
let value: String

var id: String {
return title
}
}

let rows: [Row]

init(_ booking: Booking) {
let durationMinutes = Int(booking.endDate.timeIntervalSince(booking.startDate) / 60)
let appointmentDate = Self.appointmentDateFormatter.string(from: booking.startDate)
let appointmentTimeFrame = [
Self.appointmentTimeFrameFormatter.string(from: booking.startDate),
Self.appointmentTimeFrameFormatter.string(from: booking.endDate)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: these can be simplified:

Suggested change
let appointmentDate = Self.appointmentDateFormatter.string(from: booking.startDate)
let appointmentTimeFrame = [
Self.appointmentTimeFrameFormatter.string(from: booking.startDate),
Self.appointmentTimeFrameFormatter.string(from: booking.endDate)
let appointmentDate = booking.startDate.formatted(date: .numeric, time: .omitted)
let appointmentTimeFrame = [
booking.startDate.formatted(date: .omitted, time: .shortened),
booking.endDate.formatted(date: .omitted, time: .shortened)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1220ac5

].joined(separator: " - ")

rows = [
Row(title: "Date", value: appointmentDate),
Row(title: "Time", value: appointmentTimeFrame),
Row(title: "Service", value: "Women's Haircut"),
Row(title: "Quantity", value: "1"),
Row(title: "Duration", value: String(durationMinutes)),
Row(title: "Cost", value: booking.cost)
]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation

extension BookingDetailsViewModel {
struct Section: Identifiable {
var id: String {
return content.id
}

let headerText: String?
let footerText: String?
let content: SectionContent

init(
headerText: String? = nil,
footerText: String? = nil,
content: SectionContent
) {
self.headerText = headerText
self.footerText = footerText
self.content = content
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation

extension BookingDetailsViewModel {
enum SectionContent {
case header(HeaderContent)
case appointmentDetails(AppointmentDetailsContent)
case attendance(AttendanceContent)
case payment(PaymentContent)
case customer(CustomerContent)
case teamMember(TeamMemberContent)
}
}

extension BookingDetailsViewModel.SectionContent: Identifiable {
var id: String {
switch self {
case .header:
return "header"
case .appointmentDetails:
return "appointmentDetails"
case .attendance:
return "attendance"
case .payment:
return "payment"
case .customer:
return "customer"
case .teamMember:
return "teamMember"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation
import SwiftUI

extension BookingDetailsViewModel {
enum Status {
case booked, paid
}
}

extension BookingDetailsViewModel.Status {
var labelText: String {
switch self {
case .booked:
return "Booked"
case .paid:
return "Paid"
}
}

var labelColor: Color {
switch self {
case .booked:
return Color(UIColor.systemGray6)
case .paid:
return Color(UIColor.systemGray6)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation
import struct Networking.Booking

extension BookingDetailsViewModel {
struct AttendanceContent {
}

struct PaymentContent {
}

struct CustomerContent {
}

struct TeamMemberContent {
}
}

final class BookingDetailsViewModel: ObservableObject {
let sections: [Section]

init(booking: Booking) {
let headerSection = Section.init(
content: .header(HeaderContent(booking))
)

let appointmentDetailsSection = Section(
headerText: "Appointment Details".uppercased(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: please ensure to localize this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added localizations in 9a19487

content: .appointmentDetails(AppointmentDetailsContent(booking))
)

sections = [
headerSection,
appointmentDetailsSection
]
}
}
24 changes: 24 additions & 0 deletions WooCommerce/Classes/ViewModels/Booking Details/HeaderContent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation
import struct Networking.Booking

extension BookingDetailsViewModel {
struct HeaderContent: Hashable {
static let dateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd/MM/yyyy, hh:mm a"
return dateFormatter
}()

let bookingDate: String
let serviceName: String
let customerName: String
let status: [Status]

init(_ booking: Booking) {
bookingDate = Self.dateFormatter.string(from: booking.startDate)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: similar comment as above about formatting date in a simpler way.

serviceName = "Women's Haircut"
customerName = "Margarita Nikolaevna"
status = [.paid, .booked]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import SwiftUI
import WooFoundation
import Networking

struct BookingDetailsView: View {
@Environment(\.safeAreaInsets) var safeAreaInsets: EdgeInsets

@ObservedObject private var viewModel: BookingDetailsViewModel

private enum Layout {
static let contentSidePadding: CGFloat = 16
static let headerContentVerticalPadding: CGFloat = 6
static let headerBadgesAdditionalTopPadding: CGFloat = 4
}

fileprivate enum TextFont {
static var bodyMedium: Font {
Font.body.weight(.medium)
}

static var bodyRegular: Font {
Font.body.weight(.regular)
}
}

init(_ viewModel: BookingDetailsViewModel) {
self.viewModel = viewModel
}

var body: some View {
RefreshablePlainList(action: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this type seems outdated. Since we're targeting iOS 17 minimum now, please consider using ScrollView with refreshable for simplicity.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in aa85786

print("Refresh triggered")
}) {
VStack(alignment: .leading, spacing: .zero) {
ForEach(viewModel.sections) { section in
sectionView(with: section)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.background(Color(uiColor: .systemGroupedBackground))
}
}

private extension BookingDetailsView {
func sectionView(with section: BookingDetailsViewModel.Section) -> some View {
VStack(alignment: .leading, spacing: 0) {
if let headerText = section.headerText {
ListHeaderView(
text: headerText,
alignment: .left
)
.padding(.horizontal, insets: safeAreaInsets)
.accessibility(addTraits: .isHeader)
}

sectionContentView(section.content)
.padding(.horizontal, Layout.contentSidePadding)
.background(Color(.systemBackground))
.addingTopAndBottomDividers()

if let footerText = section.footerText {
Text(footerText)
.padding(.horizontal, Layout.contentSidePadding)
.font(.footnote)
.foregroundColor(.gray)
}
}
}

@ViewBuilder
func sectionContentView(_ content: BookingDetailsViewModel.SectionContent) -> some View {
switch content {
case .header(let content):
headerView(with: content)
case .appointmentDetails(let content):
appointmentDetailsView(with: content)
default:
EmptyView()
}
}

func headerView(with headerContent: BookingDetailsViewModel.HeaderContent) -> some View {
VStack(alignment: .leading, spacing: Layout.headerContentVerticalPadding) {
Text(headerContent.bookingDate)
.font(.caption)
.foregroundColor(.secondary)
Text(headerContent.serviceName)
.font(TextFont.bodyMedium)
Text(headerContent.customerName)
.font(TextFont.bodyMedium)
.foregroundColor(.secondary)
HStack {
ForEach(headerContent.status, id: \.self) { status in
Text(status.labelText)
.font(.caption)
.padding(4)
.background(status.labelColor)
.cornerRadius(4)
}
}
.padding(.top, Layout.headerBadgesAdditionalTopPadding)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
}

func appointmentDetailsView(with content: BookingDetailsViewModel.AppointmentDetailsContent) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(content.rows) { row in
TitleAndTextFieldRow(
title: row.title,
placeholder: String(),
text: .constant(row.value),
fieldAlignment: .trailing,
keyboardType: .default,
titleFont: BookingDetailsView.TextFont.bodyMedium,
valueColor: .secondary,
valueFont: BookingDetailsView.TextFont.bodyRegular,
horizontalPadding: 0 // Parent section padding is added elsewhere,
)

if row.id != content.rows.last?.id {
Divider()
.padding(.trailing, -Layout.contentSidePadding)
}
}
}
}
}

#if DEBUG
struct BookingDetailsView_Previews: PreviewProvider {
static var previews: some View {
let now = Date()
let hourFromNow = now.addingTimeInterval(3600)
let sampleBooking = Booking(
siteID: 1,
bookingID: 123,
allDay: false,
cost: "70.00",
customerID: 456,
dateCreated: now,
dateModified: now,
endDate: hourFromNow,
googleCalendarEventID: nil,
orderID: 789,
orderItemID: 101,
parentID: 0,
productID: 112,
resourceID: 113,
startDate: now,
statusKey: "paid",
localTimezone: "America/New_York"
)
let viewModel = BookingDetailsViewModel(booking: sampleBooking)
return BookingDetailsView(viewModel)
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import UIKit
import SwiftUI

/// periphery: ignore - will be used after Booking list is ready
final class BookingDetailsViewController: UIHostingController<BookingDetailsView> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the BookingDetailsView directly in BookingTabView? If so I think this hosting controller would be redundant.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed host vc in 7b45174


private let viewModel: BookingDetailsViewModel

init(viewModel: BookingDetailsViewModel) {
self.viewModel = viewModel
super.init(rootView: BookingDetailsView(viewModel))
}

@MainActor
required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func viewDidLoad() {
super.viewDidLoad()
configureNavigationBar()
}

private func configureNavigationBar() {
navigationItem.title = NSLocalizedString("Booking Details", comment: "Booking details screen title")
navigationItem.largeTitleDisplayMode = .never
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ struct AddEditCoupon: View {
editable: true,
fieldAlignment: .leading,
keyboardType: .decimalPad,
contentColor: viewModel.amountFieldColor,
titleColor: viewModel.amountFieldColor,
valueColor: viewModel.amountFieldColor,
inputFormatter: CouponAmountInputFormatter()) { beginningEditing in
if !beginningEditing {
viewModel.validatePercentageAmountInput(withWarning: true)
Expand Down
Loading