Skip to content

Commit 3922d2c

Browse files
authored
Merge pull request #6159 from woocommerce/feat/5954-inbox-main-view
Inbox view part 1: list view with view model and sync states
2 parents afc0982 + a1d5102 commit 3922d2c

File tree

5 files changed

+437
-1
lines changed

5 files changed

+437
-1
lines changed

WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ struct HubMenu: View {
4646
showingViewStore = true
4747
case .inbox:
4848
// TODO: Inbox analytics
49-
// TODO: 5954 - Show Inbox view
5049
showingInbox = true
5150
case .reviews:
5251
ServiceLocator.analytics.track(.hubMenuOptionTapped, withProperties: [Constants.option: "reviews"])
@@ -66,6 +65,11 @@ struct HubMenu: View {
6665
}
6766
.safariSheet(isPresented: $showingWooCommerceAdmin, url: viewModel.woocommerceAdminURL)
6867
.safariSheet(isPresented: $showingViewStore, url: viewModel.storeURL)
68+
NavigationLink(destination:
69+
Inbox(viewModel: .init(siteID: viewModel.siteID)),
70+
isActive: $showingInbox) {
71+
EmptyView()
72+
}.hidden()
6973
NavigationLink(destination:
7074
ReviewsView(siteID: viewModel.siteID),
7175
isActive: $showingReviews) {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import Combine
2+
import SwiftUI
3+
import Yosemite
4+
5+
/// Shows a list of inbox notes as shown in WooCommerce Admin in core.
6+
struct Inbox: View {
7+
/// View model that drives the view.
8+
@ObservedObject private(set) var viewModel: InboxViewModel
9+
10+
init(viewModel: InboxViewModel) {
11+
self.viewModel = viewModel
12+
}
13+
14+
var body: some View {
15+
Group {
16+
switch viewModel.syncState {
17+
case .results:
18+
// TODO: 5954 - update results state
19+
InfiniteScrollList(isLoading: viewModel.shouldShowBottomActivityIndicator,
20+
loadAction: viewModel.onLoadNextPageAction) {
21+
ForEach(viewModel.notes, id: \.id) { note in
22+
TitleAndSubtitleRow(title: note.title, subtitle: note.content)
23+
}
24+
}
25+
case .empty:
26+
// TODO: 5954 - update empty state
27+
EmptyState(title: Localization.emptyStateTitle,
28+
description: Localization.emptyStateMessage,
29+
image: .emptyProductsTabImage)
30+
.frame(maxHeight: .infinity)
31+
case .syncingFirstPage:
32+
// TODO: 5954 - update placeholder state
33+
EmptyView()
34+
}
35+
}
36+
.background(Color(.listBackground).ignoresSafeArea())
37+
.navigationTitle(Localization.title)
38+
.onAppear {
39+
viewModel.onLoadTrigger.send()
40+
}
41+
}
42+
}
43+
44+
private extension Inbox {
45+
enum Localization {
46+
static let title = NSLocalizedString("Inbox", comment: "Title for the screen that shows inbox notes.")
47+
static let emptyStateTitle = NSLocalizedString("Congrats, you’ve read everything!",
48+
comment: "Title displayed if there are no inbox notes in the inbox screen.")
49+
static let emptyStateMessage = NSLocalizedString("Come back soon for more tips and insights on growing your store",
50+
comment: "Message displayed if there are no inbox notes to display in the inbox screen.")
51+
}
52+
}
53+
54+
#if DEBUG
55+
56+
/// Allows mocking for previewing `Inbox` view.
57+
private final class PreviewInboxNotesStoresManager: DefaultStoresManager {
58+
private let inboxNotes: [InboxNote]
59+
60+
init(inboxNotes: [InboxNote], sessionManager: SessionManager = SessionManager.standard) {
61+
self.inboxNotes = inboxNotes
62+
super.init(sessionManager: sessionManager)
63+
}
64+
65+
// MARK: - Overridden Methods
66+
67+
override func dispatch(_ action: Action) {
68+
if let action = action as? InboxNotesAction {
69+
onInboxNotesAction(action: action)
70+
} else {
71+
super.dispatch(action)
72+
}
73+
}
74+
75+
private func onInboxNotesAction(action: InboxNotesAction) {
76+
switch action {
77+
case .loadAllInboxNotes(_, _, _, _, _, _, let completion):
78+
completion(.success(inboxNotes))
79+
return
80+
default:
81+
return
82+
}
83+
}
84+
}
85+
86+
extension InboxNote {
87+
static func placeholder() -> InboxNote {
88+
.init(siteID: 255,
89+
id: 0,
90+
name: "",
91+
type: "",
92+
status: "",
93+
actions: [.init(id: 0, name: "", label: "Accept Apple Pay", status: "", url: "https://wordpress.com")],
94+
title: "Boost sales this holiday season with Apple Pay!",
95+
content: "",
96+
isRemoved: false,
97+
isRead: false,
98+
dateCreated: .init())
99+
}
100+
}
101+
102+
struct Inbox_Previews: PreviewProvider {
103+
static var previews: some View {
104+
Group {
105+
// Placeholder state.
106+
Inbox(viewModel: .init(siteID: 122))
107+
.preferredColorScheme(.light)
108+
Inbox(viewModel: .init(siteID: 122))
109+
.preferredColorScheme(.dark)
110+
// Empty state.
111+
Inbox(viewModel: .init(siteID: 322,
112+
stores: PreviewInboxNotesStoresManager(inboxNotes: [])))
113+
.preferredColorScheme(.light)
114+
Inbox(viewModel: .init(siteID: 322,
115+
stores: PreviewInboxNotesStoresManager(inboxNotes: [])))
116+
.preferredColorScheme(.dark)
117+
// Results state.
118+
Inbox(viewModel: .init(siteID: 322,
119+
stores: PreviewInboxNotesStoresManager(inboxNotes: [.placeholder(), .placeholder()])))
120+
.preferredColorScheme(.light)
121+
Inbox(viewModel: .init(siteID: 322,
122+
stores: PreviewInboxNotesStoresManager(inboxNotes: [.placeholder(), .placeholder()])))
123+
.preferredColorScheme(.dark)
124+
}
125+
}
126+
}
127+
128+
#endif
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import Yosemite
2+
import Combine
3+
import Foundation
4+
5+
/// View model for `Inbox` that handles actions that change the view state and provides view data.
6+
final class InboxViewModel: ObservableObject {
7+
/// Trigger to perform any one time setups.
8+
let onLoadTrigger: PassthroughSubject<Void, Never> = PassthroughSubject()
9+
10+
/// All inbox notes.
11+
@Published private(set) var notes: [InboxNote] = []
12+
13+
// MARK: Sync
14+
15+
/// Current sync status; used to determine the view state.
16+
@Published private(set) var syncState: SyncState = .empty
17+
18+
/// Tracks if the infinite scroll indicator should be displayed.
19+
@Published private(set) var shouldShowBottomActivityIndicator = false
20+
21+
/// Supports infinite scroll.
22+
private let paginationTracker: PaginationTracker
23+
24+
/// Stores to sync inbox notes and handle note actions.
25+
private let stores: StoresManager
26+
27+
private let siteID: Int64
28+
private var subscriptions = Set<AnyCancellable>()
29+
30+
init(siteID: Int64,
31+
syncState: SyncState = .empty,
32+
pageSize: Int = SyncingCoordinator.Defaults.pageSize,
33+
stores: StoresManager = ServiceLocator.stores) {
34+
self.siteID = siteID
35+
self.stores = stores
36+
self.syncState = syncState
37+
self.paginationTracker = PaginationTracker(pageSize: pageSize)
38+
39+
configurePaginationTracker()
40+
configureFirstPageLoad()
41+
}
42+
43+
/// Called when the next page should be loaded.
44+
func onLoadNextPageAction() {
45+
paginationTracker.ensureNextPageIsSynced()
46+
}
47+
}
48+
49+
// MARK: - Sync Methods
50+
51+
private extension InboxViewModel {
52+
/// Syncs the first page of inbox notes from remote.
53+
func syncFirstPage() {
54+
paginationTracker.syncFirstPage()
55+
}
56+
}
57+
58+
extension InboxViewModel: PaginationTrackerDelegate {
59+
func sync(pageNumber: Int, pageSize: Int, reason: String?, onCompletion: SyncCompletion?) {
60+
transitionToSyncingState()
61+
62+
let action = InboxNotesAction.loadAllInboxNotes(siteID: siteID,
63+
pageNumber: pageNumber,
64+
pageSize: pageSize,
65+
orderBy: .date,
66+
type: nil,
67+
status: nil) { [weak self] result in
68+
guard let self = self else { return }
69+
switch result {
70+
case .success(let notes):
71+
self.notes.append(contentsOf: notes)
72+
let hasNextPage = notes.count == pageSize
73+
onCompletion?(.success(hasNextPage))
74+
case .failure(let error):
75+
DDLogError("⛔️ Error synchronizing inbox notes: \(error)")
76+
onCompletion?(.failure(error))
77+
}
78+
self.transitionToResultsUpdatedState()
79+
}
80+
stores.dispatch(action)
81+
}
82+
}
83+
84+
// MARK: - Configuration
85+
86+
private extension InboxViewModel {
87+
func configurePaginationTracker() {
88+
paginationTracker.delegate = self
89+
}
90+
91+
func configureFirstPageLoad() {
92+
// Listens only to the first emitted event.
93+
onLoadTrigger.first()
94+
.sink { [weak self] in
95+
guard let self = self else { return }
96+
self.syncFirstPage()
97+
}
98+
.store(in: &subscriptions)
99+
}
100+
}
101+
102+
// MARK: - State Machine
103+
104+
extension InboxViewModel {
105+
/// Represents possible states for syncing inbox notes.
106+
enum SyncState: Equatable {
107+
case syncingFirstPage
108+
case results
109+
case empty
110+
}
111+
112+
/// Update states for sync from remote.
113+
func transitionToSyncingState() {
114+
shouldShowBottomActivityIndicator = true
115+
if notes.isEmpty {
116+
syncState = .syncingFirstPage
117+
}
118+
}
119+
120+
/// Update states after sync is complete.
121+
func transitionToResultsUpdatedState() {
122+
shouldShowBottomActivityIndicator = false
123+
syncState = notes.isNotEmpty ? .results: .empty
124+
}
125+
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@
208208
0262DA5823A23AC80029AF30 /* ProductShippingSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0262DA5623A23AC80029AF30 /* ProductShippingSettingsViewController.swift */; };
209209
0262DA5923A23AC80029AF30 /* ProductShippingSettingsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 0262DA5723A23AC80029AF30 /* ProductShippingSettingsViewController.xib */; };
210210
0262DA5B23A244830029AF30 /* Product+ShippingSettingsViewModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0262DA5A23A244830029AF30 /* Product+ShippingSettingsViewModels.swift */; };
211+
02645D7D27BA027B0065DC68 /* Inbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02645D7927BA027B0065DC68 /* Inbox.swift */; };
212+
02645D7E27BA027B0065DC68 /* InboxViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02645D7A27BA027B0065DC68 /* InboxViewModel.swift */; };
213+
02645D8227BA20A30065DC68 /* InboxViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02645D8127BA20A30065DC68 /* InboxViewModelTests.swift */; };
211214
02691780232600A6002AFC20 /* ProductsTabProductViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0269177F232600A6002AFC20 /* ProductsTabProductViewModelTests.swift */; };
212215
02691782232605B9002AFC20 /* PaginatedListViewControllerStateCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02691781232605B9002AFC20 /* PaginatedListViewControllerStateCoordinatorTests.swift */; };
213216
0269576A23726304001BA0BF /* KeyboardFrameObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0269576923726304001BA0BF /* KeyboardFrameObserver.swift */; };
@@ -1831,6 +1834,9 @@
18311834
0262DA5623A23AC80029AF30 /* ProductShippingSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductShippingSettingsViewController.swift; sourceTree = "<group>"; };
18321835
0262DA5723A23AC80029AF30 /* ProductShippingSettingsViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProductShippingSettingsViewController.xib; sourceTree = "<group>"; };
18331836
0262DA5A23A244830029AF30 /* Product+ShippingSettingsViewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Product+ShippingSettingsViewModels.swift"; sourceTree = "<group>"; };
1837+
02645D7927BA027B0065DC68 /* Inbox.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Inbox.swift; sourceTree = "<group>"; };
1838+
02645D7A27BA027B0065DC68 /* InboxViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InboxViewModel.swift; sourceTree = "<group>"; };
1839+
02645D8127BA20A30065DC68 /* InboxViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxViewModelTests.swift; sourceTree = "<group>"; };
18341840
0269177F232600A6002AFC20 /* ProductsTabProductViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsTabProductViewModelTests.swift; sourceTree = "<group>"; };
18351841
02691781232605B9002AFC20 /* PaginatedListViewControllerStateCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginatedListViewControllerStateCoordinatorTests.swift; sourceTree = "<group>"; };
18361842
0269576923726304001BA0BF /* KeyboardFrameObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardFrameObserver.swift; sourceTree = "<group>"; };
@@ -3745,6 +3751,23 @@
37453751
path = "Shipping Settings";
37463752
sourceTree = "<group>";
37473753
};
3754+
02645D7727BA02460065DC68 /* Inbox */ = {
3755+
isa = PBXGroup;
3756+
children = (
3757+
02645D7927BA027B0065DC68 /* Inbox.swift */,
3758+
02645D7A27BA027B0065DC68 /* InboxViewModel.swift */,
3759+
);
3760+
path = Inbox;
3761+
sourceTree = "<group>";
3762+
};
3763+
02645D8027BA20950065DC68 /* Inbox */ = {
3764+
isa = PBXGroup;
3765+
children = (
3766+
02645D8127BA20A30065DC68 /* InboxViewModelTests.swift */,
3767+
);
3768+
path = Inbox;
3769+
sourceTree = "<group>";
3770+
};
37483771
0269177E23260090002AFC20 /* Products */ = {
37493772
isa = PBXGroup;
37503773
children = (
@@ -5864,6 +5887,7 @@
58645887
D8815ADC26383E3E00EDAD62 /* CardPresentPayments */,
58655888
02564A8F246CEBCB00D6DB2A /* Containers */,
58665889
027D67CF245ADDCC0036B8DB /* Filters */,
5890+
02645D7727BA02460065DC68 /* Inbox */,
58675891
02E262C3238D04DB00B79588 /* ListSelector */,
58685892
028296E9237D289900E84012 /* Text View Screen */,
58695893
025FDD3023717D1A00824006 /* Editor */,
@@ -7075,6 +7099,7 @@
70757099
D816DDBA22265D8000903E59 /* ViewRelated */ = {
70767100
isa = PBXGroup;
70777101
children = (
7102+
02645D8027BA20950065DC68 /* Inbox */,
70787103
BAA34C1E2787494300846F3C /* Reviews */,
70797104
03FBDAF7263EE49300ACE257 /* Coupons */,
70807105
BA143222273662DE00E4B3AB /* Settings */,
@@ -8406,6 +8431,7 @@
84068431
316837DA25CCA90C00E36B2F /* OrderStatusListDataSource.swift in Sources */,
84078432
FE28F6F4268477C1004465C7 /* RoleEligibilityUseCase.swift in Sources */,
84088433
57C5FF7A25091A350074EC26 /* OrderListSyncActionUseCase.swift in Sources */,
8434+
02645D7E27BA027B0065DC68 /* InboxViewModel.swift in Sources */,
84098435
B58B4AB32108F01700076FDD /* DefaultNoticePresenter.swift in Sources */,
84108436
4512055224655FB6005D68DE /* TitleAndTextFieldWithImageTableViewCell.swift in Sources */,
84118437
57448D28242E775000A56A74 /* EmptyStateViewController.swift in Sources */,
@@ -8717,6 +8743,7 @@
87178743
DEC51AFD276AEAE3009F3DF4 /* SystemStatusReportView.swift in Sources */,
87188744
CECC759C23D61C1400486676 /* AggregateDataHelper.swift in Sources */,
87198745
02B296A722FA6DB500FD7A4C /* Date+StartAndEnd.swift in Sources */,
8746+
02645D7D27BA027B0065DC68 /* Inbox.swift in Sources */,
87208747
D81D9228222E7F0800FFA585 /* OrderStatusListViewController.swift in Sources */,
87218748
CEE006082077D14C0079161F /* OrderDetailsViewController.swift in Sources */,
87228749
AEB73C0C25CD734200A8454A /* AttributePickerViewModel.swift in Sources */,
@@ -9154,6 +9181,7 @@
91549181
B55BC1F321A8790F0011A0C0 /* StringHTMLTests.swift in Sources */,
91559182
455800CC24C6F83F00A8D117 /* ProductSettingsSectionsTests.swift in Sources */,
91569183
D85B833F2230F268002168F3 /* SummaryTableViewCellTests.swift in Sources */,
9184+
02645D8227BA20A30065DC68 /* InboxViewModelTests.swift in Sources */,
91579185
57ABE36824EB048A00A64F49 /* MockSwitchStoreUseCase.swift in Sources */,
91589186
311F827626CD8AB100DF5BAD /* MockCardReaderSettingsAlerts.swift in Sources */,
91599187
02ADC7CE23978EAA008D4BED /* PaginatedProductShippingClassListSelectorDataSourceTests.swift in Sources */,

0 commit comments

Comments
 (0)