Skip to content

Commit

Permalink
Merge pull request #697 from OneBusAway/notifications-2
Browse files Browse the repository at this point in the history
Present the donations UI from a push notification
  • Loading branch information
aaronbrethorst authored Nov 23, 2023
2 parents 23b0959 + d9468d8 commit 6fcc606
Show file tree
Hide file tree
Showing 8 changed files with 147 additions and 52 deletions.
7 changes: 6 additions & 1 deletion OBAKit/Analytics/Analytics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 4 additions & 12 deletions OBAKit/Donations/DonationLearnMoreView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
47 changes: 46 additions & 1 deletion OBAKit/Donations/DonationModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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,
Expand Down
48 changes: 45 additions & 3 deletions OBAKit/Donations/DonationsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import Foundation
import OBAKitCore
import StripeApplePay
import SwiftUI

/// Manages the visibility of donation requests.
public class DonationsManager {
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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))
}
}
33 changes: 32 additions & 1 deletion OBAKit/Orchestration/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 8 additions & 2 deletions OBAKit/PushNotifications/PushService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
28 changes: 4 additions & 24 deletions OBAKit/Settings/MoreViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 2 additions & 8 deletions OBAKit/Stops/StopViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down

0 comments on commit 6fcc606

Please sign in to comment.