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
5 changes: 4 additions & 1 deletion Modules/Sources/Networking/Model/Bookings/Booking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ public struct Booking: Codable, GeneratedCopiable, Equatable, GeneratedFakeable
}
}

extension Booking: Identifiable {
public var id: Int64 { bookingID }
}

/// Defines all of the Booking CodingKeys
///
private extension Booking {
Expand Down Expand Up @@ -180,6 +184,5 @@ public enum BookingStatus: String, CaseIterable {
case cancelled
case pendingConfirmation = "pending-confirmation"
case confirmed
case inCart = "in-cart"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This status seems to not be supported so I'm removing it.

case unknown
}
7 changes: 6 additions & 1 deletion Modules/Sources/Yosemite/Stores/BookingStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ private extension BookingStore {
let bookings = try await remote.loadAllBookings(for: siteID,
pageNumber: pageNumber,
pageSize: pageSize)
await upsertStoredBookingsInBackground(readOnlyBookings: bookings, siteID: siteID)
let shouldDeleteExistingBookings = pageNumber == Default.firstPageNumber
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is needed to clear obsolete items.

await upsertStoredBookingsInBackground(
readOnlyBookings: bookings,
siteID: siteID,
shouldDeleteExistingBookings: shouldDeleteExistingBookings
)
let hasNextPage = bookings.count == pageSize
onCompletion(.success(hasNextPage))
} catch {
Expand Down
224 changes: 222 additions & 2 deletions WooCommerce/Classes/Bookings/BookingList/BookingListView.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,234 @@
import SwiftUI
import struct Yosemite.Booking

struct BookingListView: View {
// periphery:ignore
@ObservedObject private var viewModel: BookingListViewModel
@State private var selectedTabIndex = 0

@Namespace var topID

private let tabs = [Localization.today, Localization.upcoming, Localization.all]

init(viewModel: BookingListViewModel) {
self.viewModel = viewModel
}

var body: some View {
Text("Hello, World!")
NavigationStack {
VStack {
switch viewModel.syncState {
case .empty:
headerView
Spacer()
Text("No bookings found") // TODO: update this in WOOMOB-1394
Spacer()
case .syncingFirstPage:
headerView
Spacer()
ProgressView().progressViewStyle(.circular)
Spacer()
case .results:
bookingList
}
}
.navigationTitle(Localization.viewTitle)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button {
// TODO
} label: {
Image(systemName: "magnifyingglass")
}
}
}
.task {
viewModel.loadBookings()
}
}
}
}

private extension BookingListView {
var headerView: some View {
VStack(spacing: 0) {
topTabView
Divider()
HStack {
Button {
// TODO
} label: {
Text(Localization.sortBy)
.font(.body)
.foregroundStyle(Color.accentColor)
}
Spacer()
Button {
// TODO
} label: {
Text(Localization.filter)
.font(.body)
.foregroundStyle(Color.accentColor)
}
}
.padding()
.background(Color(.listForeground(modal: false)))
Divider()
}
}

var topTabView: some View {
GeometryReader { geometry in
HStack {
ForEach(Array(tabs.enumerated()), id: \.element) { (index, title) in
Button {
withAnimation(.easeInOut(duration: 0.3)) {
selectedTabIndex = index
}
} label: {
Text(title)
.font(.subheadline)
.foregroundStyle(selectedTabIndex == index ? Color.accentColor : Color.primary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
}
.overlay(alignment: .bottom) {
Color.accentColor
.frame(width: geometry.size.width / CGFloat(tabs.count),
height: Layout.selectedTabIndicatorHeight)
.offset(x: tabIndicatorOffset(containerWidth: geometry.size.width,
tabCount: tabs.count,
selectedIndex: selectedTabIndex))
.animation(.easeInOut(duration: 0.3), value: selectedTabIndex)
}
}
.frame(height: Layout.topTabBarHeight)
.background(Color(.listForeground(modal: false)))
}

var bookingList: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
Section {
ForEach(viewModel.bookings) { item in
bookingItem(item)
}
} header: {
headerView
}
.id(topID)

InfiniteScrollIndicator(showContent: viewModel.shouldShowBottomActivityIndicator)
.padding(.top, Layout.viewPadding)
.onAppear {
viewModel.onLoadNextPageAction()
}
}
}
.refreshable {
Copy link
Contributor

Choose a reason for hiding this comment

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

Will there be a way to trigger a refresh from an empty state? I emulated an empty list response. Looks like there is no way to trigger the pull-to-refresh when the "No bookings found" is presented.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point! I updated WOOMOB-1394 to add a point about pull-to-refresh.

await viewModel.onRefreshAction()
// workaround as navigation bar is not snapped back after refreshing
proxy.scrollTo(topID, anchor: .top)
}
}
}

