-
Notifications
You must be signed in to change notification settings - Fork 121
[Bookings][Part 1] Booking details screen #16168
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
b63375b
4be259b
989a670
d83625f
d626b52
4aed3d8
0336bd1
8fa80f4
407000d
d53a9a4
7b45174
1220ac5
aa85786
9a19487
93d2f67
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| ].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(), | ||
|
||
| content: .appointmentDetails(AppointmentDetailsContent(booking)) | ||
| ) | ||
|
|
||
| sections = [ | ||
| headerSection, | ||
| appointmentDetailsSection | ||
| ] | ||
| } | ||
| } | ||
| 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) | ||
|
||
| 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: { | ||
|
||
| 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> { | ||
|
||
|
|
||
| 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 | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in 1220ac5