Signal-iOS/Signal/src/ViewControllers/Donations/DonateViewController.swift
2022-11-10 08:48:48 -08:00

1070 lines
39 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import PassKit
import Lottie
import SignalUI
import SignalServiceKit
import SignalMessaging
class DonateViewController: OWSViewController, OWSNavigationChildController {
private var backgroundColor: UIColor {
OWSTableViewController2.tableBackgroundColor(isUsingPresentedStyle: true)
}
static let cornerRadius: CGFloat = 18
static var bubbleBackgroundColor: CGColor { DonationViewsUtil.bubbleBackgroundColor.cgColor }
static var selectedColor: CGColor { Theme.accentBlueColor.cgColor }
public var preferredNavigationBarStyle: OWSNavigationBarStyle { .clear }
public var navbarBackgroundColorOverride: UIColor { backgroundColor }
private static func commonStack() -> UIStackView {
let result = UIStackView()
result.axis = .vertical
result.alignment = .fill
result.spacing = 20
return result
}
private var navigationBar: OWSNavigationBar? {
navigationController?.navigationBar as? OWSNavigationBar
}
// MARK: - Initialization
internal var state: State {
didSet {
Logger.info("[Donations] DonateViewController state changed to \(state.debugDescription)")
render(oldState: oldValue)
}
}
enum FinishResult {
case completedDonation(donateSheet: DonateViewController, badgeThanksSheet: BadgeThanksSheet)
case monthlySubscriptionCancelled(donateSheet: DonateViewController, toastText: String)
}
internal let onFinished: (FinishResult) -> Void
private var scrollToOneTimeContinueButtonWhenKeyboardAppears = false
public init(
startingDonationMode: DonationMode,
onFinished: @escaping (FinishResult) -> Void
) {
self.state = .init(donationMode: startingDonationMode)
self.onFinished = onFinished
super.init()
}
// MARK: - View callbacks
public override func viewDidLoad() {
super.viewDidLoad()
render(oldState: nil)
loadAndUpdateState()
scrollView.addSubview(stackView)
stackView.autoPinWidth(toWidthOf: scrollView)
stackView.autoPinHeightToSuperview()
let margin: CGFloat = 20
let topMargin: CGFloat = navigationBar == nil ? margin : 0
stackView.layoutMargins = .init(
top: topMargin,
leading: margin,
bottom: margin,
trailing: margin
)
stackView.isLayoutMarginsRelativeArrangement = true
view.addSubview(scrollView)
scrollView.autoPinWidthToSuperview()
scrollView.autoPinEdge(toSuperviewEdge: .top)
scrollView.autoPinEdge(.bottom, to: .bottom, of: keyboardLayoutGuideViewSafeArea)
NotificationCenter.default.addObserver(
self,
selector: #selector(didKeyboardShow),
name: UIResponder.keyboardDidShowNotification,
object: nil
)
}
public override func themeDidChange() {
super.themeDidChange()
render(oldState: nil)
}
// MARK: - Events
@objc
private func didDonationModeChange() {
let rawValue = donationModePickerView.selectedSegmentIndex
guard let newValue = DonationMode(rawValue: rawValue) else {
owsFail("[Donations] Unexpected donation mode")
}
state = state.selectDonationMode(newValue)
}
private func addAnimationView(anchor: UIView, name: String) {
if UIAccessibility.isReduceMotionEnabled { return }
let animationView = AnimationView(name: name)
animationView.loopMode = .playOnce
animationView.contentMode = .scaleAspectFit
animationView.backgroundBehavior = .forceFinish
animationView.isUserInteractionEnabled = false
stackView.addSubview(animationView)
animationView.autoPinEdge(.bottom, to: .top, of: anchor, withOffset: 30)
animationView.autoPinEdge(.leading, to: .leading, of: anchor)
animationView.autoMatch(.width, to: .width, of: anchor)
animationView.play { _ in
animationView.removeFromSuperview()
}
}
private func didSelectOneTimeAmount(
amount: FiatMoney,
animationAnchor: UIView,
animationName: String
) {
state = state.selectOneTimeAmount(.selectedPreset(amount: amount))
addAnimationView(anchor: animationAnchor, name: animationName)
}
@objc
private func didKeyboardShow() {
if scrollToOneTimeContinueButtonWhenKeyboardAppears {
scrollView.scrollIntoView(subview: oneTimeContinueButton)
scrollToOneTimeContinueButtonWhenKeyboardAppears = false
}
}
@objc
private func didTapOneTimeCustomAmountTextField() {
guard let oneTime = state.oneTime else {
owsFail("[Donations] Expected one-time state but it was not loaded")
}
state = state.selectOneTimeAmount(.choseCustomAmount(
amount: FiatMoney(currencyCode: oneTime.selectedCurrencyCode, value: 0)
))
oneTimeCustomAmountTextField.becomeFirstResponder()
scrollToOneTimeContinueButtonWhenKeyboardAppears = true
}
@objc
private func didTapMonthlySubscriptionLevelView(_ sender: UIGestureRecognizer) {
guard let view = sender.view as? MonthlySubscriptionLevelView else {
owsFail("[Donations] Tapped something other than a monthly subscription level view")
}
state = state.selectSubscriptionLevel(view.subscriptionLevel)
addAnimationView(anchor: view, name: view.animationName)
}
private func startApplePay(
with amount: FiatMoney,
donationMode: DonationMode
) {
SubscriptionManager.terminateTransactionIfPossible = false
let paymentRequest = DonationUtilities.newPaymentRequest(
for: amount,
isRecurring: {
switch donationMode {
case .oneTime: return false
case .monthly: return true
}
}()
)
let paymentController = PKPaymentAuthorizationController(paymentRequest: paymentRequest)
paymentController.delegate = self
paymentController.present { presented in
if !presented {
// This can happen under normal conditions if the user double-taps the button,
// but may also indicate a problem.
Logger.warn("[Donations] Failed to present payment controller")
}
}
}
private func presentChoosePaymentMethodSheet(
amount: FiatMoney,
badge: ProfileBadge?,
donationMode: DonationMode
) {
oneTimeCustomAmountTextField.resignFirstResponder()
let sheet = DonateChoosePaymentMethodSheet(
amount: amount,
badge: badge,
donationMode: donationMode.forChoosePaymentMethodSheet
) { [weak self] sheet in
sheet.dismiss(animated: true)
self?.startApplePay(with: amount, donationMode: donationMode)
}
present(sheet, animated: true)
}
private func didTapToContinueOneTimeDonation() {
guard let oneTime = state.oneTime else {
owsFail("[Donations] Expected the one-time state to be loaded. This should be impossible in the UI")
}
func showError(_ text: String) {
let actionSheet = ActionSheetController(message: text)
actionSheet.addAction(.init(
title: CommonStrings.okayButton,
style: .cancel,
handler: nil
))
presentActionSheet(actionSheet)
}
switch oneTime.paymentRequest {
case .noAmountSelected:
showError(NSLocalizedString(
"DONATE_SCREEN_ERROR_NO_AMOUNT_SELECTED",
comment: "If the user tries to donate to Signal but no amount is selected, this error message is shown."
))
case .amountIsTooSmall:
showError(NSLocalizedString(
"DONATE_SCREEN_ERROR_SELECT_A_LARGER_AMOUNT",
comment: "If the user tries to donate to Signal but they've entered an amount that's too small, this error message is shown."
))
case .amountIsTooLarge:
showError(NSLocalizedString(
"DONATE_SCREEN_ERROR_SELECT_A_SMALLER_AMOUNT",
comment: "If the user tries to donate to Signal but they've entered an amount that's too large, this error message is shown."
))
case let .canContinue(amount):
presentChoosePaymentMethodSheet(
amount: amount,
badge: oneTime.profileBadge,
donationMode: .oneTime
)
}
}
private func didTapToContinueMonthlyDonation() {
guard let monthlyPaymentRequest = state.monthly?.paymentRequest else {
owsFail("[Donations] Cannot make monthly donation. This should be prevented in the UI")
}
presentChoosePaymentMethodSheet(
amount: monthlyPaymentRequest.amount,
badge: monthlyPaymentRequest.profileBadge,
donationMode: .monthly
)
}
private func didTapToUpdateMonthlyDonation() {
guard let monthlyPaymentRequest = state.monthly?.paymentRequest else {
owsFail("[Donations] Cannot update monthly donation. This should be prevented in the UI")
}
let currencyString = DonationUtilities.format(money: monthlyPaymentRequest.amount)
let title = NSLocalizedString(
"SUSTAINER_VIEW_UPDATE_SUBSCRIPTION_CONFIRMATION_TITLE",
comment: "Update Subscription? Action sheet title"
)
let message = String(
format: NSLocalizedString(
"SUSTAINER_VIEW_UPDATE_SUBSCRIPTION_CONFIRMATION_MESSAGE",
comment: "Update Subscription? Action sheet message, embeds {{Price}}"
),
currencyString
)
let notNow = NSLocalizedString(
"SUSTAINER_VIEW_SUBSCRIPTION_CONFIRMATION_NOT_NOW",
comment: "Sustainer view Not Now Action sheet button"
)
let actionSheet = ActionSheetController(title: title, message: message)
actionSheet.addAction(.init(
title: CommonStrings.continueButton,
style: .default,
handler: { [weak self] _ in
self?.didTapToContinueMonthlyDonation()
}
))
actionSheet.addAction(.init(
title: notNow,
style: .cancel,
handler: nil
))
self.presentActionSheet(actionSheet)
}
private func didTapToCancelSubscription() {
let title = NSLocalizedString(
"SUSTAINER_VIEW_CANCEL_SUBSCRIPTION_CONFIRMATION_TITLE",
comment: "Confirm Cancellation? Action sheet title"
)
let message = NSLocalizedString(
"SUSTAINER_VIEW_CANCEL_SUBSCRIPTION_CONFIRMATION_MESSAGE",
comment: "Confirm Cancellation? Action sheet message"
)
let confirm = NSLocalizedString(
"SUSTAINER_VIEW_CANCEL_SUBSCRIPTION_CONFIRMATION_CONFIRM",
comment: "Confirm Cancellation? Action sheet confirm button"
)
let notNow = NSLocalizedString(
"SUSTAINER_VIEW_SUBSCRIPTION_CONFIRMATION_NOT_NOW",
comment: "Sustainer view Not Now Action sheet button"
)
let actionSheet = ActionSheetController(title: title, message: message)
actionSheet.addAction(ActionSheetAction(
title: confirm,
style: .default,
handler: { [weak self] _ in
self?.didConfirmSubscriptionCancelation()
}
))
actionSheet.addAction(ActionSheetAction(
title: notNow,
style: .cancel,
handler: nil
))
presentActionSheet(actionSheet)
}
private func didConfirmSubscriptionCancelation() {
guard let subscriberID = state.monthly?.subscriberID else {
owsFail("[Donations] No subscriber ID to cancel")
}
ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { modal in
firstly {
SubscriptionManager.cancelSubscription(for: subscriberID)
}.done(on: .main) { [weak self] in
modal.dismiss { [weak self] in
guard let self = self else { return }
self.onFinished(.monthlySubscriptionCancelled(
donateSheet: self,
toastText: NSLocalizedString(
"SUSTAINER_VIEW_SUBSCRIPTION_CANCELLED",
comment: "Toast indicating that the subscription has been cancelled"
)
))
}
}.catch { error in
modal.dismiss {}
owsFailDebug("[Donations] Failed to cancel subscription \(error)")
}
}
}
// MARK: - Loading data
private func loadAndUpdateState() {
switch state.loadState {
case .loading: return
default: break
}
state = state.loading()
loadState(currentState: state).done { [weak self] newState in
self?.state = newState
}
}
/// Try to load the data we need and put it into a new state.
///
/// This requires both one-time and monthly data to load successfully.
///
/// We could build this such that the state can be partially loaded. For
/// example, users could interact with the one-time state while the monthly
/// state continues to load, or if it fails. That'd make this screen
/// resilient to partial failures, and faster to start using.
///
/// However, this (1) significantly complicated the code when I tried it
/// (2) will soon become less important, because the server plans to add a
/// single endpoint that'll do most of this.
private func loadState(currentState: State) -> Guarantee<State> {
let oneTimeStatePromise = Self.loadOneTimeState()
let monthlyStatePromise = Self.loadMonthlyState()
return oneTimeStatePromise.then(on: .sharedUserInitiated) { oneTime in
monthlyStatePromise.map { monthly in (oneTime, monthly) }
}.then(on: .sharedUserInitiated) { [weak self] (oneTime: OneTimeData, monthly: MonthlyData) -> Promise<(oneTime: OneTimeData, monthly: MonthlyData)> in
guard let self = self else { return Promise.value((oneTime, monthly)) }
let oneTimeBadges = [oneTime.badge].compacted()
let monthlyBadges = monthly.subscriptionLevels.map { $0.badge }
let badges = oneTimeBadges + monthlyBadges
let badgePromises = badges.map {
self.profileManager.badgeStore.populateAssetsOnBadge($0)
}
return Promise.when(fulfilled: badgePromises).map { (oneTime, monthly) }
}.then(on: .sharedUserInitiated) { (oneTime: OneTimeData, monthly: MonthlyData) in
Guarantee.value(currentState.loaded(
oneTimePresets: oneTime.presets,
oneTimeBadge: oneTime.badge,
monthlySubscriptionLevels: monthly.subscriptionLevels,
currentMonthlySubscription: monthly.currentSubscription,
subscriberID: monthly.subscriberID,
previousMonthlySubscriptionCurrencyCode: monthly.previousSubscriptionCurrencyCode,
locale: Locale.current
))
}.recover(on: .sharedUserInitiated) { error -> Guarantee<State> in
Logger.warn("[Donations] \(error)")
owsFailDebugUnlessNetworkFailure(error)
return Guarantee.value(currentState.loadFailed())
}
}
private struct OneTimeData {
let presets: [Currency.Code: DonationUtilities.Preset]
let badge: ProfileBadge?
}
private static func loadOneTimeState() -> Promise<OneTimeData> {
let profileBadgePromise: Promise<ProfileBadge?> = firstly {
SubscriptionManager.getBoostBadge()
}.map {
Optional.some($0)
}.recover { error in
Logger.warn("[Donations] Failed to fetch boost badge \(error). Proceeding without it, as it is only cosmetic here")
return Guarantee<ProfileBadge?>.value(nil)
}
return firstly(on: .sharedUserInitiated) {
SubscriptionManager.getSuggestedBoostAmounts()
}.then(on: .sharedUserInitiated) { presets in
profileBadgePromise.map { badge in
.init(presets: presets, badge: badge)
}
}
}
private struct MonthlyData {
let subscriptionLevels: [SubscriptionLevel]
let currentSubscription: Subscription?
let subscriberID: Data?
let previousSubscriptionCurrencyCode: Currency.Code?
}
private static func loadMonthlyState() -> Promise<MonthlyData> {
let (subscriberID, previousCurrencyCode) = databaseStorage.read {(
SubscriptionManager.getSubscriberID(transaction: $0),
SubscriptionManager.getSubscriberCurrencyCode(transaction: $0)
)}
let currentSubscriptionPromise = DonationViewsUtil.loadCurrentSubscription(
subscriberID: subscriberID
)
return firstly(on: .sharedUserInitiated) {
DonationViewsUtil.loadSubscriptionLevels(badgeStore: self.profileManager.badgeStore)
}.then(on: .sharedUserInitiated) { subscriptionLevels in
currentSubscriptionPromise.map { currentSubscription in
.init(
subscriptionLevels: subscriptionLevels,
currentSubscription: currentSubscription,
subscriberID: subscriberID,
previousSubscriptionCurrencyCode: previousCurrencyCode
)
}
}
}
// MARK: - Top-level rendering
private let scrollView = UIScrollView()
private let stackView: UIStackView = {
let result = UIStackView()
result.axis = .vertical
result.alignment = .fill
result.spacing = 24
return result
}()
private func render(oldState: State?) {
renderHeroView(oldState: oldState)
renderBodyView(oldState: oldState)
if oldState == nil {
stackView.removeAllSubviews()
stackView.addArrangedSubviews([heroView, bodyView])
}
view.backgroundColor = backgroundColor
}
// MARK: - Hero
private lazy var avatarView: ConversationAvatarView = DonationViewsUtil.avatarView()
private lazy var heroView: DonationHeroView = {
let result = DonationHeroView(avatarView: avatarView)
result.delegate = self
return result
}()
private func renderHeroView(oldState: State?) {
let selectedProfileBadge = state.selectedProfileBadge
let shouldUpdateAvatar: Bool = (
oldState == nil ||
oldState?.selectedProfileBadge != selectedProfileBadge
)
guard shouldUpdateAvatar else { return }
heroView.rerender()
databaseStorage.read { [weak self] transaction in
self?.avatarView.update(transaction) { config in
guard let address = tsAccountManager.localAddress(with: transaction) else {
return
}
config.dataSource = .address(address)
config.addBadgeIfApplicable = true
config.fallbackBadge = selectedProfileBadge
}
}
}
// MARK: - Body
private lazy var bodyView: UIStackView = Self.commonStack()
private func renderBodyView(oldState: State?) {
switch state.loadState {
case .initializing, .loading, .loadFailed:
// TODO: We should show a different state if the loading failed.
renderLoadingBody(oldState: oldState)
case let .loaded(oneTime, monthly):
renderLoadedBody(
oldState: oldState,
donationMode: state.donationMode,
oneTime: oneTime,
monthly: monthly
)
}
}
private func renderLoadingBody(oldState: State?) {
let wasPreviouslyLoading: Bool = {
guard let oldState = oldState else { return false }
switch oldState.loadState {
case .initializing, .loading, .loadFailed:
return true
case .loaded:
return false
}
}()
if wasPreviouslyLoading { return }
bodyView.removeAllSubviews()
let spinner = loadingSpinnerView()
bodyView.addArrangedSubview(spinner)
}
private func renderLoadedBody(
oldState: State?,
donationMode: DonationMode,
oneTime: State.OneTimeState,
monthly: State.MonthlyState
) {
switch donationMode {
case .oneTime:
renderCurrencyPickerView(
oldState: oldState,
selectedCurrencyCode: oneTime.selectedCurrencyCode
)
renderOneTime(oldState: oldState, oneTime: oneTime)
case .monthly:
renderCurrencyPickerView(
oldState: oldState,
selectedCurrencyCode: monthly.selectedCurrencyCode
)
renderMonthly(oldState: oldState, monthly: monthly)
}
renderDonationModePickerView()
let wasLoaded: Bool = {
switch oldState?.loadState {
case .loaded: return true
default: return false
}
}()
if !wasLoaded || oldState?.donationMode != state.donationMode {
var subviews: [UIView] = [currencyPickerContainerView, donationModePickerView]
switch donationMode {
case .oneTime:
subviews.append(oneTimeView)
case .monthly:
subviews.append(monthlyView)
}
bodyView.removeAllSubviews()
bodyView.addArrangedSubviews(subviews)
bodyView.setCustomSpacing(18, after: currencyPickerContainerView)
// Switching modes causes animations to lose their anchors,
// so we remove them.
for subview in stackView.subviews {
if subview is AnimationView {
subview.removeFromSuperview()
}
}
}
}
// MARK: - Loading spinner
private func loadingSpinnerView() -> UIView {
let result: UIActivityIndicatorView
if #available(iOS 13, *) {
result = UIActivityIndicatorView(style: .medium)
} else {
result = UIActivityIndicatorView(style: .gray)
}
result.startAnimating()
return result
}
// MARK: - Currency picker
private var currencyPickerContainerView: UIStackView = {
let result = UIStackView()
result.axis = .vertical
result.alignment = .fill
return result
}()
private func renderCurrencyPickerView(
oldState: State?,
selectedCurrencyCode: Currency.Code
) {
if
oldState?.donationMode == state.donationMode,
oldState?.selectedCurrencyCode == selectedCurrencyCode {
return
}
let button = DonationCurrencyPickerButton(
currentCurrencyCode: selectedCurrencyCode,
hasLabel: false
) { [weak self] in
guard let self = self else { return }
let vc = CurrencyPickerViewController(
dataSource: StripeCurrencyPickerDataSource(
currentCurrencyCode: selectedCurrencyCode,
supportedCurrencyCodes: self.state.supportedCurrencyCodes
)
) { [weak self] currencyCode in
guard let self = self else { return }
self.state = self.state.selectCurrencyCode(currencyCode)
}
self.navigationController?.pushViewController(vc, animated: true)
}
currencyPickerContainerView.removeAllSubviews()
currencyPickerContainerView.addArrangedSubview(button)
}
// MARK: - Donation mode picker
private lazy var donationModePickerView: UISegmentedControl = {
let picker = UISegmentedControl()
picker.insertSegment(
withTitle: NSLocalizedString(
"DONATE_SCREEN_ONE_TIME_CHOICE",
comment: "On the donation screen, you can choose between one-time and monthly donations. This is the text on the picker for one-time donations."
),
at: DonationMode.oneTime.rawValue,
animated: false
)
picker.insertSegment(
withTitle: NSLocalizedString(
"DONATE_SCREEN_MONTHLY_CHOICE",
comment: "On the donation screen, you can choose between one-time and monthly donations. This is the text on the picker for one-time donations."
),
at: DonationMode.monthly.rawValue,
animated: false
)
picker.addTarget(self, action: #selector(didDonationModeChange), for: .valueChanged)
return picker
}()
private func renderDonationModePickerView() {
donationModePickerView.selectedSegmentIndex = state.donationMode.rawValue
}
// MARK: - One-time
private struct OneTimePresetButton {
let amount: FiatMoney
let view: OWSFlatButton
}
private var oneTimePresetButtons = [OneTimePresetButton]()
private lazy var oneTimePresetsView: UIStackView = {
let result = UIStackView()
result.axis = .vertical
result.distribution = .fillEqually
result.spacing = 20
return result
}()
private lazy var oneTimeCustomAmountTextField: OneTimeDonationCustomAmountTextField = {
guard let currencyCode = state.oneTime?.selectedCurrencyCode else {
owsFail("[Donations] In the one-time view without a currency code")
}
let field = OneTimeDonationCustomAmountTextField(currencyCode: currencyCode)
field.placeholder = NSLocalizedString(
"BOOST_VIEW_CUSTOM_AMOUNT_PLACEHOLDER",
comment: "Default text for the custom amount field of the boost view."
)
field.delegate = self
field.accessibilityIdentifier = UIView.accessibilityIdentifier(
in: self,
name: "custom_amount_text_field"
)
field.layer.cornerRadius = Self.cornerRadius
field.layer.borderWidth = DonationViewsUtil.bubbleBorderWidth
field.font = .ows_dynamicTypeBodyClamped
let tap = UITapGestureRecognizer(
target: self,
action: #selector(didTapOneTimeCustomAmountTextField)
)
field.addGestureRecognizer(tap)
return field
}()
private lazy var oneTimeContinueButton: OWSButton = {
let button = OWSButton(title: CommonStrings.continueButton) { [weak self] in
self?.didTapToContinueOneTimeDonation()
}
button.dimsWhenHighlighted = true
button.dimsWhenDisabled = true
button.layer.cornerRadius = 8
button.backgroundColor = .ows_accentBlue
button.titleLabel?.font = UIFont.ows_dynamicTypeBody.ows_semibold
button.autoSetDimension(.height, toSize: 48, relation: .greaterThanOrEqual)
return button
}()
private lazy var oneTimeView: UIStackView = Self.commonStack()
private func renderOneTime(oldState: State?, oneTime: State.OneTimeState) {
renderOneTimePresetsView(oldState: oldState, oneTime: oneTime)
renderOneTimeCustomAmountTextField(oneTime: oneTime)
renderOneTimeContinueButton(oneTime: oneTime)
switch oldState?.loadedDonationMode {
case .oneTime:
break
default:
oneTimeView.removeAllSubviews()
oneTimeView.addArrangedSubviews([
oneTimePresetsView,
oneTimeCustomAmountTextField,
oneTimeContinueButton
])
}
}
private func initialRenderOfOneTimePresetRows(preset: DonationUtilities.Preset) {
oneTimePresetsView.removeAllSubviews()
var oneTimePresetButtons = [OneTimePresetButton]()
for (rowIndex, amounts) in preset.amounts.chunked(by: 3).enumerated() {
let row = UIStackView()
row.axis = .horizontal
row.distribution = .fillEqually
row.spacing = UIDevice.current.isIPhone5OrShorter ? 8 : 14
for (colIndex, amount) in amounts.enumerated() {
let button = OWSFlatButton()
button.setPressedBlock { [weak self] in
let animationNames = [
"boost_smile",
"boost_clap",
"boost_heart_eyes",
"boost_fire",
"boost_shock",
"boost_rockets"
]
let animationIndex = (rowIndex * 3) + colIndex
self?.didSelectOneTimeAmount(
amount: amount,
animationAnchor: button,
animationName: animationNames[safe: animationIndex] ?? "boost_fire"
)
}
button.setBackgroundColors(
upColor: DonationViewsUtil.bubbleBackgroundColor,
downColor: DonationViewsUtil.bubbleBackgroundColor.withAlphaComponent(0.8)
)
button.setTitle(
title: DonationUtilities.format(money: amount),
font: .ows_regularFont(withSize: UIDevice.current.isIPhone5OrShorter ? 18 : 20),
titleColor: Theme.primaryTextColor
)
button.autoSetDimension(.height, toSize: 52, relation: .greaterThanOrEqual)
button.enableMultilineLabel()
button.layer.cornerRadius = Self.cornerRadius
button.clipsToBounds = true
button.layer.borderWidth = DonationViewsUtil.bubbleBorderWidth
row.addArrangedSubview(button)
oneTimePresetButtons.append(.init(amount: amount, view: button))
}
oneTimePresetsView.addArrangedSubview(row)
}
self.oneTimePresetButtons = oneTimePresetButtons
}
private func renderOneTimePresetsView(oldState: State?, oneTime: State.OneTimeState) {
if oldState?.loadedDonationMode != .oneTime || oldState?.oneTime?.selectedCurrencyCode != oneTime.selectedCurrencyCode {
guard let preset = oneTime.selectedPreset else {
owsFail("[Donations] It should be impossible to select a currency code without a preset")
}
initialRenderOfOneTimePresetRows(preset: preset)
}
let selectedPresetAmount: FiatMoney?
switch oneTime.selectedAmount {
case .nothingSelected, .choseCustomAmount:
selectedPresetAmount = nil
case let .selectedPreset(amount):
selectedPresetAmount = amount
}
for button in oneTimePresetButtons {
let selected = button.amount == selectedPresetAmount
button.view.layer.borderColor = selected ? Self.selectedColor : Self.bubbleBackgroundColor
}
}
private func renderOneTimeCustomAmountTextField(oneTime: State.OneTimeState) {
switch oneTime.selectedAmount {
case .nothingSelected, .selectedPreset:
oneTimeCustomAmountTextField.text = nil
oneTimeCustomAmountTextField.resignFirstResponder()
oneTimeCustomAmountTextField.layer.borderColor = Self.bubbleBackgroundColor
case let .choseCustomAmount(amount):
oneTimeCustomAmountTextField.setCurrencyCode(amount.currencyCode)
oneTimeCustomAmountTextField.layer.borderColor = Self.selectedColor
scrollView.scrollIntoView(subview: oneTimeCustomAmountTextField)
}
oneTimeCustomAmountTextField.textColor = Theme.primaryTextColor
oneTimeCustomAmountTextField.backgroundColor = DonationViewsUtil.bubbleBackgroundColor
}
private func renderOneTimeContinueButton(oneTime: State.OneTimeState) {
oneTimeContinueButton.isEnabled = {
switch oneTime.selectedAmount {
case .nothingSelected:
return false
case .selectedPreset:
return true
case let .choseCustomAmount(amount):
return amount.value > 0
}
}()
}
// MARK: - Monthly
private var monthlySubscriptionLevelViews = [MonthlySubscriptionLevelView]()
private lazy var monthlySubscriptionLevelsView: UIStackView = {
let result = Self.commonStack()
result.spacing = 10
return result
}()
private lazy var monthlyButtonsView: UIStackView = {
let result = Self.commonStack()
result.spacing = 10
return result
}()
private lazy var monthlyView: UIStackView = Self.commonStack()
private func renderMonthly(oldState: State?, monthly: State.MonthlyState) {
renderMonthlySubscriptionLevelsView(oldState: state, monthly: monthly)
renderMonthlyButtonsView(monthly: monthly)
switch oldState?.loadedDonationMode {
case .monthly:
break
default:
monthlyView.removeAllSubviews()
monthlyView.addArrangedSubviews([
monthlySubscriptionLevelsView,
monthlyButtonsView
])
}
}
private func initialRenderOfMonthlySubscriptionLevelViews(monthly: State.MonthlyState) {
monthlySubscriptionLevelsView.removeAllSubviews()
let animationNames = ["boost_fire", "boost_shock", "boost_rockets"]
monthlySubscriptionLevelViews = monthly.subscriptionLevels
.enumerated()
.map { (index, subscriptionLevel) in
MonthlySubscriptionLevelView(
subscriptionLevel: subscriptionLevel,
animationName: animationNames[safe: index] ?? "boost_fire"
)
}
for view in monthlySubscriptionLevelViews {
let tap = UITapGestureRecognizer(
target: self,
action: #selector(didTapMonthlySubscriptionLevelView)
)
view.addGestureRecognizer(tap)
monthlySubscriptionLevelsView.addArrangedSubview(view)
}
}
private func renderMonthlySubscriptionLevelsView(oldState: State?, monthly: State.MonthlyState) {
let renderedSubscriptionLevels = monthlySubscriptionLevelViews.map(\.subscriptionLevel)
if oldState?.monthly?.subscriptionLevels != renderedSubscriptionLevels {
initialRenderOfMonthlySubscriptionLevelViews(monthly: monthly)
}
for subscriptionLevelView in monthlySubscriptionLevelViews {
subscriptionLevelView.render(
currencyCode: monthly.selectedCurrencyCode,
currentSubscription: monthly.currentSubscription,
selectedSubscriptionLevel: monthly.selectedSubscriptionLevel
)
}
}
private func renderMonthlyButtonsView(monthly: State.MonthlyState) {
var buttons = [OWSButton]()
if let currentSubscription = monthly.currentSubscription {
let updateTitle = NSLocalizedString(
"DONATE_SCREEN_UPDATE_MONTHLY_SUBSCRIPTION_BUTTON",
comment: "On the donation screen, if you already have a subscription, you'll see a button to update your subscription. This is the text on that button."
)
let updateButton = OWSButton(title: updateTitle) { [weak self] in
self?.didTapToUpdateMonthlyDonation()
}
updateButton.backgroundColor = .ows_accentBlue
updateButton.titleLabel?.font = UIFont.ows_dynamicTypeBody.ows_semibold
updateButton.isEnabled = {
if currentSubscription.amount.currencyCode != monthly.selectedCurrencyCode {
return true
}
if
let selectedSubscriptionLevel = monthly.selectedSubscriptionLevel,
currentSubscription.level != selectedSubscriptionLevel.level {
return true
}
return false
}()
buttons.append(updateButton)
let cancelTitle = NSLocalizedString(
"SUSTAINER_VIEW_CANCEL_SUBSCRIPTION",
comment: "Sustainer view Cancel Subscription button title"
)
let cancelButton = OWSButton(title: cancelTitle) { [weak self] in
self?.didTapToCancelSubscription()
}
cancelButton.setTitleColor(Theme.accentBlueColor, for: .normal)
cancelButton.dimsWhenHighlighted = true
buttons.append(cancelButton)
} else {
let continueButton = OWSButton(title: CommonStrings.continueButton) { [weak self] in
self?.didTapToContinueMonthlyDonation()
}
continueButton.layer.cornerRadius = 8
continueButton.backgroundColor = .ows_accentBlue
continueButton.titleLabel?.font = UIFont.ows_dynamicTypeBody.ows_semibold
buttons.append(continueButton)
}
for button in buttons {
button.dimsWhenHighlighted = true
button.dimsWhenDisabled = true
button.layer.cornerRadius = 8
button.titleLabel?.numberOfLines = 0
button.titleLabel?.lineBreakMode = .byWordWrapping
button.titleLabel?.textAlignment = .center
button.autoSetDimension(.height, toSize: 48, relation: .greaterThanOrEqual)
}
monthlyButtonsView.removeAllSubviews()
monthlyButtonsView.addArrangedSubviews(buttons)
}
}
// MARK: - Donation hero delegate
extension DonateViewController: DonationHeroViewDelegate {
func present(readMoreSheet: DonationReadMoreSheetViewController) {
present(readMoreSheet, animated: true)
}
}
// MARK: - One-Time donation custom amount field delegate
extension DonateViewController: OneTimeDonationCustomAmountTextFieldDelegate {
func oneTimeDonationCustomAmountTextFieldStateDidChange(_ textField: OneTimeDonationCustomAmountTextField) {
self.state = self.state.selectOneTimeAmount(.choseCustomAmount(amount: textField.amount))
}
}
// MARK: - UIScrollView
fileprivate extension UIScrollView {
/// Scroll a subview into view.
///
/// Only meant for use on this screen. Your mileage may vary if used elsewhere.
func scrollIntoView(subview: UIView) {
guard let superview = subview.superview else { return }
let currentVisibleTop = contentOffset.y
let currentVisibleBottom = currentVisibleTop + bounds.height
let subviewTop = superview.convert(subview.frame.topLeft, to: self).y
let subviewBottom = superview.convert(subview.frame.bottomLeft, to: self).y
let newY: CGFloat
if subviewTop < currentVisibleTop {
newY = subviewTop
} else if subviewBottom > currentVisibleBottom {
newY = subviewBottom - bounds.height
} else {
return
}
setContentOffset(.init(x: contentOffset.x, y: newY), animated: true)
}
}