diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift new file mode 100644 index 00000000000..1968c355528 --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift @@ -0,0 +1,133 @@ +import SwiftUI +import struct Yosemite.Booking + +struct BookingListContainerView: View { + @ObservedObject private var viewModel: BookingListContainerViewModel + + init(viewModel: BookingListContainerViewModel) { + self.viewModel = viewModel + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + headerView + TabView(selection: $viewModel.selectedTab) { + ForEach(BookingListTab.allCases, id: \.rawValue) { tab in + BookingListView(viewModel: viewModel.listViewModel(for: tab)) + .tag(tab) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } + .navigationTitle(Localization.viewTitle) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button { + // TODO + } label: { + Image(systemName: "magnifyingglass") + } + } + } + } + } +} + +private extension BookingListContainerView { + 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(BookingListTab.allCases, id: \.rawValue) { tab in + Button { + withAnimation(.easeInOut(duration: 0.3)) { + viewModel.selectedTab = tab + } + } label: { + Text(tab.title) + .font(.subheadline) + .foregroundStyle(viewModel.selectedTab == tab ? Color.accentColor : Color.primary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + } + .overlay(alignment: .bottom) { + Color.accentColor + .frame(width: geometry.size.width / CGFloat(BookingListTab.allCases.count), + height: Layout.selectedTabIndicatorHeight) + .offset(x: tabIndicatorOffset(containerWidth: geometry.size.width, + tabCount: BookingListTab.allCases.count, + selectedIndex: viewModel.selectedTab.rawValue)) + .animation(.easeInOut(duration: 0.3), value: viewModel.selectedTab.rawValue) + } + } + .frame(height: Layout.topTabBarHeight) + .background(Color(.listForeground(modal: false))) + } + + /// 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 BookingListContainerView { + enum Layout { + static let topTabBarHeight: CGFloat = 44 + static let selectedTabIndicatorHeight: CGFloat = 3.0 + } + + 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" + ) + } +} diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift new file mode 100644 index 00000000000..27941dac58c --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift @@ -0,0 +1,59 @@ +import Foundation + +/// View model for `BookingListContainerView` +final class BookingListContainerViewModel: ObservableObject { + private let todayListViewModel: BookingListViewModel + private let upcomingListViewModel: BookingListViewModel + private let allListViewModel: BookingListViewModel + + @Published var selectedTab: BookingListTab = .today + + init(siteID: Int64) { + self.todayListViewModel = BookingListViewModel(siteID: siteID, type: .today) + self.upcomingListViewModel = BookingListViewModel(siteID: siteID, type: .upcoming) + self.allListViewModel = BookingListViewModel(siteID: siteID, type: .all) + } + + func listViewModel(for tab: BookingListTab) -> BookingListViewModel { + switch tab { + case .today: + todayListViewModel + case .upcoming: + upcomingListViewModel + case .all: + allListViewModel + } + } +} + +enum BookingListTab: Int, CaseIterable { + case today + case upcoming + case all + + var title: String { + switch self { + case .today: Localization.today + case .upcoming: Localization.upcoming + case .all: Localization.all + } + } + + private enum Localization { + 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" + ) + } +} diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift index 573bca2676c..4a06a35e38a 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -3,45 +3,29 @@ import struct Yosemite.Booking struct BookingListView: View { @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 { - 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") - } - } + VStack { + switch viewModel.syncState { + case .empty: + Spacer() + Text("No bookings found") // TODO: update this in WOOMOB-1394 + Spacer() + case .syncingFirstPage: + Spacer() + ProgressView().progressViewStyle(.circular) + Spacer() + case .results: + bookingList } - .task { + } + .task { + // Only load first page if no content is available. + if viewModel.bookings.isEmpty { viewModel.loadBookings() } } @@ -49,89 +33,22 @@ struct BookingListView: View { } 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) + var bookingList: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(viewModel.bookings) { item in + bookingItem(item) } - } - .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) + InfiniteScrollIndicator(showContent: viewModel.shouldShowBottomActivityIndicator) + .padding(.top, Layout.viewPadding) + .onAppear { + viewModel.onLoadNextPageAction() } - .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) - } + .refreshable { + await viewModel.onRefreshAction() } } @@ -172,63 +89,11 @@ private extension BookingListView { .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 a6f35925c84..fc599311178 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -1,4 +1,3 @@ -// periphery:ignore:all import Foundation import Yosemite import protocol Storage.StorageManagerType @@ -9,6 +8,7 @@ final class BookingListViewModel: ObservableObject { @Published private(set) var bookings: [Booking] = [] private let siteID: Int64 + private let type: BookingListTab private let stores: StoresManager private let storage: StorageManagerType @@ -33,9 +33,11 @@ final class BookingListViewModel: ObservableObject { }() init(siteID: Int64, + type: BookingListTab, stores: StoresManager = ServiceLocator.stores, storage: StorageManagerType = ServiceLocator.storageManager) { self.siteID = siteID + self.type = type self.stores = stores self.storage = storage self.paginationTracker = PaginationTracker(pageFirstIndex: pageFirstIndex) @@ -90,7 +92,12 @@ private extension BookingListViewModel { /// Updates row view models and sync state. func updateResults() { - bookings = resultsController.fetchedObjects + /// TODO: update logic for fetching bookings + if type == .all { + bookings = resultsController.fetchedObjects + } else { + bookings = [] + } transitionToResultsUpdatedState() } } diff --git a/WooCommerce/Classes/Bookings/BookingsTabView.swift b/WooCommerce/Classes/Bookings/BookingsTabView.swift index 1e3c59ed7c9..ef1fb7c350d 100644 --- a/WooCommerce/Classes/Bookings/BookingsTabView.swift +++ b/WooCommerce/Classes/Bookings/BookingsTabView.swift @@ -47,7 +47,7 @@ struct BookingsTabView: View { var body: some View { NavigationSplitView(columnVisibility: $visibility) { - BookingListView(viewModel: BookingListViewModel(siteID: siteID)) + BookingListContainerView(viewModel: BookingListContainerViewModel(siteID: siteID)) } detail: { Text("Booking Detail Screen") } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift index 69b648af0d8..b0c8a13bdc5 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift @@ -35,7 +35,7 @@ struct BookingListViewModelTests { } invocationCountOfLoadBookings += 1 } - let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) // Then #expect(viewModel.syncState == .empty) @@ -52,7 +52,7 @@ struct BookingListViewModelTests { } invocationCountOfLoadBookings += 1 } - let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) // When viewModel.loadBookings() @@ -63,7 +63,7 @@ struct BookingListViewModelTests { @Test func state_is_syncing_first_page_upon_load_bookings_if_no_existing_booking_in_storage() { // Given - let viewModel = BookingListViewModel(siteID: sampleSiteID) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all) // When viewModel.loadBookings() @@ -75,7 +75,10 @@ struct BookingListViewModelTests { @Test func state_is_results_upon_load_bookings_if_existing_bookings_in_storage() { let existingBooking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 123) insertBookings([existingBooking]) - let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: MockStoresManager(sessionManager: .testingInstance), storage: storageManager) + let viewModel = BookingListViewModel(siteID: sampleSiteID, + type: .all, + stores: MockStoresManager(sessionManager: .testingInstance), + storage: storageManager) // When viewModel.loadBookings() @@ -95,7 +98,10 @@ struct BookingListViewModelTests { self.insertBookings([booking]) onCompletion(.success(true)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores, storage: storageManager) + let viewModel = BookingListViewModel(siteID: sampleSiteID, + type: .all, + stores: stores, + storage: storageManager) var states = [BookingListViewModel.SyncState]() await confirmation("State transitions") { confirmation in @@ -129,7 +135,10 @@ struct BookingListViewModelTests { } onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores, storage: storageManager) + let viewModel = BookingListViewModel(siteID: sampleSiteID, + type: .all, + stores: stores, + storage: storageManager) var states = [BookingListViewModel.SyncState]() await confirmation("State transitions") { confirmation in @@ -170,7 +179,10 @@ struct BookingListViewModelTests { onCompletion(.success(pageNumber == 1 ? true : false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores, storage: storageManager) + let viewModel = BookingListViewModel(siteID: sampleSiteID, + type: .all, + stores: stores, + storage: storageManager) var states = [BookingListViewModel.SyncState]() await confirmation("State transitions") { confirmation in @@ -212,7 +224,10 @@ struct BookingListViewModelTests { self.insertBookings([booking1, booking2]) onCompletion(.success(true)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores, storage: storageManager) + let viewModel = BookingListViewModel(siteID: sampleSiteID, + type: .all, + stores: stores, + storage: storageManager) // When viewModel.loadBookings() @@ -233,7 +248,10 @@ struct BookingListViewModelTests { } onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores, storage: storageManager) + let viewModel = BookingListViewModel(siteID: sampleSiteID, + type: .all, + stores: stores, + storage: storageManager) // When viewModel.loadBookings() @@ -255,7 +273,10 @@ struct BookingListViewModelTests { self.insertBookings(items) onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores, storage: storageManager) + let viewModel = BookingListViewModel(siteID: sampleSiteID, + type: .all, + stores: stores, + storage: storageManager) // When viewModel.loadBookings() @@ -282,7 +303,7 @@ struct BookingListViewModelTests { onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) // When await viewModel.onRefreshAction()