Skip to content

Commit 5d91aaf

Browse files
authored
Bookings: Add booking list UI (#16177)
2 parents 3429c06 + 51d05dd commit 5d91aaf

File tree

6 files changed

+290
-13
lines changed

6 files changed

+290
-13
lines changed

Modules/Sources/Networking/Model/Bookings/Booking.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ public struct Booking: Codable, GeneratedCopiable, Equatable, GeneratedFakeable
137137
}
138138
}
139139

140+
extension Booking: Identifiable {
141+
public var id: Int64 { bookingID }
142+
}
143+
140144
/// Defines all of the Booking CodingKeys
141145
///
142146
private extension Booking {
@@ -180,6 +184,5 @@ public enum BookingStatus: String, CaseIterable {
180184
case cancelled
181185
case pendingConfirmation = "pending-confirmation"
182186
case confirmed
183-
case inCart = "in-cart"
184187
case unknown
185188
}

Modules/Sources/Yosemite/Stores/BookingStore.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@ private extension BookingStore {
5959
let bookings = try await remote.loadAllBookings(for: siteID,
6060
pageNumber: pageNumber,
6161
pageSize: pageSize)
62-
await upsertStoredBookingsInBackground(readOnlyBookings: bookings, siteID: siteID)
62+
let shouldDeleteExistingBookings = pageNumber == Default.firstPageNumber
63+
await upsertStoredBookingsInBackground(
64+
readOnlyBookings: bookings,
65+
siteID: siteID,
66+
shouldDeleteExistingBookings: shouldDeleteExistingBookings
67+
)
6368
let hasNextPage = bookings.count == pageSize
6469
onCompletion(.success(hasNextPage))
6570
} catch {
Lines changed: 222 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,234 @@
11
import SwiftUI
2+
import struct Yosemite.Booking
23

34
struct BookingListView: View {
4-
// periphery:ignore
55
@ObservedObject private var viewModel: BookingListViewModel
6+
@State private var selectedTabIndex = 0
7+
8+
@Namespace var topID
9+
10+
private let tabs = [Localization.today, Localization.upcoming, Localization.all]
611

712
init(viewModel: BookingListViewModel) {
813
self.viewModel = viewModel
914
}
1015

1116
var body: some View {
12-
Text("Hello, World!")
17+
NavigationStack {
18+
VStack {
19+
switch viewModel.syncState {
20+
case .empty:
21+
headerView
22+
Spacer()
23+
Text("No bookings found") // TODO: update this in WOOMOB-1394
24+
Spacer()
25+
case .syncingFirstPage:
26+
headerView
27+
Spacer()
28+
ProgressView().progressViewStyle(.circular)
29+
Spacer()
30+
case .results:
31+
bookingList
32+
}
33+
}
34+
.navigationTitle(Localization.viewTitle)
35+
.toolbar {
36+
ToolbarItem(placement: .confirmationAction) {
37+
Button {
38+
// TODO
39+
} label: {
40+
Image(systemName: "magnifyingglass")
41+
}
42+
}
43+
}
44+
.task {
45+
viewModel.loadBookings()
46+
}
47+
}
48+
}
49+
}
50+
51+
private extension BookingListView {
52+
var headerView: some View {
53+
VStack(spacing: 0) {
54+
topTabView
55+
Divider()
56+
HStack {
57+
Button {
58+
// TODO
59+
} label: {
60+
Text(Localization.sortBy)
61+
.font(.body)
62+
.foregroundStyle(Color.accentColor)
63+
}
64+
Spacer()
65+
Button {
66+
// TODO
67+
} label: {
68+
Text(Localization.filter)
69+
.font(.body)
70+
.foregroundStyle(Color.accentColor)
71+
}
72+
}
73+
.padding()
74+
.background(Color(.listForeground(modal: false)))
75+
Divider()
76+
}
77+
}
78+
79+
var topTabView: some View {
80+
GeometryReader { geometry in
81+
HStack {
82+
ForEach(Array(tabs.enumerated()), id: \.element) { (index, title) in
83+
Button {
84+
withAnimation(.easeInOut(duration: 0.3)) {
85+
selectedTabIndex = index
86+
}
87+
} label: {
88+
Text(title)
89+
.font(.subheadline)
90+
.foregroundStyle(selectedTabIndex == index ? Color.accentColor : Color.primary)
91+
}
92+
.frame(maxWidth: .infinity)
93+
.padding(.vertical, 12)
94+
}
95+
}
96+
.overlay(alignment: .bottom) {
97+
Color.accentColor
98+
.frame(width: geometry.size.width / CGFloat(tabs.count),
99+
height: Layout.selectedTabIndicatorHeight)
100+
.offset(x: tabIndicatorOffset(containerWidth: geometry.size.width,
101+
tabCount: tabs.count,
102+
selectedIndex: selectedTabIndex))
103+
.animation(.easeInOut(duration: 0.3), value: selectedTabIndex)
104+
}
105+
}
106+
.frame(height: Layout.topTabBarHeight)
107+
.background(Color(.listForeground(modal: false)))
108+
}
109+
110+
var bookingList: some View {
111+
ScrollViewReader { proxy in
112+
ScrollView {
113+
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
114+
Section {
115+
ForEach(viewModel.bookings) { item in
116+
bookingItem(item)
117+
}
118+
} header: {
119+
headerView
120+
}
121+
.id(topID)
122+
123+
InfiniteScrollIndicator(showContent: viewModel.shouldShowBottomActivityIndicator)
124+
.padding(.top, Layout.viewPadding)
125+
.onAppear {
126+
viewModel.onLoadNextPageAction()
127+
}
128+
}
129+
}
130+
.refreshable {
131+
await viewModel.onRefreshAction()
132+
// workaround as navigation bar is not snapped back after refreshing
133+
proxy.scrollTo(topID, anchor: .top)
134+
}
135+
}
136+
}
137+
138+
func bookingItem(_ booking: Booking) -> some View {
139+
VStack(spacing: 0) {
140+
VStack(alignment: .leading) {
141+
Text(booking.startDate.formatted(date: .numeric, time: .shortened))
142+
.font(.body)
143+
.fontWeight(.medium)
144+
.frame(maxWidth: .infinity, alignment: .leading)
145+
146+
// TODO: fetch bookable products & customer to get names or wait for API update
147+
Text(String(format: "%@ • %@", "Women's Hair cut", "Marianne"))
148+
.font(.footnote)
149+
.fontWeight(.medium)
150+
.foregroundStyle(Color.secondary)
151+
152+
HStack {
153+
// TODO: update this when attendance status is available
154+
// Update badge colors if design changes as statuses are not clarified now.
155+
statusBadge(text: "Booked", color: Layout.defaultBadgeColor)
156+
statusBadge(text: booking.bookingStatus.localizedTitle, color: Layout.defaultBadgeColor)
157+
Spacer()
158+
}
159+
}
160+
.padding(Layout.viewPadding)
161+
162+
Divider()
163+
.padding(.leading, Layout.viewPadding)
164+
}
165+
.background(Color(.listForeground(modal: false))) // TODO: update selected background color as part of selection handling
166+
}
167+
168+
func statusBadge(text: String, color: Color) -> some View {
169+
Text(text)
170+
.font(.caption2)
171+
.padding(.horizontal, 8)
172+
.padding(.vertical, 4)
173+
.background(color.clipShape(RoundedRectangle(cornerRadius: 4)))
174+
}
175+
176+
/// SwiftUI's coordinate system places (0,0) at the center of the container, so we need to:
177+
/// 1. Calculate how far the selected tab is from the left edge
178+
/// 2. Adjust for the center-based coordinate system
179+
/// 3. Center the indicator within the selected tab
180+
///
181+
func tabIndicatorOffset(containerWidth: CGFloat, tabCount: Int, selectedIndex: Int) -> CGFloat {
182+
let tabWidth = containerWidth / CGFloat(tabCount)
183+
let distanceFromLeftEdge = tabWidth * CGFloat(selectedIndex)
184+
let adjustmentForCenterOrigin = containerWidth / 2
185+
let centerWithinTab = tabWidth / 2
186+
187+
return distanceFromLeftEdge - adjustmentForCenterOrigin + centerWithinTab
13188
}
14189
}
190+
private extension BookingListView {
191+
enum Layout {
192+
static let viewPadding: CGFloat = 16
193+
static let topTabBarHeight: CGFloat = 44
194+
static let selectedTabIndicatorHeight: CGFloat = 3.0
195+
static let defaultBadgeColor = Color(uiColor: .init(light: .systemGray6, dark: .systemGray5))
196+
}
197+
198+
enum Localization {
199+
static let viewTitle = NSLocalizedString(
200+
"bookingListView.view.title",
201+
value: "Bookings",
202+
comment: "Title of the booking list view"
203+
)
204+
static let sortBy = NSLocalizedString(
205+
"bookingListView.sortBy",
206+
value: "Sort by",
207+
comment: "Button to select the order of the booking list"
208+
)
209+
static let filter = NSLocalizedString(
210+
"bookingListView.filter",
211+
value: "Filter",
212+
comment: "Button to filter the booking list"
213+
)
214+
static let today = NSLocalizedString(
215+
"bookingListView.today",
216+
value: "Today",
217+
comment: "Tab title for today's bookings"
218+
)
219+
static let upcoming = NSLocalizedString(
220+
"bookingListView.upcoming",
221+
value: "Upcoming",
222+
comment: "Tab title for upcoming bookings"
223+
)
224+
static let all = NSLocalizedString(
225+
"bookingListView.all",
226+
value: "All",
227+
comment: "Tab title for all bookings"
228+
)
229+
}
230+
}
231+
232+
#Preview {
233+
BookingListView(viewModel: BookingListViewModel(siteID: 123))
234+
}

WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,12 @@ final class BookingListViewModel: ObservableObject {
5555
}
5656

5757
/// Called when the user pulls down the list to refresh.
58-
/// - Parameter completion: called when the refresh completes.
59-
func onRefreshAction(completion: @escaping () -> Void) {
60-
paginationTracker.resync(reason: nil) {
61-
completion()
58+
@MainActor
59+
func onRefreshAction() async {
60+
await withCheckedContinuation { continuation in
61+
paginationTracker.resync(reason: nil) {
62+
continuation.resume(returning: ())
63+
}
6264
}
6365
}
6466
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import Foundation
2+
import enum Networking.BookingStatus
3+
4+
extension BookingStatus {
5+
var localizedTitle: String {
6+
switch self {
7+
case .complete:
8+
NSLocalizedString(
9+
"bookingStatus.title.complete",
10+
value: "Complete",
11+
comment: "Status of a complete booking"
12+
)
13+
case .paid:
14+
NSLocalizedString(
15+
"bookingStatus.title.paid",
16+
value: "Paid",
17+
comment: "Status of a paid booking"
18+
)
19+
case .unpaid:
20+
NSLocalizedString(
21+
"bookingStatus.title.unpaid",
22+
value: "Unpaid",
23+
comment: "Status of an unpaid booking"
24+
)
25+
case .cancelled:
26+
NSLocalizedString(
27+
"bookingStatus.title.canceled",
28+
value: "Cancelled",
29+
comment: "Status of a canceled booking"
30+
)
31+
case .pendingConfirmation:
32+
NSLocalizedString(
33+
"bookingStatus.title.pendingConfirmation",
34+
value: "Pending confirmation",
35+
comment: "Status of a pending confirmation booking"
36+
)
37+
case .confirmed:
38+
NSLocalizedString(
39+
"bookingStatus.title.confirmed",
40+
value: "Confirmed",
41+
comment: "Status of a confirmed booking"
42+
)
43+
case .unknown:
44+
NSLocalizedString(
45+
"bookingStatus.title.unknown",
46+
value: "Unknown",
47+
comment: "Status of a booking with unexpected status"
48+
)
49+
}
50+
}
51+
}

WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -285,11 +285,7 @@ struct BookingListViewModelTests {
285285
let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores)
286286

287287
// When
288-
await confirmation("Refresh completion") { confirmation in
289-
viewModel.onRefreshAction {
290-
confirmation()
291-
}
292-
}
288+
await viewModel.onRefreshAction()
293289

294290
// Then
295291
#expect(skip == 0)

0 commit comments

Comments
 (0)