func bookingItem(_ booking: Booking) -> some View {
VStack(spacing: 0) {
VStack(alignment: .leading) {
Text(booking.startDate.formatted(date: .numeric, time: .shortened))
.font(.body)
.fontWeight(.medium)
.frame(maxWidth: .infinity, alignment: .leading)

// TODO: fetch bookable products & customer to get names or wait for API update
Text(String(format: "%@ • %@", "Women's Hair cut", "Marianne"))
.font(.footnote)
.fontWeight(.medium)
.foregroundStyle(Color.secondary)

HStack {
// TODO: update this when attendance status is available
// Update badge colors if design changes as statuses are not clarified now.
statusBadge(text: "Booked", color: Layout.defaultBadgeColor)
statusBadge(text: booking.bookingStatus.localizedTitle, color: Layout.defaultBadgeColor)
Spacer()
}
}
.padding(Layout.viewPadding)

Divider()
.padding(.leading, Layout.viewPadding)
}
.background(Color(.listForeground(modal: false))) // TODO: update selected background color as part of selection handling
}

func statusBadge(text: String, color: Color) -> some View {
Text(text)
.font(.caption2)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.clipShape(RoundedRectangle(cornerRadius: 4)))
}
Comment on lines +168 to +174
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm thinking about making this reusable since the details screen has the same header. I'll do that when it's merged.


/// SwiftUI's coordinate system places (0,0) at the center of the container, so we need to:
/// 1. Calculate how far the selected tab is from the left edge
/// 2. Adjust for the center-based coordinate system
/// 3. Center the indicator within the selected tab
///
func tabIndicatorOffset(containerWidth: CGFloat, tabCount: Int, selectedIndex: Int) -> CGFloat {
let tabWidth = containerWidth / CGFloat(tabCount)
let distanceFromLeftEdge = tabWidth * CGFloat(selectedIndex)
let adjustmentForCenterOrigin = containerWidth / 2
let centerWithinTab = tabWidth / 2

return distanceFromLeftEdge - adjustmentForCenterOrigin + centerWithinTab
}
}
private extension BookingListView {
enum Layout {
static let viewPadding: CGFloat = 16
static let topTabBarHeight: CGFloat = 44
static let selectedTabIndicatorHeight: CGFloat = 3.0
static let defaultBadgeColor = Color(uiColor: .init(light: .systemGray6, dark: .systemGray5))
}

enum Localization {
static let viewTitle = NSLocalizedString(
"bookingListView.view.title",
value: "Bookings",
comment: "Title of the booking list view"
)
static let sortBy = NSLocalizedString(
"bookingListView.sortBy",
value: "Sort by",
comment: "Button to select the order of the booking list"
)
static let filter = NSLocalizedString(
"bookingListView.filter",
value: "Filter",
comment: "Button to filter the booking list"
)
static let today = NSLocalizedString(
"bookingListView.today",
value: "Today",
comment: "Tab title for today's bookings"
)
static let upcoming = NSLocalizedString(
"bookingListView.upcoming",
value: "Upcoming",
comment: "Tab title for upcoming bookings"
)
static let all = NSLocalizedString(
"bookingListView.all",
value: "All",
comment: "Tab title for all bookings"
)
}
}

#Preview {
BookingListView(viewModel: BookingListViewModel(siteID: 123))
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ final class BookingListViewModel: ObservableObject {
}

/// Called when the user pulls down the list to refresh.
/// - Parameter completion: called when the refresh completes.
func onRefreshAction(completion: @escaping () -> Void) {
paginationTracker.resync(reason: nil) {
completion()
@MainActor
func onRefreshAction() async {
await withCheckedContinuation { continuation in
paginationTracker.resync(reason: nil) {
continuation.resume(returning: ())
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation
import enum Networking.BookingStatus

extension BookingStatus {
var localizedTitle: String {
switch self {
case .complete:
NSLocalizedString(
"bookingStatus.title.complete",
value: "Complete",
comment: "Status of a complete booking"
)
case .paid:
NSLocalizedString(
"bookingStatus.title.paid",
value: "Paid",
comment: "Status of a paid booking"
)
case .unpaid:
NSLocalizedString(
"bookingStatus.title.unpaid",
value: "Unpaid",
comment: "Status of an unpaid booking"
)
case .cancelled:
NSLocalizedString(
"bookingStatus.title.canceled",
value: "Cancelled",
comment: "Status of a canceled booking"
)
case .pendingConfirmation:
NSLocalizedString(
"bookingStatus.title.pendingConfirmation",
value: "Pending confirmation",
comment: "Status of a pending confirmation booking"
)
case .confirmed:
NSLocalizedString(
"bookingStatus.title.confirmed",
value: "Confirmed",
comment: "Status of a confirmed booking"
)
case .unknown:
NSLocalizedString(
"bookingStatus.title.unknown",
value: "Unknown",
comment: "Status of a booking with unexpected status"
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,7 @@ struct BookingListViewModelTests {
let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores)

// When
await confirmation("Refresh completion") { confirmation in
viewModel.onRefreshAction {
confirmation()
}
}
await viewModel.onRefreshAction()

// Then
#expect(skip == 0)
Expand Down