Skip to content

Commit

Permalink
Blaze: Navigate to campaign creation after tapping on local notificat…
Browse files Browse the repository at this point in the history
…ions (#13968)
  • Loading branch information
itsmeichigo committed Sep 17, 2024
2 parents e2146ea + 107eb31 commit 7ac5077
Show file tree
Hide file tree
Showing 8 changed files with 104 additions and 15 deletions.
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
20.5
-----
- [*] Blaze: Schedule a reminder local notification asking to continue abandoned campaign creation flow. [https://github.com/woocommerce/woocommerce-ios/pull/13950]
- [*] Blaze: Handle tap on local notifications to open campaign creation. [https://github.com/woocommerce/woocommerce-ios/pull/13968]
- [internal] Mobile Payments: Log errors from Stripe Terminal [https://github.com/woocommerce/woocommerce-ios/pull/13976]


Expand Down
38 changes: 31 additions & 7 deletions WooCommerce/Classes/Blaze/BlazeLocalNotificationScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ final class DefaultBlazeLocalNotificationScheduler: BlazeLocalNotificationSchedu
private let pushNotesManager: PushNotesManager
private var subscriptions: Set<AnyCancellable> = []
private let blazeEligibilityChecker: BlazeEligibilityCheckerProtocol
private var switchStoreUseCase: SwitchStoreUseCaseProtocol

/// Blaze campaign ResultsController.
private lazy var blazeCampaignResultsController: ResultsController<StorageBlazeCampaignListItem> = {
Expand All @@ -37,14 +38,16 @@ final class DefaultBlazeLocalNotificationScheduler: BlazeLocalNotificationSchedu
storageManager: StorageManagerType = ServiceLocator.storageManager,
userDefaults: UserDefaults = .standard,
pushNotesManager: PushNotesManager = ServiceLocator.pushNotesManager,
blazeEligibilityChecker: BlazeEligibilityCheckerProtocol = BlazeEligibilityChecker()) {
blazeEligibilityChecker: BlazeEligibilityCheckerProtocol = BlazeEligibilityChecker(),
switchStoreUseCase: SwitchStoreUseCaseProtocol? = nil) {
self.siteID = siteID
self.stores = stores
self.storageManager = storageManager
self.pushNotesManager = pushNotesManager
self.scheduler = LocalNotificationScheduler(pushNotesManager: pushNotesManager)
self.userDefaults = userDefaults
self.blazeEligibilityChecker = blazeEligibilityChecker
self.switchStoreUseCase = switchStoreUseCase ?? SwitchStoreUseCase(stores: stores, storageManager: storageManager)
}

/// Observes user responses to local notification and updates user defaults
Expand All @@ -64,6 +67,8 @@ final class DefaultBlazeLocalNotificationScheduler: BlazeLocalNotificationSchedu
default:
break
}

navigateToCampaignCreation(from: response)
}
.store(in: &subscriptions)
}
Expand Down Expand Up @@ -102,7 +107,7 @@ final class DefaultBlazeLocalNotificationScheduler: BlazeLocalNotificationSchedu
}

let notification = LocalNotification(scenario: LocalNotification.Scenario.blazeAbandonedCampaignCreationReminder,
userInfo: [:])
userInfo: [Constants.siteIDKey: siteID])
await scheduler.cancel(scenario: .blazeAbandonedCampaignCreationReminder)
DDLogDebug("Blaze: Schedule abandoned campaign creation local notification for date \(notificationTime).")
await scheduler.schedule(notification: notification,
Expand All @@ -120,6 +125,23 @@ final class DefaultBlazeLocalNotificationScheduler: BlazeLocalNotificationSchedu
}

