Skip to content

Commit 48aea2d

Browse files
authored
Bookings: Add empty and error states for booking list (#16211)
2 parents 1d87c2e + c9eecb3 commit 48aea2d

File tree

13 files changed

+256
-12
lines changed

13 files changed

+256
-12
lines changed

WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import struct Yosemite.Booking
33

44
struct BookingListContainerView: View {
55
@ObservedObject private var viewModel: BookingListContainerViewModel
6+
@ScaledMetric private var scale: CGFloat = 1.0
67
@Binding var selectedBooking: Booking?
78

89
init(viewModel: BookingListContainerViewModel, selectedBooking: Binding<Booking?>) {
@@ -82,18 +83,19 @@ private extension BookingListContainerView {
8283
.padding(.vertical, 12)
8384
}
8485
}
86+
.frame(height: Layout.topTabBarHeight * scale)
8587
.overlay(alignment: .bottom) {
8688
Color.accentColor
8789
.frame(width: geometry.size.width / CGFloat(BookingListTab.allCases.count),
88-
height: Layout.selectedTabIndicatorHeight)
90+
height: Layout.selectedTabIndicatorHeight * scale)
8991
.offset(x: tabIndicatorOffset(containerWidth: geometry.size.width,
9092
tabCount: BookingListTab.allCases.count,
9193
selectedIndex: viewModel.selectedTab.rawValue),
92-
y: -Layout.selectedTabIndicatorHeight / 2)
94+
y: Layout.selectedTabIndicatorHeight * scale / 2)
9395
.animation(.easeInOut(duration: 0.3), value: viewModel.selectedTab.rawValue)
9496
}
9597
}
96-
.frame(height: Layout.topTabBarHeight)
98+
.frame(height: Layout.topTabBarHeight * scale)
9799
.background(Color(.listForeground(modal: false)))
98100
}
99101

WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,34 @@ enum BookingListTab: Int, CaseIterable {
6161
}
6262
}
6363

64+
func emptyStateTitle(hasFilters: Bool) -> String {
65+
guard !hasFilters else {
66+
return Localization.EmptyState.filterTitle
67+
}
68+
switch self {
69+
case .today:
70+
return Localization.EmptyState.todayTitle
71+
case .upcoming:
72+
return Localization.EmptyState.upcomingTitle
73+
case .all:
74+
return Localization.EmptyState.filterTitle
75+
}
76+
}
77+
78+
func emptyStateDescription(hasFilters: Bool) -> String {
79+
guard !hasFilters else {
80+
return Localization.EmptyState.filterDescription
81+
}
82+
switch self {
83+
case .today:
84+
return Localization.EmptyState.todayDescription
85+
case .upcoming:
86+
return Localization.EmptyState.upcomingDescription
87+
case .all:
88+
return ""
89+
}
90+
}
91+
6492
private enum Localization {
6593
static let today = NSLocalizedString(
6694
"bookingListView.today",
@@ -77,5 +105,37 @@ enum BookingListTab: Int, CaseIterable {
77105
value: "All",
78106
comment: "Tab title for all bookings"
79107
)
108+
enum EmptyState {
109+
static let todayTitle = NSLocalizedString(
110+
"bookingListView.emptyState.today.title",
111+
value: "No bookings today",
112+
comment: "Title for the empty state when no bookings for today is found"
113+
)
114+
static let todayDescription = NSLocalizedString(
115+
"bookingListView.emptyState.today.description",
116+
value: "You don't have any appointments or events scheduled for today.",
117+
comment: "Description for the empty state when no bookings for today is found"
118+
)
119+
static let upcomingTitle = NSLocalizedString(
120+
"bookingListView.emptyState.upcoming.title",
121+
value: "No upcoming bookings",
122+
comment: "Title for the empty state when there's no bookings for today"
123+
)
124+
static let upcomingDescription = NSLocalizedString(
125+
"bookingListView.emptyState.upcoming.description",
126+
value: "You don't have any future appointments or events scheduled yet.",
127+
comment: "Description for the empty state when there's no upcoming bookings"
128+
)
129+
static let filterTitle = NSLocalizedString(
130+
"bookingListView.emptyState.filter.title",
131+
value: "No bookings found",
132+
comment: "Title for the empty state when there's no bookings for the given filter"
133+
)
134+
static let filterDescription = NSLocalizedString(
135+
"bookingListView.emptyState.filter.description",
136+
value: "No bookings match your filters. Try adjusting them to see more results.",
137+
comment: "Description for the empty state when there's no bookings for the given filter"
138+
)
139+
}
80140
}
81141
}

