mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
579 lines
27 KiB
Swift
579 lines
27 KiB
Swift
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import SwiftUI
|
|
import Lucide
|
|
import Combine
|
|
|
|
public struct ProCTAModal: View {
|
|
@EnvironmentObject var host: HostWrapper
|
|
@State var proCTAImageHeight: CGFloat = 0
|
|
|
|
private let variant: ProCTAModal.Variant
|
|
private var dataManager: ImageDataManagerType
|
|
|
|
let dismissType: Modal.DismissType
|
|
let afterClosed: (() -> Void)?
|
|
let onConfirm: (() -> Void)?
|
|
let onCancel: (() -> Void)?
|
|
|
|
public init(
|
|
variant: ProCTAModal.Variant,
|
|
dataManager: ImageDataManagerType,
|
|
dismissType: Modal.DismissType = .recursive,
|
|
afterClosed: (() -> Void)? = nil,
|
|
onConfirm: (() -> Void)? = nil,
|
|
onCancel: (() -> Void)? = nil
|
|
) {
|
|
self.variant = variant
|
|
self.dataManager = dataManager
|
|
self.dismissType = dismissType
|
|
self.afterClosed = afterClosed
|
|
self.onConfirm = onConfirm
|
|
self.onCancel = onCancel
|
|
}
|
|
|
|
public var body: some View {
|
|
Modal_SwiftUI(
|
|
host: host,
|
|
dismissType: dismissType,
|
|
afterClosed: afterClosed
|
|
) { close in
|
|
VStack(spacing: 0) {
|
|
// Background images
|
|
ZStack {
|
|
if let animatedAvatarImageURL = variant.animatedAvatarImageURL {
|
|
GeometryReader { geometry in
|
|
let size: CGFloat = geometry.size.width / 1522.0 * variant.animatedAvatarImageSize
|
|
let scale: CGFloat = geometry.size.width / 1522.0
|
|
SessionAsyncImage(
|
|
source: .url(animatedAvatarImageURL),
|
|
dataManager: dataManager,
|
|
shouldAnimateImage: true,
|
|
grayscale: variant.grayscale,
|
|
content: { image in
|
|
image
|
|
.resizable()
|
|
.aspectRatio(1, contentMode: .fit)
|
|
.frame(width: size, height: size)
|
|
},
|
|
placeholder: {
|
|
if let data = try? Data(contentsOf: animatedAvatarImageURL) {
|
|
Image(uiImage: UIImage(data: data) ?? UIImage())
|
|
.resizable()
|
|
.aspectRatio(1, contentMode: .fit)
|
|
.grayscale(variant.grayscale)
|
|
.frame(width: size, height: size)
|
|
} else {
|
|
EmptyView()
|
|
}
|
|
}
|
|
)
|
|
.padding(.leading, variant.animatedAvatarImagePadding.leading * scale)
|
|
.padding(.top, variant.animatedAvatarImagePadding.top * scale)
|
|
.onAppear {
|
|
proCTAImageHeight = geometry.size.width / 1522.0 * 1258.0
|
|
}
|
|
}
|
|
.frame(height: proCTAImageHeight)
|
|
}
|
|
|
|
Image(uiImage: UIImage(named: variant.backgroundImageName) ?? UIImage())
|
|
.resizable()
|
|
.aspectRatio((1522.0/1258.0), contentMode: .fit)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.backgroundColor(themeColor: variant.themeColor)
|
|
.overlay(alignment: .bottom, content: {
|
|
ThemeLinearGradient(
|
|
themeColors: [
|
|
.clear,
|
|
.alert_background
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.framing(
|
|
maxWidth: .infinity,
|
|
height: 90
|
|
)
|
|
})
|
|
.frame(
|
|
maxWidth: .infinity,
|
|
alignment: .bottom
|
|
)
|
|
// Content
|
|
VStack(spacing: Values.largeSpacing) {
|
|
// Title
|
|
if variant.isRenewing {
|
|
HStack(spacing: Values.smallSpacing) {
|
|
Text("renew".localized())
|
|
.font(.Headings.H4)
|
|
.foregroundColor(themeColor: .textPrimary)
|
|
|
|
SessionProBadge_SwiftUI(size: .large)
|
|
}
|
|
} else {
|
|
switch variant {
|
|
case .animatedProfileImage(let isSessionProActivated, _) where isSessionProActivated:
|
|
HStack(spacing: Values.smallSpacing) {
|
|
SessionProBadge_SwiftUI(size: .large)
|
|
|
|
Text("proActivated".localized())
|
|
.font(.Headings.H4)
|
|
.foregroundColor(themeColor: .textPrimary)
|
|
}
|
|
|
|
case .groupLimit(_, let isSessionProActivated, _) where isSessionProActivated:
|
|
HStack(spacing: Values.smallSpacing) {
|
|
SessionProBadge_SwiftUI(size: .large)
|
|
|
|
Text("proGroupActivated".localized())
|
|
.font(.Headings.H4)
|
|
.foregroundColor(themeColor: .textPrimary)
|
|
}
|
|
|
|
case .expiring(let timeLeft):
|
|
let isExpired: Bool = (timeLeft?.isEmpty != false)
|
|
HStack(spacing: Values.smallSpacing) {
|
|
SessionProBadge_SwiftUI(
|
|
size: .large,
|
|
themeBackgroundColor: variant.themeColor
|
|
)
|
|
|
|
Text(isExpired ? "proExpired".localized() : "proExpiringSoon".localized())
|
|
.font(.Headings.H4)
|
|
.foregroundColor(themeColor: isExpired ? .disabled : .textPrimary)
|
|
}
|
|
|
|
default:
|
|
HStack(spacing: Values.smallSpacing) {
|
|
Text("upgradeTo".localized())
|
|
.font(.Headings.H4)
|
|
.foregroundColor(themeColor: .textPrimary)
|
|
|
|
SessionProBadge_SwiftUI(size: .large)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Description, Subtitle
|
|
VStack(spacing: 0) {
|
|
if case .animatedProfileImage(let isSessionProActivated, _) = variant, isSessionProActivated {
|
|
HStack(spacing: Values.verySmallSpacing) {
|
|
Text("proAlreadyPurchased".localized())
|
|
.font(.Body.largeRegular)
|
|
.foregroundColor(themeColor: .textSecondary)
|
|
|
|
SessionProBadge_SwiftUI(size: .medium)
|
|
}
|
|
}
|
|
|
|
if
|
|
case .groupLimit(_, let isSessionProActivated, let proBadgeImage) = variant,
|
|
isSessionProActivated
|
|
{
|
|
(Text(variant.subtitle.string) + Text(" \(Image(uiImage: proBadgeImage))"))
|
|
.font(.Body.largeRegular)
|
|
.foregroundColor(themeColor: .textSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
} else {
|
|
AttributedText(variant.subtitle)
|
|
.font(.Body.largeRegular)
|
|
.foregroundColor(themeColor: .textSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
// Benefits
|
|
if !variant.benefits.isEmpty {
|
|
VStack(alignment: .leading, spacing: Values.mediumSmallSpacing) {
|
|
ForEach(
|
|
0..<variant.benefits.count,
|
|
id: \.self
|
|
) { index in
|
|
HStack(spacing: Values.smallSpacing) {
|
|
if case .loadsMore = variant.benefits[index] {
|
|
CyclicGradientView {
|
|
AttributedText(Lucide.Icon.sparkles.attributedString(size: 17))
|
|
.font(.system(size: 17))
|
|
}
|
|
} else {
|
|
AttributedText(Lucide.Icon.circleCheck.attributedString(size: 17))
|
|
.font(.system(size: 17))
|
|
.foregroundColor(themeColor: .primary)
|
|
}
|
|
|
|
Text(variant.benefits[index].description)
|
|
.font(.Body.largeRegular)
|
|
.foregroundColor(themeColor: .textPrimary)
|
|
.frame(
|
|
maxWidth: .infinity,
|
|
alignment: .leading
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Buttons
|
|
if variant.onlyShowCloseButton {
|
|
GeometryReader { geometry in
|
|
HStack {
|
|
Button {
|
|
close(nil)
|
|
} label: {
|
|
Text(variant.confirmButtonTitle)
|
|
.font(.Body.baseRegular)
|
|
.foregroundColor(themeColor: .textPrimary)
|
|
}
|
|
.frame(
|
|
width: (geometry.size.width - Values.smallSpacing) / 2,
|
|
height: Values.largeButtonHeight
|
|
)
|
|
.backgroundColor(themeColor: .inputButton_background)
|
|
.cornerRadius(6)
|
|
.clipped()
|
|
.buttonStyle(PlainButtonStyle())
|
|
}
|
|
.frame(
|
|
width: geometry.size.width,
|
|
height: geometry.size.height,
|
|
alignment: .center
|
|
)
|
|
}
|
|
.frame(height: Values.largeButtonHeight)
|
|
} else {
|
|
HStack(spacing: Values.smallSpacing) {
|
|
// Upgrade Button
|
|
ShineButton {
|
|
close(onConfirm)
|
|
} label: {
|
|
Text(variant.confirmButtonTitle)
|
|
.font(.Body.baseRegular)
|
|
.foregroundColor(themeColor: .sessionButton_primaryFilledText)
|
|
.framing(
|
|
maxWidth: .infinity,
|
|
height: Values.largeButtonHeight
|
|
)
|
|
}
|
|
.frame(height: Values.largeButtonHeight)
|
|
.backgroundColor(themeColor: .sessionButton_primaryFilledBackground)
|
|
.cornerRadius(6)
|
|
.clipped()
|
|
.buttonStyle(PlainButtonStyle()) // prevents default blue highlight
|
|
|
|
// Cancel Button
|
|
Button {
|
|
close(nil)
|
|
onCancel?()
|
|
} label: {
|
|
Text(variant.cancelButtonTitle)
|
|
.font(.Body.baseRegular)
|
|
.foregroundColor(themeColor: .textPrimary)
|
|
.framing(
|
|
maxWidth: .infinity,
|
|
height: Values.largeButtonHeight
|
|
)
|
|
}
|
|
.backgroundColor(themeColor: .inputButton_background)
|
|
.cornerRadius(6)
|
|
.clipped()
|
|
.buttonStyle(PlainButtonStyle())
|
|
}
|
|
}
|
|
}
|
|
.padding(Values.mediumSpacing)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Variant
|
|
|
|
public extension ProCTAModal {
|
|
enum Variant {
|
|
case generic(renew: Bool)
|
|
case longerMessages(renew: Bool)
|
|
case animatedProfileImage(isSessionProActivated: Bool, renew: Bool)
|
|
case morePinnedConvos(isGrandfathered: Bool, renew: Bool)
|
|
case groupLimit(isAdmin: Bool, isSessionProActivated: Bool, proBadgeImage: UIImage)
|
|
case expiring(timeLeft: String?)
|
|
|
|
public var isRenewing: Bool {
|
|
switch self {
|
|
case .generic(let renew), .longerMessages(let renew), .animatedProfileImage(_, let renew), .morePinnedConvos(_, let renew):
|
|
return renew
|
|
case .groupLimit, .expiring:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// stringlint:ignore_contents
|
|
public var backgroundImageName: String {
|
|
switch self {
|
|
case .generic, .expiring:
|
|
return "GenericCTA.webp"
|
|
case .longerMessages:
|
|
return "HigherCharLimitCTA.webp"
|
|
case .animatedProfileImage:
|
|
return "AnimatedProfileCTA.webp"
|
|
case .morePinnedConvos:
|
|
return "PinnedConversationsCTA.webp"
|
|
case .groupLimit(let isAdmin, let isSessionProActivated, _):
|
|
switch (isAdmin, isSessionProActivated) {
|
|
case (false, false):
|
|
return "GroupNonAdminCTA.webp"
|
|
default:
|
|
return "GroupAdminCTA.webp"
|
|
}
|
|
}
|
|
}
|
|
|
|
public var themeColor: ThemeValue {
|
|
switch self {
|
|
case .expiring(let timeLeft): return (timeLeft?.isEmpty == false) ? .primary : .disabled
|
|
default: return .primary
|
|
}
|
|
}
|
|
|
|
public var grayscale: Double {
|
|
switch self {
|
|
case .expiring(let timeLeft): return (timeLeft?.isEmpty == false) ? 0.0 : 1.0
|
|
default: return 0.0
|
|
}
|
|
}
|
|
|
|
// stringlint:ignore_contents
|
|
public var animatedAvatarImageURL: URL? {
|
|
switch self {
|
|
case .generic, .animatedProfileImage, .expiring:
|
|
return Bundle.main.url(forResource: "AnimatedProfileCTAAnimationCropped", withExtension: "webp")
|
|
default: return nil
|
|
}
|
|
}
|
|
/// Note: This is a hack to manually position the animated avatar in the CTA background image to prevent heavy loading for the
|
|
/// animated webp. These coordinates are based on the full size image and get scaled during rendering based on the actual size
|
|
/// of the modal.
|
|
public var animatedAvatarImagePadding: (leading: CGFloat, top: CGFloat) {
|
|
switch self {
|
|
case .generic, .expiring: return (1303, 743)
|
|
case .animatedProfileImage: return (680, 363)
|
|
default: return (0, 0)
|
|
}
|
|
}
|
|
|
|
public var animatedAvatarImageSize: CGFloat {
|
|
switch self {
|
|
case .generic, .expiring: return 115
|
|
case .animatedProfileImage: return 200
|
|
default: return 0
|
|
}
|
|
}
|
|
|
|
public var subtitle: ThemedAttributedString {
|
|
switch self {
|
|
case .generic(let renew):
|
|
return renew ?
|
|
"proRenewMaxPotential"
|
|
.put(key: "pro", value: Constants.pro)
|
|
.put(key: "app_name", value: Constants.app_name)
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular) :
|
|
"proUserProfileModalCallToAction"
|
|
.put(key: "app_pro", value: Constants.app_pro)
|
|
.put(key: "app_name", value: Constants.app_name)
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
|
|
case .longerMessages(let renew):
|
|
return renew ?
|
|
"proRenewLongerMessages"
|
|
.put(key: "pro", value: Constants.pro)
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular) :
|
|
"proCallToActionLongerMessages"
|
|
.put(key: "app_pro", value: Constants.app_pro)
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
|
|
case .animatedProfileImage(let isSessionProActivated, let renew):
|
|
switch (isSessionProActivated, renew) {
|
|
case (true, _):
|
|
return "proAnimatedDisplayPicture"
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
|
|
case (false, true):
|
|
return "proRenewAnimatedDisplayPicture"
|
|
.put(key: "pro", value: Constants.pro)
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
|
|
case (false, false):
|
|
return "proAnimatedDisplayPictureCallToActionDescription"
|
|
.put(key: "app_pro", value: Constants.app_pro)
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
|
|
}
|
|
case .morePinnedConvos(let isGrandfathered, let renew):
|
|
switch (isGrandfathered, renew) {
|
|
case (true, false):
|
|
return "proCallToActionPinnedConversations"
|
|
.put(key: "app_pro", value: Constants.app_pro)
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
|
|
case (false, false):
|
|
return "proCallToActionPinnedConversationsMoreThan"
|
|
.put(key: "app_pro", value: Constants.app_pro)
|
|
.put(key: "limit", value: 5) // TODO: [PRO] Get from SessionProUIManager
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
|
|
case (true, true):
|
|
return "proRenewPinMoreConversations"
|
|
.put(key: "pro", value: Constants.pro)
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
|
|
case (false, true):
|
|
return "proRenewPinFiveConversations"
|
|
.put(key: "pro", value: Constants.pro)
|
|
.put(key: "limit", value: 5) // TODO: [PRO] Get from SessionProUIManager
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
|
|
}
|
|
case .groupLimit(let isAdmin, let isSessionProActivated, _):
|
|
switch (isAdmin, isSessionProActivated) {
|
|
case (_, true):
|
|
return "proGroupActivatedDescription"
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
|
|
case (true, false):
|
|
return "proUserProfileModalCallToAction"
|
|
.put(key: "app_pro", value: Constants.app_pro)
|
|
.put(key: "app_name", value: Constants.app_name)
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
|
|
case (false, false):
|
|
// TODO: Localised
|
|
return ThemedAttributedString(
|
|
string: "Want to upgrade this group to Pro? Tell one of the group admins to upgrade to Pro"
|
|
)
|
|
}
|
|
case .expiring(let timeLeft):
|
|
if let timeLeft, !timeLeft.isEmpty {
|
|
return "proExpiringSoonDescription"
|
|
.put(key: "pro", value: Constants.pro)
|
|
.put(key: "time", value: timeLeft)
|
|
.put(key: "app_pro", value: Constants.app_pro)
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
|
|
} else {
|
|
return "proExpiredDescription"
|
|
.put(key: "pro", value: Constants.pro)
|
|
.put(key: "app_pro", value: Constants.app_pro)
|
|
.localizedFormatted(baseFont: Fonts.Body.largeRegular)
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum Benefits: Equatable {
|
|
case largerGroups
|
|
case longerMessages
|
|
case animatedProfileImage
|
|
case morePinnedConvos
|
|
case loadsMore
|
|
|
|
var description: String {
|
|
return switch self {
|
|
case .largerGroups: "proFeatureListLargerGroups".localized()
|
|
case .longerMessages: "proFeatureListLongerMessages".localized()
|
|
case .animatedProfileImage: "proFeatureListAnimatedDisplayPicture".localized()
|
|
case .morePinnedConvos: "proFeatureListPinnedConversations".localized()
|
|
case .loadsMore: "proFeatureListLoadsMore".localized()
|
|
}
|
|
}
|
|
}
|
|
|
|
public var benefits: [Benefits] {
|
|
return switch self {
|
|
case .generic: [ .longerMessages, .morePinnedConvos, .loadsMore ]
|
|
case .longerMessages: [ .longerMessages, .morePinnedConvos, .loadsMore ]
|
|
case .animatedProfileImage: [ .animatedProfileImage, .longerMessages, .loadsMore ]
|
|
case .morePinnedConvos: [ .morePinnedConvos, .longerMessages, .loadsMore ]
|
|
case .groupLimit(let isAdmin, let isSessionProActivated, _):
|
|
switch (isAdmin, isSessionProActivated) {
|
|
case (true, false): [ .largerGroups, .longerMessages, .loadsMore ]
|
|
default: []
|
|
}
|
|
case .expiring: [ .longerMessages, .morePinnedConvos, .animatedProfileImage ]
|
|
}
|
|
}
|
|
|
|
public var confirmButtonTitle: String {
|
|
switch self {
|
|
case .expiring(let timeLeft):
|
|
return (timeLeft?.isEmpty == false) ? "update".localized() : "renew".localized()
|
|
default: return "theContinue".localized()
|
|
}
|
|
}
|
|
|
|
public var cancelButtonTitle: String {
|
|
guard !self.onlyShowCloseButton else {
|
|
return "close".localized()
|
|
}
|
|
|
|
switch self {
|
|
case .expiring(let timeLeft):
|
|
return (timeLeft?.isEmpty == false) ? "close".localized() : "cancel".localized()
|
|
default: return "cancel".localized()
|
|
}
|
|
}
|
|
|
|
public var onlyShowCloseButton: Bool {
|
|
switch self {
|
|
case .animatedProfileImage(let isSessionProActivated, _):
|
|
return isSessionProActivated
|
|
case .groupLimit(let isAdmin, let isSessionProActivated, _):
|
|
return (!isAdmin || isSessionProActivated)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
struct ProCTAModal_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
Group {
|
|
PreviewThemeWrapper(theme: .classicDark) {
|
|
ProCTAModal(
|
|
variant: .generic(renew: false),
|
|
dataManager: ImageDataManager(),
|
|
dismissType: .single,
|
|
afterClosed: nil
|
|
)
|
|
.environmentObject(HostWrapper())
|
|
.previewDisplayName("Classic Dark")
|
|
}
|
|
|
|
PreviewThemeWrapper(theme: .classicLight) {
|
|
ProCTAModal(
|
|
variant: .generic(renew: false),
|
|
dataManager: ImageDataManager(),
|
|
dismissType: .single,
|
|
afterClosed: nil
|
|
)
|
|
.environmentObject(HostWrapper())
|
|
.previewDisplayName("Classic Light")
|
|
}
|
|
|
|
PreviewThemeWrapper(theme: .oceanDark) {
|
|
ProCTAModal(
|
|
variant: .generic(renew: false),
|
|
dataManager: ImageDataManager(),
|
|
dismissType: .single,
|
|
afterClosed: nil
|
|
)
|
|
.environmentObject(HostWrapper())
|
|
.previewDisplayName("Ocean Dark")
|
|
}
|
|
|
|
PreviewThemeWrapper(theme: .oceanLight) {
|
|
ProCTAModal(
|
|
variant: .generic(renew: false),
|
|
dataManager: ImageDataManager(),
|
|
dismissType: .single,
|
|
afterClosed: nil
|
|
)
|
|
.environmentObject(HostWrapper())
|
|
.previewDisplayName("Ocean Light")
|
|
}
|
|
}
|
|
}
|
|
}
|