From d9468d8a2cfa99bc78fc016fd46cd85e68cc1f8e Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Wed, 22 Nov 2023 15:50:46 -0800 Subject: [PATCH] Present the donations UI from a push notification --- OBAKit/Analytics/Analytics.swift | 7 ++- OBAKit/Donations/DonationLearnMoreView.swift | 16 ++----- OBAKit/Donations/DonationModel.swift | 47 ++++++++++++++++++- OBAKit/Donations/DonationsManager.swift | 48 ++++++++++++++++++-- OBAKit/Orchestration/Application.swift | 33 +++++++++++++- OBAKit/PushNotifications/PushService.swift | 10 +++- OBAKit/Settings/MoreViewController.swift | 28 ++---------- OBAKit/Stops/StopViewController.swift | 10 +--- 8 files changed, 147 insertions(+), 52 deletions(-) diff --git a/OBAKit/Analytics/Analytics.swift b/OBAKit/Analytics/Analytics.swift index 6a58a2a22..e09687142 100644 --- a/OBAKit/Analytics/Analytics.swift +++ b/OBAKit/Analytics/Analytics.swift @@ -68,13 +68,18 @@ public class AnalyticsLabels: NSObject { /// Label used when a donation fails due to the user canceling it. @objc public static let donationCanceled = "Donation Canceled" + + /// Label used when a push notification associated with a call for donations is tapped. + @objc public static let donationPushNotificationTapped = "Donation Push Notification Tapped" + + /// Label used when a push notification results in a donation + @objc public static let donationPushNotificationSuccess = "Donation Push Notification Success" } /// Reported analytics events. @objc(OBAAnalyticsEvent) public enum AnalyticsEvent: Int { case userAction - case systemAction } /// Implement this protocol for reporting analytics events in order to be able to plug in a custom provider of your choosing. diff --git a/OBAKit/Donations/DonationLearnMoreView.swift b/OBAKit/Donations/DonationLearnMoreView.swift index 5891f44f7..b4883ef76 100644 --- a/OBAKit/Donations/DonationLearnMoreView.swift +++ b/OBAKit/Donations/DonationLearnMoreView.swift @@ -77,25 +77,17 @@ struct DonationLearnMoreView: View { .onChange(of: donationModel.donationComplete) { newValue in guard newValue else { return } - var label: String? - var value: Any? - var shouldDismiss = true + let shouldDismiss: Bool switch donationModel.result { case .completed: - label = AnalyticsLabels.donationSuccess - value = String(selectedAmountInCents) - case .failed(let error): - label = AnalyticsLabels.donationError - value = error.localizedDescription + shouldDismiss = true + case .failed: + shouldDismiss = true case .canceled, .none: shouldDismiss = false } - if let label { - analyticsModel.analytics?.reportEvent?(.systemAction, label: label, value: value) - } - if shouldDismiss { closedWithDonation(donationModel.result == .completed) dismiss() diff --git a/OBAKit/Donations/DonationModel.swift b/OBAKit/Donations/DonationModel.swift index dd81f8cb6..3e08dad2a 100644 --- a/OBAKit/Donations/DonationModel.swift +++ b/OBAKit/Donations/DonationModel.swift @@ -23,20 +23,41 @@ extension PaymentSheetResult: Equatable { } } +/// `DonationModel` is an `ObservableObject` for use in SwiftUI that manages the donation process. +/// +/// It manages the donation data, communicates with the server, and provides updates to the UI. +/// The `DonationModel` class is responsible for handling the donation process, including +/// creating payment intents, presenting the payment sheet, and reporting analytics events. +/// +/// Usage: +/// - Create an instance of `DonationModel` with the required dependencies. +/// - Call the `donate(_:recurring:)` method to initiate a donation with a +/// specified amount in cents and recurrence. +/// - Observe the `result` and `donationComplete` properties for updates on the donation process. +/// class DonationModel: ObservableObject { let obacoService: ObacoAPIService let donationsManager: DonationsManager let analytics: Analytics? + let donationPushNotificationID: String? @Published var result: PaymentSheetResult? @Published var paymentSheet: PaymentSheet? @Published var donationComplete = false - init(obacoService: ObacoAPIService, donationsManager: DonationsManager, analytics: Analytics?) { + init( + obacoService: ObacoAPIService, + donationsManager: DonationsManager, + analytics: Analytics?, + donationPushNotificationID: String? + ) { self.obacoService = obacoService self.donationsManager = donationsManager self.analytics = analytics + self.donationPushNotificationID = donationPushNotificationID } + // MARK: - Donation Sheet + @MainActor func donate(_ amountInCents: Int, recurring: Bool) async { analytics?.reportEvent?(.userAction, label: AnalyticsLabels.donateButtonTapped, value: String(amountInCents)) @@ -51,11 +72,35 @@ class DonationModel: ObservableObject { } paymentSheet.present(from: presenter) { [weak self] result in + self?.reportResult(result, amountInCents: amountInCents) self?.result = result self?.donationComplete = true } } + private func reportResult(_ result: PaymentSheetResult, amountInCents: Int) { + var label: String? + var value: Any? + + switch result { + case .completed: + label = AnalyticsLabels.donationSuccess + value = String(amountInCents) + case .failed(let error): + label = AnalyticsLabels.donationError + value = error.localizedDescription + case .canceled: break + } + + if result == .completed, let donationPushNotificationID { + analytics?.reportEvent?(.userAction, label: AnalyticsLabels.donationPushNotificationSuccess, value: donationPushNotificationID) + } + + if let label { + analytics?.reportEvent?(.userAction, label: label, value: value) + } + } + private func buildIntentConfiguration(_ amountInCents: Int, recurring: Bool) -> PaymentSheet.IntentConfiguration { let mode = PaymentSheet.IntentConfiguration.Mode.payment( amount: amountInCents, diff --git a/OBAKit/Donations/DonationsManager.swift b/OBAKit/Donations/DonationsManager.swift index b7ef6ac2b..bea8afc65 100644 --- a/OBAKit/Donations/DonationsManager.swift +++ b/OBAKit/Donations/DonationsManager.swift @@ -8,6 +8,7 @@ import Foundation import OBAKitCore import StripeApplePay +import SwiftUI /// Manages the visibility of donation requests. public class DonationsManager { @@ -16,18 +17,30 @@ public class DonationsManager { /// - Parameters: /// - bundle: The application bundle. Usually, `Bundle.main` /// - userDefaults: The user defaults object. - public init(bundle: Bundle, userDefaults: UserDefaults) { + /// - obacoService: The Obaco API service. + /// - analytics: The Analytics object. + public init( + bundle: Bundle, + userDefaults: UserDefaults, + obacoService: ObacoAPIService?, + analytics: Analytics? + ) { self.bundle = bundle self.userDefaults = userDefaults + self.obacoService = obacoService + self.analytics = analytics self.userDefaults.register( defaults: [DonationsManager.forceStripeTestModeDefaultsKey: false] ) } - // MARK: - Bundle + // MARK: - Data private let bundle: Bundle + private let obacoService: ObacoAPIService? + private let analytics: Analytics? + // MARK: - User Defaults @@ -73,7 +86,7 @@ public class DonationsManager { // MARK: - State public var donationsEnabled: Bool { - bundle.donationsEnabled + bundle.donationsEnabled && obacoService != nil } /// When true, it means the app should show an inline donation request UI. @@ -121,4 +134,33 @@ public class DonationsManager { return alert } + + func buildObservableDonationModel(donationPushNotificationID: String? = nil) -> DonationModel? { + guard donationsEnabled, let obacoService else { + return nil + } + + return DonationModel( + obacoService: obacoService, + donationsManager: self, + analytics: analytics, + donationPushNotificationID: donationPushNotificationID + ) + } + + func buildLearnMoreView(presentingController: UIViewController, donationPushNotificationID: String? = nil) -> some View { + guard let donationModel = buildObservableDonationModel(donationPushNotificationID: donationPushNotificationID) else { + fatalError() + } + + return DonationLearnMoreView { donated in + guard donated else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + presentingController.present(DonationsManager.buildDonationThankYouAlert(), animated: true) + } + } + .environmentObject(donationModel) + .environmentObject(AnalyticsModel(analytics)) + } } diff --git a/OBAKit/Orchestration/Application.swift b/OBAKit/Orchestration/Application.swift index f0f0910a5..56a69bddf 100644 --- a/OBAKit/Orchestration/Application.swift +++ b/OBAKit/Orchestration/Application.swift @@ -69,7 +69,12 @@ public class Application: CoreApplication, PushServiceDelegate { // MARK: - Public Properties - lazy var donationsManager = DonationsManager(bundle: applicationBundle, userDefaults: userDefaults) + lazy var donationsManager = DonationsManager( + bundle: applicationBundle, + userDefaults: userDefaults, + obacoService: obacoService, + analytics: analytics + ) /// Responsible for figuring out how to navigate between view controllers. @MainActor @@ -270,6 +275,26 @@ public class Application: CoreApplication, PushServiceDelegate { } } + private var presentDonationUIOnActive = false + private var donationPromptID: String? = nil + + public func pushService(_ pushService: PushService, receivedDonationPrompt id: String?) { + guard let topViewController else { + presentDonationUIOnActive = true + donationPromptID = id + return + } + + presentDonationUI(topViewController, id: id) + } + + private func presentDonationUI(_ presentingController: UIViewController, id: String?) { + analytics?.reportEvent?(.userAction, label: AnalyticsLabels.donationPushNotificationTapped, value: id) + + let learnMoreView = donationsManager.buildLearnMoreView(presentingController: presentingController, donationPushNotificationID: id) + presentingController.present(UIHostingController(rootView: learnMoreView), animated: true) + } + // MARK: - Alerts Store private var alertBulletin: AgencyAlertBulletin? @@ -330,6 +355,12 @@ public class Application: CoreApplication, PushServiceDelegate { configureConnectivity() alertsStore.checkForUpdates() + + if presentDonationUIOnActive, let topViewController { + presentDonationUI(topViewController, id: donationPromptID) + presentDonationUIOnActive = false + donationPromptID = nil + } } @objc public func applicationWillResignActive(_ application: UIApplication) { diff --git a/OBAKit/PushNotifications/PushService.swift b/OBAKit/PushNotifications/PushService.swift index 50e05f82e..48bf40435 100644 --- a/OBAKit/PushNotifications/PushService.swift +++ b/OBAKit/PushNotifications/PushService.swift @@ -44,6 +44,7 @@ public protocol PushServiceProvider: NSObjectProtocol { public protocol PushServiceDelegate: NSObjectProtocol { func pushServicePresentingController(_ pushService: PushService) -> UIViewController? func pushService(_ pushService: PushService, received arrivalDeparture: AlarmPushBody) + func pushService(_ pushService: PushService, receivedDonationPrompt id: String?) } // MARK: - PushService @@ -87,8 +88,13 @@ public class PushService: NSObject { } return } - - displayMessage(message) + else if key == "donation" { + let testID = additionalData["donation"] as? String + delegate?.pushService(self, receivedDonationPrompt: testID) + } + else { + displayMessage(message) + } } private func displayMessage(_ message: String) { diff --git a/OBAKit/Settings/MoreViewController.swift b/OBAKit/Settings/MoreViewController.swift index e0b62eb4c..3d70d7874 100644 --- a/OBAKit/Settings/MoreViewController.swift +++ b/OBAKit/Settings/MoreViewController.swift @@ -122,30 +122,10 @@ public class MoreViewController: UIViewController, } private func showDonationUI() { - guard - let obacoService = application.obacoService, - application.donationsManager.donationsEnabled - else { - return - } - - let donationModel = DonationModel( - obacoService: obacoService, - donationsManager: application.donationsManager, - analytics: application.analytics - ) - - let learnMoreView = DonationLearnMoreView { [weak self] donated in - guard donated else { return } - - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self?.present(DonationsManager.buildDonationThankYouAlert(), animated: true) - } - } - .environmentObject(donationModel) - .environmentObject(AnalyticsModel(application.analytics)) - - present(UIHostingController(rootView: learnMoreView), animated: true) + guard application.donationsManager.donationsEnabled else { return } + let view = application.donationsManager.buildLearnMoreView(presentingController: self) + let hostingController = UIHostingController(rootView: view) + present(hostingController, animated: true) } // MARK: Updates and alerts section diff --git a/OBAKit/Stops/StopViewController.swift b/OBAKit/Stops/StopViewController.swift index 83c0478ec..c17c6a770 100644 --- a/OBAKit/Stops/StopViewController.swift +++ b/OBAKit/Stops/StopViewController.swift @@ -646,18 +646,12 @@ public class StopViewController: UIViewController, private func showDonationUI() { guard - let obacoService = application.obacoService, - application.donationsManager.donationsEnabled + application.donationsManager.donationsEnabled, + let donationModel = application.donationsManager.buildObservableDonationModel() else { return } - let donationModel = DonationModel( - obacoService: obacoService, - donationsManager: application.donationsManager, - analytics: application.analytics - ) - let learnMoreView = DonationLearnMoreView { [weak self] donated in guard donated else { return }