Signal-iOS/Signal/Backups/ChooseBackupPlanViewController.swift
2025-09-04 09:09:50 -07:00

421 lines
16 KiB
Swift

//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
import StoreKit
import SwiftUI
class ChooseBackupPlanViewController: HostingController<ChooseBackupPlanView> {
typealias OnConfirmPlanSelectionBlock = (ChooseBackupPlanViewController, PlanSelection) -> Void
enum StoreKitAvailability {
case available(paidPlanDisplayPrice: String)
case unavailableForTesters
}
enum PlanSelection {
case free
case paid
}
private let backupKeyService: BackupKeyService
private let backupSettingsStore: BackupSettingsStore
private let db: DB
private let tsAccountManager: TSAccountManager
private let onConfirmPlanSelectionBlock: OnConfirmPlanSelectionBlock
private let viewModel: ChooseBackupPlanViewModel
init(
initialPlanSelection: PlanSelection?,
storeKitAvailability: StoreKitAvailability,
backupKeyService: BackupKeyService,
backupSettingsStore: BackupSettingsStore,
db: DB,
tsAccountManager: TSAccountManager,
onConfirmPlanSelectionBlock: @escaping OnConfirmPlanSelectionBlock,
) {
self.backupKeyService = backupKeyService
self.backupSettingsStore = backupSettingsStore
self.db = db
self.tsAccountManager = tsAccountManager
self.onConfirmPlanSelectionBlock = onConfirmPlanSelectionBlock
self.viewModel = ChooseBackupPlanViewModel(
initialPlanSelection: initialPlanSelection,
storeKitAvailability: storeKitAvailability,
)
super.init(wrappedView: ChooseBackupPlanView(viewModel: viewModel))
viewModel.actionsDelegate = self
}
static func load(
fromViewController: UIViewController,
initialPlanSelection: PlanSelection?,
onConfirmPlanSelectionBlock: @escaping OnConfirmPlanSelectionBlock,
) async throws(OWSAssertionError) -> ChooseBackupPlanViewController {
let storeKitAvailability: StoreKitAvailability
if FeatureFlags.Backups.avoidStoreKitForTesters {
storeKitAvailability = .unavailableForTesters
} else {
let backupSubscriptionManager = DependenciesBridge.shared.backupSubscriptionManager
let paidPlanDisplayPrice: String
do {
paidPlanDisplayPrice = try await ModalActivityIndicatorViewController
.presentAndPropagateResult(from: fromViewController) {
try await backupSubscriptionManager.subscriptionDisplayPrice()
}
} catch {
throw OWSAssertionError("Failed to get paidPlanDisplayPrice!")
}
storeKitAvailability = .available(paidPlanDisplayPrice: paidPlanDisplayPrice)
}
return ChooseBackupPlanViewController(
initialPlanSelection: initialPlanSelection,
storeKitAvailability: storeKitAvailability,
backupKeyService: DependenciesBridge.shared.backupKeyService,
backupSettingsStore: BackupSettingsStore(),
db: DependenciesBridge.shared.db,
tsAccountManager: DependenciesBridge.shared.tsAccountManager,
onConfirmPlanSelectionBlock: onConfirmPlanSelectionBlock,
)
}
}
// MARK: - ChooseBackupPlanViewModel.ActionsDelegate
extension ChooseBackupPlanViewController: ChooseBackupPlanViewModel.ActionsDelegate {
fileprivate func confirmSelection(_ planSelection: PlanSelection) {
onConfirmPlanSelectionBlock(self, planSelection)
}
}
// MARK: -
private class ChooseBackupPlanViewModel: ObservableObject {
typealias StoreKitAvailability = ChooseBackupPlanViewController.StoreKitAvailability
typealias PlanSelection = ChooseBackupPlanViewController.PlanSelection
protocol ActionsDelegate: AnyObject {
func confirmSelection(_ planSelection: PlanSelection)
}
@Published var planSelection: PlanSelection
let initialPlanSelection: PlanSelection?
let storeKitAvailability: StoreKitAvailability
weak var actionsDelegate: ActionsDelegate?
init(
initialPlanSelection: PlanSelection?,
storeKitAvailability: StoreKitAvailability,
) {
self.planSelection = initialPlanSelection ?? .free
self.initialPlanSelection = initialPlanSelection
self.storeKitAvailability = storeKitAvailability
}
// MARK: Actions
func confirmSelection() {
actionsDelegate?.confirmSelection(planSelection)
}
}
struct ChooseBackupPlanView: View {
@ObservedObject private var viewModel: ChooseBackupPlanViewModel
fileprivate init(viewModel: ChooseBackupPlanViewModel) {
self.viewModel = viewModel
}
var body: some View {
ScrollableContentPinnedFooterView {
VStack {
Image("backups-choose-plan")
.frame(width: 80, height: 80)
Spacer().frame(height: 8)
Text(OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_TITLE",
comment: "Title for a view allowing users to choose a Backup plan."
))
.font(.title.weight(.semibold))
.foregroundStyle(Color.Signal.label)
Spacer().frame(height: 12)
Text(OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_SUBTITLE",
comment: "Subtitle for a view allowing users to choose a Backup plan."
))
.appendLink(CommonStrings.learnMore) {
CurrentAppContext().open(
URL.Support.backups,
completion: nil
)
}
.foregroundStyle(Color.Signal.secondaryLabel)
Spacer().frame(height: 20)
PlanOptionView(
title: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_FREE_PLAN_TITLE",
comment: "Title for the free plan option, when choosing a Backup plan."
),
subtitle: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_FREE_PLAN_SUBTITLE",
comment: "Subtitle for the free plan option, when choosing a Backup plan."
),
bullets: [
PlanOptionView.BulletPoint(iconKey: "thread", text: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_FULL_TEXT_BACKUP",
comment: "Text for a bullet point in a list of Backup features, describing that all text messages are included."
)),
PlanOptionView.BulletPoint(iconKey: "album-tilt", text: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_RECENT_MEDIA_BACKUP",
comment: "Text for a bullet point in a list of Backup features, describing that recent media is included."
)),
],
isCurrentPlan: viewModel.initialPlanSelection == .free,
isSelected: viewModel.planSelection == .free,
onTap: {
viewModel.planSelection = .free
}
)
Spacer().frame(height: 16)
PlanOptionView(
title: {
switch viewModel.storeKitAvailability {
case .available(let paidPlanDisplayPrice):
String(
format: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_PAID_PLAN_TITLE",
comment: "Title for the paid plan option, when choosing a Backup plan. Embeds {{ the formatted monthly cost, as currency, of the paid plan }}."
),
paidPlanDisplayPrice
)
case .unavailableForTesters:
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_PAID_PLAN_NO_PURCHASES_TITLE",
comment: "Title for the paid plan option, when choosing a Backup plan as a tester."
)
}
}(),
subtitle: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_PAID_PLAN_SUBTITLE",
comment: "Subtitle for the paid plan option, when choosing a Backup plan."
),
bullets: [
PlanOptionView.BulletPoint(iconKey: "thread", text: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_FULL_TEXT_BACKUP",
comment: "Text for a bullet point in a list of Backup features, describing that all text messages are included."
)),
PlanOptionView.BulletPoint(iconKey: "album-tilt", text: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_FULL_MEDIA_BACKUP",
comment: "Text for a bullet point in a list of Backup features, describing that all media is included."
)),
PlanOptionView.BulletPoint(iconKey: "data", text: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_STORAGE_AMOUNT",
comment: "Text for a bullet point in a list of Backup features, describing the amount of included storage."
)),
],
isCurrentPlan: viewModel.initialPlanSelection == .paid,
isSelected: viewModel.planSelection == .paid,
onTap: {
viewModel.planSelection = .paid
}
)
}
.padding(.horizontal, 24)
} pinnedFooter: {
Button {
viewModel.confirmSelection()
} label: {
let text = switch viewModel.planSelection {
case .free:
CommonStrings.continueButton
case .paid:
switch viewModel.storeKitAvailability {
case .available(let paidPlanDisplayPrice):
String(
format: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_SUBSCRIBE_PAID_BUTTON_TEXT",
comment: "Text for a button that will subscribe the user to the paid Backup plan. Embeds {{ the formatted monthly cost, as currency, of the paid plan }}."
),
paidPlanDisplayPrice
)
case .unavailableForTesters:
CommonStrings.continueButton
}
}
Text(text)
.foregroundStyle(.white)
.font(.headline)
.padding(.vertical, 14)
.frame(maxWidth: .infinity)
.background(Color.Signal.ultramarine)
}
.disabled(viewModel.planSelection == viewModel.initialPlanSelection)
.buttonStyle(.plain)
.cornerRadius(12)
.padding(.horizontal, 40)
}
.multilineTextAlignment(.center)
.background(Color.Signal.groupedBackground)
}
}
// MARK: -
private struct PlanOptionView: View {
struct BulletPoint {
let iconKey: String
let text: String
}
let title: String
let subtitle: String
let bullets: [BulletPoint]
let isCurrentPlan: Bool
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(alignment: .top) {
VStack(alignment: .leading) {
if isCurrentPlan {
Label(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_CURRENT_PLAN_LABEL",
comment: "A label indicating that a given Backup plan option is what the user has already enabled."
),
systemImage: "checkmark"
)
.font(.footnote)
.foregroundStyle(Color.Signal.secondaryLabel)
.padding(.vertical, 4)
.padding(.horizontal, 12)
.background {
Capsule().fill(Color.Signal.secondaryFill)
}
}
Text(title).font(.headline)
Text(subtitle).foregroundStyle(Color.Signal.secondaryLabel)
ForEach(bullets, id: \.iconKey) { bullet in
Label {
Text(bullet.text).font(.subheadline)
} icon: {
Image(bullet.iconKey)
.foregroundStyle(
isSelected
? Color.Signal.ultramarine
: Color.Signal.label
)
}
.padding(.leading, 20)
.padding(.vertical, 2)
}
}
Spacer()
Group {
if isSelected {
Circle()
.fill(Color.Signal.ultramarine)
.overlay {
Image(systemName: "checkmark")
.resizable()
.foregroundColor(.white)
.padding(6)
}
} else {
Circle()
.stroke(Color.Signal.secondaryLabel, lineWidth: 2)
.opacity(0.3)
}
}
.frame(width: 24, height: 24)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 20)
.padding(.leading, 20)
.padding(.trailing, 16)
.background(Color.Signal.secondaryGroupedBackground)
.cornerRadius(16)
.overlay {
RoundedRectangle(cornerRadius: 16)
.stroke(
Color.Signal.ultramarine,
lineWidth: isSelected ? 3 : 0
)
}
.shadow(
color: isSelected ? .black.opacity(0.12) : .clear,
radius: 8,
y: 2
)
}
.buttonStyle(.plain)
}
}
// MARK: -
#if DEBUG
private extension ChooseBackupPlanViewModel {
static func forPreview(
storeKitAvailability: StoreKitAvailability,
) -> ChooseBackupPlanViewModel {
class ChoosePlanActionsDelegate: ChooseBackupPlanViewModel.ActionsDelegate {
func confirmSelection(_ planSelection: ChooseBackupPlanViewModel.PlanSelection) {
print("Confirming \(planSelection)")
}
}
let viewModel = ChooseBackupPlanViewModel(
initialPlanSelection: .free,
storeKitAvailability: storeKitAvailability
)
let actionsDelegate = ChoosePlanActionsDelegate()
ObjectRetainer.retainObject(actionsDelegate, forLifetimeOf: viewModel)
viewModel.actionsDelegate = actionsDelegate
return viewModel
}
}
#Preview("Purchases") {
ChooseBackupPlanView(viewModel: .forPreview(
storeKitAvailability: .available(paidPlanDisplayPrice: "$1.99")
))
}
#Preview("No Purchases") {
ChooseBackupPlanView(viewModel: .forPreview(
storeKitAvailability: .unavailableForTesters
))
}
#endif