WooCommerce/Classes/Bookings/BookingList/BookingListView.swift

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import struct Yosemite.Booking
33

44
struct BookingListView: View {
55
@ObservedObject private var viewModel: BookingListViewModel
6+
@StateObject private var connectivityMonitor = ConnectivityMonitor()
7+
@ScaledMetric private var scale: CGFloat = 1.0
68
@Binding var selectedBooking: Booking?
79

810
init(viewModel: BookingListViewModel, selectedBooking: Binding<Booking?>) {
@@ -14,9 +16,7 @@ struct BookingListView: View {
1416
VStack {
1517
switch viewModel.syncState {
1618
case .empty:
17-
Spacer()
18-
Text("No bookings found") // TODO: update this in WOOMOB-1394
19-
Spacer()
19+
emptyStateView
2020
case .syncingFirstPage:
2121
Spacer()
2222
ProgressView().progressViewStyle(.circular)
@@ -28,6 +28,12 @@ struct BookingListView: View {
2828
.task {
2929
viewModel.loadBookings()
3030
}
31+
.overlay(alignment: .bottom) {
32+
if viewModel.errorFetching {
33+
errorSnackBar
34+
.transition(.move(edge: .bottom))
35+
}
36+
}
3137
}
3238
}
3339

@@ -46,6 +52,7 @@ private extension BookingListView {
4652
}
4753
}
4854
.listStyle(.plain)
55+
.background(Color(.listBackground))
4956
.accentColor(Color(.listSelectedBackground))
5057
.refreshable {
5158
await viewModel.onRefreshAction()
@@ -88,11 +95,85 @@ private extension BookingListView {
8895
.padding(.vertical, 4)
8996
.background(color.clipShape(RoundedRectangle(cornerRadius: 4)))
9097
}
98+
99+
var emptyStateView: some View {
100+
GeometryReader { proxy in
101+
ScrollView {
102+
VStack(spacing: Layout.emptyStatePadding) {
103+
Spacer()
104+
Image(uiImage: .noBookings)
105+
.resizable()
106+
.aspectRatio(contentMode: .fit)
107+
.frame(width: Layout.emptyStateImageWidth * scale)
108+
.padding(.bottom, Layout.viewPadding)
109+
VStack(spacing: Layout.textVerticalPadding) {
110+
Text(viewModel.emptyStateTitle)
111+
.font(.title2)
112+
.fontWeight(.semibold)
113+
.foregroundStyle(.primary)
114+
Text(viewModel.emptyStateDescription)
115+
.font(.title3)
116+
.foregroundStyle(.secondary)
117+
}
118+
if viewModel.hasFilters {
119+
VStack(spacing: Layout.textVerticalPadding) {
120+
Button("Change filters") {
121+
// TODO
122+
}
123+
.buttonStyle(PrimaryButtonStyle())
124+
Button("Clear filters") {
125+
// TODO
126+
}
127+
.buttonStyle(SecondaryButtonStyle())
128+
}
129+
}
130+
Spacer()
131+
}
132+
.multilineTextAlignment(.center)
133+
.padding(.horizontal, Layout.emptyStatePadding)
134+
.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
135+
}
136+
.refreshable {
137+
await viewModel.onRefreshAction()
138+
}
139+
}
140+
}
141+
142+
var errorSnackBar: some View {
143+
Text(Localization.errorMessage)
144+
.foregroundStyle(Color(.listForeground(modal: false)))
145+
.frame(maxWidth: .infinity, alignment: .leading)
146+
.padding(Layout.viewPadding)
147+
.background {
148+
RoundedRectangle(cornerRadius: Layout.cornerRadius)
149+
.fill(Color(.text))
150+
}
151+
.padding(Layout.viewPadding)
152+
.padding(.bottom, connectivityMonitor.isOffline ? OfflineBannerView.height : 0)
153+
.contentShape(Rectangle())
154+
.onTapGesture {
155+
withAnimation {
156+
viewModel.errorFetching = false
157+
}
158+
}
159+
}
91160
}
92161

93162
private extension BookingListView {
94163
enum Layout {
164+
static let textVerticalPadding: CGFloat = 8
95165
static let viewPadding: CGFloat = 16
166+
static let emptyStatePadding: CGFloat = 24
167+
static let emptyStateImageWidth: CGFloat = 67
96168
static let defaultBadgeColor = Color(uiColor: .init(light: .systemGray6, dark: .systemGray5))
169+
static let cornerRadius: CGFloat = 8
170+
}
171+
172+
enum Localization {
173+
static let errorMessage = NSLocalizedString(
174+
"bookingList.errorMessage",
175+
value: "Error fetching bookings",
176+
comment: "Error message when fetching bookings fails"
177+
)
97178
}
98179
}

WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import SwiftUI
23
import Yosemite
34
import protocol Storage.StorageManagerType
45

@@ -7,6 +8,21 @@ final class BookingListViewModel: ObservableObject {
78

89
@Published private(set) var bookings: [Booking] = []
910

11+
@Published var errorFetching = false
12+
13+
var hasFilters: Bool {
14+
// TODO: Update when adding filters
15+
return false
16+
}
17+
18+
var emptyStateTitle: String {
19+
type.emptyStateTitle(hasFilters: hasFilters)
20+
}
21+
22+
var emptyStateDescription: String {
23+
type.emptyStateDescription(hasFilters: hasFilters)
24+
}
25+
1026
private let siteID: Int64
1127
private let type: BookingListTab
1228
private let stores: StoresManager
@@ -112,6 +128,9 @@ private extension BookingListViewModel {
112128
extension BookingListViewModel: PaginationTrackerDelegate {
113129
func sync(pageNumber: Int, pageSize: Int, reason: String?, onCompletion: SyncCompletion?) {
114130
transitionToSyncingState()
131+
withAnimation {
132+
errorFetching = false
133+
}
115134
let shouldClearCache = reason == Self.refreshCacheReason
116135
let action = BookingAction.synchronizeBookings(
117136
siteID: siteID,
@@ -127,6 +146,9 @@ extension BookingListViewModel: PaginationTrackerDelegate {
127146

128147
case .failure(let error):
129148
DDLogError("⛔️ Error synchronizing bookings: \(error)")
149+
withAnimation {
150+
self?.errorFetching = true
151+
}
130152
onCompletion?(.failure(error))
131153
}
132154

WooCommerce/Classes/Bookings/BookingsTabView.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import SwiftUI
2+
import Combine
23
import struct Yosemite.Booking
34

45
/// Hosting view for `BookingsTabView`
@@ -14,10 +15,6 @@ final class BookingsTabViewHostingController: UIHostingController<BookingsTabVie
1415
fatalError("init(coder:) has not been implemented")
1516
}
1617

17-
override var shouldShowOfflineBanner: Bool {
18-
return true
19-
}
20-
2118
func didSwitchStore(id: Int64) {
2219
rootView = BookingsTabView(siteID: id)
2320
}
@@ -41,6 +38,7 @@ struct BookingsTabView: View {
4138
@State private var selectedBooking: Booking?
4239
@State private var visibility: NavigationSplitViewVisibility = .automatic
4340
@StateObject private var bookingListContainerViewModel: BookingListContainerViewModel
41+
@StateObject private var connectivityMonitor = ConnectivityMonitor()
4442

4543
init(siteID: Int64) {
4644
_bookingListContainerViewModel = StateObject(wrappedValue: BookingListContainerViewModel(siteID: siteID))
@@ -58,5 +56,11 @@ struct BookingsTabView: View {
5856
}
5957
}
6058
.navigationSplitViewStyle(.balanced)
59+
.safeAreaInset(edge: .bottom) {
60+
if connectivityMonitor.isOffline {
61+
OfflineBannerViewRepresentable()
62+
.frame(height: OfflineBannerView.height)
63+
}
64+
}
6165
}
6266
}

WooCommerce/Classes/Extensions/UIImage+Woo.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,10 @@ extension UIImage {
832832
return UIImage(named: "woo-wp-no-site")!.imageFlippedForRightToLeftLayoutDirection()
833833
}
834834

835+
static var noBookings: UIImage {
836+
UIImage(named: "no-bookings")!
837+
}
838+
835839
static var incorrectRoleError: UIImage {
836840
return UIImage(named: "woo-incorrect-role-error")!.imageFlippedForRightToLeftLayoutDirection()
837841
}

WooCommerce/Classes/ViewRelated/ReusableViews/OfflineBannerView.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,17 @@ final class OfflineBannerView: UIView {
3636
messageLabel.text = NSLocalizedString("Offline - using cached data", comment: "Message for offline banner")
3737
messageLabel.applyCalloutStyle()
3838
messageLabel.textColor = .white
39+
messageLabel.adjustsFontForContentSizeCategory = false
40+
messageLabel.adjustsFontSizeToFitWidth = true
3941
messageLabel.translatesAutoresizingMaskIntoConstraints = false
4042

4143
stackView.addArrangedSubview(imageView)
4244
stackView.addArrangedSubview(messageLabel)
4345

4446
addSubview(stackView)
4547
NSLayoutConstraint.activate([
46-
stackView.safeLeadingAnchor.constraint(greaterThanOrEqualTo: safeLeadingAnchor, constant: 0),
47-
stackView.safeBottomAnchor.constraint(greaterThanOrEqualTo: safeBottomAnchor, constant: 0),
48+
stackView.safeLeadingAnchor.constraint(greaterThanOrEqualTo: safeLeadingAnchor, constant: 8),
49+
stackView.safeBottomAnchor.constraint(greaterThanOrEqualTo: safeBottomAnchor, constant: 8),
4850
stackView.centerXAnchor.constraint(equalTo: centerXAnchor),
4951
stackView.centerYAnchor.constraint(equalTo: centerYAnchor)
5052
])
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import SwiftUI
2+
import Combine
3+
import WooFoundation
4+
5+
/// Observable object that monitors connectivity status
6+
///
7+
final class ConnectivityMonitor: ObservableObject {
8+
@Published private(set) var isOffline: Bool = false
9+
10+
private let connectivityObserver: ConnectivityObserver
11+
12+
init(connectivityObserver: ConnectivityObserver = ServiceLocator.connectivityObserver) {
13+
self.connectivityObserver = connectivityObserver
14+
observeConnectivity()
15+
}
16+
17+
private func observeConnectivity() {
18+
connectivityObserver.statusPublisher
19+
.map { status in
20+
status == .notReachable
21+
}
22+
.receive(on: DispatchQueue.main)
23+
.assign(to: &$isOffline)
24+
}
25+
}
26+
27+
/// SwiftUI wrapper for UIKit OfflineBannerView
28+
///
29+
struct OfflineBannerViewRepresentable: UIViewRepresentable {
30+
func makeUIView(context: Context) -> OfflineBannerView {
31+
let view = OfflineBannerView(frame: .zero)
32+
view.backgroundColor = .gray
33+
return view
34+
}
35+
36+
func updateUIView(_ uiView: OfflineBannerView, context: Context) {
37+
// No updates needed
38+
}
39+
}

0 commit comments

Comments
 (0)