private extension DefaultBlazeLocalNotificationScheduler {
func navigateToCampaignCreation(from response: UNNotificationResponse) {
guard let siteID = response.notification.request.content.userInfo[Constants.siteIDKey] as? Int64 else {
DDLogDebug("Blaze: no site ID found in location notification user info to navigate to campaign creation")
return
}

/// Switching store is needed for Blaze eligibility check.
switchStoreUseCase.switchStore(with: siteID) { [weak self] _ in
Task { @MainActor [weak self] in
guard let self, await isEligibleForBlaze() else {
return
}
MainTabBarController.navigateToBlazeCampaignCreation(for: siteID)
}
}
}

func isEligibleForBlaze() async -> Bool {
guard let site = stores.sessionManager.defaultSite else {
return false
Expand All @@ -130,21 +152,21 @@ private extension DefaultBlazeLocalNotificationScheduler {
/// Performs initial fetch from storage and updates results.
func observeStorageAndScheduleNotifications() {
blazeCampaignResultsController.onDidChangeContent = { [weak self] in
self?.scheduleLocalNotificationIfNeeded()
self?.scheduleNoCampaignReminderIfNeeded()
}
blazeCampaignResultsController.onDidResetContent = { [weak self] in
self?.scheduleLocalNotificationIfNeeded()
self?.scheduleNoCampaignReminderIfNeeded()
}

do {
try blazeCampaignResultsController.performFetch()
scheduleLocalNotificationIfNeeded()
scheduleNoCampaignReminderIfNeeded()
} catch {
ServiceLocator.crashLogging.logError(error)
}
}

func scheduleLocalNotificationIfNeeded() {
func scheduleNoCampaignReminderIfNeeded() {
guard !userDefaults.blazeNoCampaignReminderOpened() else {
DDLogDebug("Blaze: User interacted with a previously scheduled no campaign local notification. Don't schedule again.")
return
Expand Down Expand Up @@ -186,7 +208,7 @@ private extension DefaultBlazeLocalNotificationScheduler {

Task { @MainActor in
let notification = LocalNotification(scenario: LocalNotification.Scenario.blazeNoCampaignReminder,
userInfo: [:])
userInfo: [Constants.siteIDKey: siteID])
await scheduler.cancel(scenario: .blazeNoCampaignReminder)
DDLogDebug("Blaze: Schedule no campaign local notification for date \(notificationTime).")
await scheduler.schedule(notification: notification,
Expand All @@ -199,6 +221,8 @@ private extension DefaultBlazeLocalNotificationScheduler {

private extension DefaultBlazeLocalNotificationScheduler {
enum Constants {
static let siteIDKey = "site_id"

enum NoCampaignReminder {
static let daysDurationForNotification = 30
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ enum BlazeCampaignListSource: String {
///
final class BlazeCampaignListHostingController: UIHostingController<BlazeCampaignListView> {
private var coordinator: BlazeCampaignCreationCoordinator?
private var startsCampaignCreationOnAppear: Bool

/// View model for the list.
private let viewModel: BlazeCampaignListViewModel

init(viewModel: BlazeCampaignListViewModel) {
init(viewModel: BlazeCampaignListViewModel,
startsCampaignCreationOnAppear: Bool = false) {
self.viewModel = viewModel
self.startsCampaignCreationOnAppear = startsCampaignCreationOnAppear
super.init(rootView: BlazeCampaignListView(viewModel: viewModel))

rootView.onCreateCampaign = { [weak self] productID in
Expand All @@ -37,11 +40,19 @@ final class BlazeCampaignListHostingController: UIHostingController<BlazeCampaig
required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if startsCampaignCreationOnAppear {
startsCampaignCreationOnAppear = false
startCampaignCreation()
}
}
}

/// Private helper
private extension BlazeCampaignListHostingController {
func startCampaignCreation(productID: Int64?) {
func startCampaignCreation(productID: Int64? = nil) {
guard let navigationController else {
return
}
Expand All @@ -66,17 +77,21 @@ private extension BlazeCampaignListHostingController {
struct BlazeCampaignListHostingControllerRepresentable: UIViewControllerRepresentable {
private let siteID: Int64
private let selectedCampaignID: String?
private let startsCampaignCreationOnAppear: Bool

init(siteID: Int64,
selectedCampaignID: String? = nil) {
selectedCampaignID: String? = nil,
startsCampaignCreationOnAppear: Bool = false) {
self.siteID = siteID
self.selectedCampaignID = selectedCampaignID
self.startsCampaignCreationOnAppear = startsCampaignCreationOnAppear
}
func makeUIViewController(context: Context) -> BlazeCampaignListHostingController {

let viewModel = BlazeCampaignListViewModel(siteID: siteID,
selectedCampaignID: selectedCampaignID ?? nil)
return BlazeCampaignListHostingController(viewModel: viewModel)
return BlazeCampaignListHostingController(viewModel: viewModel,
startsCampaignCreationOnAppear: startsCampaignCreationOnAppear)
}

func updateUIViewController(_ uiViewController: BlazeCampaignListHostingController, context: Context) {
Expand Down
2 changes: 2 additions & 0 deletions WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ private extension HubMenu {
reviewDetailView(parcel: parcel)
case .blazeCampaignDetails(let campaignID):
BlazeCampaignListHostingControllerRepresentable(siteID: viewModel.siteID, selectedCampaignID: campaignID)
case .blazeCampaignCreation:
BlazeCampaignListHostingControllerRepresentable(siteID: viewModel.siteID, startsCampaignCreationOnAppear: true)
}
}
.navigationBarTitleDisplayMode(.inline)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ final class HubMenuViewController: UIHostingController<HubMenu> {
viewModel.navigateToDestination(.blazeCampaignDetails(campaignID: campaignID))
}

func showBlazeCampaignCreation() {
viewModel.navigateToDestination(.blazeCampaignCreation)
}

/// Pushes the Settings & Privacy screen onto the navigation stack.
///
func showPrivacySettings() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ enum HubMenuNavigationDestination: Hashable {
case settings
case blaze
case blazeCampaignDetails(campaignID: String)
case blazeCampaignCreation
case wooCommerceAdmin
case viewStore
case inbox
Expand Down
15 changes: 15 additions & 0 deletions WooCommerce/Classes/ViewRelated/MainTabBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,21 @@ extension MainTabBarController {
})
}

static func navigateToBlazeCampaignCreation(for siteID: Int64) {
showStore(with: Int64(siteID), onCompletion: { storeIsShown in
// It failed to show the campaign's store.
guard storeIsShown else {
return
}

DispatchQueue.main.asyncAfter(deadline: .now() + Constants.blazeScreenTransitionsDelay) {
switchToHubMenuTab() { hubMenuViewController in
hubMenuViewController?.showBlazeCampaignCreation()
}
}
})
}

static func presentOrderCreationFlow(for customerID: Int64, billing: Address?, shipping: Address?) {
switchToOrdersTab {
let tabBar = AppDelegate.shared.tabBarController
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,10 @@ final class BlazeLocalNotificationSchedulerTests: XCTestCase {
self.pushNotesManager.requestedLocalNotifications.isNotEmpty
}

let scenario = pushNotesManager.requestedLocalNotifications.first?.scenario
XCTAssertEqual(scenario, LocalNotification.Scenario.blazeNoCampaignReminder)
let notification = try XCTUnwrap(pushNotesManager.requestedLocalNotifications.first)
XCTAssertEqual(notification.scenario, LocalNotification.Scenario.blazeNoCampaignReminder)
let siteIDFromNotification = try XCTUnwrap(notification.userInfo["site_id"] as? Int64)
XCTAssertEqual(siteID, siteIDFromNotification)
}

func test_no_campaign_notification_is_not_scheduled_when_only_evergreen_campaign_exists_in_storage() async throws {
Expand Down Expand Up @@ -323,8 +325,10 @@ final class BlazeLocalNotificationSchedulerTests: XCTestCase {
self.pushNotesManager.requestedLocalNotifications.isNotEmpty
}

let scenario = try XCTUnwrap(pushNotesManager.requestedLocalNotifications.first?.scenario)
XCTAssertEqual(scenario, LocalNotification.Scenario.blazeAbandonedCampaignCreationReminder)
let notification = try XCTUnwrap(pushNotesManager.requestedLocalNotifications.first)
XCTAssertEqual(notification.scenario, LocalNotification.Scenario.blazeAbandonedCampaignCreationReminder)
let siteIDFromNotification = try XCTUnwrap(notification.userInfo["site_id"] as? Int64)
XCTAssertEqual(siteID, siteIDFromNotification)
}

func test_abandoned_creation_notification_is_not_scheduled_when_store_is_not_eligible_for_blaze() async throws {
Expand Down Expand Up @@ -443,6 +447,29 @@ final class BlazeLocalNotificationSchedulerTests: XCTestCase {
self.defaults[.blazeNoCampaignReminderOpened] == true
}
}

func test_it_triggers_store_switching_when_handling_user_response_to_local_notifcations() throws {
// Given
let blazeEligibilityChecker = MockBlazeEligibilityChecker(isSiteEligible: true)
let switchStoreUseCase = MockSwitchStoreUseCase()
let sut = DefaultBlazeLocalNotificationScheduler(siteID: siteID,
stores: stores,
storageManager: storageManager,
userDefaults: defaults,
pushNotesManager: pushNotesManager,
blazeEligibilityChecker: blazeEligibilityChecker,
switchStoreUseCase: switchStoreUseCase)
sut.observeNotificationUserResponse()

// When
let response = try XCTUnwrap(MockNotificationResponse(actionIdentifier: "",
requestIdentifier: LocalNotification.Scenario.blazeNoCampaignReminder.identifier,
notificationUserInfo: ["site_id": siteID]))
pushNotesManager.sendLocalNotificationResponse(response)

// Then
XCTAssert(switchStoreUseCase.destinationStoreIDs == [siteID])
}
}

private extension BlazeLocalNotificationSchedulerTests {
Expand Down

0 comments on commit 7ac5077

Please sign in to comment.