Skip to content

Commit 6724eb6

Browse files
authored
Merge pull request #9801 from woocommerce/issue/9582-badge-hub-menu-to-highlight-tap-to-pay
[Mobile Payments] Add badges leading to Set up Tap to Pay
2 parents 0bd238a + 7fea486 commit 6724eb6

File tree

19 files changed

+385
-86
lines changed

19 files changed

+385
-86
lines changed

Experiments/Experiments/DefaultFeatureFlagService.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
5757
return buildConfig == .localDeveloper || buildConfig == .appStore
5858
case .tapToPayOnIPhoneMilestone2:
5959
return true
60+
case .tapToPayBadge:
61+
return buildConfig == .localDeveloper || buildConfig == .alpha
6062
case .domainSettings:
6163
return true
6264
case .jetpackSetupWithApplicationPassword:

Experiments/Experiments/FeatureFlag.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ public enum FeatureFlag: Int {
6464
///
6565
case tapToPayOnIPhoneMilestone2
6666

67+
/// Enables badging the route to Set up Tap to Pay on iPhone on eligible devices
68+
///
69+
case tapToPayBadge
70+
6771
/// Store creation MVP.
6872
///
6973
case storeCreationMVP

Storage/Storage/Model/Feature Announcements/FeatureAnnouncementCampaign.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public enum FeatureAnnouncementCampaign: String, Codable, Equatable {
77
case inPersonPaymentsCashOnDelivery = "ipp_not_user"
88
case inPersonPaymentsFirstTransaction = "ipp_new_user"
99
case inPersonPaymentsPowerUsers = "ipp_power_user"
10+
case tapToPayHubMenuBadge = "tap_to_pay_hub_menu_badge"
1011

1112
/// Added for use in `test_setFeatureAnnouncementDismissed_with_another_campaign_previously_dismissed_keeps_values_for_both`
1213
/// This can be removed when we have a second campaign, which can be used in the above test instead.

WooCommerce/Classes/ViewModels/MainTabViewModel.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ final class MainTabViewModel {
9292

9393
listenToReviewsBadgeReloadRequired()
9494
retrieveShouldShowReviewsBadgeOnHubMenuTabValue()
95+
96+
listenToNewFeatureBadgeReloadRequired()
97+
retrieveShouldShowNewFeatureBadgeOnHubMenuTabValue()
9598
}
9699
}
97100

@@ -197,6 +200,21 @@ private extension MainTabViewModel {
197200
object: nil)
198201
}
199202

203+
204+
func listenToNewFeatureBadgeReloadRequired() {
205+
NotificationCenter.default.addObserver(self,
206+
selector: #selector(setUpTapToPayViewDidAppear),
207+
name: .setUpTapToPayViewDidAppear,
208+
object: nil)
209+
210+
}
211+
212+
/// Updates the badge after the Set up Tap to Pay flow did appear
213+
///
214+
@objc func setUpTapToPayViewDidAppear() {
215+
shouldShowNewFeatureBadgeOnHubMenuTab = false
216+
}
217+
200218
/// Retrieves whether we should show the reviews on the Menu button and updates `shouldShowReviewsBadge`
201219
///
202220
@objc func retrieveShouldShowReviewsBadgeOnHubMenuTabValue() {
@@ -211,6 +229,22 @@ private extension MainTabViewModel {
211229
storesManager.dispatch(notificationCountAction)
212230
}
213231

232+
/// Retrieves whether we should show the new feature badge on the Menu button
233+
///
234+
func retrieveShouldShowNewFeatureBadgeOnHubMenuTabValue() {
235+
let action = AppSettingsAction.getFeatureAnnouncementVisibility(campaign: .tapToPayHubMenuBadge) { [weak self] result in
236+
guard let self = self else { return }
237+
switch result {
238+
case .success(let visible):
239+
self.shouldShowNewFeatureBadgeOnHubMenuTab = visible && self.featureFlagService.isFeatureFlagEnabled(.tapToPayBadge)
240+
case .failure:
241+
self.shouldShowNewFeatureBadgeOnHubMenuTab = false
242+
}
243+
}
244+
245+
storesManager.dispatch(action)
246+
}
247+
214248
/// Listens for changes on the menu badge display logic and updates it depending on them
215249
///
216250
func synchronizeShouldShowBadgeOnHubMenuTabLogic() {

WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/SetUpTapToPayInformationViewController.swift

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

3+
extension NSNotification.Name {
4+
/// Posted whenever the hub menu view did appear.
5+
///
6+
public static let setUpTapToPayViewDidAppear = Foundation.Notification.Name(rawValue: "com.woocommerce.ios.setUpTapToPayViewDidAppear")
7+
}
8+
39
/// This view controller is used when no reader is connected. It assists
410
/// the merchant in connecting to a reader.
511
///
@@ -129,6 +135,7 @@ struct SetUpTapToPayInformationView: View {
129135
.scrollVerticallyIfNeeded()
130136
}
131137
.padding()
138+
.onAppear(perform: viewModel.viewDidAppear)
132139
}
133140
}
134141

WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/SetUpTapToPayInformationViewModel.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ final class SetUpTapToPayInformationViewModel: PaymentSettingsFlowPresentedViewM
129129
dismiss?()
130130
}
131131

132+
func viewDidAppear() {
133+
let action = AppSettingsAction.setFeatureAnnouncementDismissed(campaign: .tapToPayHubMenuBadge, remindAfterDays: nil, onCompletion: nil)
134+
stores.dispatch(action)
135+
NotificationCenter.default.post(name: .setUpTapToPayViewDidAppear, object: nil)
136+
}
137+
132138
/// Updates whether the view this viewModel is associated with should be shown or not
133139
/// Notifies the viewModel owner if a change occurs via didChangeShouldShow
134140
///

WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/InPersonPaymentsMenuViewController.swift

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ final class InPersonPaymentsMenuViewController: UIViewController {
2222

2323
private lazy var inPersonPaymentsLearnMoreViewModel = LearnMoreViewModel.inPersonPayments(source: .paymentsMenu)
2424

25-
private let viewModel: InPersonPaymentsMenuViewModel = InPersonPaymentsMenuViewModel()
25+
private let viewModel: InPersonPaymentsMenuViewModel
2626

2727
private let cashOnDeliveryToggleRowViewModel: InPersonPaymentsCashOnDeliveryToggleRowViewModel
2828

@@ -68,9 +68,11 @@ final class InPersonPaymentsMenuViewController: UIViewController {
6868
/// - viewDidLoadAction: Provided as a one-time callback on viewDidLoad, originally to handle universal link navigation correctly.
6969
init(stores: StoresManager = ServiceLocator.stores,
7070
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService,
71+
shouldShowBadgeOnSetUpTapToPay: Bool,
7172
viewDidLoadAction: ((InPersonPaymentsMenuViewController) -> Void)? = nil) {
7273
self.stores = stores
7374
self.featureFlagService = featureFlagService
75+
self.viewModel = InPersonPaymentsMenuViewModel(shouldShowBadgeOnSetUpTapToPay: shouldShowBadgeOnSetUpTapToPay)
7476
self.cardPresentPaymentsOnboardingUseCase = CardPresentPaymentsOnboardingUseCase()
7577
self.cashOnDeliveryToggleRowViewModel = InPersonPaymentsCashOnDeliveryToggleRowViewModel()
7678
self.setUpFlowOnlyEnabledAfterOnboardingComplete = !featureFlagService.isFeatureFlagEnabled(.tapToPayOnIPhoneMilestone2)
@@ -304,7 +306,7 @@ private extension InPersonPaymentsMenuViewController {
304306
configureCollectPayment(cell: cell)
305307
case let cell as LeftImageTitleSubtitleToggleTableViewCell where row == .toggleEnableCashOnDelivery:
306308
configureToggleEnableCashOnDelivery(cell: cell)
307-
case let cell as LeftImageTableViewCell where row == .setUpTapToPayOnIPhone:
309+
case let cell as BadgedLeftImageTableViewCell where row == .setUpTapToPayOnIPhone:
308310
configureSetUpTapToPayOnIPhone(cell: cell)
309311
case let cell as LeftImageTableViewCell where row == .tapToPayOnIPhoneFeedback:
310312
configureTapToPayOnIPhoneFeedback(cell: cell)
@@ -366,11 +368,12 @@ private extension InPersonPaymentsMenuViewController {
366368
})
367369
}
368370

369-
func configureSetUpTapToPayOnIPhone(cell: LeftImageTableViewCell) {
371+
func configureSetUpTapToPayOnIPhone(cell: BadgedLeftImageTableViewCell) {
370372
prepareForReuse(cell)
371373
cell.accessibilityIdentifier = "set-up-tap-to-pay"
372374
cell.configure(image: .tapToPayOnIPhoneIcon,
373-
text: Localization.tapToPayOnIPhone)
375+
text: Localization.tapToPayOnIPhone,
376+
showBadge: viewModel.shouldBadgeTapToPayOnIPhone)
374377

375378
if setUpFlowOnlyEnabledAfterOnboardingComplete {
376379
cell.accessoryType = enableSetUpTapToPayOnIPhoneCell ? .disclosureIndicator : .none
@@ -418,6 +421,14 @@ private extension InPersonPaymentsMenuViewController {
418421
self?.configureSections(shouldShowTapToPayOnIPhoneFeedback: shouldShowFeedbackRow)
419422
self?.tableView.reloadData()
420423
}.store(in: &cancellables)
424+
425+
viewModel.$shouldBadgeTapToPayOnIPhone.sink { [weak self] _ in
426+
self?.configureSections()
427+
// ensures that the cell will be configured with the correct value for the badge
428+
DispatchQueue.main.async {
429+
self?.tableView.reloadData()
430+
}
431+
}.store(in: &cancellables)
421432
}
422433

423434
private func configureWebViewPresentation() {
@@ -732,6 +743,8 @@ private enum Row: CaseIterable {
732743
return LeftImageTitleSubtitleTableViewCell.self
733744
case .toggleEnableCashOnDelivery:
734745
return LeftImageTitleSubtitleToggleTableViewCell.self
746+
case .setUpTapToPayOnIPhone:
747+
return BadgedLeftImageTableViewCell.self
735748
default:
736749
return LeftImageTableViewCell.self
737750
}
@@ -754,8 +767,10 @@ private extension InPersonPaymentsMenuViewController {
754767
/// SwiftUI wrapper for CardReaderSettingsPresentingViewController
755768
///
756769
struct InPersonPaymentsMenu: UIViewControllerRepresentable {
770+
let shouldShowBadgeOnSetUpTapToPay: Bool
771+
757772
func makeUIViewController(context: Context) -> some UIViewController {
758-
InPersonPaymentsMenuViewController()
773+
InPersonPaymentsMenuViewController(shouldShowBadgeOnSetUpTapToPay: shouldShowBadgeOnSetUpTapToPay)
759774
}
760775

761776
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {

WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/InPersonPaymentsMenuViewModel.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,16 @@ final class InPersonPaymentsMenuViewModel {
3939

4040
@Published private(set) var isEligibleForTapToPayOnIPhone: Bool = false
4141
@Published private(set) var shouldShowTapToPayOnIPhoneFeedbackRow: Bool = false
42+
@Published private(set) var shouldBadgeTapToPayOnIPhone: Bool = false
4243

4344
let cardPresentPaymentsConfiguration: CardPresentPaymentsConfiguration
4445

4546
init(dependencies: Dependencies = Dependencies(),
46-
cardPresentPaymentsConfiguration: CardPresentPaymentsConfiguration = CardPresentConfigurationLoader().configuration) {
47+
cardPresentPaymentsConfiguration: CardPresentPaymentsConfiguration = CardPresentConfigurationLoader().configuration,
48+
shouldShowBadgeOnSetUpTapToPay: Bool) {
4749
self.dependencies = dependencies
4850
self.cardPresentPaymentsConfiguration = cardPresentPaymentsConfiguration
51+
self.shouldBadgeTapToPayOnIPhone = shouldShowBadgeOnSetUpTapToPay
4952
}
5053

5154
func viewDidLoad() {
@@ -97,6 +100,10 @@ final class InPersonPaymentsMenuViewModel {
97100
selector: #selector(refreshTapToPayFeedbackVisibility),
98101
name: .firstInPersonPaymentsTransactionsWereUpdated,
99102
object: nil)
103+
NotificationCenter.default.addObserver(self,
104+
selector: #selector(setUpTapToPayViewDidAppear),
105+
name: .setUpTapToPayViewDidAppear,
106+
object: nil)
100107
}
101108

102109
@objc func refreshTapToPayFeedbackVisibility() {
@@ -106,6 +113,10 @@ final class InPersonPaymentsMenuViewModel {
106113
checkShouldShowTapToPayFeedbackRow(siteID: siteID)
107114
}
108115

116+
@objc private func setUpTapToPayViewDidAppear() {
117+
self.shouldBadgeTapToPayOnIPhone = false
118+
}
119+
109120
func orderCardReaderPressed() {
110121
analytics.track(.paymentsMenuOrderCardReaderTapped)
111122
showWebView = PurchaseCardReaderWebViewViewModel(configuration: cardPresentPaymentsConfiguration,
@@ -123,6 +134,9 @@ final class InPersonPaymentsMenuViewModel {
123134
NotificationCenter.default.removeObserver(self,
124135
name: .firstInPersonPaymentsTransactionsWereUpdated,
125136
object: nil)
137+
NotificationCenter.default.removeObserver(self,
138+
name: .setUpTapToPayViewDidAppear,
139+
object: nil)
126140
}
127141
}
128142

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

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ struct HubMenu: View {
3232
viewModel.presentSwitchStore()
3333
} label: {
3434
Row(title: viewModel.storeTitle,
35-
badge: viewModel.planName,
35+
titleBadge: viewModel.planName,
36+
iconBadge: nil,
3637
description: viewModel.storeURL.host ?? viewModel.storeURL.absoluteString,
3738
icon: .remote(viewModel.avatarURL),
3839
chevron: viewModel.switchStoreEnabled ? .down : .none,
@@ -51,7 +52,8 @@ struct HubMenu: View {
5152
handleTap(menu: menu)
5253
} label: {
5354
Row(title: menu.title,
54-
badge: nil,
55+
titleBadge: nil,
56+
iconBadge: menu.iconBadge,
5557
description: menu.description,
5658
icon: .local(menu.icon),
5759
chevron: .leading)
@@ -68,7 +70,8 @@ struct HubMenu: View {
6870
handleTap(menu: menu)
6971
} label: {
7072
Row(title: menu.title,
71-
badge: nil,
73+
titleBadge: nil,
74+
iconBadge: menu.iconBadge,
7275
description: menu.description,
7376
icon: .local(menu.icon),
7477
chevron: .leading)
@@ -102,7 +105,7 @@ struct HubMenu: View {
102105
EmptyView()
103106
}.hidden()
104107
NavigationLink(destination:
105-
InPersonPaymentsMenu()
108+
InPersonPaymentsMenu(shouldShowBadgeOnSetUpTapToPay: viewModel.shouldShowNewFeatureBadgeOnPayments)
106109
.navigationTitle(InPersonPaymentsView.Localization.title),
107110
isActive: $showingPayments) {
108111
EmptyView()
@@ -198,9 +201,13 @@ private extension HubMenu {
198201
///
199202
let title: String
200203

201-
/// Optional badge text. Render next to `title`
204+
/// Text badge displayed adjacent to the title
202205
///
203-
let badge: String?
206+
let titleBadge: String?
207+
208+
/// Badge displayed on the icon.
209+
///
210+
let iconBadge: HubMenuBadgeType?
204211

205212
/// Row Description
206213
///
@@ -225,31 +232,42 @@ private extension HubMenu {
225232

226233
HStack(spacing: .zero) {
227234
/// iOS 16, aligns the list dividers to the first text position.
228-
/// This tricks the system by rendering an empty text and forcing the list lo align the divider to it.
235+
/// This tricks the system by rendering an empty text and forcing the list to align the divider to it.
229236
/// Without this, the divider will be rendered from the title and will not cover the icon.
230237
/// Ideally we would want to use the `alignmentGuide` modifier but that is only available on iOS 16.
231238
///
232239
Text("")
233240

234-
// Icon
235-
Group {
236-
switch icon {
237-
case .local(let asset):
238-
Circle()
239-
.fill(Color(.init(light: .listBackground, dark: .secondaryButtonBackground)))
240-
.frame(width: HubMenu.Constants.avatarSize, height: HubMenu.Constants.avatarSize)
241-
.overlay {
242-
Image(uiImage: asset)
243-
.resizable()
244-
.frame(width: HubMenu.Constants.iconSize, height: HubMenu.Constants.iconSize)
245-
}
246-
247-
case .remote(let url):
248-
KFImage(url)
249-
.placeholder { Image(uiImage: .gravatarPlaceholderImage).resizable() }
250-
.resizable()
251-
.frame(width: HubMenu.Constants.avatarSize, height: HubMenu.Constants.avatarSize)
252-
.clipShape(Circle())
241+
ZStack {
242+
// Icon
243+
Group {
244+
switch icon {
245+
case .local(let asset):
246+
Circle()
247+
.fill(Color(.init(light: .listBackground, dark: .secondaryButtonBackground)))
248+
.frame(width: HubMenu.Constants.avatarSize, height: HubMenu.Constants.avatarSize)
249+
.overlay {
250+
Image(uiImage: asset)
251+
.resizable()
252+
.frame(width: HubMenu.Constants.iconSize, height: HubMenu.Constants.iconSize)
253+
}
254+
255+
case .remote(let url):
256+
KFImage(url)
257+
.placeholder { Image(uiImage: .gravatarPlaceholderImage).resizable() }
258+
.resizable()
259+
.frame(width: HubMenu.Constants.avatarSize, height: HubMenu.Constants.avatarSize)
260+
.clipShape(Circle())
261+
}
262+
}
263+
.overlay(alignment: .topTrailing) {
264+
// Badge
265+
if case .dot = iconBadge {
266+
Circle()
267+
.fill(Color(.accent))
268+
.frame(width: HubMenu.Constants.dotBadgeSize)
269+
.padding(HubMenu.Constants.dotBadgePadding)
270+
}
253271
}
254272
}
255273
}
@@ -263,8 +281,8 @@ private extension HubMenu {
263281
.headlineStyle()
264282
.accessibilityIdentifier(titleAccessibilityID ?? "")
265283

266-
if let badge, badge.isNotEmpty {
267-
BadgeView(text: badge)
284+
if let titleBadge, titleBadge.isNotEmpty {
285+
BadgeView(text: titleBadge)
268286
}
269287
}
270288

@@ -298,6 +316,8 @@ private extension HubMenu {
298316
static let chevronSize: CGFloat = 20
299317
static let iconSize: CGFloat = 20
300318
static let trackingOptionKey = "option"
319+
static let dotBadgePadding = EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 2)
320+
static let dotBadgeSize: CGFloat = 6
301321

302322
/// Spacing for the badge view in the avatar row.
303323
///

0 commit comments

Comments
 (0)