diff --git a/Modules/Sources/Networking/Model/Bookings/Booking.swift b/Modules/Sources/Networking/Model/Bookings/Booking.swift index c05ab7beb05..a77dc822f08 100644 --- a/Modules/Sources/Networking/Model/Bookings/Booking.swift +++ b/Modules/Sources/Networking/Model/Bookings/Booking.swift @@ -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 { @@ -180,6 +184,5 @@ public enum BookingStatus: String, CaseIterable { case cancelled case pendingConfirmation = "pending-confirmation" case confirmed - case inCart = "in-cart" case unknown } diff --git a/Modules/Sources/Yosemite/Stores/BookingStore.swift b/Modules/Sources/Yosemite/Stores/BookingStore.swift index b36aa97bae9..d25260ca1a5 100644 --- a/Modules/Sources/Yosemite/Stores/BookingStore.swift +++ b/Modules/Sources/Yosemite/Stores/BookingStore.swift @@ -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 + await upsertStoredBookingsInBackground( + readOnlyBookings: bookings, + siteID: siteID, + shouldDeleteExistingBookings: shouldDeleteExistingBookings + ) let hasNextPage = bookings.count == pageSize onCompletion(.success(hasNextPage)) } catch { diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift index bcd9ff48e38..573bca2676c 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -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 { + 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))) + } + + /// 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)) +} diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift index a49a93de064..a6f35925c84 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -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: ()) + } } } } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingStatus+Helpers.swift b/WooCommerce/Classes/Bookings/BookingList/BookingStatus+Helpers.swift new file mode 100644 index 00000000000..77982258dd4 --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingList/BookingStatus+Helpers.swift @@ -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" + ) + } + } +} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift index 46b627a42fb..69b648af0d8 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift @@ -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)