Skip to content

Commit 1b61a37

Browse files
authored
Merge pull request #7948 from woocommerce/issue/7916-extract-jitm-uiview
[Just In Time Messages] Display Just In Time Message on the My Store screen (visual UI only)
2 parents 570b7ef + 7da028c commit 1b61a37

File tree

11 files changed

+181
-7
lines changed

11 files changed

+181
-7
lines changed

Experiments/Experiments/DefaultFeatureFlagService.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
4747
return buildConfig == .localDeveloper || buildConfig == .alpha
4848
case .simplifiedLoginFlowI1:
4949
return buildConfig == .localDeveloper || buildConfig == .alpha
50+
case .justInTimeMessagesOnDashboard:
51+
return buildConfig == .localDeveloper || buildConfig == .alpha
5052
case .productsOnboarding:
5153
return buildConfig == .localDeveloper || buildConfig == .alpha
5254
default:

Experiments/Experiments/FeatureFlag.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ public enum FeatureFlag: Int {
9191
///
9292
case simplifiedLoginFlowI1
9393

94+
/// Just In Time Messages on Dashboard
95+
///
96+
case justInTimeMessagesOnDashboard
97+
9498
/// Hides products onboarding development.
9599
///
96100
case productsOnboarding

Networking/Networking/Model/JustInTimeMessage.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Codegen
33

44
/// Just In Time Message
55
/// Also referred to as JITM, these messages are triggered on a per WPcom user basis, and can be requested for particular contexts within the app.
6-
/// They are generally displayed as a title, description, and CTA button
6+
/// They are generally displayed as a title, description, and Call To Action (CTA) button
77
///
88
public struct JustInTimeMessage: GeneratedCopiable, GeneratedFakeable, Equatable {
99
/// Site Identifier
@@ -18,15 +18,15 @@ public struct JustInTimeMessage: GeneratedCopiable, GeneratedFakeable, Equatable
1818
///
1919
public let featureClass: String
2020

21-
/// Validity of the JITM in seconds
21+
/// TTL, or Time To Live: validity of the JITM's client-side dismissal in seconds, only relevant after dismissal.
2222
///
2323
public let ttl: Int64
2424

2525
/// Content of the JITM: in particular, the title and description of the message
2626
///
2727
public let content: Content
2828

29-
/// The Call to Action for the JITM: in particular, the button text and link to open
29+
/// CTA, or Call to Action: button details for the JITM: in particular, the button text and link to open
3030
///
3131
public let cta: CTA
3232

WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,7 @@ extension WooAnalyticsEvent {
579579
case paymentMethods = "payment_methods"
580580
case productDetail = "product_detail"
581581
case settings
582+
case myStore = "my_store"
582583
}
583584

584585
/// Keys for the Feature Card properties

WooCommerce/Classes/ViewModels/Feature Announcement Cards/FeatureAnnouncementCardViewModel.swift

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,26 @@ import WooFoundation
55

66
private typealias FeatureCardEvent = WooAnalyticsEvent.FeatureCard
77

8-
class FeatureAnnouncementCardViewModel {
8+
protocol AnnouncementCardViewModelProtocol {
9+
var showDividers: Bool { get }
10+
var badgeType: BadgeView.BadgeType { get }
11+
12+
var title: String { get }
13+
var message: String { get }
14+
var buttonTitle: String? { get }
15+
var image: UIImage { get }
16+
17+
func onAppear()
18+
func ctaTapped()
19+
20+
var showDismissConfirmation: Bool { get }
21+
var dismissAlertTitle: String { get }
22+
var dismissAlertMessage: String { get }
23+
func dontShowAgainTapped()
24+
func remindLaterTapped()
25+
}
26+
27+
class FeatureAnnouncementCardViewModel: AnnouncementCardViewModelProtocol {
928
private let analytics: Analytics
1029
private let config: Configuration
1130
private let stores: StoresManager
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Foundation
2+
import UIKit
3+
4+
struct JustInTimeMessageAnnouncementCardViewModel: AnnouncementCardViewModelProtocol {
5+
var showDividers: Bool = false
6+
7+
var badgeType: BadgeView.BadgeType = .tip
8+
9+
var title: String
10+
11+
var message: String
12+
13+
var buttonTitle: String?
14+
15+
var image: UIImage = .paymentsFeatureBannerImage
16+
17+
func onAppear() {
18+
// No-op
19+
}
20+
21+
func ctaTapped() {
22+
// No-op
23+
}
24+
25+
var showDismissConfirmation: Bool = false
26+
27+
var dismissAlertTitle: String = ""
28+
29+
var dismissAlertMessage: String = ""
30+
31+
func dontShowAgainTapped() {
32+
// No-op
33+
}
34+
35+
func remindLaterTapped() {
36+
// No-op
37+
}
38+
39+
}

WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import UIKit
33
import Gridicons
44
import WordPressUI
55
import Yosemite
6+
import SwiftUI
67

78
// MARK: - DashboardViewController
89
//
@@ -73,6 +74,13 @@ final class DashboardViewController: UIViewController {
7374
})
7475
}()
7576

77+
private var hasAnnouncementFeatureFlag: Bool { ServiceLocator.featureFlagService.isFeatureFlagEnabled(.justInTimeMessagesOnDashboard)
78+
}
79+
80+
private var announcementViewHostingController: ConstraintsUpdatingHostingController<AnnouncementCardWrapper>?
81+
82+
private var announcementView: UIView?
83+
7684
/// Bottom Jetpack benefits banner, shown when the site is connected to Jetpack without Jetpack-the-plugin.
7785
private lazy var bottomJetpackBenefitsBannerController = JetpackBenefitsBannerHostingController()
7886
private var contentBottomToJetpackBenefitsBannerConstraint: NSLayoutConstraint?
@@ -117,6 +125,10 @@ final class DashboardViewController: UIViewController {
117125
observeNavigationBarHeightForStoreNameLabelVisibility()
118126
observeStatsVersionForDashboardUIUpdates()
119127
trackProductsOnboardingEligibility()
128+
observeAnnouncements()
129+
if hasAnnouncementFeatureFlag {
130+
viewModel.syncAnnouncements(for: siteID)
131+
}
120132
Task { @MainActor in
121133
await reloadDashboardUIStatsVersion(forced: true)
122134
}
@@ -136,6 +148,18 @@ final class DashboardViewController: UIViewController {
136148
override var shouldShowOfflineBanner: Bool {
137149
return true
138150
}
151+
152+
internal override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
153+
super.willTransition(to: newCollection, with: coordinator)
154+
updateAnnouncementCardVisibility(with: newCollection)
155+
}
156+
157+
/// Hide the announcement card in compact (landscape phone)
158+
///
159+
func updateAnnouncementCardVisibility(with newCollection: UITraitCollection) {
160+
let shouldHideCard = newCollection.verticalSizeClass == .compact
161+
announcementView?.isHidden = shouldHideCard
162+
}
139163
}
140164

141165
// MARK: - Configuration
@@ -281,6 +305,60 @@ private extension DashboardViewController {
281305
}.store(in: &subscriptions)
282306
}
283307

308+
// This is used so we have a specific type for the view while applying modifiers.
309+
struct AnnouncementCardWrapper: View {
310+
let cardView: FeatureAnnouncementCardView
311+
312+
var body: some View {
313+
cardView.background(Color(.listForeground))
314+
}
315+
}
316+
317+
func observeAnnouncements() {
318+
viewModel.$announcementViewModel.sink { [weak self] viewModel in
319+
guard let self = self else { return }
320+
self.removeAnnouncement()
321+
guard let viewModel = viewModel else {
322+
return
323+
}
324+
325+
let cardView = FeatureAnnouncementCardView(viewModel: viewModel,
326+
dismiss: {},
327+
callToAction: {})
328+
329+
self.showAnnouncement(AnnouncementCardWrapper(cardView: cardView))
330+
}
331+
.store(in: &subscriptions)
332+
}
333+
334+
private func removeAnnouncement() {
335+
guard let announcementView = announcementView else {
336+
return
337+
}
338+
announcementView.removeFromSuperview()
339+
announcementViewHostingController?.removeFromParent()
340+
announcementViewHostingController = nil
341+
self.announcementView = nil
342+
}
343+
344+
private func showAnnouncement(_ cardView: AnnouncementCardWrapper) {
345+
let hostingController = ConstraintsUpdatingHostingController(rootView: cardView)
346+
guard let uiView = hostingController.view else {
347+
return
348+
}
349+
announcementViewHostingController = hostingController
350+
announcementView = uiView
351+
352+
addChild(hostingController)
353+
let indexAfterHeader = (headerStackView.arrangedSubviews.firstIndex(of: innerStackView) ?? -1) + 1
354+
headerStackView.insertArrangedSubview(uiView, at: indexAfterHeader)
355+
356+
updateAnnouncementCardVisibility(with: traitCollection)
357+
358+
hostingController.didMove(toParent: self)
359+
hostingController.view.layoutIfNeeded()
360+
}
361+
284362
/// Display the error banner at the top of the dashboard content (below the site title)
285363
///
286364
func showTopBannerView() {

WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewModel.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ final class DashboardViewModel {
77
/// Stats v4 is shown by default, then falls back to v3 if store stats are unavailable.
88
@Published private(set) var statsVersion: StatsVersion = .v4
99

10+
@Published private(set) var announcementViewModel: AnnouncementCardViewModelProtocol? = nil
11+
1012
private let stores: StoresManager
1113

1214
init(stores: StoresManager = ServiceLocator.stores) {
@@ -94,12 +96,35 @@ final class DashboardViewModel {
9496
})
9597
stores.dispatch(action)
9698
}
99+
100+
/// Checks for announcements to show on the dashboard
101+
///
102+
func syncAnnouncements(for siteID: Int64) {
103+
if ServiceLocator.featureFlagService.isFeatureFlagEnabled(.justInTimeMessagesOnDashboard) {
104+
let action = JustInTimeMessageAction.loadMessage(
105+
siteID: siteID,
106+
screen: Constants.dashboardScreenName,
107+
hook: .adminNotices) { result in
108+
switch result {
109+
case let .success(.some(message)):
110+
let viewModel = JustInTimeMessageAnnouncementCardViewModel(title: message.title,
111+
message: message.detail,
112+
buttonTitle: message.buttonTitle)
113+
self.announcementViewModel = viewModel
114+
default:
115+
break
116+
}
117+
}
118+
stores.dispatch(action)
119+
}
120+
}
97121
}
98122

99123
// MARK: - Constants
100124
//
101125
private extension DashboardViewModel {
102126
enum Constants {
103127
static let topEarnerStatsLimit: Int = 5
128+
static let dashboardScreenName = "my_store"
104129
}
105130
}

WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/FeatureAnnouncementCardView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import SwiftUI
22

33
struct FeatureAnnouncementCardView: View {
4-
private let viewModel: FeatureAnnouncementCardViewModel
4+
private let viewModel: AnnouncementCardViewModelProtocol
55
@State private var showingDismissActionSheet = false
66

77
let dismiss: (() -> Void)?
88
let callToAction: (() -> Void)?
99

10-
init(viewModel: FeatureAnnouncementCardViewModel,
10+
init(viewModel: AnnouncementCardViewModelProtocol,
1111
dismiss: (() -> Void)? = nil,
1212
callToAction: (() -> Void)? = nil) {
1313
self.viewModel = viewModel

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@
443443
031B10E3274FE2AE007390BA /* CardPresentModalConnectionFailedUpdateAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031B10E2274FE2AE007390BA /* CardPresentModalConnectionFailedUpdateAddress.swift */; };
444444
035C6DEB273EA12D00F70406 /* SoftwareUpdateTypeProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035C6DEA273EA12D00F70406 /* SoftwareUpdateTypeProperty.swift */; };
445445
035F2308275690970019E1B0 /* CardPresentModalConnectingFailedUpdatePostalCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 035F2307275690970019E1B0 /* CardPresentModalConnectingFailedUpdatePostalCode.swift */; };
446+
0366EAE12909A37800B51755 /* JustInTimeMessageAnnouncementCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0366EAE02909A37800B51755 /* JustInTimeMessageAnnouncementCardViewModel.swift */; };
446447
036F6EA6281847D5006D84F8 /* PaymentCaptureOrchestratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 036F6EA5281847D5006D84F8 /* PaymentCaptureOrchestratorTests.swift */; };
447448
0371C3682875E47B00277E2C /* FeatureAnnouncementCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0371C3672875E47B00277E2C /* FeatureAnnouncementCardViewModel.swift */; };
448449
0371C36A2876DBCA00277E2C /* FeatureAnnouncementCardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0371C3692876DBCA00277E2C /* FeatureAnnouncementCardViewModelTests.swift */; };
@@ -2372,6 +2373,7 @@
23722373
031B10E2274FE2AE007390BA /* CardPresentModalConnectionFailedUpdateAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalConnectionFailedUpdateAddress.swift; sourceTree = "<group>"; };
23732374
035C6DEA273EA12D00F70406 /* SoftwareUpdateTypeProperty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftwareUpdateTypeProperty.swift; sourceTree = "<group>"; };
23742375
035F2307275690970019E1B0 /* CardPresentModalConnectingFailedUpdatePostalCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalConnectingFailedUpdatePostalCode.swift; sourceTree = "<group>"; };
2376+
0366EAE02909A37800B51755 /* JustInTimeMessageAnnouncementCardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JustInTimeMessageAnnouncementCardViewModel.swift; sourceTree = "<group>"; };
23752377
036F6EA5281847D5006D84F8 /* PaymentCaptureOrchestratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentCaptureOrchestratorTests.swift; sourceTree = "<group>"; };
23762378
0371C3672875E47B00277E2C /* FeatureAnnouncementCardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureAnnouncementCardViewModel.swift; sourceTree = "<group>"; };
23772379
0371C3692876DBCA00277E2C /* FeatureAnnouncementCardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureAnnouncementCardViewModelTests.swift; sourceTree = "<group>"; };
@@ -4885,6 +4887,7 @@
48854887
isa = PBXGroup;
48864888
children = (
48874889
0371C3672875E47B00277E2C /* FeatureAnnouncementCardViewModel.swift */,
4890+
0366EAE02909A37800B51755 /* JustInTimeMessageAnnouncementCardViewModel.swift */,
48884891
0371C36D2876E92D00277E2C /* UpsellCardReadersCampaign.swift */,
48894892
AEE085B42897C871007ACE20 /* LinkedProductsPromoCampaign.swift */,
48904893
);
@@ -10150,6 +10153,7 @@
1015010153
021DD44D286A3A8D004F0468 /* UIViewController+Navigation.swift in Sources */,
1015110154
B958A7CB28B3D4A100823EEF /* RouteMatcher.swift in Sources */,
1015210155
0279F0E4252DC9670098D7DE /* ProductVariationLoadUseCase.swift in Sources */,
10156+
0366EAE12909A37800B51755 /* JustInTimeMessageAnnouncementCardViewModel.swift in Sources */,
1015310157
CCF87BC02790582500461C43 /* ProductVariationSelector.swift in Sources */,
1015410158
02CA63DC23D1ADD100BBF148 /* DeviceMediaLibraryPicker.swift in Sources */,
1015510159
021A84E0257DFC2A00BC71D1 /* RefundShippingLabelViewController.swift in Sources */,

0 commit comments

Comments
 (0)