mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
423 lines
20 KiB
Swift
423 lines
20 KiB
Swift
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import SwiftUI
|
|
import Lucide
|
|
|
|
public struct SessionProPaymentScreen: View {
|
|
@EnvironmentObject var host: HostWrapper
|
|
@EnvironmentObject var toolbarManager: ToolbarManager
|
|
@State private var isNavigationActive: Bool = false
|
|
@State var currentSelection: Int
|
|
@State private var isShowingTooltip: Bool = false
|
|
@State var isPendingPurchase: Bool = false
|
|
|
|
/// There is an issue on `.onAnyInteraction` of the List and `.onTapGuesture` of the TooltipsIcon. The `.onAnyInteraction` will be called first when tapping the TooltipsIcon to dismiss a tooltip.
|
|
/// This will result in the tooltip will show again right after it dismissed when tapping the TooltipsIcon. This `suppressUntil` is a workaround to fix this issue.
|
|
@State var suppressUntil: Date = .distantPast
|
|
|
|
let tooltipViewId: String = "SessionProPaymentScreenToolTip" // stringlint:ignore
|
|
private let coordinateSpaceName: String = "SessionProPaymentScreen" // stringlint:ignore
|
|
|
|
private let viewModel: SessionProPaymentScreenContent.ViewModelType
|
|
|
|
public init(viewModel: SessionProPaymentScreenContent.ViewModelType) {
|
|
self.viewModel = viewModel
|
|
if
|
|
case .update(let currentPlan, _, _, _, _, _) = viewModel.dataModel.flow,
|
|
let indexOfCurrentPlan = viewModel.dataModel.plans.firstIndex(of: currentPlan)
|
|
{
|
|
self.currentSelection = indexOfCurrentPlan
|
|
} else {
|
|
self.currentSelection = 0
|
|
}
|
|
}
|
|
|
|
public var body: some View {
|
|
GeometryReader { geometry in
|
|
ScrollView(.vertical, showsIndicators: false) {
|
|
ZStack(alignment: .topLeading) {
|
|
content
|
|
.padding(.horizontal, Values.largeSpacing)
|
|
.frame(
|
|
maxWidth: .infinity,
|
|
minHeight: geometry.size.height
|
|
)
|
|
.onAnyInteraction(scrollCoordinateSpaceName: coordinateSpaceName) {
|
|
guard self.isShowingTooltip else { return }
|
|
suppressUntil = Date().addingTimeInterval(0.2)
|
|
withAnimation(.spring()) {
|
|
self.isShowingTooltip = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.coordinateSpace(name: coordinateSpaceName)
|
|
.popoverView(
|
|
content: {
|
|
ZStack {
|
|
if case .update(let currentPlan, _, _, _, _, _) = viewModel.dataModel.flow, let discountPercent = currentPlan.discountPercent {
|
|
Text(
|
|
"proDiscountTooltip"
|
|
.put(key: "percent", value: discountPercent)
|
|
.put(key: "app_pro", value: Constants.app_pro)
|
|
.put(key: "pro", value: Constants.pro)
|
|
.localized()
|
|
)
|
|
.font(.Body.smallRegular)
|
|
.multilineTextAlignment(.center)
|
|
.foregroundColor(themeColor: .textPrimary)
|
|
.padding(.horizontal, Values.smallSpacing)
|
|
.padding(.vertical, Values.smallSpacing)
|
|
.frame(maxWidth: 250)
|
|
}
|
|
}
|
|
},
|
|
backgroundThemeColor: .toast_background,
|
|
isPresented: $isShowingTooltip,
|
|
position: .topRight,
|
|
offset: 50,
|
|
viewId: tooltipViewId
|
|
)
|
|
}
|
|
}
|
|
|
|
private var content: some View {
|
|
VStack(spacing: Values.mediumSmallSpacing) {
|
|
ListItemLogoWithPro(
|
|
info: .init(
|
|
themeStyle: {
|
|
switch viewModel.dataModel.flow {
|
|
case .refund, .cancel: return .disabled
|
|
default: return .normal
|
|
}
|
|
}(),
|
|
glowingBackgroundStyle: .base,
|
|
state: .success,
|
|
description: viewModel.dataModel.flow.description
|
|
)
|
|
)
|
|
|
|
switch viewModel.dataModel.flow {
|
|
case .purchase(let billingAccess):
|
|
if billingAccess {
|
|
SessionProPlanPurchaseContent(
|
|
currentSelection: $currentSelection,
|
|
isShowingTooltip: $isShowingTooltip,
|
|
suppressUntil: $suppressUntil,
|
|
isPendingPurchase: $isPendingPurchase,
|
|
currentPlan: nil,
|
|
sessionProPlans: viewModel.dataModel.plans,
|
|
actionButtonTitle: "upgrade".localized(),
|
|
actionType: "proUpgradingAction".localized(),
|
|
activationType: "proActivatingActivation".localized(),
|
|
purchaseAction: { updatePlan() },
|
|
openTosPrivacyAction: { openTosPrivacy() }
|
|
)
|
|
} else {
|
|
NoBillingAccessContent(
|
|
isRenewingPro: false,
|
|
originatingPlatform: .iOS,
|
|
openProRoadmapAction: { openUrl(Constants.session_pro_roadmap) }
|
|
)
|
|
}
|
|
|
|
case .renew(let originatingPlatform, let billingAccess):
|
|
if billingAccess {
|
|
SessionProPlanPurchaseContent(
|
|
currentSelection: $currentSelection,
|
|
isShowingTooltip: $isShowingTooltip,
|
|
suppressUntil: $suppressUntil,
|
|
isPendingPurchase: $isPendingPurchase,
|
|
currentPlan: nil,
|
|
sessionProPlans: viewModel.dataModel.plans,
|
|
actionButtonTitle: "renew".localized(),
|
|
actionType: "proRenewingAction".localized(),
|
|
activationType: "proReactivatingActivation".localized(),
|
|
purchaseAction: { updatePlan() },
|
|
openTosPrivacyAction: { openTosPrivacy() }
|
|
)
|
|
} else {
|
|
NoBillingAccessContent(
|
|
isRenewingPro: true,
|
|
originatingPlatform: originatingPlatform,
|
|
openProRoadmapAction: { openUrl(Constants.session_pro_roadmap) },
|
|
openPlatformStoreWebsiteAction: { openUrl(Constants.apple_store_subscriptions_url) }
|
|
)
|
|
}
|
|
|
|
case .update(let currentPlan, let expiredOn, let isAutoRenewing, let originatingPlatform, let isNonOriginatingAccount, let billingAccess):
|
|
if originatingPlatform != .iOS || isNonOriginatingAccount == true {
|
|
UpdatePlanNonOriginatingPlatformContent(
|
|
currentPlan: currentPlan,
|
|
currentPlanExpiredOn: expiredOn,
|
|
isAutoRenewing: isAutoRenewing,
|
|
originatingPlatform: originatingPlatform,
|
|
openPlatformStoreWebsiteAction: { openUrl(Constants.google_play_store_subscriptions_url) }
|
|
)
|
|
} else {
|
|
if billingAccess {
|
|
SessionProPlanPurchaseContent(
|
|
currentSelection: $currentSelection,
|
|
isShowingTooltip: $isShowingTooltip,
|
|
suppressUntil: $suppressUntil,
|
|
isPendingPurchase: $isPendingPurchase,
|
|
currentPlan: currentPlan,
|
|
sessionProPlans: viewModel.dataModel.plans,
|
|
actionButtonTitle: "updateAccess".put(key: "pro", value: Constants.pro).localized(),
|
|
actionType: "proUpdatingAction".localized(),
|
|
activationType: "",
|
|
purchaseAction: { updatePlan() },
|
|
openTosPrivacyAction: { openTosPrivacy() }
|
|
)
|
|
} else {
|
|
NoBillingAccessContent(
|
|
isRenewingPro: false,
|
|
originatingPlatform: originatingPlatform,
|
|
openProRoadmapAction: { openUrl(Constants.session_pro_roadmap) }
|
|
)
|
|
}
|
|
}
|
|
|
|
case .refund(let originatingPlatform, let isNonOriginatingAccount, let requestedAt):
|
|
if originatingPlatform == .iOS && isNonOriginatingAccount != true {
|
|
RequestRefundOriginatingPlatformContent(
|
|
requestRefundAction: {
|
|
Task {
|
|
await viewModel.requestRefund(
|
|
success: {
|
|
DispatchQueue.main.async {
|
|
host.controller?.navigationController?.popViewController(animated: true)
|
|
}
|
|
},
|
|
failure: {
|
|
// TODO: [PRO] Request refund failure behaviour
|
|
}
|
|
)
|
|
}
|
|
}
|
|
)
|
|
} else {
|
|
RequestRefundNonOriginatorContent(
|
|
originatingPlatform: originatingPlatform,
|
|
isNonOriginatingAccount: isNonOriginatingAccount,
|
|
requestedAt: requestedAt,
|
|
openPlatformStoreWebsiteAction: {
|
|
openUrl(
|
|
isNonOriginatingAccount == true ?
|
|
Constants.app_store_refund_support :
|
|
Constants.google_play_store_subscriptions_url
|
|
)
|
|
}
|
|
)
|
|
}
|
|
|
|
case .cancel(let originatingPlatform):
|
|
if originatingPlatform == .iOS {
|
|
CancelPlanOriginatingPlatformContent(
|
|
cancelPlanAction: {
|
|
Task {
|
|
await viewModel.cancelPro(
|
|
success: {
|
|
DispatchQueue.main.async {
|
|
host.controller?.navigationController?.popViewController(animated: true)
|
|
}
|
|
},
|
|
failure: {
|
|
// TODO: [PRO] Failed to cancel plan
|
|
}
|
|
)
|
|
}
|
|
}
|
|
)
|
|
} else {
|
|
CancelPlanNonOriginatingPlatformContent(
|
|
originatingPlatform: originatingPlatform,
|
|
openPlatformStoreWebsiteAction: { openUrl(Constants.google_play_store_subscriptions_url) }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updatePlan() {
|
|
isPendingPurchase = true
|
|
let updatedPlan = viewModel.dataModel.plans[currentSelection]
|
|
switch viewModel.dataModel.flow {
|
|
case .update(let currentPlan, let expiredOn, let isAutoRenewing, _, _, _):
|
|
if let updatedPlanExpiredOn = Calendar.current.date(byAdding: .month, value: updatedPlan.duration, to: expiredOn) {
|
|
let confirmationModal = ConfirmationModal(
|
|
info: .init(
|
|
title: "updateAccess"
|
|
.put(key: "pro", value: Constants.pro)
|
|
.localized(),
|
|
body: .attributedText(
|
|
isAutoRenewing ?
|
|
"proUpdateAccessDescription"
|
|
.put(key: "current_plan_length", value: currentPlan.durationString)
|
|
.put(key: "selected_plan_length", value: updatedPlan.durationString)
|
|
.put(key: "selected_plan_length_singular", value: updatedPlan.durationStringSingular)
|
|
.put(key: "date", value: expiredOn.formatted("MMM dd, yyyy"))
|
|
.put(key: "pro", value: Constants.pro)
|
|
.localizedFormatted(Fonts.Body.largeRegular) :
|
|
"proUpdateAccessExpireDescription"
|
|
.put(key: "date", value: expiredOn.formatted("MMM dd, yyyy"))
|
|
.put(key: "selected_plan_length", value: updatedPlan.durationString)
|
|
.put(key: "pro", value: Constants.pro)
|
|
.localizedFormatted(Fonts.Body.largeRegular),
|
|
scrollMode: .never
|
|
),
|
|
confirmTitle: "update".localized(),
|
|
onConfirm: { _ in
|
|
Task {
|
|
await viewModel.purchase(
|
|
planInfo: updatedPlan,
|
|
success: {
|
|
Task { @MainActor in
|
|
onPaymentSuccess(expiredOn: updatedPlanExpiredOn)
|
|
}
|
|
},
|
|
failure: {
|
|
Task { @MainActor in
|
|
onPaymentFailed()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
)
|
|
)
|
|
self.host.controller?.present(confirmationModal, animated: true)
|
|
}
|
|
case .purchase, .renew:
|
|
Task {
|
|
await viewModel.purchase(
|
|
planInfo: updatedPlan,
|
|
success: {
|
|
Task { @MainActor in
|
|
onPaymentSuccess(expiredOn: nil)
|
|
}
|
|
},
|
|
failure: {
|
|
Task { @MainActor in
|
|
onPaymentFailed()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
default: break
|
|
}
|
|
}
|
|
|
|
@MainActor private func onPaymentSuccess(expiredOn: Date?) {
|
|
isPendingPurchase = false
|
|
guard !self.viewModel.isFromBottomSheet else {
|
|
let sessionProBottomSheet: BottomSheetHostingViewController = BottomSheetHostingViewController(
|
|
bottomSheet: BottomSheet(
|
|
hasCloseButton: true,
|
|
contentPrefferedHeight: 480
|
|
) {
|
|
SessionProPlanUpdatedScreen(
|
|
flow: self.viewModel.dataModel.flow,
|
|
expiredOn: expiredOn,
|
|
isFromBottomSheet: true
|
|
)
|
|
.backgroundColor(themeColor: .backgroundPrimary)
|
|
}
|
|
)
|
|
self.host.controller?.dismiss(animated: false)
|
|
self.host.controller?.presentingViewController?.present(sessionProBottomSheet, animated: true)
|
|
return
|
|
}
|
|
|
|
let viewController: SessionHostingViewController = SessionHostingViewController(
|
|
rootView: SessionProPlanUpdatedScreen(
|
|
flow: self.viewModel.dataModel.flow,
|
|
expiredOn: expiredOn,
|
|
isFromBottomSheet: false
|
|
)
|
|
)
|
|
viewController.modalTransitionStyle = .crossDissolve
|
|
viewController.modalPresentationStyle = .overFullScreen
|
|
self.host.controller?.present(viewController, animated: true)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) {
|
|
self.host.controller?.navigationController?.popViewController(animated: false)
|
|
}
|
|
}
|
|
|
|
@MainActor private func onPaymentFailed() {
|
|
isPendingPurchase = false
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "urlOpen".localized(),
|
|
body: .attributedText(
|
|
"paymentProError"
|
|
.put(key: "action_type", value: "proUpdatingAction".localized())
|
|
.put(key: "pro", value: Constants.pro)
|
|
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)),
|
|
scrollMode: .automatic
|
|
),
|
|
confirmTitle: "retry".localized(),
|
|
confirmStyle: .alert_text,
|
|
cancelTitle: "helpSupport".localized(),
|
|
cancelStyle: .alert_text,
|
|
onConfirm: { _ in
|
|
// TODO: [PRO] Retry connecting to Pro backend
|
|
},
|
|
onCancel: { _ in
|
|
self.openUrl(Constants.session_pro_support_url)
|
|
}
|
|
)
|
|
)
|
|
|
|
self.host.controller?.present(modal, animated: true)
|
|
}
|
|
|
|
private func openTosPrivacy() {
|
|
let modal: ModalHostingViewController = ModalHostingViewController(
|
|
modal: MutipleLinksModal(
|
|
links: [
|
|
Constants.session_pro_terms_url,
|
|
Constants.session_pro_privacy_url
|
|
],
|
|
openURL: { url in
|
|
if let extensionContext = self.host.controller?.extensionContext {
|
|
extensionContext.open(url, completionHandler: nil)
|
|
}
|
|
}
|
|
)
|
|
)
|
|
self.host.controller?.present(modal, animated: true)
|
|
}
|
|
|
|
private func openUrl(_ urlString: String) {
|
|
guard let url: URL = URL(string: urlString) else { return }
|
|
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "urlOpen".localized(),
|
|
body: .attributedText(
|
|
"urlOpenDescription"
|
|
.put(key: "url", value: url.absoluteString)
|
|
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)),
|
|
scrollMode: .automatic
|
|
),
|
|
confirmTitle: "open".localized(),
|
|
confirmStyle: .danger,
|
|
cancelTitle: "urlCopy".localized(),
|
|
cancelStyle: .alert_text,
|
|
onConfirm: { _ in
|
|
if let extensionContext = self.host.controller?.extensionContext {
|
|
extensionContext.open(url, completionHandler: nil)
|
|
}
|
|
},
|
|
onCancel: { modal in
|
|
UIPasteboard.general.string = url.absoluteString
|
|
modal.close()
|
|
}
|
|
)
|
|
)
|
|
|
|
self.host.controller?.present(modal, animated: true)
|
|
}
|
|
}
|