diff --git a/Experiments/Experiments/DefaultFeatureFlagService.swift b/Experiments/Experiments/DefaultFeatureFlagService.swift index fa197cd8e41..16db3e0bd24 100644 --- a/Experiments/Experiments/DefaultFeatureFlagService.swift +++ b/Experiments/Experiments/DefaultFeatureFlagService.swift @@ -57,6 +57,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService { return buildConfig == .localDeveloper || buildConfig == .appStore case .tapToPayOnIPhoneMilestone2: return true + case .tapToPayBadge: + return buildConfig == .localDeveloper || buildConfig == .alpha case .domainSettings: return true case .jetpackSetupWithApplicationPassword: diff --git a/Experiments/Experiments/FeatureFlag.swift b/Experiments/Experiments/FeatureFlag.swift index 26925cf39bf..f4848d7e9d8 100644 --- a/Experiments/Experiments/FeatureFlag.swift +++ b/Experiments/Experiments/FeatureFlag.swift @@ -64,6 +64,10 @@ public enum FeatureFlag: Int { /// case tapToPayOnIPhoneMilestone2 + /// Enables badging the route to Set up Tap to Pay on iPhone on eligible devices + /// + case tapToPayBadge + /// Store creation MVP. /// case storeCreationMVP diff --git a/Storage/Storage/Model/Feature Announcements/FeatureAnnouncementCampaign.swift b/Storage/Storage/Model/Feature Announcements/FeatureAnnouncementCampaign.swift index 25cf14ed3f4..1a30983809e 100644 --- a/Storage/Storage/Model/Feature Announcements/FeatureAnnouncementCampaign.swift +++ b/Storage/Storage/Model/Feature Announcements/FeatureAnnouncementCampaign.swift @@ -7,6 +7,7 @@ public enum FeatureAnnouncementCampaign: String, Codable, Equatable { case inPersonPaymentsCashOnDelivery = "ipp_not_user" case inPersonPaymentsFirstTransaction = "ipp_new_user" case inPersonPaymentsPowerUsers = "ipp_power_user" + case tapToPayHubMenuBadge = "tap_to_pay_hub_menu_badge" /// Added for use in `test_setFeatureAnnouncementDismissed_with_another_campaign_previously_dismissed_keeps_values_for_both` /// This can be removed when we have a second campaign, which can be used in the above test instead. diff --git a/WooCommerce/Classes/ViewModels/MainTabViewModel.swift b/WooCommerce/Classes/ViewModels/MainTabViewModel.swift index 11105100456..ab8c171adab 100644 --- a/WooCommerce/Classes/ViewModels/MainTabViewModel.swift +++ b/WooCommerce/Classes/ViewModels/MainTabViewModel.swift @@ -92,6 +92,9 @@ final class MainTabViewModel { listenToReviewsBadgeReloadRequired() retrieveShouldShowReviewsBadgeOnHubMenuTabValue() + + listenToNewFeatureBadgeReloadRequired() + retrieveShouldShowNewFeatureBadgeOnHubMenuTabValue() } } @@ -197,6 +200,21 @@ private extension MainTabViewModel { object: nil) } + + func listenToNewFeatureBadgeReloadRequired() { + NotificationCenter.default.addObserver(self, + selector: #selector(setUpTapToPayViewDidAppear), + name: .setUpTapToPayViewDidAppear, + object: nil) + + } + + /// Updates the badge after the Set up Tap to Pay flow did appear + /// + @objc func setUpTapToPayViewDidAppear() { + shouldShowNewFeatureBadgeOnHubMenuTab = false + } + /// Retrieves whether we should show the reviews on the Menu button and updates `shouldShowReviewsBadge` /// @objc func retrieveShouldShowReviewsBadgeOnHubMenuTabValue() { @@ -211,6 +229,22 @@ private extension MainTabViewModel { storesManager.dispatch(notificationCountAction) } + /// Retrieves whether we should show the new feature badge on the Menu button + /// + func retrieveShouldShowNewFeatureBadgeOnHubMenuTabValue() { + let action = AppSettingsAction.getFeatureAnnouncementVisibility(campaign: .tapToPayHubMenuBadge) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let visible): + self.shouldShowNewFeatureBadgeOnHubMenuTab = visible && self.featureFlagService.isFeatureFlagEnabled(.tapToPayBadge) + case .failure: + self.shouldShowNewFeatureBadgeOnHubMenuTab = false + } + } + + storesManager.dispatch(action) + } + /// Listens for changes on the menu badge display logic and updates it depending on them /// func synchronizeShouldShowBadgeOnHubMenuTabLogic() { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/SetUpTapToPayInformationViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/SetUpTapToPayInformationViewController.swift index d78c1d4dc24..358bbc317bf 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/SetUpTapToPayInformationViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/SetUpTapToPayInformationViewController.swift @@ -1,5 +1,11 @@ import SwiftUI +extension NSNotification.Name { + /// Posted whenever the hub menu view did appear. + /// + public static let setUpTapToPayViewDidAppear = Foundation.Notification.Name(rawValue: "com.woocommerce.ios.setUpTapToPayViewDidAppear") +} + /// This view controller is used when no reader is connected. It assists /// the merchant in connecting to a reader. /// @@ -129,6 +135,7 @@ struct SetUpTapToPayInformationView: View { .scrollVerticallyIfNeeded() } .padding() + .onAppear(perform: viewModel.viewDidAppear) } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/SetUpTapToPayInformationViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/SetUpTapToPayInformationViewModel.swift index f811ba3477c..b3eb4cb65cd 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/SetUpTapToPayInformationViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/SetUpTapToPayInformationViewModel.swift @@ -129,6 +129,12 @@ final class SetUpTapToPayInformationViewModel: PaymentSettingsFlowPresentedViewM dismiss?() } + func viewDidAppear() { + let action = AppSettingsAction.setFeatureAnnouncementDismissed(campaign: .tapToPayHubMenuBadge, remindAfterDays: nil, onCompletion: nil) + stores.dispatch(action) + NotificationCenter.default.post(name: .setUpTapToPayViewDidAppear, object: nil) + } + /// Updates whether the view this viewModel is associated with should be shown or not /// Notifies the viewModel owner if a change occurs via didChangeShouldShow /// diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/InPersonPaymentsMenuViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/InPersonPaymentsMenuViewController.swift index cdb8e470524..10592246a9d 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/InPersonPaymentsMenuViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/InPersonPaymentsMenuViewController.swift @@ -22,7 +22,7 @@ final class InPersonPaymentsMenuViewController: UIViewController { private lazy var inPersonPaymentsLearnMoreViewModel = LearnMoreViewModel.inPersonPayments(source: .paymentsMenu) - private let viewModel: InPersonPaymentsMenuViewModel = InPersonPaymentsMenuViewModel() + private let viewModel: InPersonPaymentsMenuViewModel private let cashOnDeliveryToggleRowViewModel: InPersonPaymentsCashOnDeliveryToggleRowViewModel @@ -68,9 +68,11 @@ final class InPersonPaymentsMenuViewController: UIViewController { /// - viewDidLoadAction: Provided as a one-time callback on viewDidLoad, originally to handle universal link navigation correctly. init(stores: StoresManager = ServiceLocator.stores, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, + shouldShowBadgeOnSetUpTapToPay: Bool, viewDidLoadAction: ((InPersonPaymentsMenuViewController) -> Void)? = nil) { self.stores = stores self.featureFlagService = featureFlagService + self.viewModel = InPersonPaymentsMenuViewModel(shouldShowBadgeOnSetUpTapToPay: shouldShowBadgeOnSetUpTapToPay) self.cardPresentPaymentsOnboardingUseCase = CardPresentPaymentsOnboardingUseCase() self.cashOnDeliveryToggleRowViewModel = InPersonPaymentsCashOnDeliveryToggleRowViewModel() self.setUpFlowOnlyEnabledAfterOnboardingComplete = !featureFlagService.isFeatureFlagEnabled(.tapToPayOnIPhoneMilestone2) @@ -304,7 +306,7 @@ private extension InPersonPaymentsMenuViewController { configureCollectPayment(cell: cell) case let cell as LeftImageTitleSubtitleToggleTableViewCell where row == .toggleEnableCashOnDelivery: configureToggleEnableCashOnDelivery(cell: cell) - case let cell as LeftImageTableViewCell where row == .setUpTapToPayOnIPhone: + case let cell as BadgedLeftImageTableViewCell where row == .setUpTapToPayOnIPhone: configureSetUpTapToPayOnIPhone(cell: cell) case let cell as LeftImageTableViewCell where row == .tapToPayOnIPhoneFeedback: configureTapToPayOnIPhoneFeedback(cell: cell) @@ -366,11 +368,12 @@ private extension InPersonPaymentsMenuViewController { }) } - func configureSetUpTapToPayOnIPhone(cell: LeftImageTableViewCell) { + func configureSetUpTapToPayOnIPhone(cell: BadgedLeftImageTableViewCell) { prepareForReuse(cell) cell.accessibilityIdentifier = "set-up-tap-to-pay" cell.configure(image: .tapToPayOnIPhoneIcon, - text: Localization.tapToPayOnIPhone) + text: Localization.tapToPayOnIPhone, + showBadge: viewModel.shouldBadgeTapToPayOnIPhone) if setUpFlowOnlyEnabledAfterOnboardingComplete { cell.accessoryType = enableSetUpTapToPayOnIPhoneCell ? .disclosureIndicator : .none @@ -418,6 +421,14 @@ private extension InPersonPaymentsMenuViewController { self?.configureSections(shouldShowTapToPayOnIPhoneFeedback: shouldShowFeedbackRow) self?.tableView.reloadData() }.store(in: &cancellables) + + viewModel.$shouldBadgeTapToPayOnIPhone.sink { [weak self] _ in + self?.configureSections() + // ensures that the cell will be configured with the correct value for the badge + DispatchQueue.main.async { + self?.tableView.reloadData() + } + }.store(in: &cancellables) } private func configureWebViewPresentation() { @@ -732,6 +743,8 @@ private enum Row: CaseIterable { return LeftImageTitleSubtitleTableViewCell.self case .toggleEnableCashOnDelivery: return LeftImageTitleSubtitleToggleTableViewCell.self + case .setUpTapToPayOnIPhone: + return BadgedLeftImageTableViewCell.self default: return LeftImageTableViewCell.self } @@ -754,8 +767,10 @@ private extension InPersonPaymentsMenuViewController { /// SwiftUI wrapper for CardReaderSettingsPresentingViewController /// struct InPersonPaymentsMenu: UIViewControllerRepresentable { + let shouldShowBadgeOnSetUpTapToPay: Bool + func makeUIViewController(context: Context) -> some UIViewController { - InPersonPaymentsMenuViewController() + InPersonPaymentsMenuViewController(shouldShowBadgeOnSetUpTapToPay: shouldShowBadgeOnSetUpTapToPay) } func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/InPersonPaymentsMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/InPersonPaymentsMenuViewModel.swift index 5d3e4d46d3b..fd52859da7d 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/InPersonPaymentsMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/InPersonPaymentsMenuViewModel.swift @@ -39,13 +39,16 @@ final class InPersonPaymentsMenuViewModel { @Published private(set) var isEligibleForTapToPayOnIPhone: Bool = false @Published private(set) var shouldShowTapToPayOnIPhoneFeedbackRow: Bool = false + @Published private(set) var shouldBadgeTapToPayOnIPhone: Bool = false let cardPresentPaymentsConfiguration: CardPresentPaymentsConfiguration init(dependencies: Dependencies = Dependencies(), - cardPresentPaymentsConfiguration: CardPresentPaymentsConfiguration = CardPresentConfigurationLoader().configuration) { + cardPresentPaymentsConfiguration: CardPresentPaymentsConfiguration = CardPresentConfigurationLoader().configuration, + shouldShowBadgeOnSetUpTapToPay: Bool) { self.dependencies = dependencies self.cardPresentPaymentsConfiguration = cardPresentPaymentsConfiguration + self.shouldBadgeTapToPayOnIPhone = shouldShowBadgeOnSetUpTapToPay } func viewDidLoad() { @@ -97,6 +100,10 @@ final class InPersonPaymentsMenuViewModel { selector: #selector(refreshTapToPayFeedbackVisibility), name: .firstInPersonPaymentsTransactionsWereUpdated, object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(setUpTapToPayViewDidAppear), + name: .setUpTapToPayViewDidAppear, + object: nil) } @objc func refreshTapToPayFeedbackVisibility() { @@ -106,6 +113,10 @@ final class InPersonPaymentsMenuViewModel { checkShouldShowTapToPayFeedbackRow(siteID: siteID) } + @objc private func setUpTapToPayViewDidAppear() { + self.shouldBadgeTapToPayOnIPhone = false + } + func orderCardReaderPressed() { analytics.track(.paymentsMenuOrderCardReaderTapped) showWebView = PurchaseCardReaderWebViewViewModel(configuration: cardPresentPaymentsConfiguration, @@ -123,6 +134,9 @@ final class InPersonPaymentsMenuViewModel { NotificationCenter.default.removeObserver(self, name: .firstInPersonPaymentsTransactionsWereUpdated, object: nil) + NotificationCenter.default.removeObserver(self, + name: .setUpTapToPayViewDidAppear, + object: nil) } } diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift index 89789d11960..aa0ef45fc84 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift @@ -32,7 +32,8 @@ struct HubMenu: View { viewModel.presentSwitchStore() } label: { Row(title: viewModel.storeTitle, - badge: viewModel.planName, + titleBadge: viewModel.planName, + iconBadge: nil, description: viewModel.storeURL.host ?? viewModel.storeURL.absoluteString, icon: .remote(viewModel.avatarURL), chevron: viewModel.switchStoreEnabled ? .down : .none, @@ -51,7 +52,8 @@ struct HubMenu: View { handleTap(menu: menu) } label: { Row(title: menu.title, - badge: nil, + titleBadge: nil, + iconBadge: menu.iconBadge, description: menu.description, icon: .local(menu.icon), chevron: .leading) @@ -68,7 +70,8 @@ struct HubMenu: View { handleTap(menu: menu) } label: { Row(title: menu.title, - badge: nil, + titleBadge: nil, + iconBadge: menu.iconBadge, description: menu.description, icon: .local(menu.icon), chevron: .leading) @@ -102,7 +105,7 @@ struct HubMenu: View { EmptyView() }.hidden() NavigationLink(destination: - InPersonPaymentsMenu() + InPersonPaymentsMenu(shouldShowBadgeOnSetUpTapToPay: viewModel.shouldShowNewFeatureBadgeOnPayments) .navigationTitle(InPersonPaymentsView.Localization.title), isActive: $showingPayments) { EmptyView() @@ -198,9 +201,13 @@ private extension HubMenu { /// let title: String - /// Optional badge text. Render next to `title` + /// Text badge displayed adjacent to the title /// - let badge: String? + let titleBadge: String? + + /// Badge displayed on the icon. + /// + let iconBadge: HubMenuBadgeType? /// Row Description /// @@ -225,31 +232,42 @@ private extension HubMenu { HStack(spacing: .zero) { /// iOS 16, aligns the list dividers to the first text position. - /// This tricks the system by rendering an empty text and forcing the list lo align the divider to it. + /// This tricks the system by rendering an empty text and forcing the list to align the divider to it. /// Without this, the divider will be rendered from the title and will not cover the icon. /// Ideally we would want to use the `alignmentGuide` modifier but that is only available on iOS 16. /// Text("") - // Icon - Group { - switch icon { - case .local(let asset): - Circle() - .fill(Color(.init(light: .listBackground, dark: .secondaryButtonBackground))) - .frame(width: HubMenu.Constants.avatarSize, height: HubMenu.Constants.avatarSize) - .overlay { - Image(uiImage: asset) - .resizable() - .frame(width: HubMenu.Constants.iconSize, height: HubMenu.Constants.iconSize) - } - - case .remote(let url): - KFImage(url) - .placeholder { Image(uiImage: .gravatarPlaceholderImage).resizable() } - .resizable() - .frame(width: HubMenu.Constants.avatarSize, height: HubMenu.Constants.avatarSize) - .clipShape(Circle()) + ZStack { + // Icon + Group { + switch icon { + case .local(let asset): + Circle() + .fill(Color(.init(light: .listBackground, dark: .secondaryButtonBackground))) + .frame(width: HubMenu.Constants.avatarSize, height: HubMenu.Constants.avatarSize) + .overlay { + Image(uiImage: asset) + .resizable() + .frame(width: HubMenu.Constants.iconSize, height: HubMenu.Constants.iconSize) + } + + case .remote(let url): + KFImage(url) + .placeholder { Image(uiImage: .gravatarPlaceholderImage).resizable() } + .resizable() + .frame(width: HubMenu.Constants.avatarSize, height: HubMenu.Constants.avatarSize) + .clipShape(Circle()) + } + } + .overlay(alignment: .topTrailing) { + // Badge + if case .dot = iconBadge { + Circle() + .fill(Color(.accent)) + .frame(width: HubMenu.Constants.dotBadgeSize) + .padding(HubMenu.Constants.dotBadgePadding) + } } } } @@ -263,8 +281,8 @@ private extension HubMenu { .headlineStyle() .accessibilityIdentifier(titleAccessibilityID ?? "") - if let badge, badge.isNotEmpty { - BadgeView(text: badge) + if let titleBadge, titleBadge.isNotEmpty { + BadgeView(text: titleBadge) } } @@ -298,6 +316,8 @@ private extension HubMenu { static let chevronSize: CGFloat = 20 static let iconSize: CGFloat = 20 static let trackingOptionKey = "option" + static let dotBadgePadding = EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 2) + static let dotBadgeSize: CGFloat = 6 /// Spacing for the badge view in the avatar row. /// diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewController.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewController.swift index f3181631713..aed061679ca 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewController.swift @@ -7,7 +7,8 @@ final class HubMenuViewController: UIHostingController { private let viewModel: HubMenuViewModel init(siteID: Int64, navigationController: UINavigationController?) { - self.viewModel = HubMenuViewModel(siteID: siteID, navigationController: navigationController) + self.viewModel = HubMenuViewModel(siteID: siteID, + navigationController: navigationController) super.init(rootView: HubMenu(viewModel: viewModel)) configureTabBarItem() } @@ -29,7 +30,9 @@ final class HubMenuViewController: UIHostingController { } func showPaymentsMenu(onCompletion: ((InPersonPaymentsMenuViewController) -> Void)? = nil) -> InPersonPaymentsMenuViewController { - let inPersonPaymentsMenuViewController = InPersonPaymentsMenuViewController(viewDidLoadAction: onCompletion) + let inPersonPaymentsMenuViewController = InPersonPaymentsMenuViewController( + shouldShowBadgeOnSetUpTapToPay: viewModel.shouldShowNewFeatureBadgeOnPayments, + viewDidLoadAction: onCompletion) show(inPersonPaymentsMenuViewController, sender: self) return inPersonPaymentsMenuViewController diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift index 3c9de916968..80e6de07db2 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift @@ -61,6 +61,10 @@ final class HubMenuViewModel: ObservableObject { private var storePickerCoordinator: StorePickerCoordinator? + @Published private(set) var shouldShowNewFeatureBadgeOnPayments: Bool = false + + private var cancellables: Set = [] + init(siteID: Int64, navigationController: UINavigationController? = nil, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, @@ -74,6 +78,8 @@ final class HubMenuViewModel: ObservableObject { self.switchStoreEnabled = stores.isAuthenticatedWithoutWPCom == false observeSiteForUIUpdates() observePlanName() + listenToNewFeatureBadgeReloadRequired() + retrieveShouldShowNewFeatureBadgeOnPaymentsValue() } func viewDidAppear() { @@ -97,7 +103,10 @@ final class HubMenuViewModel: ObservableObject { } private func setupGeneralElements() { - generalElements = [Payments(), WoocommerceAdmin(), ViewStore(), Reviews()] + generalElements = [Payments(iconBadge: shouldShowNewFeatureBadgeOnPayments ? .dot : nil), + WoocommerceAdmin(), + ViewStore(), + Reviews()] if generalAppSettings.betaFeatureEnabled(.inAppPurchases) { generalElements.append(InAppPurchases()) } @@ -129,6 +138,36 @@ final class HubMenuViewModel: ObservableObject { stores.dispatch(action) } + private func listenToNewFeatureBadgeReloadRequired() { + NotificationCenter.default.addObserver(self, + selector: #selector(setUpTapToPayViewDidAppear), + name: .setUpTapToPayViewDidAppear, + object: nil) + + } + + /// Retrieves whether we should show the new feature badge on the Menu button + /// + func retrieveShouldShowNewFeatureBadgeOnPaymentsValue() { + let action = AppSettingsAction.getFeatureAnnouncementVisibility(campaign: .tapToPayHubMenuBadge) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let visible): + self.shouldShowNewFeatureBadgeOnPayments = visible && self.featureFlagService.isFeatureFlagEnabled(.tapToPayBadge) + case .failure: + self.shouldShowNewFeatureBadgeOnPayments = false + } + } + + stores.dispatch(action) + } + + /// Updates the badge after the Set up Tap to Pay flow did appear + /// + @objc private func setUpTapToPayViewDidAppear() { + self.shouldShowNewFeatureBadgeOnPayments = false + } + /// Present the `StorePickerViewController` using the `StorePickerCoordinator`, passing the navigation controller from the entry point. /// func presentSwitchStore() { @@ -214,6 +253,10 @@ final class HubMenuViewModel: ObservableObject { } .assign(to: &$planName) } + + deinit { + NotificationCenter.default.removeObserver(self, name: .setUpTapToPayViewDidAppear, object: nil) + } } protocol HubMenuItem { @@ -224,6 +267,7 @@ protocol HubMenuItem { var iconColor: UIColor { get } var accessibilityIdentifier: String { get } var trackingOption: String { get } + var iconBadge: HubMenuBadgeType? { get } } extension HubMenuItem { @@ -243,6 +287,7 @@ extension HubMenuViewModel { let iconColor: UIColor = .primary let accessibilityIdentifier: String = "dashboard-settings-button" let trackingOption: String = "settings" + let iconBadge: HubMenuBadgeType? = nil } struct Payments: HubMenuItem { @@ -255,6 +300,11 @@ extension HubMenuViewModel { let iconColor: UIColor = .withColorStudio(.orange) let accessibilityIdentifier: String = "menu-payments" let trackingOption: String = "payments" + let iconBadge: HubMenuBadgeType? + + init(iconBadge: HubMenuBadgeType? = nil) { + self.iconBadge = iconBadge + } } struct WoocommerceAdmin: HubMenuItem { @@ -266,6 +316,7 @@ extension HubMenuViewModel { let iconColor: UIColor = .wooBlue let accessibilityIdentifier: String = "menu-woocommerce-admin" let trackingOption: String = "admin_menu" + let iconBadge: HubMenuBadgeType? = nil } struct ViewStore: HubMenuItem { @@ -277,6 +328,7 @@ extension HubMenuViewModel { let iconColor: UIColor = .accent let accessibilityIdentifier: String = "menu-view-store" let trackingOption: String = "view_store" + let iconBadge: HubMenuBadgeType? = nil } struct Inbox: HubMenuItem { @@ -288,6 +340,7 @@ extension HubMenuViewModel { let iconColor: UIColor = .withColorStudio(.blue, shade: .shade40) let accessibilityIdentifier: String = "menu-inbox" let trackingOption: String = "inbox" + let iconBadge: HubMenuBadgeType? = nil } struct Coupons: HubMenuItem { @@ -300,6 +353,7 @@ extension HubMenuViewModel { dark: .withColorStudio(.green, shade: .shade50)) let accessibilityIdentifier: String = "menu-coupons" let trackingOption: String = "coupons" + let iconBadge: HubMenuBadgeType? = nil } struct Reviews: HubMenuItem { @@ -311,6 +365,7 @@ extension HubMenuViewModel { let iconColor: UIColor = .primary let accessibilityIdentifier: String = "menu-reviews" let trackingOption: String = "reviews" + let iconBadge: HubMenuBadgeType? = nil } struct InAppPurchases: HubMenuItem { @@ -322,6 +377,7 @@ extension HubMenuViewModel { let iconColor: UIColor = .red let accessibilityIdentifier: String = "menu-iap" let trackingOption: String = "debug-iap" + let iconBadge: HubMenuBadgeType? = nil } struct Subscriptions: HubMenuItem { @@ -333,6 +389,7 @@ extension HubMenuViewModel { let iconColor: UIColor = .primary let accessibilityIdentifier: String = "menu-subscriptions" let trackingOption: String = "upgrades" + let iconBadge: HubMenuBadgeType? = nil } enum Localization { @@ -359,3 +416,7 @@ extension HubMenuViewModel { static let subscriptionsDescription = NSLocalizedString("Manage your subscription", comment: "Description of one of the hub menu options") } } + +enum HubMenuBadgeType { + case dot +} diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index 254e1692453..537ddd7fbab 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -517,6 +517,8 @@ private extension MainTabBarController { } hubMenuTabCoordinator?.activate(siteID: siteID) + viewModel.loadHubMenuTabBadge() + // Set dashboard to be the default tab. selectedIndex = WooTab.myStore.visibleIndex() } diff --git a/WooCommerce/Classes/ViewRelated/NotificationsBadgeController.swift b/WooCommerce/Classes/ViewRelated/NotificationsBadgeController.swift index fe2fab42f6a..3be25b05cb7 100644 --- a/WooCommerce/Classes/ViewRelated/NotificationsBadgeController.swift +++ b/WooCommerce/Classes/ViewRelated/NotificationsBadgeController.swift @@ -94,48 +94,3 @@ private extension NotificationsBadgeController { static let tagOffset = 999 } } - - -// MARK: - DotView UIView -// -private class DotView: UIView { - - private var borderWidth = CGFloat(1) // Border line width defaults to 1 - - private let color: UIColor - - /// Designated Initializer - /// - init(frame: CGRect, color: UIColor, borderWidth: CGFloat) { - self.color = color - super.init(frame: frame) - self.borderWidth = borderWidth - setupSubviews() - } - - /// Required Initializer - /// - required init?(coder aDecoder: NSCoder) { - color = UIColor.primary - - super.init(coder: aDecoder) - setupSubviews() - } - - private func setupSubviews() { - backgroundColor = .clear - } - - override func draw(_ rect: CGRect) { - let path = UIBezierPath(ovalIn: CGRect(x: rect.origin.x + borderWidth, - y: rect.origin.y + borderWidth, - width: rect.size.width - borderWidth * 2, - height: rect.size.height - borderWidth * 2)) - color.setFill() - path.fill() - - path.lineWidth = borderWidth - UIColor.basicBackground.setStroke() - path.stroke() - } -} diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/BadgedLeftImageTableViewCell.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/BadgedLeftImageTableViewCell.swift new file mode 100644 index 00000000000..183b82db0e4 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/BadgedLeftImageTableViewCell.swift @@ -0,0 +1,93 @@ +import UIKit + + +/// Represents a regular UITableView Cell: [Image | Text | Disclosure] +/// +class BadgedLeftImageTableViewCell: UITableViewCell { + + /// Left Image + /// + var leftImage: UIImage? { + get { + return imageView?.image + } + set { + imageView?.image = newValue + } + } + + /// Label's Text + /// + var labelText: String? { + get { + return textLabel?.text + } + set { + textLabel?.text = newValue + } + } + + // MARK: - Overridden Methods + + override func awakeFromNib() { + super.awakeFromNib() + configureBackground() + imageView?.tintColor = .primary + textLabel?.applyBodyStyle() + } + + private func configureBackground() { + applyDefaultBackgroundStyle() + + //Background when selected + selectedBackgroundView = UIView() + selectedBackgroundView?.backgroundColor = .listBackground + } +} + +// MARK: - Public Methods +// +extension BadgedLeftImageTableViewCell { + func configure(image: UIImage, text: String, showBadge: Bool) { + imageView?.image = image + textLabel?.text = text + badgeImage(visible: showBadge) + } +} + +private extension BadgedLeftImageTableViewCell { + func badgeImage(visible: Bool) { + guard let imageView, + imageView.frame != .zero else { + return + } + + if visible { + dotView?.removeFromSuperview() // ensures we don't end up with multiple dotViews + let dot = DotView(frame: CGRect(x: xOffset(in: imageView), + y: DotConstants.yOffset, + width: DotConstants.diameter, + height: DotConstants.diameter), + color: .accent, + borderWidth: DotConstants.borderWidth) + imageView.insertSubview(dot, at: 1) + } else { + dotView?.fadeOut() + dotView?.removeFromSuperview() + } + } + + private var dotView: DotView? { + imageView?.subviews.first(where: { $0 is DotView }) as? DotView + } + + private func xOffset(in imageView: UIImageView) -> CGFloat { + return imageView.frame.width - DotConstants.diameter + } + + private enum DotConstants { + static let diameter = CGFloat(9) + static let borderWidth = CGFloat(1) + static let yOffset = CGFloat(0) + } +} diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/BadgedLeftImageTableViewCell.xib b/WooCommerce/Classes/ViewRelated/ReusableViews/BadgedLeftImageTableViewCell.xib new file mode 100644 index 00000000000..1f6bbba5bd7 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/BadgedLeftImageTableViewCell.xib @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/DotView.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/DotView.swift new file mode 100644 index 00000000000..4f12b55859a --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/DotView.swift @@ -0,0 +1,43 @@ +import UIKit + +class DotView: UIView { + + private var borderWidth = CGFloat(1) // Border line width defaults to 1 + + private let color: UIColor + + /// Designated Initializer + /// + init(frame: CGRect, color: UIColor, borderWidth: CGFloat) { + self.color = color + super.init(frame: frame) + self.borderWidth = borderWidth + setupSubviews() + } + + /// Required Initializer + /// + required init?(coder aDecoder: NSCoder) { + color = UIColor.primary + + super.init(coder: aDecoder) + setupSubviews() + } + + private func setupSubviews() { + backgroundColor = .clear + } + + override func draw(_ rect: CGRect) { + let path = UIBezierPath(ovalIn: CGRect(x: rect.origin.x + borderWidth, + y: rect.origin.y + borderWidth, + width: rect.size.width - borderWidth * 2, + height: rect.size.height - borderWidth * 2)) + color.setFill() + path.fill() + + path.lineWidth = borderWidth + UIColor.basicBackground.setStroke() + path.stroke() + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index b51c53f9d1f..37b0d4f3b71 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -562,6 +562,9 @@ 0371C3682875E47B00277E2C /* FeatureAnnouncementCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0371C3672875E47B00277E2C /* FeatureAnnouncementCardViewModel.swift */; }; 0371C36A2876DBCA00277E2C /* FeatureAnnouncementCardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0371C3692876DBCA00277E2C /* FeatureAnnouncementCardViewModelTests.swift */; }; 0371C36E2876E92D00277E2C /* UpsellCardReadersCampaign.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0371C36D2876E92D00277E2C /* UpsellCardReadersCampaign.swift */; }; + 0373A12B2A1D1E4D00731236 /* BadgedLeftImageTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 0373A12A2A1D1E4D00731236 /* BadgedLeftImageTableViewCell.xib */; }; + 0373A12D2A1D1E6000731236 /* BadgedLeftImageTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0373A12C2A1D1E6000731236 /* BadgedLeftImageTableViewCell.swift */; }; + 0373A12F2A1D1F2100731236 /* DotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0373A12E2A1D1F2100731236 /* DotView.swift */; }; 0375799928201F750083F2E1 /* CardPresentPaymentsReadinessUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0375799828201F750083F2E1 /* CardPresentPaymentsReadinessUseCase.swift */; }; 0375799B28227EDE0083F2E1 /* CardPresentPaymentsOnboardingPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0375799A28227EDE0083F2E1 /* CardPresentPaymentsOnboardingPresenter.swift */; }; 0375799D2822F9040083F2E1 /* MockCardPresentPaymentsOnboardingPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0375799C2822F9040083F2E1 /* MockCardPresentPaymentsOnboardingPresenter.swift */; }; @@ -2858,6 +2861,9 @@ 0371C3672875E47B00277E2C /* FeatureAnnouncementCardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureAnnouncementCardViewModel.swift; sourceTree = ""; }; 0371C3692876DBCA00277E2C /* FeatureAnnouncementCardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureAnnouncementCardViewModelTests.swift; sourceTree = ""; }; 0371C36D2876E92D00277E2C /* UpsellCardReadersCampaign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpsellCardReadersCampaign.swift; sourceTree = ""; }; + 0373A12A2A1D1E4D00731236 /* BadgedLeftImageTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BadgedLeftImageTableViewCell.xib; sourceTree = ""; }; + 0373A12C2A1D1E6000731236 /* BadgedLeftImageTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgedLeftImageTableViewCell.swift; sourceTree = ""; }; + 0373A12E2A1D1F2100731236 /* DotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DotView.swift; sourceTree = ""; }; 0375799828201F750083F2E1 /* CardPresentPaymentsReadinessUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentPaymentsReadinessUseCase.swift; sourceTree = ""; }; 0375799A28227EDE0083F2E1 /* CardPresentPaymentsOnboardingPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentPaymentsOnboardingPresenter.swift; sourceTree = ""; }; 0375799C2822F9040083F2E1 /* MockCardPresentPaymentsOnboardingPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCardPresentPaymentsOnboardingPresenter.swift; sourceTree = ""; }; @@ -9417,6 +9423,8 @@ CE2A9FBE23BFB1BE002BEC1C /* LedgerTableViewCell.xib */, CE1EC8EA20B8A3FF009762BF /* LeftImageTableViewCell.swift */, CE1EC8EB20B8A3FF009762BF /* LeftImageTableViewCell.xib */, + 0373A12C2A1D1E6000731236 /* BadgedLeftImageTableViewCell.swift */, + 0373A12A2A1D1E4D00731236 /* BadgedLeftImageTableViewCell.xib */, E16058F8285876E600E471D4 /* LeftImageTitleSubtitleTableViewCell.swift */, E16058F6285876DE00E471D4 /* LeftImageTitleSubtitleTableViewCell.xib */, 0304E36528BE1EED00A80191 /* LeftImageTitleSubtitleToggleTableViewCell.swift */, @@ -9477,6 +9485,7 @@ AE6C4FDE28A15BFE00EAC00D /* FeatureAnnouncementCardCell.xib */, B9B0391928A68ADE00DC1C83 /* ConstraintsUpdatingHostingController.swift */, 0204F0C929C047A400CFC78F /* SelfSizingHostingController.swift */, + 0373A12E2A1D1F2100731236 /* DotView.swift */, ); path = ReusableViews; sourceTree = ""; @@ -10686,6 +10695,7 @@ D843D5C822434A08001BFA55 /* ManualTrackingViewController.xib in Resources */, 3F587021281B9494004F7556 /* LaunchScreen.storyboard in Resources */, 027D4A8E2526FD1800108626 /* SettingsViewController.xib in Resources */, + 0373A12B2A1D1E4D00731236 /* BadgedLeftImageTableViewCell.xib in Resources */, B6E851F7276331110041D1BA /* RefundFeesDetailsTableViewCell.xib in Resources */, 7441EBCA226A71AA008BF83D /* TitleBodyTableViewCell.xib in Resources */, 740382DC2267D94100A627F4 /* LargeImageTableViewCell.xib in Resources */, @@ -11195,6 +11205,7 @@ 02BBD6E729A268F300243BE2 /* StoreOnboardingViewModel.swift in Sources */, B9B0391828A6838400DC1C83 /* PermanentNoticeView.swift in Sources */, DEC6C51827466B59006832D3 /* StoreStatsEmptyView.swift in Sources */, + 0373A12D2A1D1E6000731236 /* BadgedLeftImageTableViewCell.swift in Sources */, 31FE28C225E6D338003519F2 /* LearnMoreTableViewCell.swift in Sources */, 02D45647231CB1FB008CF0A9 /* UIImage+Dot.swift in Sources */, E11228BE2707267F004E9F2D /* CardPresentModalUpdateFailedNonRetryable.swift in Sources */, @@ -11925,6 +11936,7 @@ CCF87BC02790582500461C43 /* ProductVariationSelector.swift in Sources */, 02CA63DC23D1ADD100BBF148 /* DeviceMediaLibraryPicker.swift in Sources */, 021A84E0257DFC2A00BC71D1 /* RefundShippingLabelViewController.swift in Sources */, + 0373A12F2A1D1F2100731236 /* DotView.swift in Sources */, DE279BA826E9C8E3002BA963 /* ShippingLabelSinglePackage.swift in Sources */, DEC2962726C17AD8005A056B /* ShippingLabelCustomsForm+Localization.swift in Sources */, 26A630FE253F63C300CBC3B1 /* RefundableOrderItem.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/InPersonPaymentsMenuViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/InPersonPaymentsMenuViewModelTests.swift index ee2a981c680..5e84ff02347 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/InPersonPaymentsMenuViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/InPersonPaymentsMenuViewModelTests.swift @@ -28,7 +28,8 @@ class InPersonPaymentsMenuViewModelTests: XCTestCase { configuration = CardPresentPaymentsConfiguration(country: "US") sut = InPersonPaymentsMenuViewModel(dependencies: dependencies, - cardPresentPaymentsConfiguration: configuration) + cardPresentPaymentsConfiguration: configuration, + shouldShowBadgeOnSetUpTapToPay: false) } func test_viewDidLoad_synchronizes_payment_gateways() throws { @@ -91,7 +92,8 @@ class InPersonPaymentsMenuViewModelTests: XCTestCase { stripeSmallestCurrencyUnitMultiplier: 100) sut = InPersonPaymentsMenuViewModel(dependencies: dependencies, - cardPresentPaymentsConfiguration: configuration) + cardPresentPaymentsConfiguration: configuration, + shouldShowBadgeOnSetUpTapToPay: false) // When sut.viewDidLoad() @@ -116,7 +118,8 @@ class InPersonPaymentsMenuViewModelTests: XCTestCase { stripeSmallestCurrencyUnitMultiplier: 100) sut = InPersonPaymentsMenuViewModel(dependencies: dependencies, - cardPresentPaymentsConfiguration: configuration) + cardPresentPaymentsConfiguration: configuration, + shouldShowBadgeOnSetUpTapToPay: false) waitFor { promise in self.stores.whenReceivingAction(ofType: CardPresentPaymentAction.self) { action in diff --git a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift index 2a076f395d2..9158d6a4209 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift @@ -242,7 +242,7 @@ final class MainTabBarControllerTests: XCTestCase { pushNotificationsManager.sendInactiveNotification(pushNotification) // Simulate that the network call returns a parcel - let receivedAction = try XCTUnwrap(storesManager.receivedActions.first as? ProductReviewAction) + let receivedAction = try XCTUnwrap(storesManager.receivedActions.first(where: { $0 is ProductReviewAction }) as? ProductReviewAction) guard case .retrieveProductReviewFromNote(_, let completion) = receivedAction else { return XCTFail("Expected retrieveProductReviewFromNote action.") }